Muhammad-Mehdi commited on
Commit
070c931
·
1 Parent(s): 0f62e9d

wired blog2video api phase 1

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +0 -131
  2. backend/alembic/versions/0005_video_quota.py +48 -0
  3. backend/alembic/versions/0006_plans.py +71 -0
  4. backend/alembic/versions/0007_subscription_plan_fk.py +36 -0
  5. backend/alembic/versions/0008_b2v_videos.py +40 -0
  6. backend/alembic/versions/0009_b2v_resources.py +111 -0
  7. backend/alembic/versions/0010_b2v_video_refs.py +96 -0
  8. backend/alembic/versions/0011_one_archive_notebook.py +48 -0
  9. backend/alembic/versions/0012_artifact_status_len.py +26 -0
  10. backend/app/config.py +9 -0
  11. backend/app/llm/signatures.py +0 -16
  12. backend/app/main.py +6 -1
  13. backend/app/models/__init__.py +14 -1
  14. backend/app/models/content.py +100 -1
  15. backend/app/models/user.py +48 -2
  16. backend/app/pipeline/media.py +6 -90
  17. backend/app/routers/artifacts.py +11 -10
  18. backend/app/routers/billing.py +3 -2
  19. backend/app/routers/notebooks.py +14 -5
  20. backend/app/routers/video_edit.py +212 -0
  21. backend/app/routers/video_styles.py +88 -0
  22. backend/app/routers/video_templates.py +277 -0
  23. backend/app/routers/video_voices.py +267 -0
  24. backend/app/routers/videos.py +742 -0
  25. backend/app/services/artifacts.py +5 -2
  26. backend/app/services/auth_identity.py +4 -1
  27. backend/app/services/b2v_access.py +179 -0
  28. backend/app/services/billing.py +8 -2
  29. backend/app/services/blog2video.py +326 -0
  30. backend/app/services/plans.py +136 -19
  31. backend/app/services/renderer.py +0 -2
  32. backend/app/services/stripe_billing.py +9 -0
  33. backend/app/services/usage.py +6 -4
  34. backend/app/services/video_limits.py +93 -0
  35. backend/app/services/video_quota.py +239 -0
  36. backend/app/worker.py +5 -2
  37. backend/requirements.txt +1 -0
  38. backend/tests/conftest.py +2 -0
  39. backend/tests/test_billing.py +67 -1
  40. backend/tests/test_buildout.py +19 -13
  41. backend/tests/test_simplified_flows.py +14 -1
  42. backend/tests/test_stripe_billing.py +34 -0
  43. backend/tests/test_videos.py +960 -0
  44. docs/BLOG2VIDEO.md +131 -0
  45. frontend/seo/prerender.ts +13 -3
  46. frontend/src/App.tsx +8 -0
  47. frontend/src/api/client.ts +19 -4
  48. frontend/src/api/endpoints.ts +237 -2
  49. frontend/src/api/types.ts +298 -4
  50. frontend/src/components/AppShell.tsx +3 -1
.env.example DELETED
@@ -1,131 +0,0 @@
1
- # ---- App
2
- ENV=development
3
- APP_NAME=Notestack
4
- FRONTEND_URL=http://localhost:5173
5
- API_URL=http://localhost:8000
6
- CORS_ORIGINS=http://localhost:5173
7
- INTERNAL_TOKEN=replace-with-random-token
8
- BILLING_ENABLED=false
9
- # Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
10
- # (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
11
- # Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
12
- # checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
13
- # customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
14
- # customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
15
- # invoice.payment_action_required, charge.dispute.created.
16
- STRIPE_SECRET_KEY=
17
- STRIPE_WEBHOOK_SECRET=
18
- STRIPE_PRICE_WRITER_MONTHLY=
19
- STRIPE_PRICE_WRITER_ANNUAL=
20
- STRIPE_PRICE_STUDIO_MONTHLY=
21
- STRIPE_PRICE_STUDIO_ANNUAL=
22
-
23
- # ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
24
- JWT_SECRET=replace-with-64-random-chars
25
- JWT_ALGORITHM=HS256
26
- JWT_EXPIRATION_HOURS=72
27
- JWT_REFRESH_EXPIRATION_DAYS=30
28
-
29
- # ---- Database (also backs the job queue)
30
- # Local: a SQLite file, created by `alembic upgrade head` run from backend/.
31
- # Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
32
- # DATABASE_URL=postgresql+psycopg://user:pass@host:5432/dbname?sslmode=require
33
- # docker-compose ignores this and points at its own Postgres container.
34
- DATABASE_URL=sqlite:///./notestack.db
35
-
36
- # ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
37
- # Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
38
- # Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
39
- # (default {API_URL}/api/auth/google/callback, e.g. http://localhost:8000/api/auth/google/callback)
40
- # to "Authorized redirect URIs" on the OAuth client. Never prefix the secret with VITE_.
41
- GOOGLE_CLIENT_ID=
42
- GOOGLE_CLIENT_SECRET=
43
- GOOGLE_REDIRECT_URI=
44
-
45
- # ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
46
- EMAIL_PROVIDER=console
47
- RESEND_API_KEY=
48
- FROM_EMAIL=Notestack <hello@notestack.ai>
49
- ALERTS_EMAIL=arslan@firebird-technologies.com
50
- UNSUBSCRIBE_SECRET=replace-me
51
- # Hour (UTC, 0 to 23) at which scheduled update email campaigns send their daily batch.
52
- UPDATE_EMAIL_SEND_HOUR=9
53
-
54
- # ---- Local dev conveniences
55
- # The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
56
- RUN_WORKER_IN_API=
57
- # auto = local disk (LOCAL_STORAGE_DIR) when no R2 account or endpoint is set, else R2
58
- STORAGE_BACKEND=auto
59
- LOCAL_STORAGE_DIR=.storage
60
-
61
- # ---- Storage: Cloudflare R2
62
- # Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
63
- # Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
64
- R2_ACCOUNT_ID=
65
- R2_ACCESS_KEY_ID=minioadmin
66
- R2_SECRET_ACCESS_KEY=minioadmin
67
- R2_BUCKET=notestack-assets
68
- R2_ENDPOINT_URL=
69
- R2_PUBLIC_BASE_URL=
70
- # Folder inside the bucket when it is shared with other apps, e.g. notestack. Empty = bucket root.
71
- R2_PREFIX=
72
- # How long presigned download links stay valid, in seconds.
73
- R2_PRESIGN_TTL_SECONDS=3600
74
- # Largest file a browser may upload (bytes). 209715200 = 200 MB.
75
- MAX_UPLOAD_BYTES=209715200
76
-
77
- # ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
78
- # GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
79
- # Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
80
- LLM_MODEL=openai/glm-5.3
81
- LLM_FAST_MODEL=openai/glm-5.3-flash
82
- LLM_API_BASE=https://api.z.ai/api/paas/v4
83
- LLM_API_KEY=
84
- LLM_TEMPERATURE=1.0
85
- LLM_MAX_TOKENS=16000
86
- LLM_REASONING_EFFORT=low
87
- # Fallback chat triage model when Jev is not configured. Empty = LLM_FAST_MODEL.
88
- LLM_TRIAGE_MODEL=
89
-
90
- # ---- Chat triage: TypeSafe Jev (https://typesafe.ai)
91
- # A fast classifier (typed choices, no text generation) that decides what each chat message needs
92
- # before any research runs. With a key set, greetings, off topic and app questions never reach the
93
- # LLM, and archive questions get a quick or deep research budget. Empty = LLM triage above.
94
- TYPESAFE_API_KEY=
95
- TYPESAFE_MODEL=jev-latest
96
- TYPESAFE_URL=https://api.typesafe.ai
97
-
98
- # Firecrawl (firecrawl.dev): lets Connect read sites with no RSS feed, including JavaScript sites
99
- # (Next.js, Framer, Webflow), and makes single URL imports reliable. Empty = plain HTTP fetching only.
100
- FIRECRAWL_API_KEY=
101
-
102
- # ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
103
- CORPUS_CACHE_DIR=.corpus-cache
104
- RESEARCH_MAX_STEPS=10
105
-
106
- # ---- Worker (jobs queue lives in the database; no Redis)
107
- WORKER_CONCURRENCY=4
108
- WORKER_POLL_SECONDS=1
109
- JOB_STALE_SECONDS=900
110
-
111
- # ---- ElevenLabs (audio overviews, video narration, voice cloning)
112
- ELEVENLABS_API_KEY=
113
- ELEVENLABS_MODEL=eleven_multilingual_v2
114
- ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
115
- ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
116
-
117
- # ---- Renderer (Remotion service for videos, quote cards and carousels: cd renderer && npm start)
118
- RENDERER_URL=http://localhost:3100
119
-
120
- # ---- Launchpad social publishing
121
- # Bluesky needs nothing here (users connect with an app password).
122
- # X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
123
- # Callback URI: {API_URL}/api/social/x/callback e.g. http://localhost:8000/api/social/x/callback
124
- # LinkedIn: app with the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products.
125
- # Redirect URL: {API_URL}/api/social/linkedin/callback
126
- X_CLIENT_ID=
127
- X_CLIENT_SECRET=
128
- LINKEDIN_CLIENT_ID=
129
- LINKEDIN_CLIENT_SECRET=
130
- # Fernet key for stored social tokens; derived from JWT_SECRET when empty.
131
- SOCIAL_TOKEN_KEY=
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/alembic/versions/0005_video_quota.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """subscriptions.video_plan / videos_used / video_limit / videos_period_start: the video allowance
2
+
3
+ Revision ID: 0005_video_quota
4
+ Revises: 0004_archive_notebook
5
+ Create Date: 2026-09-29
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0005_video_quota"
11
+ down_revision = "0004_archive_notebook"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+ COLUMNS = ("video_plan", "videos_used", "video_limit", "videos_period_start")
16
+
17
+
18
+ def upgrade() -> None:
19
+ # 0001 builds from the live models, so a fresh database may already have the columns.
20
+ existing = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("subscriptions")}
21
+ new = {
22
+ "video_plan": sa.Column("video_plan", sa.String(20), nullable=False, server_default="free"),
23
+ "videos_used": sa.Column("videos_used", sa.Integer(), nullable=False, server_default="0"),
24
+ "video_limit": sa.Column("video_limit", sa.Integer(), nullable=False, server_default="0"),
25
+ "videos_period_start": sa.Column("videos_period_start", sa.DateTime(timezone=True), nullable=True),
26
+ }
27
+ missing = [new[name] for name in COLUMNS if name not in existing]
28
+ if missing:
29
+ with op.batch_alter_table("subscriptions") as batch:
30
+ for column in missing:
31
+ batch.add_column(column)
32
+ # Same limits as app/services/plans.py; the app re-syncs on first use (billing disabled -> studio).
33
+ op.execute("""
34
+ UPDATE subscriptions SET
35
+ video_plan = CASE WHEN status IN ('active', 'trialing', 'past_due') AND plan IN ('writer', 'studio')
36
+ THEN plan ELSE 'free' END
37
+ """)
38
+ op.execute("""
39
+ UPDATE subscriptions SET
40
+ video_limit = CASE video_plan WHEN 'studio' THEN 30 WHEN 'writer' THEN 10 ELSE 1 END,
41
+ videos_period_start = COALESCE(videos_period_start, CURRENT_TIMESTAMP)
42
+ """)
43
+
44
+
45
+ def downgrade() -> None:
46
+ with op.batch_alter_table("subscriptions") as batch:
47
+ for name in reversed(COLUMNS):
48
+ batch.drop_column(name)
backend/alembic/versions/0006_plans.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """plans: one row per plan (limits, price, pricing-page copy), editable without a deploy
2
+
3
+ Seeded with the plans that were hard-coded in app/services/plans.py. Rows that already exist are left alone.
4
+
5
+ Revision ID: 0006_plans
6
+ Revises: 0005_video_quota
7
+ Create Date: 2026-09-29
8
+ """
9
+ from datetime import UTC, datetime
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ revision = "0006_plans"
15
+ down_revision = "0005_video_quota"
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+ SEED = [
20
+ {"id": "free", "sort_order": 0, "name": "Free", "tagline": "For trying Notestack on your archive",
21
+ "price_monthly_usd": 0, "sources": 1, "indexed_posts": 5, "audio_minutes": 3, "videos": 1, "launch_kits": 2,
22
+ "voice_cloning": False, "brand_kit": False,
23
+ "features": ["1 source, your latest 5 posts indexed", "Grounded research chat with citations",
24
+ "3 min of audio overviews a month", "1 video a month", "2 Launch Kits a month"]},
25
+ {"id": "writer", "sort_order": 1, "name": "Writer", "tagline": "For writers publishing every week",
26
+ "price_monthly_usd": 24.99, "sources": 3, "indexed_posts": 500, "audio_minutes": 60, "videos": 10,
27
+ "launch_kits": 50, "voice_cloning": True, "brand_kit": True,
28
+ "features": ["3 sources, 500 indexed posts", "60 min of audio overviews a month", "10 videos a month",
29
+ "50 Launch Kits a month", "Voice cloning with consent", "Your brand colors and logo"]},
30
+ {"id": "studio", "sort_order": 2, "name": "Studio", "tagline": "For publications and power users",
31
+ "price_monthly_usd": 48.99, "sources": 10, "indexed_posts": 5000, "audio_minutes": 240, "videos": 30,
32
+ "launch_kits": -1, "voice_cloning": True, "brand_kit": True,
33
+ "features": ["10 sources, 5,000 indexed posts", "240 min of audio overviews a month", "30 videos a month",
34
+ "Unlimited Launch Kits", "Launchpad calendar and resurfacing", "Priority rendering"]},
35
+ ]
36
+
37
+
38
+ def upgrade() -> None:
39
+ bind = op.get_bind()
40
+ # 0001 builds from the live models, so a fresh database may already have the table (empty).
41
+ if "plans" not in sa.inspect(bind).get_table_names():
42
+ op.create_table(
43
+ "plans",
44
+ sa.Column("id", sa.String(20), primary_key=True),
45
+ sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
46
+ sa.Column("name", sa.String(50), nullable=False),
47
+ sa.Column("tagline", sa.String(200), nullable=False, server_default=""),
48
+ sa.Column("price_monthly_usd", sa.Float(), nullable=False, server_default="0"),
49
+ sa.Column("sources", sa.Integer(), nullable=False, server_default="1"),
50
+ sa.Column("indexed_posts", sa.Integer(), nullable=False, server_default="5"),
51
+ sa.Column("audio_minutes", sa.Integer(), nullable=False, server_default="0"),
52
+ sa.Column("videos", sa.Integer(), nullable=False, server_default="0"),
53
+ sa.Column("launch_kits", sa.Integer(), nullable=False, server_default="0"),
54
+ sa.Column("voice_cloning", sa.Boolean(), nullable=False, server_default=sa.false()),
55
+ sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false()),
56
+ sa.Column("features", sa.JSON(), nullable=False),
57
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
58
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
59
+ )
60
+ types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
61
+ "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
62
+ plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*SEED[0], "created_at", "updated_at"]))
63
+ existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
64
+ now = datetime.now(UTC)
65
+ missing = [{**p, "created_at": now, "updated_at": now} for p in SEED if p["id"] not in existing]
66
+ if missing:
67
+ op.bulk_insert(plans, missing)
68
+
69
+
70
+ def downgrade() -> None:
71
+ op.drop_table("plans")
backend/alembic/versions/0007_subscription_plan_fk.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """subscriptions.plan -> plans.id: the plan is picked from the plans table, not typed as free text
2
+
3
+ Unknown plan values (typos, removed plans) become 'free' first so the constraint can be added.
4
+
5
+ Revision ID: 0007_subscription_plan_fk
6
+ Revises: 0006_plans
7
+ Create Date: 2026-09-29
8
+ """
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+
12
+ revision = "0007_subscription_plan_fk"
13
+ down_revision = "0006_plans"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+ NAME = "fk_subscriptions_plan_plans"
18
+
19
+
20
+ def upgrade() -> None:
21
+ bind = op.get_bind()
22
+ # 0001 builds from the live models, so a fresh database may already have the constraint.
23
+ if any(fk["referred_table"] == "plans" for fk in sa.inspect(bind).get_foreign_keys("subscriptions")):
24
+ return
25
+ op.execute("UPDATE subscriptions SET plan = 'free' WHERE plan IS NULL OR plan NOT IN (SELECT id FROM plans)")
26
+ with op.batch_alter_table("subscriptions") as batch:
27
+ batch.create_foreign_key(NAME, "plans", ["plan"], ["id"], onupdate="CASCADE")
28
+
29
+
30
+ def downgrade() -> None:
31
+ fks = sa.inspect(op.get_bind()).get_foreign_keys("subscriptions")
32
+ name = next((fk["name"] for fk in fks if fk["referred_table"] == "plans"), None)
33
+ if not name:
34
+ return # unnamed (built by 0001 on SQLite): nothing we can drop by name
35
+ with op.batch_alter_table("subscriptions") as batch:
36
+ batch.drop_constraint(name, type_="foreignkey")
backend/alembic/versions/0008_b2v_videos.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """b2v_videos: which workspace owns each blog2video video, and whether its allowance is still charged
2
+
3
+ blog2video now only takes our account's API key, so this table is our access control and refund ledger.
4
+ blog2video no longer connects to this database: drop the role it used (see docs/BLOG2VIDEO.md).
5
+
6
+ Revision ID: 0008_b2v_videos
7
+ Revises: 0007_subscription_plan_fk
8
+ Create Date: 2026-09-30
9
+ """
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision = "0008_b2v_videos"
14
+ down_revision = "0007_subscription_plan_fk"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ # 0001 builds from the live models, so a fresh database may already have the table.
21
+ if sa.inspect(op.get_bind()).has_table("b2v_videos"):
22
+ return
23
+ op.create_table(
24
+ "b2v_videos",
25
+ sa.Column("id", sa.Uuid(), primary_key=True),
26
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
27
+ sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL"), nullable=True),
28
+ sa.Column("b2v_video_id", sa.Integer(), nullable=True, unique=True),
29
+ sa.Column("idempotency_key", sa.String(100), nullable=False, unique=True),
30
+ sa.Column("quota_state", sa.String(20), nullable=False, server_default="charged"),
31
+ sa.Column("status", sa.String(40), nullable=False, server_default="queued"),
32
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
33
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
34
+ )
35
+ op.create_index("ix_b2v_videos_ws_created", "b2v_videos", ["workspace_id", "created_at"])
36
+ op.create_index("ix_b2v_videos_artifact_id", "b2v_videos", ["artifact_id"])
37
+
38
+
39
+ def downgrade() -> None:
40
+ op.drop_table("b2v_videos")
backend/alembic/versions/0009_b2v_resources.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """blog2video resources per workspace: templates, custom voices, saved voices, styles, usage counters;
2
+ b2v_videos.created_via / template_ref; plans.video_premium / video_limits
3
+
4
+ Everything made with our blog2video key belongs to one account, so these tables are what keeps each workspace's
5
+ templates, voices and styles its own (app/services/b2v_access.py).
6
+
7
+ Revision ID: 0009_b2v_resources
8
+ Revises: 0008_b2v_videos
9
+ Create Date: 2026-09-30
10
+ """
11
+ import json
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+
16
+ revision = "0009_b2v_resources"
17
+ down_revision = "0008_b2v_videos"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+ # Same as app/services/plans.py
22
+ VIDEO_LIMITS = {
23
+ "free": {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
24
+ "voice_samples_daily": 0, "custom_voices": 0},
25
+ "writer": {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
26
+ "voice_samples_daily": 20, "custom_voices": 3},
27
+ "studio": {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
28
+ "voice_samples_daily": 40, "custom_voices": 10},
29
+ }
30
+
31
+
32
+ def _ws() -> sa.Column:
33
+ return sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False,
34
+ index=True)
35
+
36
+
37
+ def _times() -> list[sa.Column]:
38
+ return [sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
39
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())]
40
+
41
+
42
+ def upgrade() -> None:
43
+ bind = op.get_bind()
44
+ inspector = sa.inspect(bind)
45
+
46
+ # 0001 builds from the live models, so a fresh database may already have all of this.
47
+ video_cols = {c["name"] for c in inspector.get_columns("b2v_videos")}
48
+ with op.batch_alter_table("b2v_videos") as batch:
49
+ if "created_via" not in video_cols:
50
+ batch.add_column(sa.Column("created_via", sa.String(10), nullable=False, server_default="v1"))
51
+ if "template_ref" not in video_cols:
52
+ batch.add_column(sa.Column("template_ref", sa.String(100), nullable=True))
53
+ batch.alter_column("idempotency_key", existing_type=sa.String(100), nullable=True)
54
+
55
+ if not inspector.has_table("b2v_templates"):
56
+ op.create_table("b2v_templates",
57
+ sa.Column("b2v_template_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
58
+ sa.Column("name", sa.String(255), nullable=False),
59
+ sa.Column("ready", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
60
+ if not inspector.has_table("b2v_custom_voices"):
61
+ op.create_table("b2v_custom_voices",
62
+ sa.Column("b2v_custom_voice_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
63
+ sa.Column("voice_id", sa.String(100), nullable=False, unique=True),
64
+ sa.Column("name", sa.String(255), nullable=False),
65
+ sa.Column("source", sa.String(20), nullable=False),
66
+ sa.Column("preview_url", sa.String(1000), nullable=True), *_times())
67
+ if not inspector.has_table("user_saved_voices"):
68
+ op.create_table("user_saved_voices",
69
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
70
+ primary_key=True),
71
+ sa.Column("voice_id", sa.String(100), primary_key=True),
72
+ sa.Column("name", sa.String(255), nullable=False),
73
+ sa.Column("preview_url", sa.String(1000), nullable=True),
74
+ sa.Column("gender", sa.String(20), nullable=True),
75
+ sa.Column("accent", sa.String(50), nullable=True),
76
+ sa.Column("premium", sa.Boolean(), nullable=False, server_default=sa.false()),
77
+ sa.Column("is_custom", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
78
+ if not inspector.has_table("b2v_styles"):
79
+ op.create_table("b2v_styles",
80
+ sa.Column("b2v_style_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
81
+ sa.Column("name", sa.String(80), nullable=False), *_times())
82
+ if not inspector.has_table("usage_counters"):
83
+ op.create_table("usage_counters",
84
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
85
+ primary_key=True),
86
+ sa.Column("period", sa.String(10), primary_key=True),
87
+ sa.Column("metric", sa.String(30), primary_key=True),
88
+ sa.Column("used", sa.Integer(), nullable=False, server_default="0"))
89
+
90
+ plan_cols = {c["name"] for c in inspector.get_columns("plans")}
91
+ with op.batch_alter_table("plans") as batch:
92
+ if "video_premium" not in plan_cols:
93
+ batch.add_column(sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false()))
94
+ if "video_limits" not in plan_cols:
95
+ batch.add_column(sa.Column("video_limits", sa.JSON(), nullable=True))
96
+ plans = sa.table("plans", sa.column("id", sa.String), sa.column("video_premium", sa.Boolean),
97
+ sa.column("video_limits", sa.JSON))
98
+ for plan_id, limits in VIDEO_LIMITS.items():
99
+ op.execute(plans.update().where(plans.c.id == plan_id)
100
+ .values(video_premium=plan_id != "free", video_limits=json.loads(json.dumps(limits))))
101
+
102
+
103
+ def downgrade() -> None:
104
+ with op.batch_alter_table("plans") as batch:
105
+ batch.drop_column("video_limits")
106
+ batch.drop_column("video_premium")
107
+ for table in ("usage_counters", "b2v_styles", "user_saved_voices", "b2v_custom_voices", "b2v_templates"):
108
+ op.drop_table(table)
109
+ with op.batch_alter_table("b2v_videos") as batch:
110
+ batch.drop_column("template_ref")
111
+ batch.drop_column("created_via")
backend/alembic/versions/0010_b2v_video_refs.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """b2v_videos holds each video's full reference (who made it, title, source, links); video limits 1 total / 10 / 20
2
+
3
+ - b2v_videos: user_id, title, source_url, aspect_ratio, preview_url, video_url, backfilled from the workspace owner
4
+ and the linked artifact.
5
+ - plans: videos_monthly (False on Free: 1 video in total, never refilled); Studio 20 videos a month.
6
+
7
+ Revision ID: 0010_b2v_video_refs
8
+ Revises: 0009_b2v_resources
9
+ Create Date: 2026-09-30
10
+ """
11
+ import json
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+
16
+ revision = "0010_b2v_video_refs"
17
+ down_revision = "0009_b2v_resources"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+ VIDEO_COLUMNS = {
22
+ "user_id": lambda: sa.Column("user_id", sa.Uuid(), nullable=True),
23
+ "title": lambda: sa.Column("title", sa.String(300), nullable=True),
24
+ "source_url": lambda: sa.Column("source_url", sa.String(2000), nullable=True),
25
+ "aspect_ratio": lambda: sa.Column("aspect_ratio", sa.String(20), nullable=True),
26
+ "preview_url": lambda: sa.Column("preview_url", sa.String(1000), nullable=True),
27
+ "video_url": lambda: sa.Column("video_url", sa.String(1000), nullable=True),
28
+ }
29
+
30
+ # Same as app/services/plans.py
31
+ PLAN_VIDEOS = {"free": (1, False, "1 video a month", "1 video to try it"),
32
+ "writer": (10, True, None, None),
33
+ "studio": (20, True, "30 videos a month", "20 videos a month")}
34
+
35
+
36
+ def upgrade() -> None:
37
+ bind = op.get_bind()
38
+ inspector = sa.inspect(bind)
39
+
40
+ # 0001 builds from the live models, so a fresh database may already have these columns.
41
+ existing = {c["name"] for c in inspector.get_columns("b2v_videos")}
42
+ missing = [make() for name, make in VIDEO_COLUMNS.items() if name not in existing]
43
+ if missing:
44
+ with op.batch_alter_table("b2v_videos") as batch:
45
+ for column in missing:
46
+ batch.add_column(column)
47
+ if "user_id" not in existing:
48
+ batch.create_foreign_key("fk_b2v_videos_user_id_users", "users", ["user_id"], ["id"],
49
+ ondelete="SET NULL")
50
+ batch.create_index("ix_b2v_videos_user_id", ["user_id"])
51
+
52
+ # Backfill: the workspace owner made it; title and links come from the artifact.
53
+ rows = bind.execute(sa.text("""
54
+ SELECT v.id, w.owner_id, a.content_json FROM b2v_videos v
55
+ JOIN workspaces w ON w.id = v.workspace_id
56
+ LEFT JOIN artifacts a ON a.id = v.artifact_id
57
+ WHERE v.user_id IS NULL
58
+ """)).fetchall()
59
+ for vid, owner_id, content in rows:
60
+ c = content if isinstance(content, dict) else json.loads(content or "{}")
61
+ bind.execute(sa.text("""
62
+ UPDATE b2v_videos SET user_id = :u, title = COALESCE(title, :t), aspect_ratio = COALESCE(aspect_ratio, :ar),
63
+ preview_url = COALESCE(preview_url, :p), video_url = COALESCE(video_url, :v) WHERE id = :id
64
+ """), {"u": owner_id, "t": (c.get("title") or None), "ar": c.get("aspect_ratio"), "p": c.get("preview_url"),
65
+ "v": c.get("video_url"), "id": vid})
66
+
67
+ plan_cols = {c["name"] for c in inspector.get_columns("plans")}
68
+ if "videos_monthly" not in plan_cols:
69
+ with op.batch_alter_table("plans") as batch:
70
+ batch.add_column(sa.Column("videos_monthly", sa.Boolean(), nullable=False, server_default=sa.true()))
71
+ for plan_id, (videos, monthly, old, new) in PLAN_VIDEOS.items():
72
+ row = bind.execute(sa.text("SELECT features FROM plans WHERE id = :id"), {"id": plan_id}).fetchone()
73
+ if row is None:
74
+ continue
75
+ features = row[0] if isinstance(row[0], list) else json.loads(row[0] or "[]")
76
+ if old:
77
+ features = [new if f == old else f for f in features]
78
+ bind.execute(sa.text("UPDATE plans SET videos = :v, videos_monthly = :m, features = :f WHERE id = :id"),
79
+ {"v": videos, "m": monthly, "f": json.dumps(features), "id": plan_id})
80
+
81
+
82
+ def downgrade() -> None:
83
+ with op.batch_alter_table("plans") as batch:
84
+ batch.drop_column("videos_monthly")
85
+ # A database built from the models (0001) names these differently, so look them up.
86
+ inspector = sa.inspect(op.get_bind())
87
+ fks = [fk["name"] for fk in inspector.get_foreign_keys("b2v_videos")
88
+ if fk["constrained_columns"] == ["user_id"] and fk.get("name")]
89
+ indexes = [ix["name"] for ix in inspector.get_indexes("b2v_videos") if ix["column_names"] == ["user_id"]]
90
+ with op.batch_alter_table("b2v_videos") as batch:
91
+ for name in indexes:
92
+ batch.drop_index(name)
93
+ for name in fks:
94
+ batch.drop_constraint(name, type_="foreignkey")
95
+ for name in reversed(list(VIDEO_COLUMNS)):
96
+ batch.drop_column(name)
backend/alembic/versions/0011_one_archive_notebook.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One "All posts" notebook per workspace
2
+
3
+ Two requests opening the Notebooks page at once could each create the archive notebook. This merges the copies into
4
+ the oldest one (its posts, chats and artifacts move over) and adds a unique index so it cannot happen again.
5
+
6
+ Revision ID: 0011_one_archive_notebook
7
+ Revises: 0010_b2v_video_refs
8
+ Create Date: 2026-10-01
9
+ """
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision = "0011_one_archive_notebook"
14
+ down_revision = "0010_b2v_video_refs"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+ INDEX = "uq_notebooks_one_archive"
19
+
20
+
21
+ def upgrade() -> None:
22
+ conn = op.get_bind()
23
+ rows = conn.execute(sa.text(
24
+ "SELECT workspace_id, id FROM notebooks WHERE is_archive ORDER BY workspace_id, created_at, id"
25
+ )).all()
26
+ keep: dict = {}
27
+ for ws, nb in rows:
28
+ if ws not in keep:
29
+ keep[ws] = nb
30
+ continue
31
+ ids = {"keep": keep[ws], "dup": nb}
32
+ conn.execute(sa.text(
33
+ "INSERT INTO notebook_documents (notebook_id, document_id) "
34
+ "SELECT :keep, document_id FROM notebook_documents WHERE notebook_id = :dup AND document_id NOT IN "
35
+ "(SELECT document_id FROM notebook_documents WHERE notebook_id = :keep)"
36
+ ), ids)
37
+ conn.execute(sa.text("UPDATE chats SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
38
+ conn.execute(sa.text("UPDATE artifacts SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
39
+ conn.execute(sa.text("DELETE FROM notebooks WHERE id = :dup"), ids)
40
+
41
+ # 0001 builds from the live models, so a fresh database may already have the index.
42
+ if INDEX not in {i["name"] for i in sa.inspect(conn).get_indexes("notebooks")}:
43
+ op.create_index(INDEX, "notebooks", ["workspace_id"], unique=True,
44
+ postgresql_where=sa.text("is_archive"), sqlite_where=sa.text("is_archive"))
45
+
46
+
47
+ def downgrade() -> None:
48
+ op.drop_index(INDEX, table_name="notebooks")
backend/alembic/versions/0012_artifact_status_len.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Longer artifact status
2
+
3
+ A video's artifact now takes blog2video's job statuses as they are (e.g. "language_regenerating", 21 characters),
4
+ which do not fit the old 20.
5
+
6
+ Revision ID: 0012_artifact_status_len
7
+ Revises: 0011_one_archive_notebook
8
+ Create Date: 2026-10-02
9
+ """
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision = "0012_artifact_status_len"
14
+ down_revision = "0011_one_archive_notebook"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ with op.batch_alter_table("artifacts") as batch:
21
+ batch.alter_column("status", existing_type=sa.String(20), type_=sa.String(40), existing_nullable=False)
22
+
23
+
24
+ def downgrade() -> None:
25
+ with op.batch_alter_table("artifacts") as batch:
26
+ batch.alter_column("status", existing_type=sa.String(40), type_=sa.String(20), existing_nullable=False)
backend/app/config.py CHANGED
@@ -101,6 +101,15 @@ class Settings(BaseSettings):
101
  linkedin_client_id: str = ""
102
  linkedin_client_secret: str = ""
103
 
 
 
 
 
 
 
 
 
 
104
  # Renderer
105
  renderer_url: str = "http://localhost:3100"
106
  internal_token: str = "internal-change-me"
 
101
  linkedin_client_id: str = ""
102
  linkedin_client_secret: str = ""
103
 
104
+ # blog2video: makes the videos. We call its public API with our own account's key (b2v_live_...), so every
105
+ # video is charged to that account. Backend only: the key must never reach the browser or the logs.
106
+ b2v_api_base_url: str = Field("", validation_alias=AliasChoices("B2V_API_BASE_URL", "B2V_API_URL"))
107
+ b2v_api_key: str = ""
108
+ # blog2video's web app: serves the template poster images (<app>/template-posters/<id>.webp).
109
+ b2v_app_url: str = "https://blog2video.app"
110
+ b2v_timeout_seconds: float = 30 # creates, uploads and full-project reads; other calls also wait 30s (STATUS_TIMEOUT)
111
+ b2v_capacity_alert_below: int = 20 # log an error when our blog2video account has fewer videos left
112
+
113
  # Renderer
114
  renderer_url: str = "http://localhost:3100"
115
  internal_token: str = "internal-change-me"
backend/app/llm/signatures.py CHANGED
@@ -47,14 +47,6 @@ class ScriptLine(BaseModel):
47
  sources: list[SourceRef] = Field(default_factory=list)
48
 
49
 
50
- class Scene(BaseModel):
51
- type: Literal["title", "section", "pull_quote", "number", "outro"]
52
- on_screen_text: str
53
- narration: str
54
- duration_hint_s: float
55
- visual: str = ""
56
-
57
-
58
  class Hook(BaseModel):
59
  text: str
60
  strength: float = Field(ge=0, le=1)
@@ -165,14 +157,6 @@ class PodcastScript(dspy.Signature):
165
  lines: list[ScriptLine] = dspy.OutputField()
166
 
167
 
168
- class VideoStoryboard(dspy.Signature):
169
- """Turn the post into a scene list for a Remotion composition."""
170
-
171
- passages: list[str] = dspy.InputField()
172
- aspect: Literal["16:9", "9:16", "1:1"] = dspy.InputField()
173
- scenes: list[Scene] = dspy.OutputField()
174
-
175
-
176
  class HookGenerator(dspy.Signature):
177
  """Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
178
 
 
47
  sources: list[SourceRef] = Field(default_factory=list)
48
 
49
 
 
 
 
 
 
 
 
 
50
  class Hook(BaseModel):
51
  text: str
52
  strength: float = Field(ge=0, le=1)
 
157
  lines: list[ScriptLine] = dspy.OutputField()
158
 
159
 
 
 
 
 
 
 
 
 
160
  class HookGenerator(dspy.Signature):
161
  """Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
162
 
backend/app/main.py CHANGED
@@ -21,6 +21,10 @@ from app.routers import (
21
  storage,
22
  topics,
23
  unsubscribe,
 
 
 
 
24
  voice,
25
  )
26
  from app.routers import settings as settings_router
@@ -86,8 +90,9 @@ def health():
86
  return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
87
 
88
 
 
89
  for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
90
- launchpad, settings_router):
91
  app.include_router(r.router)
92
  app.include_router(sources.documents_router)
93
  app.include_router(launchpad.links_router)
 
21
  storage,
22
  topics,
23
  unsubscribe,
24
+ video_edit,
25
+ video_styles,
26
+ video_voices,
27
+ videos,
28
  voice,
29
  )
30
  from app.routers import settings as settings_router
 
90
  return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
91
 
92
 
93
+ # video_templates (custom templates) is switched off: users pick only from the built-in templates.
94
  for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
95
+ launchpad, videos, video_edit, video_voices, video_styles, settings_router):
96
  app.include_router(r.router)
97
  app.include_router(sources.documents_router)
98
  app.include_router(launchpad.links_router)
backend/app/models/__init__.py CHANGED
@@ -1,5 +1,9 @@
1
  from app.models.content import (
2
  Artifact,
 
 
 
 
3
  CalendarItem,
4
  Chat,
5
  Citation,
@@ -16,16 +20,22 @@ from app.models.content import (
16
  Topic,
17
  TrackedLink,
18
  Upload,
 
19
  UsageEvent,
 
20
  VoiceConsent,
21
  VoiceProfile,
22
  )
23
  from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
24
- from app.models.user import AuthProvider, BillingEvent, Subscription, User, Workspace, WorkspaceMember
25
 
26
  __all__ = [
27
  "Artifact",
28
  "AuthProvider",
 
 
 
 
29
  "BillingEvent",
30
  "CalendarItem",
31
  "Chat",
@@ -38,6 +48,7 @@ __all__ = [
38
  "Job",
39
  "Message",
40
  "Notebook",
 
41
  "NotebookDocument",
42
  "SocialAccount",
43
  "Source",
@@ -47,7 +58,9 @@ __all__ = [
47
  "UpdateEmail",
48
  "UpdateEmailSend",
49
  "Upload",
 
50
  "UsageEvent",
 
51
  "User",
52
  "VerificationPurpose",
53
  "VoiceConsent",
 
1
  from app.models.content import (
2
  Artifact,
3
+ B2VCustomVoice,
4
+ B2VStyle,
5
+ B2VTemplate,
6
+ B2VVideo,
7
  CalendarItem,
8
  Chat,
9
  Citation,
 
20
  Topic,
21
  TrackedLink,
22
  Upload,
23
+ UsageCounter,
24
  UsageEvent,
25
+ UserSavedVoice,
26
  VoiceConsent,
27
  VoiceProfile,
28
  )
29
  from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
30
+ from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
31
 
32
  __all__ = [
33
  "Artifact",
34
  "AuthProvider",
35
+ "B2VCustomVoice",
36
+ "B2VStyle",
37
+ "B2VTemplate",
38
+ "B2VVideo",
39
  "BillingEvent",
40
  "CalendarItem",
41
  "Chat",
 
48
  "Job",
49
  "Message",
50
  "Notebook",
51
+ "PlanRecord",
52
  "NotebookDocument",
53
  "SocialAccount",
54
  "Source",
 
58
  "UpdateEmail",
59
  "UpdateEmailSend",
60
  "Upload",
61
+ "UsageCounter",
62
  "UsageEvent",
63
+ "UserSavedVoice",
64
  "User",
65
  "VerificationPurpose",
66
  "VoiceConsent",
backend/app/models/content.py CHANGED
@@ -14,6 +14,7 @@ from sqlalchemy import (
14
  Text,
15
  UniqueConstraint,
16
  Uuid,
 
17
  )
18
  from sqlalchemy.orm import Mapped, mapped_column, relationship
19
 
@@ -60,6 +61,9 @@ class Document(IdMixin, TimestampMixin, Base):
60
 
61
  class Notebook(IdMixin, TimestampMixin, Base):
62
  __tablename__ = "notebooks"
 
 
 
63
 
64
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
65
  title: Mapped[str] = mapped_column(String(300))
@@ -143,11 +147,106 @@ class Artifact(IdMixin, TimestampMixin, Base):
143
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
144
  # summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
145
  type: Mapped[str] = mapped_column(String(30))
146
- status: Mapped[str] = mapped_column(String(20), default="pending")
 
 
147
  content_json: Mapped[dict] = mapped_column(JSON, default=dict)
148
  storage_key: Mapped[str | None] = mapped_column(String(500))
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  class Job(IdMixin, TimestampMixin, Base):
152
  """Covers ingest, tts and render jobs. kind decides which fields matter.
153
 
 
14
  Text,
15
  UniqueConstraint,
16
  Uuid,
17
+ text,
18
  )
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
20
 
 
61
 
62
  class Notebook(IdMixin, TimestampMixin, Base):
63
  __tablename__ = "notebooks"
64
+ # One "All posts" notebook per workspace: two requests opening it at once cannot both create one.
65
+ __table_args__ = (Index("uq_notebooks_one_archive", "workspace_id", unique=True,
66
+ postgresql_where=text("is_archive"), sqlite_where=text("is_archive")),)
67
 
68
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
69
  title: Mapped[str] = mapped_column(String(300))
 
147
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
148
  # summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
149
  type: Mapped[str] = mapped_column(String(30))
150
+ # pending | generating | ready | failed | rendering | review; a video also takes blog2video's job statuses as they
151
+ # are: regenerating (template switch) | voice_regenerating | language_regenerating | script_regenerating
152
+ status: Mapped[str] = mapped_column(String(40), default="pending")
153
  content_json: Mapped[dict] = mapped_column(JSON, default=dict)
154
  storage_key: Mapped[str | None] = mapped_column(String(500))
155
 
156
 
157
+ class B2VVideo(IdMixin, TimestampMixin, Base):
158
+ """One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
159
+ blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
160
+ and whether the workspace's video allowance is still charged for it.
161
+
162
+ Our API key reaches every video it created, for every workspace, so this table (with the artifact) is what
163
+ ties a blog2video id to one workspace. quota_state moves charged -> refunded (or -> kept once generated), with a
164
+ conditional UPDATE (see video_quota.refund), so the status poller and the sweep can not refund twice."""
165
+
166
+ __tablename__ = "b2v_videos"
167
+ __table_args__ = (Index("ix_b2v_videos_ws_created", "workspace_id", "created_at"),)
168
+
169
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
170
+ user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"),
171
+ index=True) # who made it
172
+ artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"),
173
+ index=True)
174
+ # blog2video's video id, which is also its project id. Null until blog2video accepts the create.
175
+ b2v_video_id: Mapped[int | None] = mapped_column(Integer, unique=True)
176
+ idempotency_key: Mapped[str | None] = mapped_column(String(100), unique=True) # "<workspace_id>:<uuid>"; v1 only
177
+ created_via: Mapped[str] = mapped_column(String(10), default="v1", server_default="v1") # v1 | upload
178
+ template_ref: Mapped[str | None] = mapped_column(String(100)) # e.g. custom_58: blocks deleting that template
179
+ title: Mapped[str | None] = mapped_column(String(300))
180
+ source_url: Mapped[str | None] = mapped_column(String(2000)) # the link it was made from (null for files, text)
181
+ aspect_ratio: Mapped[str | None] = mapped_column(String(20))
182
+ preview_url: Mapped[str | None] = mapped_column(String(1000)) # public live preview (embed token)
183
+ video_url: Mapped[str | None] = mapped_column(String(1000)) # the rendered MP4
184
+ quota_state: Mapped[str] = mapped_column(String(20), default="charged") # charged | refunded | kept
185
+ status: Mapped[str] = mapped_column(String(40), default="queued") # last known blog2video status
186
+
187
+
188
+ class B2VTemplate(TimestampMixin, Base):
189
+ """A custom template this workspace made. Used on videos as custom_<b2v_template_id>; only when ready."""
190
+
191
+ __tablename__ = "b2v_templates"
192
+
193
+ b2v_template_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
194
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
195
+ name: Mapped[str] = mapped_column(String(255))
196
+ ready: Mapped[bool] = mapped_column(Boolean, default=False) # code generation finished ('complete')
197
+
198
+
199
+ class B2VCustomVoice(TimestampMixin, Base):
200
+ """A designed or cloned voice this workspace made. Videos reference it by voice_id (custom_voice_id)."""
201
+
202
+ __tablename__ = "b2v_custom_voices"
203
+
204
+ b2v_custom_voice_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) # preview/delete
205
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
206
+ voice_id: Mapped[str] = mapped_column(String(100), unique=True) # the ElevenLabs id sent as custom_voice_id
207
+ name: Mapped[str] = mapped_column(String(255))
208
+ source: Mapped[str] = mapped_column(String(20)) # prompt | preset | clone
209
+ preview_url: Mapped[str | None] = mapped_column(String(1000))
210
+
211
+
212
+ class UserSavedVoice(TimestampMixin, Base):
213
+ """The workspace's "My voices" list. Kept only here: blog2video's saved-voice list is shared by everyone."""
214
+
215
+ __tablename__ = "user_saved_voices"
216
+
217
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
218
+ primary_key=True)
219
+ voice_id: Mapped[str] = mapped_column(String(100), primary_key=True)
220
+ name: Mapped[str] = mapped_column(String(255))
221
+ preview_url: Mapped[str | None] = mapped_column(String(1000))
222
+ gender: Mapped[str | None] = mapped_column(String(20))
223
+ accent: Mapped[str | None] = mapped_column(String(50))
224
+ premium: Mapped[bool] = mapped_column(Boolean, default=False) # a paid built-in voice, or any custom voice
225
+ is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
226
+
227
+
228
+ class B2VStyle(TimestampMixin, Base):
229
+ """A custom video style this workspace made. Used on videos as custom:<b2v_style_id>."""
230
+
231
+ __tablename__ = "b2v_styles"
232
+
233
+ b2v_style_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
234
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
235
+ name: Mapped[str] = mapped_column(String(80))
236
+
237
+
238
+ class UsageCounter(Base):
239
+ """Per-workspace counters for the video features blog2video only limits account-wide (video_limits.py)."""
240
+
241
+ __tablename__ = "usage_counters"
242
+
243
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
244
+ primary_key=True)
245
+ period: Mapped[str] = mapped_column(String(10), primary_key=True) # 2026-10 | 2026-10-02 | all
246
+ metric: Mapped[str] = mapped_column(String(30), primary_key=True)
247
+ used: Mapped[int] = mapped_column(Integer, default=0)
248
+
249
+
250
  class Job(IdMixin, TimestampMixin, Base):
251
  """Covers ingest, tts and render jobs. kind decides which fields matter.
252
 
backend/app/models/user.py CHANGED
@@ -2,7 +2,20 @@ import enum
2
  import uuid
3
  from datetime import datetime
4
 
5
- from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint, Uuid
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from sqlalchemy.orm import Mapped, mapped_column, relationship
7
 
8
  from app.db import Base
@@ -64,12 +77,45 @@ class Subscription(IdMixin, TimestampMixin, Base):
64
  workspace_id: Mapped[uuid.UUID] = mapped_column(
65
  Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
66
  )
67
- plan: Mapped[str] = mapped_column(String(20), default="free") # free | writer | studio
 
68
  status: Mapped[str] = mapped_column(String(20), default="active")
69
  provider: Mapped[str | None] = mapped_column(String(20))
70
  provider_customer_id: Mapped[str | None] = mapped_column(String(100))
71
  provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
72
  current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
 
75
  class BillingEvent(Base):
 
2
  import uuid
3
  from datetime import datetime
4
 
5
+ from sqlalchemy import (
6
+ JSON,
7
+ Boolean,
8
+ DateTime,
9
+ Enum,
10
+ Float,
11
+ ForeignKey,
12
+ Integer,
13
+ String,
14
+ UniqueConstraint,
15
+ Uuid,
16
+ false,
17
+ true,
18
+ )
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
20
 
21
  from app.db import Base
 
77
  workspace_id: Mapped[uuid.UUID] = mapped_column(
78
  Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
79
  )
80
+ # A row of the plans table. Only kept while status is active/trialing/past_due (see plans.effective_plan).
81
+ plan: Mapped[str] = mapped_column(String(20), ForeignKey("plans.id", onupdate="CASCADE"), default="free")
82
  status: Mapped[str] = mapped_column(String(20), default="active")
83
  provider: Mapped[str | None] = mapped_column(String(20))
84
  provider_customer_id: Mapped[str | None] = mapped_column(String(100))
85
  provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
86
  current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
87
+ # Video allowance, enforced by us (see app/services/video_quota.py): +1 per video before we call blog2video,
88
+ # -1 when it fails; video_plan/video_limit follow the effective plan and videos_used resets each period.
89
+ video_plan: Mapped[str] = mapped_column(String(20), default="free", server_default="free")
90
+ videos_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
91
+ video_limit: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
92
+ videos_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
93
+
94
+
95
+ class PlanRecord(TimestampMixin, Base):
96
+ """One row per plan: limits, price and pricing-page copy. Edit a row to change a plan without a deploy
97
+ (app/services/plans.py reloads within a minute). Ids are fixed: Stripe prices and subscriptions use them."""
98
+
99
+ __tablename__ = "plans"
100
+
101
+ id: Mapped[str] = mapped_column(String(20), primary_key=True) # free | writer | studio
102
+ sort_order: Mapped[int] = mapped_column(Integer, default=0) # cheapest first; upgrades go up this order
103
+ name: Mapped[str] = mapped_column(String(50))
104
+ tagline: Mapped[str] = mapped_column(String(200), default="")
105
+ price_monthly_usd: Mapped[float] = mapped_column(Float, default=0)
106
+ sources: Mapped[int] = mapped_column(Integer, default=1)
107
+ indexed_posts: Mapped[int] = mapped_column(Integer, default=5)
108
+ audio_minutes: Mapped[int] = mapped_column(Integer, default=0)
109
+ videos: Mapped[int] = mapped_column(Integer, default=0)
110
+ launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
111
+ voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
112
+ brand_kit: Mapped[bool] = mapped_column(Boolean, default=False)
113
+ features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
114
+ # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
115
+ video_premium: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) # premium options
116
+ # False: `videos` is a lifetime total that never refills (Free). True: per month.
117
+ videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
118
+ video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS
119
 
120
 
121
  class BillingEvent(Base):
backend/app/pipeline/media.py CHANGED
@@ -1,4 +1,4 @@
1
- """Audio overviews (two hosts, ElevenLabs), videos and stills (Remotion renderer)."""
2
 
3
  import uuid
4
 
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
8
  from app.config import settings
9
  from app.corpus import Corpus
10
  from app.llm import run
11
- from app.llm.signatures import PickQuotes, PodcastScript, VideoStoryboard
12
  from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
13
  from app.pipeline.generate import NothingToDo
14
  from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
@@ -17,12 +17,6 @@ from app.services.jobs import record_usage, update_job
17
  from app.services.renderer import PermanentJobError, brand_props, request_render
18
  from app.services.storage import keys, storage
19
 
20
- SCENE_TYPES = {"title", "section", "pull_quote", "number", "outro"}
21
- VIDEO_STYLES = {
22
- "short": ("ShortVertical", "9:16"),
23
- "explainer": ("ExplainerLong", "16:9"),
24
- }
25
-
26
 
27
  def artifact_docs(db: Session, artifact: Artifact) -> list[Document]:
28
  if artifact.document_id:
@@ -112,91 +106,13 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
112
  return {"artifact_id": str(artifact.id), "duration_s": round(t, 1)}
113
 
114
 
115
- # Video
116
-
117
-
118
- def _clamp(value, lo: float, hi: float) -> float:
119
- try:
120
- return max(lo, min(hi, float(value)))
121
- except (TypeError, ValueError):
122
- return lo
123
 
124
 
125
  def make_video(db: Session, job: Job, artifact: Artifact) -> None:
126
- style = job.params.get("style", "short")
127
- if style == "audiogram":
128
  return make_audiogram(db, job, artifact)
129
- composition, aspect = VIDEO_STYLES[style]
130
- docs = artifact_docs(db, artifact)
131
- if not docs:
132
- raise NothingToDo("Add posts before making a video.")
133
- corpus = Corpus(artifact.workspace_id)
134
- workspace = db.get(Workspace, artifact.workspace_id)
135
- update_job(db, job, progress=0.05, message="Storyboarding")
136
- passages = passages_for(corpus, docs, budget_chars=60_000)
137
- out = run.predict(VideoStoryboard, db=db, workspace_id=artifact.workspace_id, job=job,
138
- passages=passages, aspect=aspect)
139
- scenes = [s for s in (out.get("scenes") or []) if (s.get("on_screen_text") or "").strip()]
140
- if not scenes:
141
- raise RuntimeError("The storyboard came back empty")
142
- max_scenes = 8 if style == "short" else 14
143
- scenes = scenes[:max_scenes]
144
-
145
- captions: list[dict] = []
146
- audio = bytearray()
147
- cursor = 0.0
148
- voice_a, _ = host_voices(db, artifact.workspace_id)
149
- narrator = host_settings(db, artifact.workspace_id)["host_a"]
150
- out_scenes = []
151
- for i, s in enumerate(scenes):
152
- narration = (s.get("narration") or "").strip()
153
- duration = _clamp(s.get("duration_hint_s"), 2.0, 20.0)
154
- if narration and settings.elevenlabs_api_key:
155
- update_job(db, job, progress=0.15 + 0.5 * i / len(scenes), message=f"Narrating scene {i + 1}")
156
- clip, words = tts.synthesize_with_timestamps(artifact.workspace_id, narration, voice_a, narrator)
157
- seconds = tts.mp3_seconds(clip)
158
- # Pad with silence so each clip starts exactly when its scene does.
159
- audio += _silence(cursor - tts.mp3_seconds(bytes(audio)))
160
- offset = int(cursor * 1000)
161
- captions += [{**w, "startMs": w["startMs"] + offset, "endMs": w["endMs"] + offset} for w in words]
162
- audio += clip
163
- duration = max(duration, seconds + 0.3)
164
- cursor += duration
165
- scene_type = s.get("type") if s.get("type") in SCENE_TYPES else "section"
166
- out_scenes.append({"type": scene_type, "onScreenText": s["on_screen_text"][:300],
167
- "narration": narration, "durationS": round(_clamp(duration, 0.5, 60), 2)})
168
-
169
- props: dict = {"scenes": out_scenes, "captions": captions, "brand": brand_props(workspace)}
170
- title = docs[0].title if len(docs) == 1 else (artifact.content_json or {}).get("title", "Notestack")
171
- if composition == "ShortVertical":
172
- hook = next((sc["onScreenText"] for sc in out_scenes if sc["type"] == "title"), title)
173
- props["hook"] = hook[:120]
174
- else:
175
- props["title"] = title[:140]
176
- if audio:
177
- key = keys.artifact(artifact.workspace_id, artifact.id, "narration", "mp3")
178
- storage.put_bytes(key, bytes(audio), "audio/mpeg")
179
- props["audioUrl"] = storage.presign_get(key, ttl=6 * 3600)
180
- artifact.content_json = {**(artifact.content_json or {}), "style": style, "composition": composition,
181
- "scenes": out_scenes, "duration_s": round(cursor, 1)}
182
- artifact.status = "rendering"
183
- db.commit()
184
- update_job(db, job, progress=0.7, message="Handing off to the renderer")
185
- request_render(job, artifact, composition, props)
186
- record_usage(db, workspace_id=artifact.workspace_id, kind="video", provider="remotion",
187
- quantity=round(cursor, 1), unit="seconds", job=job)
188
-
189
-
190
- _SILENT_FRAME = None
191
-
192
-
193
- def _silence(seconds: float) -> bytes:
194
- """MPEG1 Layer III 128 kbps 44.1 kHz silent frames (417 bytes, ~26 ms each)."""
195
- global _SILENT_FRAME
196
- if _SILENT_FRAME is None:
197
- _SILENT_FRAME = bytes([0xFF, 0xFB, 0x90, 0x64]) + bytes(413)
198
- frames = int(seconds / 0.026122)
199
- return _SILENT_FRAME * max(frames, 0)
200
 
201
 
202
  def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
@@ -217,7 +133,7 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
217
  db.commit()
218
  update_job(db, job, progress=0.1, message="Handing off to the renderer")
219
  request_render(job, artifact, "AudiogramSquare", props)
220
- record_usage(db, workspace_id=artifact.workspace_id, kind="video", provider="remotion",
221
  quantity=props["durationS"], unit="seconds", job=job)
222
 
223
 
 
1
+ """Audio overviews (two hosts, ElevenLabs), audiograms and stills (Remotion renderer)."""
2
 
3
  import uuid
4
 
 
8
  from app.config import settings
9
  from app.corpus import Corpus
10
  from app.llm import run
11
+ from app.llm.signatures import PickQuotes, PodcastScript
12
  from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
13
  from app.pipeline.generate import NothingToDo
14
  from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
 
17
  from app.services.renderer import PermanentJobError, brand_props, request_render
18
  from app.services.storage import keys, storage
19
 
 
 
 
 
 
 
20
 
21
  def artifact_docs(db: Session, artifact: Artifact) -> list[Document]:
22
  if artifact.document_id:
 
106
  return {"artifact_id": str(artifact.id), "duration_s": round(t, 1)}
107
 
108
 
109
+ # Video: blog2video makes videos now (app/routers/videos.py). The renderer still makes audiograms.
 
 
 
 
 
 
 
110
 
111
 
112
  def make_video(db: Session, job: Job, artifact: Artifact) -> None:
113
+ if job.params.get("style") == "audiogram":
 
114
  return make_audiogram(db, job, artifact)
115
+ raise PermanentJobError("Videos are now made in the Videos editor.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
 
118
  def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
 
133
  db.commit()
134
  update_job(db, job, progress=0.1, message="Handing off to the renderer")
135
  request_render(job, artifact, "AudiogramSquare", props)
136
+ record_usage(db, workspace_id=artifact.workspace_id, kind="render", provider="remotion",
137
  quantity=props["durationS"], unit="seconds", job=job)
138
 
139
 
backend/app/routers/artifacts.py CHANGED
@@ -21,7 +21,7 @@ ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "caro
21
 
22
 
23
  class RenderIn(BaseModel):
24
- composition: Literal["ShortVertical", "ExplainerLong", "AudiogramSquare", "QuoteCard", "CarouselSlide"]
25
  props: dict
26
 
27
 
@@ -33,8 +33,8 @@ class GenerateIn(BaseModel):
33
  # audio_overview
34
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
35
  minutes: int = Field(6, ge=1, le=30)
36
- # video
37
- style: Literal["short", "explainer", "audiogram"] = "short"
38
  audio_artifact_id: uuid.UUID | None = None
39
  # carousel
40
  slides: list[dict] | None = None
@@ -81,12 +81,12 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
81
  body.notebook_id = ensure_archive_notebook(ctx).id
82
  params: dict = {}
83
  content: dict = {}
84
- if body.type == "video" and body.style == "audiogram":
 
 
85
  if not body.audio_artifact_id:
86
  raise HTTPException(400, "Pick an audio overview for the audiogram")
87
  source = get_artifact_or_404(ctx, body.audio_artifact_id)
88
- seconds = float((source.content_json or {}).get("duration_s", 60))
89
- check_limit(ctx.db, ctx.workspace, "video_minutes", seconds / 60)
90
  title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
91
  params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
92
  artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
@@ -101,10 +101,6 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
101
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
102
  params = {"format": body.format, "minutes": body.minutes}
103
  title = f"Audio overview: {target}"
104
- elif body.type == "video":
105
- check_limit(ctx.db, ctx.workspace, "video_minutes", 1 if body.style == "short" else 3)
106
- params = {"style": body.style}
107
- title = f"{'Short' if body.style == 'short' else 'Explainer'} video: {target}"
108
  elif body.type == "launch_kit":
109
  if not body.document_id:
110
  raise HTTPException(400, "A Launch Kit is made from one post")
@@ -174,6 +170,11 @@ def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get
174
  @router.delete("/{artifact_id}")
175
  def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
176
  a = get_artifact_or_404(ctx, artifact_id)
 
 
 
 
 
177
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
178
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
179
  ctx.db.delete(a)
 
21
 
22
 
23
  class RenderIn(BaseModel):
24
+ composition: Literal["AudiogramSquare", "QuoteCard", "CarouselSlide"]
25
  props: dict
26
 
27
 
 
33
  # audio_overview
34
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
35
  minutes: int = Field(6, ge=1, le=30)
36
+ # video: only audiograms here; other videos are made by blog2video (/api/videos)
37
+ style: str = "audiogram"
38
  audio_artifact_id: uuid.UUID | None = None
39
  # carousel
40
  slides: list[dict] | None = None
 
81
  body.notebook_id = ensure_archive_notebook(ctx).id
82
  params: dict = {}
83
  content: dict = {}
84
+ if body.type == "video" and body.style != "audiogram":
85
+ raise HTTPException(410, "Videos moved to /app/videos")
86
+ if body.type == "video":
87
  if not body.audio_artifact_id:
88
  raise HTTPException(400, "Pick an audio overview for the audiogram")
89
  source = get_artifact_or_404(ctx, body.audio_artifact_id)
 
 
90
  title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
91
  params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
92
  artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
 
101
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
102
  params = {"format": body.format, "minutes": body.minutes}
103
  title = f"Audio overview: {target}"
 
 
 
 
104
  elif body.type == "launch_kit":
105
  if not body.document_id:
106
  raise HTTPException(400, "A Launch Kit is made from one post")
 
170
  @router.delete("/{artifact_id}")
171
  def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
172
  a = get_artifact_or_404(ctx, artifact_id)
173
+ if a.type == "video" and (a.content_json or {}).get("provider") == "blog2video":
174
+ from app.routers.videos import remove_video # also removes it from blog2video
175
+
176
+ remove_video(ctx, a)
177
+ return {"ok": True}
178
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
179
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
180
  ctx.db.delete(a)
backend/app/routers/billing.py CHANGED
@@ -8,7 +8,7 @@ from app.auth import Ctx, get_ctx
8
  from app.config import settings
9
  from app.db import get_db
10
  from app.services.billing import billing_status, create_checkout_url, create_portal_url
11
- from app.services.plans import PLAN_ORDER, PLANS, effective_plan, plan_dict
12
 
13
  router = APIRouter(prefix="/api/billing", tags=["billing"])
14
 
@@ -40,7 +40,8 @@ def checkout(body: CheckoutIn, ctx: Ctx = Depends(get_ctx)):
40
  if not settings.billing_enabled:
41
  raise HTTPException(409, {"code": "billing_disabled", "message": "Checkout is not available yet."})
42
  current = effective_plan(ctx.db, ctx.workspace)
43
- if PLAN_ORDER.index(body.plan) <= PLAN_ORDER.index(current.id):
 
44
  raise HTTPException(409, {"code": "already_on_plan", "message": f"You are already on {current.name}."})
45
  return {"url": create_checkout_url(ctx.db, ctx.user, ctx.workspace, PLANS[body.plan], body.cycle)}
46
 
 
8
  from app.config import settings
9
  from app.db import get_db
10
  from app.services.billing import billing_status, create_checkout_url, create_portal_url
11
+ from app.services.plans import PLANS, effective_plan, plan_dict, plan_order
12
 
13
  router = APIRouter(prefix="/api/billing", tags=["billing"])
14
 
 
40
  if not settings.billing_enabled:
41
  raise HTTPException(409, {"code": "billing_disabled", "message": "Checkout is not available yet."})
42
  current = effective_plan(ctx.db, ctx.workspace)
43
+ order = plan_order()
44
+ if body.plan not in PLANS or order.index(body.plan) <= order.index(current.id):
45
  raise HTTPException(409, {"code": "already_on_plan", "message": f"You are already on {current.name}."})
46
  return {"url": create_checkout_url(ctx.db, ctx.user, ctx.workspace, PLANS[body.plan], body.cycle)}
47
 
backend/app/routers/notebooks.py CHANGED
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
7
  from fastapi.responses import StreamingResponse
8
  from pydantic import BaseModel, Field
9
  from sqlalchemy import delete, func, select
 
10
 
11
  from app.auth import Ctx, get_ctx
12
  from app.config import settings
@@ -77,12 +78,20 @@ ARCHIVE_TITLE = "All posts"
77
 
78
  def ensure_archive_notebook(ctx: Ctx) -> Notebook:
79
  """The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
80
- nb = ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id, Notebook.is_archive.is_(True)))
 
 
 
 
81
  if not nb:
82
- nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
83
- description="Every indexed post in your archive, kept up to date.")
84
- ctx.db.add(nb)
85
- ctx.db.flush()
 
 
 
 
86
  ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
87
  _add_docs(ctx, nb, ids)
88
  ctx.db.commit()
 
7
  from fastapi.responses import StreamingResponse
8
  from pydantic import BaseModel, Field
9
  from sqlalchemy import delete, func, select
10
+ from sqlalchemy.exc import IntegrityError
11
 
12
  from app.auth import Ctx, get_ctx
13
  from app.config import settings
 
78
 
79
  def ensure_archive_notebook(ctx: Ctx) -> Notebook:
80
  """The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
81
+ def find() -> Notebook | None:
82
+ return ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id,
83
+ Notebook.is_archive.is_(True)))
84
+
85
+ nb = find()
86
  if not nb:
87
+ try:
88
+ nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
89
+ description="Every indexed post in your archive, kept up to date.")
90
+ ctx.db.add(nb)
91
+ ctx.db.flush()
92
+ except IntegrityError: # a concurrent request made it first (uq_notebooks_one_archive)
93
+ ctx.db.rollback()
94
+ nb = find()
95
  ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
96
  _add_docs(ctx, nb, ids)
97
  ctx.db.commit()
backend/app/routers/video_edit.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The video editor: one allowlisted proxy onto blog2video's /api/projects/{id}/... endpoints.
2
+
3
+ <METHOD> /api/videos/{artifact_id}/p/<path> -> <METHOD> /api/projects/{blog2video id}/<path>
4
+
5
+ Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
6
+ (b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
7
+ premium (★) features need a paid plan, and AI edits come out of the workspace's own counter (given back if
8
+ blog2video refuses). A template switch also uses one video, as it does on blog2video.
9
+
10
+ JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import uuid
16
+ from dataclasses import dataclass
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
19
+ from starlette.concurrency import run_in_threadpool
20
+ from starlette.datastructures import UploadFile
21
+
22
+ from app.auth import Ctx, get_ctx
23
+ from app.routers.videos import b2v_ready, store, upstream
24
+ from app.services import b2v_access, video_limits, video_quota
25
+ from app.services import blog2video as b2v
26
+ from app.services.plans import effective_plan, plan_limit_error
27
+
28
+ router = APIRouter(prefix="/api/videos", tags=["videos"])
29
+
30
+ MAX_JSON_BYTES = 512 * 1024
31
+ MAX_FILE_BYTES = 50 * 1024 * 1024
32
+ S = r"\d+" # a scene id
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Rule:
37
+ ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
38
+ per_scene: bool = False # ai_edits is per scene_ids entry (avatar batches)
39
+ premium: str | None = None # ★: what to call it in the upgrade message
40
+ uses_video: bool = False # a template switch: one video from the allowance
41
+ raw: bool = False # binary answer (download, still)
42
+ timeout: float = 30
43
+ status: str | None = None # artifact status to show once it started
44
+
45
+
46
+ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
47
+ # Project settings, logo, assets
48
+ ("GET", "", Rule(timeout=60)), # the whole project: slow on a far-away database
49
+ ("PATCH", "update-project", Rule()),
50
+ ("PATCH", "", Rule()),
51
+ ("POST", "logo", Rule()),
52
+ ("DELETE", "logo", Rule()),
53
+ ("PATCH", rf"assets/{S}/exclude", Rule()),
54
+ ("DELETE", rf"assets/{S}", Rule()),
55
+ ("GET", "layouts", Rule()),
56
+ ("GET", "status", Rule()),
57
+ # Scenes
58
+ ("PUT", rf"scenes/{S}", Rule()),
59
+ ("DELETE", rf"scenes/{S}", Rule()),
60
+ ("PUT", "bulk-update-scenes", Rule()),
61
+ ("POST", "scenes/reorder", Rule()),
62
+ ("POST", rf"scenes/{S}/regenerate", Rule(ai_edits=1, timeout=120)),
63
+ ("POST", "scenes/add", Rule(ai_edits=1)),
64
+ ("GET", "scenes/add-status", Rule()),
65
+ # Images
66
+ ("POST", rf"scenes/{S}/image", Rule(timeout=60)),
67
+ ("POST", rf"scenes/{S}/generate-image", Rule(ai_edits=1, timeout=120)),
68
+ ("PATCH", rf"scenes/{S}/image-focus", Rule()),
69
+ ("POST", "images/move", Rule()),
70
+ ("POST", "images/swap", Rule()),
71
+ ("POST", "images/duplicate", Rule()),
72
+ ("POST", "images/assign-existing", Rule()),
73
+ # Stock footage
74
+ ("GET", "stock-footage/search", Rule()),
75
+ ("POST", rf"scenes/{S}/stock-footage", Rule(ai_edits=1, timeout=60)),
76
+ ("GET", "stock-footage/pending", Rule()),
77
+ ("POST", "stock-footage/link", Rule()),
78
+ ("POST", "stock-footage/approve", Rule(status="generating")),
79
+ ("POST", "stock-footage/reject", Rule(status="generating")),
80
+ # Voice, voiceovers, language, template
81
+ ("POST", rf"scenes/{S}/voiceover", Rule(timeout=60)),
82
+ # blog2video keeps the project's status during a voiceover delete; marking ours lets a reload resume its progress
83
+ # (voice-change-status reports it, kind "delete"), and the next status check sets it back.
84
+ ("POST", "delete-voiceover", Rule(status="voice_regenerating")),
85
+ ("POST", "change-voice", Rule(status="voice_regenerating")),
86
+ ("GET", "voice-change-status", Rule()),
87
+ ("POST", "change-language", Rule(status="language_regenerating")),
88
+ ("GET", "language-change-status", Rule()),
89
+ ("POST", "change-template-regenerate-layouts", Rule(uses_video=True, status="regenerating")),
90
+ ("GET", "template-change-status", Rule()),
91
+ # Refresh the script
92
+ ("POST", "regenerate-script", Rule(status="script_regenerating")),
93
+ ("GET", "regenerate-script-status", Rule()),
94
+ ("GET", "regenerate-script/preview", Rule()),
95
+ ("POST", "regenerate-script/verify", Rule()),
96
+ ("POST", "regenerate-script/regenerate", Rule()),
97
+ # AI chat editing ★
98
+ ("POST", "chat", Rule(ai_edits=1, premium="AI chat editing", timeout=120)),
99
+ ("GET", "chat/history", Rule(premium="AI chat editing")),
100
+ # Avatars ★ (blog2video charges the whole batch at authorize: 10 AI edits per scene)
101
+ ("POST", "avatar-batch/authorize", Rule(ai_edits=10, per_scene=True, premium="Avatars")),
102
+ ("POST", rf"scenes/{S}/avatar", Rule(premium="Avatars")),
103
+ ("GET", rf"scenes/{S}/avatar-status", Rule()),
104
+ ("DELETE", rf"scenes/{S}/avatar", Rule()),
105
+ ("POST", rf"scenes/{S}/avatar-matte", Rule(premium="Avatars")),
106
+ ("POST", "avatar-matte-all", Rule(premium="Avatars")),
107
+ ("POST", "avatar-retry-failed", Rule(premium="Avatars")),
108
+ ("GET", "avatar-progress", Rule()),
109
+ ("POST", "avatar-portrait", Rule(premium="Avatars", timeout=60)),
110
+ ("DELETE", "avatar-portrait", Rule()),
111
+ ("PATCH", rf"scenes/{S}/avatar-focus", Rule()),
112
+ ("PATCH", rf"scenes/{S}/avatar-appearance", Rule()),
113
+ # Render, download, frames
114
+ ("POST", "render", Rule(status="rendering")),
115
+ ("GET", "render-status", Rule()),
116
+ ("POST", "cancel-render", Rule()),
117
+ ("GET", "download-url", Rule()),
118
+ ("GET", "download", Rule(raw=True, timeout=120)),
119
+ ("GET", "download-studio", Rule(raw=True, premium="The Remotion source download", timeout=120)),
120
+ ("GET", "render-still", Rule(raw=True, timeout=60)),
121
+ ("POST", "render-stills", Rule(timeout=120)),
122
+ ]]
123
+
124
+
125
+ def rule_for(method: str, path: str) -> Rule | None:
126
+ for m, pattern, rule in RULES:
127
+ if m == method and pattern.match(path):
128
+ return rule
129
+ return None
130
+
131
+
132
+ async def _read_body(request: Request) -> tuple[dict | None, dict | None, list | None]:
133
+ """(json, form fields, files) from the browser's request."""
134
+ ctype = request.headers.get("content-type", "")
135
+ if ctype.startswith("multipart/form-data"):
136
+ form = await request.form()
137
+ data, files = {}, []
138
+ for key, value in form.multi_items():
139
+ if isinstance(value, UploadFile):
140
+ content = await value.read(MAX_FILE_BYTES + 1)
141
+ if len(content) > MAX_FILE_BYTES:
142
+ raise HTTPException(413, "That file is too large.")
143
+ files.append((key, (value.filename or "file", content, value.content_type or
144
+ "application/octet-stream")))
145
+ else:
146
+ data[key] = value
147
+ return None, data, files
148
+ raw = await request.body()
149
+ if not raw:
150
+ return None, None, None
151
+ if len(raw) > MAX_JSON_BYTES:
152
+ raise HTTPException(413, "Request too large")
153
+ try:
154
+ return json.loads(raw), None, None
155
+ except ValueError:
156
+ raise HTTPException(400, "Send JSON") from None
157
+
158
+
159
+ def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
160
+ """Premium, references and counters, all before blog2video is called. Returns AI edits taken."""
161
+ plan = effective_plan(ctx.db, ctx.workspace)
162
+ if rule.premium:
163
+ video_limits.require_premium(ctx.db, ctx.workspace, rule.premium, plan)
164
+ refs = body if isinstance(body, dict) else (form or {})
165
+ with upstream(): # checking a template or voice may need blog2video's catalog
166
+ b2v_access.check_refs(ctx, refs, plan)
167
+ edits = rule.ai_edits
168
+ if rule.per_scene:
169
+ scenes = (body or {}).get("scene_ids") if isinstance(body, dict) else None
170
+ if not isinstance(scenes, list) or not scenes:
171
+ raise HTTPException(400, "Pick at least one scene")
172
+ edits *= len(scenes)
173
+ if edits:
174
+ video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
175
+ if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
176
+ if edits:
177
+ video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
178
+ raise plan_limit_error(plan, "videos", "Switching the template uses a video, and you have none left.")
179
+ return edits
180
+
181
+
182
+ @router.api_route("/{artifact_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
183
+ async def project_proxy(artifact_id: uuid.UUID, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
184
+ _: None = Depends(b2v_ready)):
185
+ path = path.strip("/")
186
+ rule = rule_for(request.method, path)
187
+ if rule is None:
188
+ raise HTTPException(404, "Not found")
189
+ a, vid, _row = b2v_access.video_or_404(ctx, artifact_id)
190
+ body, form, files = await _read_body(request)
191
+ edits = await run_in_threadpool(_charge, ctx, rule, body, form)
192
+ params = dict(request.query_params)
193
+
194
+ def call():
195
+ return b2v.project_call(request.method, vid, path, json=body, data=form, files=files or None,
196
+ params=params or None, timeout=rule.timeout, raw=rule.raw)
197
+
198
+ try:
199
+ with upstream():
200
+ result = await run_in_threadpool(call)
201
+ except HTTPException:
202
+ if edits:
203
+ video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
204
+ if rule.uses_video:
205
+ video_quota.unreserve(ctx.db, ctx.workspace.id)
206
+ raise
207
+ if rule.status:
208
+ store(ctx, a, status=rule.status)
209
+ if rule.raw:
210
+ content, ctype = result
211
+ return Response(content, media_type=ctype, headers={"Cache-Control": "no-store"})
212
+ return result
backend/app/routers/video_styles.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom video styles: writing guidance for the script, used on videos as custom:<id>.
2
+
3
+ Styles are made on our one blog2video account, so b2v_styles records which workspace made each; only that
4
+ workspace sees, uses, edits or deletes it. Built-in styles are read-only here, and "Your Style" (which learns from
5
+ every workspace's edits) is never offered. The account-wide style selection, pin and built-in overrides are never
6
+ touched (blog2video.DENIED).
7
+ """
8
+
9
+ from typing import Literal
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException
12
+ from pydantic import BaseModel, Field
13
+
14
+ from app.auth import Ctx, get_ctx
15
+ from app.models import B2VStyle
16
+ from app.routers.videos import b2v_ready, upstream
17
+ from app.services import b2v_access
18
+ from app.services import blog2video as b2v
19
+
20
+ router = APIRouter(prefix="/api/video-styles", tags=["videos"])
21
+
22
+
23
+ class DraftIn(BaseModel):
24
+ prompt: str = Field(min_length=20, max_length=1000)
25
+
26
+
27
+ class StyleIn(BaseModel):
28
+ name: str = Field(min_length=1, max_length=80)
29
+ guidance: str = Field(min_length=1, max_length=2000)
30
+ creation_method: Literal["manual", "ai"] = "manual"
31
+ source_prompt: str | None = Field(None, max_length=1000)
32
+
33
+
34
+ class StylePatchIn(BaseModel):
35
+ name: str = Field(min_length=1, max_length=80)
36
+ guidance: str = Field(min_length=1, max_length=2000)
37
+ version: int = Field(ge=0)
38
+
39
+
40
+ @router.get("")
41
+ def list_styles(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
42
+ with upstream():
43
+ return b2v_access.visible_styles(ctx)
44
+
45
+
46
+ @router.post("/ai-draft")
47
+ def ai_draft(body: DraftIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
48
+ """A name and guidance written from a description; nothing is saved."""
49
+ with upstream():
50
+ return b2v.request("POST", "/api/video-styles/ai-draft", json=body.model_dump(), timeout=90)
51
+
52
+
53
+ def _out(s: dict) -> dict:
54
+ return {"id": s.get("id"), "custom_id": s.get("custom_id"), "name": s.get("name"), "guidance": s.get("guidance"),
55
+ "description": s.get("description"), "version": s.get("version"), "kind": "custom"}
56
+
57
+
58
+ @router.post("", status_code=201)
59
+ def create_style(body: StyleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
60
+ with upstream():
61
+ made = b2v.request("POST", "/api/video-styles/custom", json=body.model_dump())
62
+ ctx.db.add(B2VStyle(b2v_style_id=int(made["custom_id"]), workspace_id=ctx.workspace.id, name=body.name))
63
+ ctx.db.commit()
64
+ return _out(made)
65
+
66
+
67
+ @router.patch("/{style_id}")
68
+ def update_style(style_id: int, body: StylePatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
69
+ row = b2v_access.owned_style(ctx, style_id)
70
+ with upstream():
71
+ made = b2v.request("PATCH", f"/api/video-styles/custom/{style_id}", json=body.model_dump())
72
+ row.name = body.name
73
+ ctx.db.commit()
74
+ return _out(made)
75
+
76
+
77
+ @router.delete("/{style_id}")
78
+ def delete_style(style_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
79
+ row = b2v_access.owned_style(ctx, style_id)
80
+ try:
81
+ with upstream():
82
+ b2v.request("DELETE", f"/api/video-styles/custom/{style_id}")
83
+ except HTTPException as e:
84
+ if e.status_code != 404:
85
+ raise
86
+ ctx.db.delete(row)
87
+ ctx.db.commit()
88
+ return {"ok": True}
backend/app/routers/video_templates.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom video templates: made from a website, a document or a description, then generated and edited.
2
+
3
+ Templates are made on our one blog2video account, which has a small lifetime number of template slots (a delete
4
+ does not give one back). b2v_templates records which workspace made each template; only that workspace sees, uses,
5
+ edits or deletes it, and each workspace has its own share of slots and daily AI steps (video_limits).
6
+
7
+ Create: extract a theme (/extract/url | /extract/doc | /extract/prompt) -> POST "" (uses a slot) -> /{id}/generate
8
+ -> poll /{id}/generation-status until complete (the template becomes usable in the wizard) or error.
9
+ Edit: /{id}/p/<path> -> /api/custom-templates/{id}/<path>, allowlisted in RULES.
10
+ """
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
17
+ from pydantic import BaseModel, Field
18
+ from sqlalchemy import select
19
+ from starlette.concurrency import run_in_threadpool
20
+ from starlette.datastructures import UploadFile as StarletteUpload
21
+
22
+ from app.auth import Ctx, get_ctx
23
+ from app.models import B2VTemplate, B2VVideo
24
+ from app.routers.videos import b2v_ready, upstream
25
+ from app.services import b2v_access, video_limits
26
+ from app.services import blog2video as b2v
27
+ from app.services.plans import effective_plan, plan_limit_error
28
+
29
+ router = APIRouter(prefix="/api/video-templates", tags=["videos"])
30
+
31
+ DOC_MAX_BYTES = 10 * 1024 * 1024
32
+ LOGO_MAX_BYTES = 2 * 1024 * 1024
33
+ AI_TIMEOUT = 120
34
+
35
+
36
+ def _out(t: B2VTemplate) -> dict:
37
+ return {"id": t.b2v_template_id, "ref": f"custom_{t.b2v_template_id}", "name": t.name, "ready": t.ready,
38
+ "created_at": t.created_at.isoformat() if t.created_at else None}
39
+
40
+
41
+ def _can_make(ctx: Ctx) -> None:
42
+ """Refuse early (before any AI work) when the workspace has no template slots left."""
43
+ plan = effective_plan(ctx.db, ctx.workspace)
44
+ limit = int(plan.video_limits.get("templates", 0))
45
+ if limit >= 0 and video_limits.used(ctx.db, ctx.workspace.id, "templates") >= limit:
46
+ raise plan_limit_error(plan, "video_limits.templates",
47
+ "Your plan does not include custom templates." if limit == 0
48
+ else f"You have made all {limit} custom templates your plan includes.")
49
+
50
+
51
+ def _counted(ctx: Ctx, metric: str, call):
52
+ video_limits.take(ctx.db, ctx.workspace, metric)
53
+ try:
54
+ with upstream():
55
+ return call()
56
+ except HTTPException:
57
+ video_limits.give_back(ctx.db, ctx.workspace.id, metric)
58
+ raise
59
+
60
+
61
+ @router.get("")
62
+ def list_templates(ctx: Ctx = Depends(get_ctx)):
63
+ limits = video_limits.report(ctx.db, ctx.workspace)["limits"]
64
+ return {"templates": [_out(t) for t in b2v_access.my_templates(ctx)],
65
+ "limits": {k: limits[k] for k in ("templates", "template_ai_daily")}}
66
+
67
+
68
+ # 1. Extract a theme
69
+
70
+
71
+ class ExtractUrlIn(BaseModel):
72
+ url: str = Field(min_length=1, max_length=2048)
73
+
74
+
75
+ class ExtractPromptIn(BaseModel):
76
+ prompt: str = Field(min_length=15, max_length=5000)
77
+ name: str | None = Field(None, max_length=255)
78
+
79
+
80
+ @router.post("/extract/url")
81
+ def extract_from_url(body: ExtractUrlIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
82
+ if not re.match(r"https?://", body.url):
83
+ raise HTTPException(400, "Enter a link that starts with http:// or https://")
84
+ _can_make(ctx)
85
+ with upstream():
86
+ return b2v.request("POST", "/api/custom-templates/extract-theme", json=body.model_dump(), timeout=AI_TIMEOUT)
87
+
88
+
89
+ @router.post("/extract/prompt")
90
+ def extract_from_prompt(body: ExtractPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
91
+ _can_make(ctx)
92
+ return _counted(ctx, "template_ai_daily", lambda: b2v.request(
93
+ "POST", "/api/custom-templates/extract-theme-from-prompt", json=body.model_dump(), timeout=AI_TIMEOUT))
94
+
95
+
96
+ @router.post("/extract/doc")
97
+ async def extract_from_doc(file: UploadFile = File(...), name: str = Form(""), ctx: Ctx = Depends(get_ctx),
98
+ _: None = Depends(b2v_ready)):
99
+ data = await file.read(DOC_MAX_BYTES + 1)
100
+ if len(data) > DOC_MAX_BYTES:
101
+ raise HTTPException(400, "Keep the document under 10 MB.")
102
+ _can_make(ctx)
103
+ files = {"file": (file.filename or "brand.pdf", data, file.content_type or "application/octet-stream")}
104
+ return await run_in_threadpool(_counted, ctx, "template_ai_daily", lambda: b2v.request(
105
+ "POST", "/api/custom-templates/extract-theme-from-doc", data={"name": name[:255]}, files=files,
106
+ timeout=AI_TIMEOUT))
107
+
108
+
109
+ # 2. Create (uses a slot) and 3. generate
110
+
111
+
112
+ class CreateIn(BaseModel):
113
+ name: str = Field(min_length=1, max_length=255)
114
+ theme: dict
115
+ source_url: str | None = Field(None, max_length=2048)
116
+ logo_urls: list[str] | None = Field(None, max_length=20)
117
+ og_image: str | None = Field(None, max_length=2048)
118
+ screenshot_url: str | None = Field(None, max_length=2048)
119
+ reason: str | None = Field(None, max_length=2000)
120
+
121
+
122
+ @router.post("", status_code=201)
123
+ def create_template(body: CreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
124
+ made = _counted(ctx, "templates", lambda: b2v.request("POST", "/api/custom-templates",
125
+ json=body.model_dump(exclude_none=True), timeout=60))
126
+ row = B2VTemplate(b2v_template_id=int(made["id"]), workspace_id=ctx.workspace.id, name=body.name[:255])
127
+ ctx.db.add(row)
128
+ ctx.db.commit()
129
+ return _out(row)
130
+
131
+
132
+ @router.post("/{template_id}/generate")
133
+ def generate(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
134
+ b2v_access.owned_template(ctx, template_id)
135
+ return _counted(ctx, "template_ai_daily", lambda: b2v.request(
136
+ "POST", f"/api/custom-templates/{template_id}/generate-code", timeout=60))
137
+
138
+
139
+ @router.get("/{template_id}/generation-status")
140
+ def generation_status(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
141
+ row = b2v_access.owned_template(ctx, template_id)
142
+ with upstream():
143
+ s = b2v.request("GET", f"/api/custom-templates/{template_id}/generation-status") or {}
144
+ ready = s.get("status") == "complete"
145
+ if ready != row.ready:
146
+ row.ready = ready
147
+ ctx.db.commit()
148
+ return {**s, "ready": ready}
149
+
150
+
151
+ @router.delete("/{template_id}")
152
+ def delete_template(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
153
+ """blog2video refuses (template_in_use) while any video on the account uses the template. We force it only
154
+ when every such video is this workspace's: another workspace's video must never lose its template."""
155
+ row = b2v_access.owned_template(ctx, template_id)
156
+ ref = f"custom_{template_id}"
157
+ try:
158
+ with upstream():
159
+ b2v.request("DELETE", f"/api/custom-templates/{template_id}")
160
+ except HTTPException as e:
161
+ if e.status_code == 404:
162
+ pass
163
+ elif e.status_code == 409:
164
+ others = ctx.db.scalar(select(B2VVideo.id).where(B2VVideo.template_ref == ref,
165
+ B2VVideo.workspace_id != ctx.workspace.id).limit(1))
166
+ if others is not None:
167
+ raise HTTPException(409, "This template is in use and can not be deleted.") from None
168
+ with upstream():
169
+ b2v.request("DELETE", f"/api/custom-templates/{template_id}", params={"force": "true"})
170
+ else:
171
+ raise
172
+ ctx.db.delete(row)
173
+ ctx.db.commit()
174
+ return {"ok": True} # the slot is not given back: blog2video's slots are lifetime
175
+
176
+
177
+ # Editing: allowlisted proxy
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class Rule:
182
+ ai_edit: bool = False # ★ one AI edit
183
+ metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
184
+ raw: bool = False
185
+
186
+
187
+ K = r"(intro|outro|content_\d+)" # scene keys
188
+ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
189
+ ("GET", "", Rule()),
190
+ ("PUT", "", Rule()),
191
+ ("GET", "code", Rule()),
192
+ ("POST", "upload-logo", Rule()),
193
+ ("POST", f"scenes/{K}/ai-edit", Rule(ai_edit=True)),
194
+ ("GET", f"scenes/{K}/ai-edit/status", Rule()),
195
+ ("GET", "scene-drafts", Rule()),
196
+ ("GET", f"scenes/{K}/draft", Rule()),
197
+ ("POST", f"scenes/{K}/draft/apply", Rule()),
198
+ ("POST", f"scenes/{K}/draft/discard", Rule()),
199
+ ("PATCH", f"scenes/{K}/chart", Rule()),
200
+ ("PATCH", f"scenes/{K}/font-defaults", Rule()),
201
+ ("PATCH", "scenes/font-defaults", Rule()),
202
+ ("GET", "versions", Rule()),
203
+ ("POST", r"versions/\d+/rollback", Rule()),
204
+ ("POST", "regenerate-code", Rule(metric="templates")), # uses a slot, like creating one
205
+ ("POST", "resume-generation", Rule()),
206
+ ("POST", "rating", Rule()),
207
+ ]]
208
+
209
+
210
+ def _rule(method: str, path: str) -> Rule | None:
211
+ return next((r for m, p, r in RULES if m == method and p.match(path)), None)
212
+
213
+
214
+ def _take(ctx: Ctx, rule: Rule) -> list[str]:
215
+ taken: list[str] = []
216
+ try:
217
+ if rule.ai_edit:
218
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI template editing")
219
+ video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
220
+ taken.append("ai_edits")
221
+ if rule.metric:
222
+ video_limits.take(ctx.db, ctx.workspace, rule.metric)
223
+ taken.append(rule.metric)
224
+ except HTTPException:
225
+ for metric in taken:
226
+ video_limits.give_back(ctx.db, ctx.workspace.id, metric)
227
+ raise
228
+ return taken
229
+
230
+
231
+ @router.api_route("/{template_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH"])
232
+ async def template_proxy(template_id: int, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
233
+ _: None = Depends(b2v_ready)):
234
+ path = path.strip("/")
235
+ rule = _rule(request.method, path)
236
+ if rule is None:
237
+ raise HTTPException(404, "Not found")
238
+ row = b2v_access.owned_template(ctx, template_id)
239
+ body = form = files = None
240
+ if request.headers.get("content-type", "").startswith("multipart/form-data"):
241
+ parsed = await request.form()
242
+ form, files = {}, []
243
+ for key, value in parsed.multi_items():
244
+ if isinstance(value, StarletteUpload):
245
+ content = await value.read(LOGO_MAX_BYTES + 1)
246
+ if len(content) > LOGO_MAX_BYTES:
247
+ raise HTTPException(413, "Keep the file under 2 MB.")
248
+ files.append((key, (value.filename or "logo.png", content, value.content_type or "image/png")))
249
+ else:
250
+ form[key] = value
251
+ elif raw := await request.body():
252
+ try:
253
+ body = json.loads(raw)
254
+ except ValueError:
255
+ raise HTTPException(400, "Send JSON") from None
256
+ taken = await run_in_threadpool(_take, ctx, rule)
257
+
258
+ def call():
259
+ return b2v.request(request.method, f"/api/custom-templates/{template_id}/{path}".rstrip("/"), json=body,
260
+ data=form, files=files or None, params=dict(request.query_params) or None,
261
+ timeout=AI_TIMEOUT, raw=rule.raw)
262
+
263
+ try:
264
+ with upstream():
265
+ result = await run_in_threadpool(call)
266
+ except HTTPException:
267
+ for metric in taken:
268
+ video_limits.give_back(ctx.db, ctx.workspace.id, metric)
269
+ raise
270
+ if request.method == "PUT" and path == "" and isinstance(body, dict) and body.get("name"):
271
+ row.name = str(body["name"])[:255]
272
+ ctx.db.commit()
273
+ if rule.raw:
274
+ content, ctype = result
275
+ return Response(content, media_type=ctype)
276
+ return result
277
+
backend/app/routers/video_voices.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voices for videos: the workspace's saved voices, the built-in library, and custom voices (design, clone).
2
+
3
+ "My voices" (user_saved_voices) lives only here: blog2video's saved-voice list is one list for the whole account.
4
+ Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
5
+ only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
6
+ of the workspace's own daily and total counters (video_limits).
7
+ """
8
+
9
+ import logging
10
+ from typing import Literal
11
+
12
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
13
+ from pydantic import BaseModel, Field
14
+ from sqlalchemy import select
15
+
16
+ from app.auth import Ctx, get_ctx
17
+ from app.models import B2VCustomVoice, UserSavedVoice
18
+ from app.routers.videos import b2v_ready, upstream
19
+ from app.services import b2v_access, video_limits
20
+ from app.services import blog2video as b2v
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ router = APIRouter(prefix="/api/video-voices", tags=["videos"])
25
+
26
+ CLONE_MAX_BYTES = 50 * 1024 * 1024
27
+ STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
28
+
29
+
30
+ def _labels(v: dict) -> dict:
31
+ return v.get("labels") or {}
32
+
33
+
34
+ def _voice_out(v: UserSavedVoice) -> dict:
35
+ return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
36
+ "accent": v.accent, "premium": v.premium, "is_custom": v.is_custom}
37
+
38
+
39
+ def _library_out(v: dict, saved: set[str]) -> dict:
40
+ labels = _labels(v)
41
+ return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
42
+ "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
43
+ "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
44
+ "premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved}
45
+
46
+
47
+ def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
48
+ labels = _labels(v)
49
+ row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
50
+ name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
51
+ preview_url=v.get("preview_url"),
52
+ gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid")
53
+ ctx.db.merge(row)
54
+ return row
55
+
56
+
57
+ def _saved(ctx: Ctx) -> list[UserSavedVoice]:
58
+ return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
59
+ .order_by(UserSavedVoice.created_at)))
60
+
61
+
62
+ @router.get("")
63
+ def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
64
+ """My voices (for the wizard), the built-in library (to save from) and my custom voices."""
65
+ with upstream():
66
+ library = b2v.prebuilt_voices()
67
+ saved = _saved(ctx)
68
+ if not saved:
69
+ for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]:
70
+ _save_builtin(ctx, v)
71
+ ctx.db.commit()
72
+ saved = _saved(ctx)
73
+ ids = {v.voice_id for v in saved}
74
+ custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
75
+ .order_by(B2VCustomVoice.created_at.desc()))
76
+ return {"saved": [_voice_out(v) for v in saved],
77
+ "library": [_library_out(v, ids) for v in library if v.get("voice_id")],
78
+ "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
79
+ "preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
80
+
81
+
82
+ class SaveIn(BaseModel):
83
+ voice_id: str = Field(min_length=1, max_length=100)
84
+
85
+
86
+ @router.post("/saved", status_code=201)
87
+ def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
88
+ custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
89
+ B2VCustomVoice.workspace_id == ctx.workspace.id))
90
+ if custom:
91
+ row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
92
+ preview_url=custom.preview_url, premium=True, is_custom=True))
93
+ else:
94
+ with upstream():
95
+ v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
96
+ if not v:
97
+ raise HTTPException(404, "Voice not found")
98
+ if v.get("plan") == "paid":
99
+ video_limits.require_premium(ctx.db, ctx.workspace, "This voice")
100
+ row = _save_builtin(ctx, v)
101
+ ctx.db.commit()
102
+ return _voice_out(row)
103
+
104
+
105
+ @router.delete("/saved/{voice_id}")
106
+ def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
107
+ row = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": voice_id})
108
+ if not row:
109
+ raise HTTPException(404, "Voice not found")
110
+ ctx.db.delete(row)
111
+ ctx.db.commit()
112
+ return {"ok": True}
113
+
114
+
115
+ # Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice
116
+
117
+
118
+ class DesignPromptIn(BaseModel):
119
+ prompt: str = Field(min_length=20, max_length=1000)
120
+
121
+
122
+ class DesignPresetIn(BaseModel):
123
+ gender: str = Field("", max_length=30)
124
+ age: str = Field("", max_length=30)
125
+ persona: str = Field("", max_length=100)
126
+ speed: str = Field("", max_length=30)
127
+ accent: str = Field("", max_length=60)
128
+
129
+
130
+ def _design(ctx: Ctx, path: str, body: dict) -> dict:
131
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice")
132
+ video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan)
133
+ try:
134
+ with upstream():
135
+ data = b2v.request("POST", path, json=body, timeout=90) or {}
136
+ except HTTPException:
137
+ video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily")
138
+ raise
139
+ return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")}
140
+ for p in data.get("previews") or []]}
141
+
142
+
143
+ @router.post("/design/prompt")
144
+ def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
145
+ return _design(ctx, "/api/voices/design-from-prompt", body.model_dump())
146
+
147
+
148
+ @router.post("/design/preset")
149
+ def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
150
+ return _design(ctx, "/api/voices/design-from-preset", body.model_dump())
151
+
152
+
153
+ class KeepIn(BaseModel):
154
+ generated_voice_id: str = Field(min_length=1, max_length=200)
155
+ source: Literal["prompt", "preset"]
156
+ name: str = Field(min_length=1, max_length=100)
157
+ prompt_text: str | None = Field(None, max_length=1000)
158
+ preview_url: str | None = Field(None, max_length=1000)
159
+
160
+
161
+ def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
162
+ row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id,
163
+ voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
164
+ preview_url=made.get("preview_url"))
165
+ ctx.db.add(row)
166
+ ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
167
+ preview_url=row.preview_url, premium=True, is_custom=True))
168
+ ctx.db.commit()
169
+ return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
170
+ "preview_url": row.preview_url, "saved": True}
171
+
172
+
173
+ def _make_custom(ctx: Ctx, source: str, send) -> dict:
174
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
175
+ video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan)
176
+ try:
177
+ with upstream():
178
+ made = send()
179
+ except HTTPException:
180
+ video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
181
+ raise
182
+ return _record_custom(ctx, made, source)
183
+
184
+
185
+ @router.post("/custom", status_code=201)
186
+ def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
187
+ """Keep one designed preview as a permanent custom voice."""
188
+ payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form",
189
+ "name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url}
190
+ return _make_custom(ctx, body.source,
191
+ lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60))
192
+
193
+
194
+ @router.post("/clone", status_code=201)
195
+ async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True),
196
+ file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
197
+ """Clone a voice from one audio or video file (up to 50 MB)."""
198
+ data = await file.read(CLONE_MAX_BYTES + 1)
199
+ if not data:
200
+ raise HTTPException(400, "The file is empty.")
201
+ if len(data) > CLONE_MAX_BYTES:
202
+ raise HTTPException(400, "The file must be under 50 MB.")
203
+ form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"}
204
+ files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")}
205
+ return _make_custom(ctx, "clone",
206
+ lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180))
207
+
208
+
209
+ @router.get("/custom/{custom_id}/preview")
210
+ def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
211
+ row = b2v_access.owned_custom_voice(ctx, custom_id)
212
+ if row.preview_url:
213
+ return {"preview_url": row.preview_url, "ready": True}
214
+ with upstream():
215
+ result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {}
216
+ if result.get("preview_url"):
217
+ row.preview_url = result["preview_url"]
218
+ saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
219
+ if saved:
220
+ saved.preview_url = row.preview_url
221
+ ctx.db.commit()
222
+ return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))}
223
+
224
+
225
+ @router.delete("/custom/{custom_id}")
226
+ def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
227
+ row = b2v_access.owned_custom_voice(ctx, custom_id)
228
+ try:
229
+ with upstream():
230
+ b2v.request("DELETE", f"/api/voices/custom/{custom_id}")
231
+ except HTTPException as e:
232
+ if e.status_code != 404:
233
+ raise
234
+ saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
235
+ if saved:
236
+ ctx.db.delete(saved)
237
+ ctx.db.delete(row)
238
+ ctx.db.commit()
239
+ video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
240
+ return {"ok": True}
241
+
242
+
243
+ # Voice sample ★: a few seconds of the chosen voice with the chosen tuning
244
+
245
+
246
+ class SampleIn(BaseModel):
247
+ voice_gender: Literal["female", "male"] = "female"
248
+ voice_accent: str = Field("american", max_length=30)
249
+ custom_voice_id: str | None = Field(None, max_length=100)
250
+ voice_emotion: str | None = Field(None, max_length=200)
251
+ video_style: str | None = Field(None, max_length=40)
252
+
253
+
254
+ @router.post("/sample")
255
+ def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
256
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples")
257
+ payload = body.model_dump(exclude_none=True)
258
+ with upstream(): # checking the voice may need blog2video's catalog
259
+ b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan)
260
+ video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan)
261
+ try:
262
+ with upstream():
263
+ audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True)
264
+ except HTTPException:
265
+ video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily")
266
+ raise
267
+ return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"})
backend/app/routers/videos.py ADDED
@@ -0,0 +1,742 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Videos, made by blog2video: the 3-step wizard (Project, Template, Voice), script review, status and delete.
2
+ Editing after creation is in video_edit.py; voices, templates and styles have their own routers.
3
+
4
+ The browser only talks to these routes; the backend calls blog2video with our account's API key, which reaches
5
+ every video we ever made. So every route that takes a video first checks it belongs to the current workspace
6
+ (b2v_access.owned_video: 404 otherwise), and every create has its template, style and voice checked (check_refs).
7
+
8
+ A video is an Artifact(type="video") with content_json.provider == "blog2video", so it shows up in the Library and
9
+ Studio like everything else, plus a b2v_videos row: the ownership and allowance ledger. Routes take our artifact id.
10
+
11
+ Videos made through the old partner integration have no b2v_videos row. blog2video answers 404 for them now, so
12
+ they are "legacy": we keep showing the preview/video links we stored and refuse edits.
13
+ """
14
+
15
+ import json
16
+ import logging
17
+ import re
18
+ import uuid
19
+ from collections.abc import Callable
20
+ from contextlib import contextmanager
21
+ from typing import Literal
22
+
23
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
24
+ from pydantic import BaseModel, Field, ValidationError, model_validator
25
+ from sqlalchemy import select
26
+
27
+ from app.auth import Ctx, get_ctx
28
+ from app.config import settings
29
+ from app.models import Artifact, B2VVideo, Document, Notebook, Upload
30
+ from app.routers.sources import is_locked
31
+ from app.services import b2v_access, video_limits, video_quota
32
+ from app.services import blog2video as b2v
33
+ from app.services.artifacts import serialize_artifact
34
+ from app.services.plans import Plan, plan_limit_error
35
+ from app.services.storage import storage
36
+ from app.services.video_quota import sync_video_quota, video_usage
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ router = APIRouter(prefix="/api/videos", tags=["videos"])
41
+
42
+ MIN_CONTENT = 50
43
+ MAX_CONTENT = 100_000
44
+ MAX_LINKS = 10
45
+ MAX_POSTS = 200 # a notebook's posts, combined into one video (the ingest SCAN_CAP)
46
+ MAX_FILES = 5
47
+ FILE_MAX_BYTES = 5 * 1024 * 1024
48
+ FILE_TYPES = {".pdf", ".docx", ".pptx", ".md", ".txt", ".vtt"}
49
+ LOGO_MAX_BYTES = 2 * 1024 * 1024
50
+ LOGO_TYPES = {"image/png", "image/jpeg", "image/webp", "image/svg+xml"}
51
+ LANGUAGES = [("en", "English"), ("es", "Spanish"), ("fr", "French"), ("de", "German"), ("it", "Italian"),
52
+ ("pt", "Portuguese"), ("nl", "Dutch"), ("pl", "Polish"), ("hi", "Hindi"), ("ar", "Arabic"),
53
+ ("ja", "Japanese"), ("ko", "Korean"), ("zh", "Chinese"), ("tr", "Turkish"), ("ru", "Russian"),
54
+ ("id", "Indonesian")]
55
+
56
+ # blog2video status -> our artifact status
57
+ STATUS_MAP = {"failed": "failed", "error": "failed", "awaiting_stock_footage_review": "review",
58
+ "awaiting_script_review": "review", "rendering": "rendering"}
59
+ # blog2video's statuses while an edit job runs on a finished video, kept on the artifact as they are so the list and
60
+ # the editor can tell them from first generation: regenerating is a template switch (its relayout).
61
+ JOB_STATUSES = {"regenerating", "voice_regenerating", "language_regenerating", "script_regenerating"}
62
+ # Statuses the video list re-checks with blog2video, in case the work ended while nobody was watching.
63
+ BUSY_STATUSES = JOB_STATUSES | {"generating", "rendering"}
64
+
65
+
66
+ def b2v_ready(ctx: Ctx = Depends(get_ctx)) -> None:
67
+ if not b2v.configured():
68
+ raise HTTPException(503, {"code": "video_not_configured", "message": "Video is not set up yet."})
69
+ sync_video_quota(ctx.db, ctx.workspace)
70
+
71
+
72
+ @contextmanager
73
+ def upstream():
74
+ """blog2video errors -> our HTTP errors. Problems with our own blog2video account (out of videos, key or plan)
75
+ are not the user's: they get "temporarily unavailable" and ops get an ALERT log line."""
76
+ try:
77
+ yield
78
+ except b2v.Denied:
79
+ raise HTTPException(404, "Not found") from None
80
+ except b2v.NotFound as e:
81
+ raise HTTPException(404, str(e)) from None # e.g. "Scene not found": the editor reloads
82
+ except (b2v.Busy, b2v.TemplateInUse) as e:
83
+ raise HTTPException(e.status, str(e)) from None
84
+ except b2v.BadRequest as e:
85
+ raise HTTPException(400, str(e)) from None
86
+ except b2v.B2VError as e:
87
+ raise HTTPException(e.status if e.status == 503 else 502, str(e)) from None
88
+
89
+
90
+ ROW_FIELDS = {"video_url", "preview_url", "title"}
91
+
92
+
93
+ def store(ctx: Ctx, a: Artifact, row: B2VVideo | None = None, **fields) -> None:
94
+ """Merge fields into content_json (and status), and the reference fields into the video's row, committing only
95
+ when something changed."""
96
+ if row is not None:
97
+ for key in ROW_FIELDS & fields.keys():
98
+ if fields[key] is not None and getattr(row, key) != fields[key]:
99
+ setattr(row, key, fields[key])
100
+ ctx.db.commit()
101
+ content = dict(a.content_json or {})
102
+ status = fields.pop("status", None)
103
+ changed = {k: v for k, v in fields.items() if v is not None and content.get(k) != v}
104
+ if changed or (status and status != a.status):
105
+ a.content_json = {**content, **changed}
106
+ if status:
107
+ a.status = status
108
+ ctx.db.commit()
109
+
110
+
111
+ def sync_status(ctx: Ctx, a: Artifact, row: B2VVideo, s: dict) -> None:
112
+ raw = str(s.get("status") or "")
113
+ video_quota.record_status(ctx.db, row, "failed" if s.get("error") and raw not in STATUS_MAP else raw)
114
+ if s.get("error") or raw in STATUS_MAP:
115
+ status = STATUS_MAP.get(raw, "failed")
116
+ elif raw in JOB_STATUSES:
117
+ status = raw
118
+ elif s.get("ready") or raw in {"generated", "done"}:
119
+ status = "ready"
120
+ else:
121
+ status = "generating"
122
+ store(ctx, a, row, status=status, b2v_status=raw or None, step=s.get("step"), video_url=s.get("video_url"),
123
+ error=s.get("error"))
124
+
125
+
126
+ # The wizard's options (every field of blog2video's 3 steps)
127
+
128
+
129
+ class VideoOptions(BaseModel):
130
+ # Step 1: Project (script review is always off: blog2video goes straight to the video)
131
+ stock_footage_enabled: bool = True
132
+ aspect_ratio: Literal["landscape", "portrait"] = "landscape"
133
+ video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short"
134
+ logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
135
+ logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
136
+ logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
137
+ # Step 2: Template
138
+ template: str = Field("default", max_length=100)
139
+ video_style: str = Field("auto", max_length=40)
140
+ accent_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
141
+ bg_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
142
+ text_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
143
+ # Step 3: Voice
144
+ content_language: str | None = Field(None, max_length=10) # None = detect from the content
145
+ voice_gender: Literal["female", "male", "none"] = "female"
146
+ voice_accent: str = Field("american", max_length=30)
147
+ custom_voice_id: str | None = Field(None, max_length=100)
148
+ voice_emotion: str | None = Field(None, max_length=200) # ["<expressiveness>","<speed>","<emotion>",...]
149
+ bgm_track_id: str | None = Field(None, max_length=100)
150
+ bgm_volume: float = Field(0.10, ge=0, le=1)
151
+
152
+ def payload(self) -> dict:
153
+ """The fields blog2video takes, as it names them."""
154
+ # Only the wizard's own fields: a subclass (VideoCreateIn) adds the source, which is sent separately.
155
+ data = self.model_dump(include=set(VideoOptions.model_fields) - {"logo_upload_id"}, exclude_none=True,
156
+ mode="json")
157
+ data["script_review_enabled"] = False
158
+ if self.voice_gender == "none":
159
+ data.pop("custom_voice_id", None)
160
+ data.pop("voice_emotion", None)
161
+ if not self.bgm_track_id:
162
+ data.pop("bgm_volume", None)
163
+ return data
164
+
165
+
166
+ class VideoCreateIn(VideoOptions):
167
+ # Source: exactly one of these. document_ids are a notebook's ticked posts, combined into one video.
168
+ document_id: uuid.UUID | None = None
169
+ document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
170
+ url: str | None = Field(None, max_length=2000)
171
+ content: str | None = Field(None, max_length=MAX_CONTENT)
172
+ title: str | None = Field(None, max_length=300)
173
+ notebook_id: uuid.UUID | None = None # names a combined video after its notebook
174
+
175
+ @model_validator(mode="after")
176
+ def one_source(self):
177
+ if sum(x is not None for x in (self.document_id, self.document_ids, self.url, self.content)) != 1:
178
+ raise ValueError("Pick one source: a notebook, a post, a link or pasted text")
179
+ if self.document_ids and len(self.document_ids) == 1:
180
+ self.document_id, self.document_ids = self.document_ids[0], None
181
+ return self
182
+
183
+
184
+ class VideoBatchIn(VideoOptions):
185
+ urls: list[str] = Field(min_length=1, max_length=MAX_LINKS)
186
+
187
+
188
+ def _not_indexed(count: int) -> HTTPException:
189
+ return HTTPException(400, f"{count} of these posts {'is' if count == 1 else 'are'} not indexed on your plan. "
190
+ "Untick them or upgrade to index your whole archive.")
191
+
192
+
193
+ def _combined(ctx: Ctx, body: VideoCreateIn) -> tuple[str, str]:
194
+ """Several posts as one text, oldest first, each given an equal share so none is cut off entirely."""
195
+ ids = set(body.document_ids or [])
196
+ docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
197
+ Document.workspace_id == ctx.workspace.id)))
198
+ if len(docs) != len(ids):
199
+ raise HTTPException(404, "Post not found")
200
+ locked = sum(is_locked(d) for d in docs)
201
+ if locked:
202
+ raise _not_indexed(locked)
203
+ docs.sort(key=lambda d: (d.published_at is None, d.published_at.timestamp() if d.published_at else 0))
204
+ share = MAX_CONTENT // len(docs)
205
+ text = "\n\n".join(f"# {d.title}\n\n{(d.clean_text or '').strip()}"[:share] for d in docs)
206
+ nb = ctx.db.scalar(select(Notebook).where(Notebook.id == body.notebook_id,
207
+ Notebook.workspace_id == ctx.workspace.id)) if body.notebook_id else None
208
+ return (nb.title if nb else f"{docs[0].title} + {len(docs) - 1} more"), text
209
+
210
+
211
+ def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
212
+ """The blog2video source fields and a title for our artifact."""
213
+ if body.document_ids:
214
+ title, text = _combined(ctx, body)
215
+ elif body.document_id:
216
+ doc = ctx.db.scalar(select(Document).where(Document.id == body.document_id,
217
+ Document.workspace_id == ctx.workspace.id))
218
+ if not doc:
219
+ raise HTTPException(404, "Post not found")
220
+ if is_locked(doc):
221
+ raise _not_indexed(1)
222
+ if re.match(r"https?://", doc.url or ""):
223
+ return {"url": doc.url, "title": doc.title[:255]}, doc.title
224
+ # Uploaded files have no public link (upload://name): send their text instead.
225
+ title, text = doc.title, doc.clean_text or ""
226
+ elif body.url:
227
+ if not re.match(r"https?://", body.url):
228
+ raise HTTPException(400, "Enter a link that starts with http:// or https://")
229
+ return {"url": body.url}, body.title or body.url
230
+ else:
231
+ title, text = body.title or "", body.content or ""
232
+ if not title.strip():
233
+ raise HTTPException(400, "Give the pasted text a title")
234
+ text = text.strip()
235
+ if len(text) < MIN_CONTENT:
236
+ raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
237
+ return {"content": text[:MAX_CONTENT], "title": title[:255]}, title
238
+
239
+
240
+ def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
241
+ """Sent with every create, and returned by blog2video: ties the video back to us from its side too."""
242
+ return {"workspace_id": str(ctx.workspace.id), "user_id": str(ctx.user.id), "video_ref": str(row.id), **extra}
243
+
244
+
245
+ def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
246
+ payload = options.payload()
247
+ with upstream(): # checking a template or voice may need blog2video's catalog
248
+ plan = b2v_access.check_refs(ctx, payload)
249
+ accent = (ctx.workspace.brand_json or {}).get("accent")
250
+ if plan.brand_kit and accent and "accent_color" not in payload:
251
+ payload["accent_color"] = accent
252
+ return payload, plan
253
+
254
+
255
+ def _logo(ctx: Ctx, upload_id: uuid.UUID | None) -> tuple[str, str, bytes] | None:
256
+ if not upload_id:
257
+ return None
258
+ upload = ctx.db.scalar(select(Upload).where(Upload.id == upload_id, Upload.workspace_id == ctx.workspace.id))
259
+ if not upload or upload.status != "complete" or upload.content_type not in LOGO_TYPES:
260
+ raise HTTPException(400, "The logo must be a PNG, JPG, WebP or SVG.")
261
+ data = storage.get_bytes(upload.key)
262
+ if len(data) > LOGO_MAX_BYTES:
263
+ raise HTTPException(400, "The logo must be under 2 MB.")
264
+ return upload.filename, upload.content_type, data
265
+
266
+
267
+ def _start(ctx: Ctx, plan: Plan, title: str, aspect_ratio: str, template: str, created_via: str,
268
+ call: Callable[[B2VVideo], int], logo: tuple[str, str, bytes] | None = None,
269
+ document_id: uuid.UUID | None = None, source_url: str | None = None) -> Artifact:
270
+ """Reserve one video, record it, ask blog2video for it (call returns its id), refund if that fails."""
271
+ if not video_quota.reserve(ctx.db, ctx.workspace.id):
272
+ usage = video_usage(ctx.db, ctx.workspace)
273
+ raise plan_limit_error(plan, "videos",
274
+ f"You have made {usage['used']} of {usage['limit']} videos this period.")
275
+ try:
276
+ artifact = Artifact(workspace_id=ctx.workspace.id, type="video", status="generating", document_id=document_id,
277
+ content_json={"provider": "blog2video", "title": title[:300],
278
+ "aspect_ratio": aspect_ratio})
279
+ ctx.db.add(artifact)
280
+ ctx.db.flush()
281
+ row = B2VVideo(workspace_id=ctx.workspace.id, user_id=ctx.user.id, artifact_id=artifact.id,
282
+ created_via=created_via, template_ref=template, title=title[:300], source_url=source_url,
283
+ aspect_ratio=aspect_ratio,
284
+ # Keys are unique across our whole blog2video account, so they start with the workspace id.
285
+ idempotency_key=f"{ctx.workspace.id}:{uuid.uuid4()}" if created_via == "v1" else None)
286
+ ctx.db.add(row)
287
+ ctx.db.commit()
288
+ except Exception:
289
+ ctx.db.rollback()
290
+ video_quota.unreserve(ctx.db, ctx.workspace.id)
291
+ raise
292
+ try:
293
+ with upstream():
294
+ vid = call(row)
295
+ except HTTPException:
296
+ # Refused, or no answer after every retry: the user gets the video back.
297
+ video_quota.refund(ctx.db, row, "failed")
298
+ ctx.db.delete(artifact)
299
+ ctx.db.commit()
300
+ raise
301
+ row.b2v_video_id, row.status = vid, "queued"
302
+ ctx.db.commit()
303
+ store(ctx, artifact, row, b2v_video_id=vid, b2v_status="queued")
304
+ if logo:
305
+ try:
306
+ b2v.upload_logo(vid, *logo)
307
+ except b2v.B2VError:
308
+ log.warning("logo upload failed for video artifact %s", artifact.id) # the video is made without it
309
+ return artifact
310
+
311
+
312
+ @router.post("", status_code=201)
313
+ def create_video(body: VideoCreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
314
+ """From a link, pasted text or one of the workspace's posts: POST /api/v1/videos, which starts generating."""
315
+ source, title = _source(ctx, body)
316
+ payload, plan = _check(ctx, body)
317
+ logo = _logo(ctx, body.logo_upload_id)
318
+
319
+ def call(row: B2VVideo) -> int:
320
+ # Retried with the same key on no answer: blog2video returns the same video, charged once.
321
+ created = b2v.create_video({**source, **payload, "external_user_id": str(ctx.workspace.id),
322
+ "metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id),
323
+ document_id=str(body.document_id) if body.document_id
324
+ else None,
325
+ document_ids=[str(d) for d in body.document_ids or []]
326
+ or None)},
327
+ row.idempotency_key)
328
+ return int(created["video_id"])
329
+
330
+ a = _start(ctx, plan, title, body.aspect_ratio, body.template, "v1", call, logo, body.document_id,
331
+ source_url=source.get("url"))
332
+ return serialize_artifact(a)
333
+
334
+
335
+ @router.post("/batch", status_code=201)
336
+ def create_videos(body: VideoBatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
337
+ """Multi-link: one video per link, each charged and keyed on its own. Background music is single-video only."""
338
+ urls = [u.strip() for u in body.urls if re.match(r"https?://\S+", u.strip())]
339
+ if not urls:
340
+ raise HTTPException(400, "Enter at least one link that starts with http:// or https://")
341
+ options = body.model_copy(update={"bgm_track_id": None})
342
+ payload, plan = _check(ctx, options)
343
+ logo = _logo(ctx, body.logo_upload_id)
344
+ made: list[dict] = []
345
+ for url in urls:
346
+ def call(row: B2VVideo, url=url) -> int:
347
+ created = b2v.create_video({"url": url, **payload, "external_user_id": str(ctx.workspace.id),
348
+ "metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id))},
349
+ row.idempotency_key)
350
+ return int(created["video_id"])
351
+
352
+ try:
353
+ made.append(serialize_artifact(_start(ctx, plan, url, body.aspect_ratio, body.template, "v1", call,
354
+ logo, source_url=url)))
355
+ except HTTPException as e:
356
+ if not made:
357
+ raise
358
+ # Keep what was made; say where it stopped (usually the allowance ran out).
359
+ detail = e.detail if isinstance(e.detail, str) else (e.detail or {}).get("message")
360
+ made.append({"error": detail, "url": url})
361
+ break
362
+ return made
363
+
364
+
365
+ @router.post("/upload", status_code=201)
366
+ async def create_from_files(files: list[UploadFile] = File(...), options: str = Form("{}"),
367
+ ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
368
+ """From up to 5 documents (pdf, docx, pptx, md, txt, vtt; 5 MB each): blog2video parses them itself.
369
+ `options` is the wizard's VideoOptions as JSON."""
370
+ try:
371
+ opts = VideoOptions.model_validate(json.loads(options or "{}"))
372
+ except (ValueError, ValidationError) as e:
373
+ raise HTTPException(422, str(e)[:500]) from None
374
+ if not files or len(files) > MAX_FILES:
375
+ raise HTTPException(400, f"Add 1 to {MAX_FILES} files.")
376
+ parts: list[tuple[str, bytes, str]] = []
377
+ for f in files:
378
+ name = f.filename or "document"
379
+ if not any(name.lower().endswith(ext) for ext in FILE_TYPES):
380
+ raise HTTPException(400, f"{name}: use PDF, DOCX, PPTX, MD, TXT or VTT.")
381
+ data = await f.read(FILE_MAX_BYTES + 1)
382
+ if len(data) > FILE_MAX_BYTES:
383
+ raise HTTPException(400, f"{name} is over 5 MB.")
384
+ parts.append((name, data, f.content_type or "application/octet-stream"))
385
+ payload, plan = _check(ctx, opts)
386
+ logo = _logo(ctx, opts.logo_upload_id)
387
+ title = parts[0][0].rsplit(".", 1)[0] if len(parts) == 1 else f"{len(parts)} documents"
388
+ form = {k: (str(v).lower() if isinstance(v, bool) else str(v)) for k, v in payload.items()}
389
+
390
+ def call(row: B2VVideo) -> int:
391
+ project = b2v.upload_project({**form, "name": title[:255]}, parts)
392
+ pid = int(project["id"])
393
+ try:
394
+ b2v.generate(pid)
395
+ except b2v.B2VError:
396
+ try:
397
+ b2v.delete_project(pid)
398
+ except b2v.B2VError:
399
+ log.warning("could not remove blog2video project %s after generate failed", pid)
400
+ raise
401
+ return pid
402
+
403
+ a = _start(ctx, plan, title, opts.aspect_ratio, opts.template, "upload", call, logo)
404
+ return serialize_artifact(a)
405
+
406
+
407
+ # Read
408
+
409
+
410
+ @router.get("/config")
411
+ def video_config(ctx: Ctx = Depends(get_ctx)):
412
+ return {"configured": b2v.configured(), **video_limits.report(ctx.db, ctx.workspace)}
413
+
414
+
415
+ @router.get("/quota")
416
+ def quota(ctx: Ctx = Depends(get_ctx)):
417
+ return video_usage(ctx.db, ctx.workspace)
418
+
419
+
420
+ def _with_posters(t: dict) -> dict:
421
+ """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
422
+ have none; the picker then shows the template's colors); designer templates carry their own image."""
423
+ if t.get("preview_image_url") and not t.get("preview_url"):
424
+ return {**t, "preview_url": t["preview_image_url"]}
425
+ if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"):
426
+ return t
427
+ base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
428
+ return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
429
+
430
+
431
+ @router.get("/catalog")
432
+ def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
433
+ """What the wizard offers this workspace: built-in and designer templates, its own ready custom templates,
434
+ built-in and its own styles, music, languages. Nothing another workspace made."""
435
+ with upstream():
436
+ builtin = b2v.builtin_templates()
437
+ try:
438
+ crafted = b2v.crafted_templates()
439
+ except b2v.B2VError:
440
+ crafted = []
441
+ mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
442
+ for t in b2v_access.my_templates(ctx, ready_only=True)]
443
+ styles = b2v_access.visible_styles(ctx)
444
+ music = b2v.music_tracks()
445
+ return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
446
+ "crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)],
447
+ "my_templates": mine, "video_styles": styles,
448
+ "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
449
+
450
+
451
+ def _restore_missing_artifacts(ctx: Ctx) -> None:
452
+ """A video whose Library item is gone but which was never deleted (b2v_videos is the record) gets its item back,
453
+ rebuilt from the row. E.g. items removed by the Library's delete before it also removed the blog2video video."""
454
+ orphans = ctx.db.scalars(select(B2VVideo).where(
455
+ B2VVideo.workspace_id == ctx.workspace.id, B2VVideo.artifact_id.is_(None),
456
+ B2VVideo.b2v_video_id.is_not(None), B2VVideo.status != "deleted",
457
+ B2VVideo.quota_state != "refunded")).all()
458
+ for row in orphans:
459
+ status = ("ready" if row.status in video_quota.FINISHED else
460
+ "failed" if row.status in video_quota.FAILED else
461
+ row.status if row.status in JOB_STATUSES else STATUS_MAP.get(row.status, "generating"))
462
+ a = Artifact(workspace_id=ctx.workspace.id, type="video", status=status,
463
+ content_json={k: v for k, v in {
464
+ "provider": "blog2video", "b2v_video_id": row.b2v_video_id, "b2v_status": row.status,
465
+ "title": row.title or "Video", "aspect_ratio": row.aspect_ratio,
466
+ "preview_url": row.preview_url, "video_url": row.video_url}.items() if v is not None})
467
+ ctx.db.add(a)
468
+ ctx.db.flush()
469
+ row.artifact_id = a.id
470
+ if orphans:
471
+ ctx.db.commit()
472
+
473
+
474
+ def _needs_title(row: B2VVideo) -> bool:
475
+ return not row.title or row.title == row.source_url or bool(re.match(r"https?://", row.title))
476
+
477
+
478
+ def _set_title(ctx: Ctx, row: B2VVideo, a: Artifact | None, name: str | None) -> None:
479
+ """blog2video names a video after reading its source; until then we only had the link."""
480
+ name = (name or "").strip()[:300]
481
+ if not name or re.match(r"https?://", name) or row.title == name:
482
+ return
483
+ row.title = name
484
+ if a is not None:
485
+ a.content_json = {**(a.content_json or {}), "title": name}
486
+ ctx.db.commit()
487
+
488
+
489
+ def _refresh_titles(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]]) -> None:
490
+ """Real titles for videos still titled by their link, from blog2video's video list (one call)."""
491
+ todo = {row.b2v_video_id: (a, row) for a, row in pairs
492
+ if row.created_via == "v1" and row.b2v_video_id and _needs_title(row)}
493
+ if not todo or not b2v.configured():
494
+ return
495
+ try:
496
+ listed = b2v.request("GET", "/api/v1/videos", params={"external_user_id": str(ctx.workspace.id), "limit": 100})
497
+ except b2v.B2VError as e:
498
+ log.warning("could not refresh video titles: %s", e)
499
+ return
500
+ for item in (listed or {}).get("items") or []:
501
+ if item.get("video_id") in todo:
502
+ a, row = todo[item["video_id"]]
503
+ _set_title(ctx, row, a, item.get("name"))
504
+
505
+
506
+ @router.get("")
507
+ def list_videos(ctx: Ctx = Depends(get_ctx), limit: int = 30, offset: int = 0):
508
+ """The workspace's videos, newest first, with what the list rows show: title, status, source, scenes, date."""
509
+ _restore_missing_artifacts(ctx)
510
+ arts = [a for a in ctx.db.scalars(
511
+ select(Artifact).where(Artifact.workspace_id == ctx.workspace.id, Artifact.type == "video")
512
+ .order_by(Artifact.created_at.desc()).limit(min(limit, 100)).offset(offset)
513
+ ).all() if (a.content_json or {}).get("provider") == "blog2video"]
514
+ rows = {r.artifact_id: r for r in ctx.db.scalars(
515
+ select(B2VVideo).where(B2VVideo.artifact_id.in_([a.id for a in arts])))} if arts else {}
516
+ _refresh_titles(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
517
+ _refresh_busy(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
518
+ out = []
519
+ for a in arts:
520
+ row, content = rows.get(a.id), a.content_json or {}
521
+ out.append({**serialize_artifact(a), "source_url": row.source_url if row else None,
522
+ "scenes": content.get("scenes"), "b2v_status": (row.status if row else content.get("b2v_status"))})
523
+ return out
524
+
525
+
526
+ def _refresh_busy(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]], limit: int = 10) -> None:
527
+ """Ask blog2video about videos still generating, rendering or in an edit job, so one that finished (or failed)
528
+ while nobody had it open shows its real state. A few per page; a blog2video hiccup leaves the stored status."""
529
+ busy = [(a, r) for a, r in pairs if a.status in BUSY_STATUSES and r.b2v_video_id is not None][:limit]
530
+ for a, row in busy:
531
+ try:
532
+ sync_status(ctx, a, row, b2v.status(row.b2v_video_id, row.created_via))
533
+ except b2v.B2VError as e:
534
+ log.info("status refresh for video %s skipped: %s", row.b2v_video_id, e)
535
+
536
+
537
+ def _legacy(a: Artifact) -> dict:
538
+ content = a.content_json or {}
539
+ return {"legacy": True, "status": content.get("b2v_status"), "ready": bool(content.get("video_url")),
540
+ "video_url": content.get("video_url"), "preview_url": content.get("preview_url"),
541
+ "artifact_status": a.status, "artifact": serialize_artifact(a)}
542
+
543
+
544
+ @router.get("/{artifact_id}/status")
545
+ def video_status(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
546
+ a, vid, row = b2v_access.owned_video(ctx, artifact_id)
547
+ if row is None:
548
+ return _legacy(a)
549
+ try:
550
+ with upstream():
551
+ s = b2v.status(vid, row.created_via)
552
+ except HTTPException as e:
553
+ if e.status_code != 404:
554
+ raise
555
+ s = {"status": "failed", "error": "The video service lost this video."}
556
+ sync_status(ctx, a, row, s)
557
+ return {**s, "video_id": vid, "artifact_status": a.status}
558
+
559
+
560
+ def preview_url(ctx: Ctx, a: Artifact, vid: int, row: B2VVideo | None = None) -> str | None:
561
+ """The public live-preview link (no key needed, safe for the browser). Minted once and kept."""
562
+ url = (row.preview_url if row else None) or (a.content_json or {}).get("preview_url")
563
+ if url:
564
+ return url
565
+ try:
566
+ url = (b2v.preview_token(vid) or {}).get("preview_url")
567
+ except b2v.B2VError as e:
568
+ log.warning("preview token for video %s failed: %s", vid, e)
569
+ return None
570
+ store(ctx, a, row, preview_url=url)
571
+ return url
572
+
573
+
574
+ def _media_url(project_id: int, asset: dict | None, subdir: str, filename: str) -> str:
575
+ if asset and asset.get("r2_url"):
576
+ return asset["r2_url"]
577
+ return f"{settings.b2v_api_base_url.rstrip('/')}/media/projects/{project_id}/{subdir}/{filename}"
578
+
579
+
580
+ def _enrich(project: dict) -> dict:
581
+ """What the scene rows show, worked out once here: each scene's audio and image links (blog2video only gives
582
+ file names), its layout and font sizes, and totals for the preview caption."""
583
+ pid = project.get("id")
584
+ assets = [a for a in project.get("assets") or [] if not a.get("excluded")]
585
+ latest: dict[tuple[str, str], dict] = {}
586
+ for a in sorted(assets, key=lambda a: a.get("id") or 0): # a regenerated file is a newer asset, same name
587
+ latest[(a.get("asset_type"), a.get("filename"))] = a
588
+ total, images = 0.0, 0
589
+ scenes = []
590
+ for sc in sorted(project.get("scenes") or [], key=lambda s: s.get("order") or 0):
591
+ try:
592
+ code = json.loads(sc.get("remotion_code") or "{}")
593
+ except ValueError:
594
+ code = {}
595
+ custom = str(project.get("template") or "").startswith("custom_")
596
+ props = (code.get("layoutConfig") if custom else code.get("layoutProps")) or {}
597
+ audio = None
598
+ if sc.get("voiceover_path"):
599
+ name = re.split(r"[/\\]", sc["voiceover_path"])[-1]
600
+ asset = latest.get(("audio", name))
601
+ audio = _media_url(pid, asset, "audio", name) + (f"?v={asset['id']}" if asset else "")
602
+ pics = []
603
+ # Stills sit in assignedImage(s); a stock clip in assignedVideo(s). Both fill the scene's visual slot.
604
+ for key in ("assignedImage", "assignedImages", "image", "images", "assignedVideo", "assignedVideos"):
605
+ vals = props.get(key)
606
+ clip = key.startswith("assignedVideo")
607
+ for name in ([vals] if isinstance(vals, str) else vals if isinstance(vals, list) else []):
608
+ if isinstance(name, str) and name and not name.startswith("http"):
609
+ first, second = ("video", "image") if clip else ("image", "video")
610
+ asset = latest.get((first, name)) or latest.get((second, name))
611
+ kind = asset.get("asset_type") if asset else first
612
+ pics.append({"filename": name, "asset_id": asset.get("id") if asset else None, "kind": kind,
613
+ "url": _media_url(pid, asset, "videos" if kind == "video" else "images", name)})
614
+ images += len(pics)
615
+ total += float(sc.get("duration_seconds") or 0) + float(sc.get("extra_hold_seconds") or 0)
616
+ scenes.append({**sc, "audio_url": audio, "images": pics,
617
+ "layout": code.get("layout") or sc.get("preferred_layout"),
618
+ "title_font_size": props.get("titleFontSize"),
619
+ "desc_font_size": props.get("descriptionFontSize")})
620
+ return {**project, "scenes": scenes,
621
+ "summary": {"scenes": len(scenes), "duration_seconds": round(total),
622
+ "images": sum(1 for a in assets if a.get("asset_type") == "image") or images,
623
+ "clips": sum(1 for a in assets if a.get("asset_type") == "video")}}
624
+
625
+
626
+ @router.get("/{artifact_id}")
627
+ def get_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
628
+ a, vid, row = b2v_access.owned_video(ctx, artifact_id)
629
+ if row is None:
630
+ return _legacy(a)
631
+ with upstream():
632
+ project = b2v.get_project(vid)
633
+ if row is not None:
634
+ _set_title(ctx, row, a, project.get("name"))
635
+ store(ctx, a, row, video_url=project.get("r2_video_url"), scenes=len(project.get("scenes") or []) or None)
636
+ return {"video_id": vid, "status": project.get("status"), "project": _enrich(project),
637
+ "preview_url": preview_url(ctx, a, vid, row), "video_url": project.get("r2_video_url"),
638
+ "artifact": serialize_artifact(a)}
639
+
640
+
641
+ @router.delete("/{artifact_id}")
642
+ def delete_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
643
+ """Removes it from Notestack and from blog2video."""
644
+ a, _, _ = b2v_access.owned_video(ctx, artifact_id)
645
+ remove_video(ctx, a)
646
+ return {"ok": True}
647
+
648
+
649
+ def remove_video(ctx: Ctx, a: Artifact) -> None:
650
+ """Delete the video on blog2video, then the artifact. The b2v_videos row stays as the record that it was made
651
+ (status "deleted"): a lifetime allowance (Free) must not get a video back because one was deleted."""
652
+ row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id, B2VVideo.workspace_id == ctx.workspace.id))
653
+ if row is not None and row.b2v_video_id is not None and b2v.configured():
654
+ try:
655
+ b2v.delete_project(row.b2v_video_id)
656
+ except b2v.NotFound:
657
+ pass
658
+ except b2v.B2VError as e:
659
+ raise HTTPException(502, str(e)) from None
660
+ if row is not None:
661
+ row.status, row.artifact_id = "deleted", None
662
+ ctx.db.delete(a)
663
+ ctx.db.commit()
664
+
665
+
666
+ # Script review (status awaiting_script_review): read, rewrite with AI, approve
667
+
668
+
669
+ class DraftScene(BaseModel):
670
+ id: int
671
+ title: str = Field(max_length=255)
672
+ display_text: str | None = Field(None, max_length=3000)
673
+ narration_text: str = Field(max_length=6000)
674
+
675
+
676
+ class ReviewPreviewIn(BaseModel):
677
+ title: str = Field(min_length=1, max_length=255)
678
+ display_text: str = Field(min_length=1, max_length=3000)
679
+ narration_text: str = Field("", max_length=6000)
680
+ draft_scenes: list[DraftScene] = Field(max_length=200)
681
+ revision: int = Field(0, ge=0)
682
+
683
+
684
+ class ReviewAiIn(ReviewPreviewIn):
685
+ instruction: str = Field(min_length=2, max_length=1000)
686
+
687
+
688
+ class ReviewApproveScene(DraftScene):
689
+ preferred_layout: str | None = Field(None, max_length=100)
690
+ source_fingerprint: str | None = Field(None, max_length=200)
691
+ accepted_ai_instructions: list[str] = Field(default_factory=list, max_length=10)
692
+
693
+
694
+ class ReviewApproveIn(BaseModel):
695
+ scenes: list[ReviewApproveScene] = Field(max_length=200)
696
+
697
+
698
+ @router.get("/{artifact_id}/script")
699
+ def get_script(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
700
+ _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
701
+ with upstream():
702
+ project = b2v.get_project(vid)
703
+ scenes = sorted(project.get("scenes") or [], key=lambda s: s.get("order", 0))
704
+ return {"status": project.get("status"), "scenes": [
705
+ {k: s.get(k) for k in ("id", "order", "title", "display_text", "narration_text", "preferred_layout")}
706
+ for s in scenes]}
707
+
708
+
709
+ @router.post("/{artifact_id}/script/scenes/{scene_id}/narration-preview")
710
+ def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreviewIn, ctx: Ctx = Depends(get_ctx),
711
+ _: None = Depends(b2v_ready)):
712
+ """Rewrite one scene's narration to match its edited title and text."""
713
+ _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
714
+ with upstream():
715
+ return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/narration-preview",
716
+ json=body.model_dump(), timeout=60)
717
+
718
+
719
+ @router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview")
720
+ def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx),
721
+ _: None = Depends(b2v_ready)):
722
+ """★ Rewrite one scene from an instruction. Uses one AI edit."""
723
+ _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
724
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites")
725
+ video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
726
+ try:
727
+ with upstream():
728
+ return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview",
729
+ json=body.model_dump(), timeout=60)
730
+ except HTTPException:
731
+ video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits")
732
+ raise
733
+
734
+
735
+ @router.post("/{artifact_id}/script/approve")
736
+ def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
737
+ _: None = Depends(b2v_ready)):
738
+ a, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
739
+ with upstream():
740
+ result = b2v.project_call("POST", vid, "script-review/approve", json=body.model_dump(), timeout=60)
741
+ store(ctx, a, status="generating")
742
+ return result
backend/app/services/artifacts.py CHANGED
@@ -62,8 +62,11 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
62
  "document_id": str(a.document_id) if a.document_id else None,
63
  "content": content,
64
  "storage_key": a.storage_key,
65
- "url": storage.presign_get(a.storage_key) if a.storage_key else None,
66
- "download_url": storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key else None,
 
 
 
67
  "slide_urls": [storage.presign_get(k) for k in slides],
68
  "created_at": a.created_at.isoformat() if a.created_at else None,
69
  "job": serialize_job(job) if job else None,
 
62
  "document_id": str(a.document_id) if a.document_id else None,
63
  "content": content,
64
  "storage_key": a.storage_key,
65
+ # blog2video videos live on blog2video's storage (video_url once rendered)
66
+ "url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
67
+ "download_url": (storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key
68
+ else content.get("video_url")),
69
+ "provider": content.get("provider"),
70
  "slide_urls": [storage.presign_get(k) for k in slides],
71
  "created_at": a.created_at.isoformat() if a.created_at else None,
72
  "job": serialize_job(job) if job else None,
backend/app/services/auth_identity.py CHANGED
@@ -9,6 +9,7 @@ from sqlalchemy import select
9
  from sqlalchemy.orm import Session
10
 
11
  from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
 
12
 
13
 
14
  class IdentityError(Exception):
@@ -65,7 +66,9 @@ def _bootstrap_workspace(db: Session, user: User) -> None:
65
  db.add(workspace)
66
  db.flush()
67
  db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
68
- db.add(Subscription(workspace_id=workspace.id, plan="free"))
 
 
69
 
70
 
71
  def resolve_or_create_google_user(
 
9
  from sqlalchemy.orm import Session
10
 
11
  from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
12
+ from app.services.plans import PLANS
13
 
14
 
15
  class IdentityError(Exception):
 
66
  db.add(workspace)
67
  db.flush()
68
  db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
69
+ free = PLANS["free"]
70
+ db.add(Subscription(workspace_id=workspace.id, plan="free", video_plan=free.id, video_limit=free.videos,
71
+ videos_period_start=datetime.now(UTC)))
72
 
73
 
74
  def resolve_or_create_google_user(
backend/app/services/b2v_access.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Who owns what on our one blog2video account.
2
+
3
+ blog2video's ownership checks stop at the account: any video, custom template, custom voice or style made with our
4
+ key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
5
+ belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
6
+ edit body has its references checked before it is forwarded (check_refs).
7
+ """
8
+
9
+ import uuid
10
+
11
+ from fastapi import HTTPException
12
+ from sqlalchemy import select
13
+
14
+ from app.auth import Ctx
15
+ from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
16
+ from app.services import blog2video as b2v
17
+ from app.services.plans import Plan, effective_plan
18
+ from app.services.video_limits import require_premium
19
+
20
+ BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
21
+ PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
22
+
23
+
24
+ def not_found(what: str = "Not found") -> HTTPException:
25
+ return HTTPException(404, what)
26
+
27
+
28
+ # Videos
29
+
30
+
31
+ def owned_video(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo | None]:
32
+ """The artifact, blog2video's id, and our ledger row (None for videos made before the API key).
33
+
34
+ The row is the reference: its b2v_video_id (and created_via) is what every blog2video call uses. The artifact's
35
+ content_json only keeps a copy for the Library, and is read only for legacy videos, which have no row."""
36
+ a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id,
37
+ Artifact.type == "video"))
38
+ content = (a.content_json or {}) if a else {}
39
+ if not a or content.get("provider") != "blog2video":
40
+ raise not_found("Video not found")
41
+ row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id,
42
+ B2VVideo.workspace_id == ctx.workspace.id))
43
+ if row is not None and row.b2v_video_id is not None:
44
+ return a, row.b2v_video_id, row
45
+ if row is None and content.get("b2v_video_id"):
46
+ return a, int(content["b2v_video_id"]), None # legacy
47
+ raise not_found("Video not found") # the create never got an id from blog2video
48
+
49
+
50
+ def video_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo]:
51
+ """A video this workspace owns and blog2video can still reach with our key."""
52
+ a, vid, row = owned_video(ctx, artifact_id)
53
+ if row is None:
54
+ raise HTTPException(410, {"code": "video_legacy",
55
+ "message": "This video was made before the video service changed and can not be "
56
+ "edited any more. Its links still work."})
57
+ return a, vid, row
58
+
59
+
60
+ # Templates, voices, styles
61
+
62
+
63
+ def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate:
64
+ row = ctx.db.scalar(select(B2VTemplate).where(B2VTemplate.b2v_template_id == template_id,
65
+ B2VTemplate.workspace_id == ctx.workspace.id))
66
+ if not row:
67
+ raise not_found("Template not found")
68
+ return row
69
+
70
+
71
+ def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice:
72
+ row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id,
73
+ B2VCustomVoice.workspace_id == ctx.workspace.id))
74
+ if not row:
75
+ raise not_found("Voice not found")
76
+ return row
77
+
78
+
79
+ def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
80
+ row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
81
+ B2VStyle.workspace_id == ctx.workspace.id))
82
+ if not row:
83
+ raise not_found("Style not found")
84
+ return row
85
+
86
+
87
+ def _custom_id(ref: str, prefix: str) -> int | None:
88
+ raw = ref.removeprefix(prefix)
89
+ return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
90
+
91
+
92
+ def crafted_ids() -> set[str]:
93
+ out = set()
94
+ for t in b2v.crafted_templates():
95
+ tid = t.get("id") if isinstance(t, dict) else t
96
+ if isinstance(tid, str) and tid.startswith("crafted_"):
97
+ out.add(tid)
98
+ return out
99
+
100
+
101
+ def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
102
+ if template == "default":
103
+ return
104
+ if (cid := _custom_id(template, "custom_")) is not None:
105
+ row = owned_template(ctx, cid)
106
+ if not row.ready:
107
+ raise HTTPException(400, "That template is still being made.")
108
+ return
109
+ if template.startswith("crafted_"):
110
+ # blog2video silently falls back to "default" for an unknown id, so check it exists.
111
+ if template not in crafted_ids():
112
+ raise not_found("Template not found")
113
+ require_premium(ctx.db, ctx.workspace, "Designer templates", plan)
114
+ return
115
+ if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
116
+ raise not_found("Template not found")
117
+
118
+
119
+ def check_style(ctx: Ctx, style: str) -> None:
120
+ if style in BUILTIN_STYLES:
121
+ return
122
+ if (sid := _custom_id(style, "custom:")) is not None:
123
+ owned_style(ctx, sid)
124
+ return
125
+ raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
126
+
127
+
128
+ def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
129
+ """A built-in voice (premium if blog2video marks it paid) or one of this workspace's custom voices."""
130
+ prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
131
+ if voice_id in prebuilt:
132
+ if prebuilt[voice_id].get("plan") == "paid":
133
+ require_premium(ctx.db, ctx.workspace, "This voice", plan)
134
+ return
135
+ if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id,
136
+ B2VCustomVoice.workspace_id == ctx.workspace.id)):
137
+ require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
138
+ return
139
+ raise not_found("Voice not found")
140
+
141
+
142
+ def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
143
+ """Every id in a create or edit body must be this workspace's, and premium options need a paid plan."""
144
+ plan = plan or effective_plan(ctx.db, ctx.workspace)
145
+ if body.get("template"):
146
+ check_template(ctx, str(body["template"]), plan)
147
+ if body.get("video_style"):
148
+ check_style(ctx, str(body["video_style"]))
149
+ if body.get("custom_voice_id") and body.get("voice_gender") != "none":
150
+ check_voice(ctx, str(body["custom_voice_id"]), plan)
151
+ if str(body.get("video_length") or "") in PREMIUM_LENGTHS:
152
+ require_premium(ctx.db, ctx.workspace, "Longer videos", plan)
153
+ if body.get("voice_emotion"):
154
+ require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan)
155
+ return plan
156
+
157
+
158
+ # Catalog, filtered to what this workspace may see
159
+
160
+
161
+ def visible_styles(ctx: Ctx) -> list[dict]:
162
+ """Built-in styles (read-only) and this workspace's custom styles. Never "Your Style", never others'."""
163
+ mine = set(ctx.db.scalars(select(B2VStyle.b2v_style_id).where(B2VStyle.workspace_id == ctx.workspace.id)))
164
+ out = []
165
+ for s in (b2v.video_styles() or {}).get("styles") or []:
166
+ if s.get("kind") == "builtin" and s.get("id") in BUILTIN_STYLES:
167
+ out.append({"id": s["id"], "name": s.get("name"), "description": s.get("description"), "kind": "builtin"})
168
+ elif s.get("kind") == "custom" and s.get("custom_id") in mine:
169
+ out.append({"id": s["id"], "custom_id": s["custom_id"], "name": s.get("name"),
170
+ "description": s.get("description"), "guidance": s.get("guidance"),
171
+ "version": s.get("version"), "kind": "custom"})
172
+ return out
173
+
174
+
175
+ def my_templates(ctx: Ctx, ready_only: bool = False) -> list[B2VTemplate]:
176
+ q = select(B2VTemplate).where(B2VTemplate.workspace_id == ctx.workspace.id)
177
+ if ready_only:
178
+ q = q.where(B2VTemplate.ready.is_(True))
179
+ return list(ctx.db.scalars(q.order_by(B2VTemplate.created_at.desc())))
backend/app/services/billing.py CHANGED
@@ -13,13 +13,14 @@ from app.config import settings
13
  from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
14
  from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
15
  from app.services.usage import month_usage
 
16
 
17
  CYCLES = {"monthly", "annual"}
18
  LOW_FUEL = 0.8 # meters at or above this share of the allowance start nudging
19
 
20
  METERS = {
21
  "audio_minutes": ("Audio overviews", "min"),
22
- "video_minutes": ("Video renders", "min"),
23
  "launch_kits": ("Launch Kits", ""),
24
  }
25
 
@@ -90,7 +91,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
90
  left = "Unlimited" if kits["limit"] < 0 else f"{kits['limit']}"
91
  out.append(_nudge(f"act-kit-{month}", "action", "Launch your latest post",
92
  f"{left} Launch Kits this month, none used yet. One click turns a post into threads, "
93
- "LinkedIn posts, quote cards and a short video.", "Build a Launch Kit",
94
  "/app/launch-kit", priority=70))
95
  elif kits["limit"] > 0 and kits["used"] < kits["limit"]:
96
  left = kits["limit"] - kits["used"]
@@ -119,6 +120,8 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
119
  def billing_status(db: Session, workspace: Workspace) -> dict:
120
  plan = effective_plan(db, workspace)
121
  used = month_usage(db, workspace.id)
 
 
122
  sources = _count(db, Source, workspace.id)
123
  meters = _meters(plan, used, sources)
124
  upgrade = next_plan(plan)
@@ -134,6 +137,7 @@ def billing_status(db: Session, workspace: Workspace) -> dict:
134
  "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
135
  "meters": meters,
136
  "since": used["since"],
 
137
  "nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
138
  }
139
 
@@ -156,6 +160,8 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro
156
  sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
157
  sub.current_period_end = current_period_end
158
  db.commit()
 
 
159
  if workspace and effective_plan(db, workspace).indexed_posts != before:
160
  resync_sources(db, workspace_id)
161
  return sub
 
13
  from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
14
  from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
15
  from app.services.usage import month_usage
16
+ from app.services.video_quota import sync_video_quota, video_usage
17
 
18
  CYCLES = {"monthly", "annual"}
19
  LOW_FUEL = 0.8 # meters at or above this share of the allowance start nudging
20
 
21
  METERS = {
22
  "audio_minutes": ("Audio overviews", "min"),
23
+ "videos": ("Videos", ""),
24
  "launch_kits": ("Launch Kits", ""),
25
  }
26
 
 
91
  left = "Unlimited" if kits["limit"] < 0 else f"{kits['limit']}"
92
  out.append(_nudge(f"act-kit-{month}", "action", "Launch your latest post",
93
  f"{left} Launch Kits this month, none used yet. One click turns a post into threads, "
94
+ "LinkedIn posts and quote cards.", "Build a Launch Kit",
95
  "/app/launch-kit", priority=70))
96
  elif kits["limit"] > 0 and kits["used"] < kits["limit"]:
97
  left = kits["limit"] - kits["used"]
 
120
  def billing_status(db: Session, workspace: Workspace) -> dict:
121
  plan = effective_plan(db, workspace)
122
  used = month_usage(db, workspace.id)
123
+ videos = video_usage(db, workspace)
124
+ used["videos"] = videos["used"]
125
  sources = _count(db, Source, workspace.id)
126
  meters = _meters(plan, used, sources)
127
  upgrade = next_plan(plan)
 
137
  "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
138
  "meters": meters,
139
  "since": used["since"],
140
+ "videos_resets_at": videos["resets_at"],
141
  "nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
142
  }
143
 
 
160
  sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
161
  sub.current_period_end = current_period_end
162
  db.commit()
163
+ if workspace:
164
+ sync_video_quota(db, workspace)
165
  if workspace and effective_plan(db, workspace).indexed_posts != before:
166
  resync_sources(db, workspace_id)
167
  return sub
backend/app/services/blog2video.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """blog2video: turns a post into an editable, narrated video. Every call goes through here.
2
+
3
+ NoteStack is an ordinary blog2video API customer: each request carries our account's key
4
+ (Authorization: Bearer b2v_live_...), and everything made with it (videos, custom templates, custom voices, video
5
+ styles) belongs to that one account. blog2video does not know our workspaces and its ownership checks stop at the
6
+ account, so callers must check ownership first (app/services/b2v_access.py) and charge the workspace's own
7
+ allowances (app/services/video_quota.py, app/services/video_limits.py).
8
+
9
+ Some endpoints change state shared by every workspace, or hand one workspace another's data. They are refused here,
10
+ before anything is sent (DENIED). The key never reaches the browser or the logs.
11
+ """
12
+
13
+ import logging
14
+ import re
15
+ import threading
16
+ import time
17
+ from collections.abc import Callable
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from app.config import settings
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ STATUS_TIMEOUT = 30 # every call without its own timeout (status polls, edits): blog2video can take well over 10s
27
+ UPLOAD_TIMEOUT = 120
28
+ CREATE_ATTEMPTS = 3 # a create is retried with the same Idempotency-Key, which never makes a duplicate
29
+ CATALOG_TTL = 3600
30
+
31
+ # Account-wide endpoints NoteStack must never call (the integration guide, section 11).
32
+ DENIED: list[tuple[str, re.Pattern]] = [
33
+ (m, re.compile(p)) for m, p in [
34
+ ("POST", r"^/api/projects/?$"), # same URL within 10 minutes returns another workspace's project
35
+ ("POST", r"^/api/projects/bulk/?$"),
36
+ ("GET", r"^/api/projects/?$"), # every project on the account
37
+ ("GET", r"^/api/projects/template-availability/?$"),
38
+ ("*", r"^/api/voices/saved(/.*)?$"), # one saved-voice list shared by everyone
39
+ ("*", r"^/api/video-styles/builtin/.*$"), # changes a built-in style for everyone
40
+ ("*", r"^/api/video-styles/pin/?$"),
41
+ ("*", r"^/api/video-styles/selection/?$"),
42
+ ("*", r"^/api/video-styles/your-style/?$"),
43
+ ("*", r"^/api/auth/me/script-preferences/?$"),
44
+ ]
45
+ ]
46
+
47
+
48
+ class B2VError(RuntimeError):
49
+ def __init__(self, message: str, status: int = 502, detail: Any = None):
50
+ super().__init__(message)
51
+ self.status = status
52
+ self.detail = detail
53
+
54
+
55
+ class AccountOutOfVideos(B2VError):
56
+ """Our blog2video account has no videos (or template slots) left. Not the user's fault; ops must buy more."""
57
+
58
+
59
+ class NotFound(B2VError):
60
+ pass
61
+
62
+
63
+ class Busy(B2VError):
64
+ """Another change is still running (409), or an account rate limit was hit (429)."""
65
+
66
+
67
+ class TemplateInUse(B2VError):
68
+ """409 template_in_use: a video on the account still uses this custom template."""
69
+
70
+
71
+ class BadRequest(B2VError):
72
+ pass
73
+
74
+
75
+ class Upstream(B2VError):
76
+ """blog2video is down or slow, or rejected our key or plan (a configuration problem, not the user's)."""
77
+
78
+
79
+ class Unreachable(Upstream):
80
+ """No answer at all (network error or timeout): a create may or may not have happened."""
81
+
82
+
83
+ class Denied(B2VError):
84
+ """We refused to send this: the endpoint is account-wide (see DENIED)."""
85
+
86
+
87
+ UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
88
+ NOT_RESPONDING = "The video service is not responding. Try again in a minute."
89
+
90
+
91
+ def configured() -> bool:
92
+ return bool(settings.b2v_api_base_url and settings.b2v_api_key)
93
+
94
+
95
+ def _base() -> str:
96
+ return settings.b2v_api_base_url.rstrip("/")
97
+
98
+
99
+ def denied(method: str, path: str) -> bool:
100
+ path = path.split("?", 1)[0]
101
+ return any(m in ("*", method.upper()) and p.match(path) for m, p in DENIED)
102
+
103
+
104
+ def _body(resp: httpx.Response) -> Any:
105
+ try:
106
+ return resp.json()
107
+ except ValueError:
108
+ return resp.text[:500]
109
+
110
+
111
+ def _error_code(body: Any) -> str | None:
112
+ """blog2video errors look like {"detail": {"error": "..."}} or {"detail": "..."}."""
113
+ if not isinstance(body, dict):
114
+ return None
115
+ detail = body.get("detail", body)
116
+ if isinstance(detail, dict):
117
+ return detail.get("error")
118
+ return detail if isinstance(detail, str) else None
119
+
120
+
121
+ def _message(detail: Any, fallback: str) -> str:
122
+ if isinstance(detail, str) and detail.strip():
123
+ return detail.strip()[:300]
124
+ if isinstance(detail, dict) and isinstance(detail.get("message"), str):
125
+ return detail["message"][:300]
126
+ if isinstance(detail, list): # 422s list the fields: [{"loc": [..., "url"], "msg": "..."}]
127
+ parts = [f"{(d.get('loc') or ['?'])[-1]}: {d.get('msg')}" for d in detail if isinstance(d, dict)]
128
+ if parts:
129
+ return "; ".join(parts)[:300]
130
+ return fallback
131
+
132
+
133
+ def _raise_for(resp: httpx.Response, path: str) -> None:
134
+ if resp.status_code < 400:
135
+ return
136
+ body = _body(resp)
137
+ detail = body.get("detail", body) if isinstance(body, dict) else body
138
+ code = _error_code(body) or ""
139
+ status = resp.status_code
140
+ text = str(detail).lower()
141
+ if status == 402 or (status == 403 and "video limit reached" in text):
142
+ log.error("ALERT blog2video: our account is out of videos (%s): buy credits", path)
143
+ raise AccountOutOfVideos(UNAVAILABLE, 503, detail)
144
+ if status == 403 and code == "custom_template_limit":
145
+ log.error("ALERT blog2video: our account is out of custom template slots (%s): buy slots", path)
146
+ raise AccountOutOfVideos("Creating templates is temporarily unavailable. Try again later.", 503, detail)
147
+ if status == 401:
148
+ log.error("ALERT blog2video rejected our API key on %s (missing, revoked or rotated): update B2V_API_KEY",
149
+ path)
150
+ raise Upstream(UNAVAILABLE, 503, detail)
151
+ if status == 403:
152
+ log.error("ALERT blog2video refused %s with 403 (%s): is the account still on a paid plan?", path,
153
+ code or text[:120])
154
+ raise Upstream(UNAVAILABLE, 503, detail)
155
+ if 400 <= status < 500 and status not in (401, 403):
156
+ # What blog2video said, so a refused edit can be traced (never includes our key).
157
+ log.warning("blog2video %s -> %s: %s", path, status, str(detail)[:300])
158
+ if status == 404:
159
+ raise NotFound(_message(detail, "Not found"), 404, detail)
160
+ if status == 409 and code == "template_in_use":
161
+ raise TemplateInUse("A video still uses this template.", 409, detail)
162
+ if status == 409 or (status == 400 and "running" in text):
163
+ raise Busy(_message(detail, "Another change is still running, try again in a moment."), 409, detail)
164
+ if status == 429:
165
+ raise Busy(_message(detail, "The daily limit was reached. Try again later."), 429, detail)
166
+ if status in (400, 413, 415, 422):
167
+ raise BadRequest(_message(detail, str(code or "blog2video rejected the request")), 400, detail)
168
+ raise Upstream(NOT_RESPONDING, 502, detail)
169
+
170
+
171
+ def _retry_after(resp: httpx.Response) -> float:
172
+ try:
173
+ return min(float(resp.headers.get("Retry-After", 1)), 5)
174
+ except ValueError:
175
+ return 1
176
+
177
+
178
+ def request(method: str, path: str, *, json: Any = None, params: dict | None = None, data: dict | None = None,
179
+ files: Any = None, headers: dict | None = None, timeout: float | None = None,
180
+ raw: bool = False) -> Any:
181
+ """Call blog2video with our key. JSON by default; `data` + `files` send multipart; `raw` returns
182
+ (bytes, content_type) for audio and downloads. A POST with an Idempotency-Key (a create) is retried on no
183
+ answer, 5xx and 429, always with the same key: blog2video returns the original video, charged once."""
184
+ if denied(method, path):
185
+ log.error("refused to call account-wide blog2video endpoint %s %s", method, path)
186
+ raise Denied("Not available.", 404)
187
+ if not configured():
188
+ raise Upstream("Video is not set up yet.", 503)
189
+ url = f"{_base()}{path}"
190
+ timeout = timeout or STATUS_TIMEOUT
191
+ retryable = method == "POST" and bool(headers and headers.get("Idempotency-Key"))
192
+ attempts = CREATE_ATTEMPTS if retryable else 1
193
+ for attempt in range(1, attempts + 1):
194
+ last = attempt == attempts
195
+ started = time.monotonic()
196
+ try:
197
+ resp = httpx.request(method, url, json=json, params=params, data=data, files=files, timeout=timeout,
198
+ headers={**(headers or {}), "Authorization": f"Bearer {settings.b2v_api_key}"})
199
+ except httpx.HTTPError as e:
200
+ log.warning("blog2video %s %s failed (attempt %d): %s", method, path, attempt, type(e).__name__)
201
+ if last:
202
+ raise Unreachable(NOT_RESPONDING) from None
203
+ time.sleep(attempt)
204
+ continue
205
+ log.info("blog2video %s %s -> %s in %.0fms", method, path, resp.status_code,
206
+ (time.monotonic() - started) * 1000)
207
+ if not last and (resp.status_code >= 500 or resp.status_code == 429):
208
+ time.sleep(_retry_after(resp) if resp.status_code == 429 else attempt)
209
+ continue
210
+ _raise_for(resp, path)
211
+ if raw:
212
+ return resp.content, resp.headers.get("content-type", "application/octet-stream")
213
+ return None if resp.status_code == 204 or not resp.content else _body(resp)
214
+ raise AssertionError("unreachable")
215
+
216
+
217
+ # Shared catalog: the same for every workspace, cached for an hour.
218
+
219
+ _cache: dict[str, tuple[float, Any]] = {}
220
+ _cache_lock = threading.Lock()
221
+
222
+
223
+ def cached(key: str, fetch: Callable[[], Any], ttl: float = CATALOG_TTL) -> Any:
224
+ with _cache_lock:
225
+ hit = _cache.get(key)
226
+ if hit and time.monotonic() - hit[0] < ttl:
227
+ return hit[1]
228
+ try:
229
+ value = fetch()
230
+ except B2VError:
231
+ if hit is None:
232
+ raise
233
+ # blog2video is slow or down: the last copy is better than failing the request.
234
+ log.warning("blog2video catalog %r could not be refreshed; using the copy from %.0fs ago",
235
+ key, time.monotonic() - hit[0])
236
+ return hit[1]
237
+ with _cache_lock:
238
+ _cache[key] = (time.monotonic(), value)
239
+ return value
240
+
241
+
242
+ def clear_cache() -> None:
243
+ with _cache_lock:
244
+ _cache.clear()
245
+
246
+
247
+ def _catalog_timeout() -> float:
248
+ """Catalog lists are fetched about once an hour, so wait as long as a create would rather than fail."""
249
+ return settings.b2v_timeout_seconds
250
+
251
+
252
+ def builtin_templates() -> list[dict]:
253
+ return cached("templates", lambda: request("GET", "/api/templates", timeout=_catalog_timeout()) or [])
254
+
255
+
256
+ def crafted_templates() -> list[dict]:
257
+ return cached("crafted", lambda: request("GET", "/api/crafted-templates", timeout=_catalog_timeout()) or [])
258
+
259
+
260
+ def prebuilt_voices() -> list[dict]:
261
+ """voice_id, name, preview_url, labels, description, plan ('free' | 'paid')."""
262
+ return cached("prebuilt", lambda: (request("GET", "/api/voices/prebuilt", timeout=_catalog_timeout()) or {})
263
+ .get("voices") or [])
264
+
265
+
266
+ def music_tracks() -> list[dict]:
267
+ """track_id, display_name, mood, r2_url."""
268
+ return cached("music", lambda: request("GET", "/api/background-music/tracks", timeout=_catalog_timeout()) or [])
269
+
270
+
271
+ def video_styles() -> dict:
272
+ """Account-wide picker payload. Filter before use (b2v_access.visible_styles)."""
273
+ return request("GET", "/api/video-styles") or {}
274
+
275
+
276
+ def account() -> Any:
277
+ """Our blog2video account: plan, videos, AI-edit allowance and credits, template slots."""
278
+ return request("GET", "/api/auth/me")
279
+
280
+
281
+ # Videos
282
+
283
+ def create_video(body: dict, idempotency_key: str) -> Any:
284
+ return request("POST", "/api/v1/videos", json=body, headers={"Idempotency-Key": idempotency_key},
285
+ timeout=settings.b2v_timeout_seconds)
286
+
287
+
288
+ def upload_project(form: dict, files: list[tuple[str, bytes, str]]) -> Any:
289
+ """Create a project from documents. Generation starts with generate()."""
290
+ return request("POST", "/api/projects/upload", data=form, timeout=UPLOAD_TIMEOUT,
291
+ files=[("files", (name, content, ctype)) for name, content, ctype in files])
292
+
293
+
294
+ def generate(project_id: int) -> Any:
295
+ return request("POST", f"/api/projects/{project_id}/generate", timeout=settings.b2v_timeout_seconds)
296
+
297
+
298
+ def status(project_id: int, created_via: str = "v1") -> Any:
299
+ if created_via == "upload":
300
+ return request("GET", f"/api/projects/{project_id}/status")
301
+ return request("GET", f"/api/v1/videos/{project_id}/status")
302
+
303
+
304
+ def get_project(project_id: int) -> Any:
305
+ """The whole project (scenes, assets). Much heavier than a status poll, so it gets the long timeout."""
306
+ return request("GET", f"/api/projects/{project_id}", timeout=settings.b2v_timeout_seconds)
307
+
308
+
309
+ def delete_project(project_id: int) -> Any:
310
+ # blog2video removes the project's files before answering, which can take well over the 10s status timeout:
311
+ # timing out early reported a failure for a delete that went on to succeed.
312
+ return request("DELETE", f"/api/projects/{project_id}", timeout=settings.b2v_timeout_seconds)
313
+
314
+
315
+ def upload_logo(project_id: int, filename: str, content_type: str, content: bytes) -> Any:
316
+ return request("POST", f"/api/projects/{project_id}/logo", files={"file": (filename, content, content_type)},
317
+ timeout=UPLOAD_TIMEOUT)
318
+
319
+
320
+ def preview_token(project_id: int) -> Any:
321
+ return request("POST", f"/api/embed/token/{project_id}")
322
+
323
+
324
+ def project_call(method: str, project_id: int, sub: str, **kwargs) -> Any:
325
+ """Any /api/projects/{id}/... editor endpoint. Callers allowlist `sub` (app/routers/video_edit.py)."""
326
+ return request(method, f"/api/projects/{project_id}/{sub.lstrip('/')}", **kwargs)
backend/app/services/plans.py CHANGED
@@ -1,12 +1,22 @@
1
- """Three tiers. While BILLING_ENABLED is false every workspace gets Studio limits for free."""
 
 
2
 
3
- from dataclasses import asdict, dataclass
 
 
 
 
4
 
5
  from fastapi import HTTPException
 
 
6
  from sqlalchemy.orm import Session
7
 
8
  from app.config import settings
9
- from app.models import Subscription, Workspace
 
 
10
 
11
  ANNUAL_DISCOUNT = 0.25 # annual billing: 25% off the monthly price
12
 
@@ -20,14 +30,38 @@ class Plan:
20
  sources: int
21
  indexed_posts: int
22
  audio_minutes: int
23
- video_minutes: int
24
  launch_kits: int # -1 = unlimited
25
  voice_cloning: bool
26
  brand_kit: bool
27
  features: tuple[str, ...]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
 
 
 
 
 
 
29
 
30
- PLANS: dict[str, Plan] = {
 
31
  "free": Plan(
32
  id="free",
33
  name="Free",
@@ -36,7 +70,7 @@ PLANS: dict[str, Plan] = {
36
  sources=1,
37
  indexed_posts=5,
38
  audio_minutes=3,
39
- video_minutes=1,
40
  launch_kits=2,
41
  voice_cloning=False,
42
  brand_kit=False,
@@ -44,9 +78,11 @@ PLANS: dict[str, Plan] = {
44
  "1 source, your latest 5 posts indexed",
45
  "Grounded research chat with citations",
46
  "3 min of audio overviews a month",
47
- "1 min of video a month",
48
  "2 Launch Kits a month",
49
  ),
 
 
50
  ),
51
  "writer": Plan(
52
  id="writer",
@@ -56,18 +92,20 @@ PLANS: dict[str, Plan] = {
56
  sources=3,
57
  indexed_posts=500,
58
  audio_minutes=60,
59
- video_minutes=30,
60
  launch_kits=50,
61
  voice_cloning=True,
62
  brand_kit=True,
63
  features=(
64
  "3 sources, 500 indexed posts",
65
  "60 min of audio overviews a month",
66
- "30 min of video renders a month",
67
  "50 Launch Kits a month",
68
  "Voice cloning with consent",
69
  "Your brand colors and logo",
70
  ),
 
 
71
  ),
72
  "studio": Plan(
73
  id="studio",
@@ -77,22 +115,90 @@ PLANS: dict[str, Plan] = {
77
  sources=10,
78
  indexed_posts=5000,
79
  audio_minutes=240,
80
- video_minutes=120,
81
  launch_kits=-1,
82
  voice_cloning=True,
83
  brand_kit=True,
84
  features=(
85
  "10 sources, 5,000 indexed posts",
86
  "240 min of audio overviews a month",
87
- "120 min of video renders a month",
88
  "Unlimited Launch Kits",
89
  "Launchpad calendar and resurfacing",
90
  "Priority rendering",
91
  ),
 
 
92
  ),
93
  }
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  def to_99(amount: float) -> float:
97
  """Nearest price ending in .99 (18.74 -> 18.99, 36.74 -> 36.99)."""
98
  return 0.0 if amount <= 0 else round(max(0.99, round(amount + 0.01) - 0.01), 2)
@@ -130,28 +236,39 @@ def effective_plan(db: Session, workspace: Workspace) -> Plan:
130
  return PLANS.get(sub.plan, PLANS["free"])
131
 
132
 
133
- PLAN_ORDER = ["free", "writer", "studio"]
 
134
 
135
 
136
  def next_plan(plan: Plan) -> Plan | None:
137
- i = PLAN_ORDER.index(plan.id)
138
- return PLANS[PLAN_ORDER[i + 1]] if i + 1 < len(PLAN_ORDER) else None
 
139
 
140
 
141
  def upgrade_for(plan: Plan, kind: str | None = None) -> str | None:
142
  """Cheapest plan above this one that raises the limit named by kind (or simply the next tier)."""
143
- for pid in PLAN_ORDER[PLAN_ORDER.index(plan.id) + 1:]:
 
144
  candidate = PLANS[pid]
145
  if kind is None:
146
  return pid
147
- if kind == "voice_cloning" and candidate.voice_cloning:
148
- return pid
149
- current, better = getattr(plan, kind, 0), getattr(candidate, kind, 0)
150
- if better < 0 or (isinstance(better, int | float) and better > current):
 
151
  return pid
152
  return None
153
 
154
 
 
 
 
 
 
 
 
155
  def plan_limit_error(plan: Plan, kind: str, message: str) -> HTTPException:
156
  """402 the frontend turns into the out of fuel popup."""
157
  return HTTPException(402, {"code": "plan_limit", "message": message, "kind": kind,
 
1
+ """Plans live in the `plans` table (edit a row to change limits or prices; it takes effect within a minute).
2
+ DEFAULT_PLANS below seeds that table and is used whenever it is empty or unreachable.
3
+ While BILLING_ENABLED is false every workspace gets Studio limits for free."""
4
 
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections.abc import Iterator, Mapping
9
+ from dataclasses import asdict, dataclass, field
10
 
11
  from fastapi import HTTPException
12
+ from sqlalchemy import select
13
+ from sqlalchemy.exc import SQLAlchemyError
14
  from sqlalchemy.orm import Session
15
 
16
  from app.config import settings
17
+ from app.models import PlanRecord, Subscription, Workspace
18
+
19
+ log = logging.getLogger(__name__)
20
 
21
  ANNUAL_DISCOUNT = 0.25 # annual billing: 25% off the monthly price
22
 
 
30
  sources: int
31
  indexed_posts: int
32
  audio_minutes: int
33
+ videos: int # blog2video videos per month, or in total when videos_monthly is False
34
  launch_kits: int # -1 = unlimited
35
  voice_cloning: bool
36
  brand_kit: bool
37
  features: tuple[str, ...]
38
+ # Premium (★) video options: long durations, paid and custom voices, voice tuning and samples, AI script
39
+ # rewrites, AI chat editing, avatars, the Remotion source download.
40
+ video_premium: bool = False
41
+ # Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none).
42
+ video_limits: dict = field(default_factory=dict)
43
+ videos_monthly: bool = True
44
+
45
+
46
+ # metric -> period it counts over (app/services/video_limits.py)
47
+ VIDEO_LIMITS = {
48
+ "ai_edits": "month", # scene regenerate/add, AI images, stock clips, AI chat, template AI edits, avatars
49
+ "templates": "all", # custom templates: blog2video's slots are lifetime, never given back
50
+ "template_ai_daily": "day", # template theme extraction from a prompt or document, code generation
51
+ "voice_designs_daily": "day",
52
+ "voice_samples_daily": "day",
53
+ "custom_voices": "all", # kept voices; deleting one frees its place
54
+ }
55
 
56
+ FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
57
+ "voice_samples_daily": 0, "custom_voices": 0}
58
+ WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
59
+ "voice_samples_daily": 20, "custom_voices": 3}
60
+ STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
61
+ "voice_samples_daily": 40, "custom_voices": 10}
62
 
63
+
64
+ DEFAULT_PLANS: dict[str, Plan] = {
65
  "free": Plan(
66
  id="free",
67
  name="Free",
 
70
  sources=1,
71
  indexed_posts=5,
72
  audio_minutes=3,
73
+ videos=1,
74
  launch_kits=2,
75
  voice_cloning=False,
76
  brand_kit=False,
 
78
  "1 source, your latest 5 posts indexed",
79
  "Grounded research chat with citations",
80
  "3 min of audio overviews a month",
81
+ "1 video to try it",
82
  "2 Launch Kits a month",
83
  ),
84
+ video_limits=FREE_VIDEO_LIMITS,
85
+ videos_monthly=False,
86
  ),
87
  "writer": Plan(
88
  id="writer",
 
92
  sources=3,
93
  indexed_posts=500,
94
  audio_minutes=60,
95
+ videos=10,
96
  launch_kits=50,
97
  voice_cloning=True,
98
  brand_kit=True,
99
  features=(
100
  "3 sources, 500 indexed posts",
101
  "60 min of audio overviews a month",
102
+ "10 videos a month",
103
  "50 Launch Kits a month",
104
  "Voice cloning with consent",
105
  "Your brand colors and logo",
106
  ),
107
+ video_premium=True,
108
+ video_limits=WRITER_VIDEO_LIMITS,
109
  ),
110
  "studio": Plan(
111
  id="studio",
 
115
  sources=10,
116
  indexed_posts=5000,
117
  audio_minutes=240,
118
+ videos=20,
119
  launch_kits=-1,
120
  voice_cloning=True,
121
  brand_kit=True,
122
  features=(
123
  "10 sources, 5,000 indexed posts",
124
  "240 min of audio overviews a month",
125
+ "20 videos a month",
126
  "Unlimited Launch Kits",
127
  "Launchpad calendar and resurfacing",
128
  "Priority rendering",
129
  ),
130
+ video_premium=True,
131
+ video_limits=STUDIO_VIDEO_LIMITS,
132
  ),
133
  }
134
 
135
 
136
+ CACHE_SECONDS = 60
137
+
138
+
139
+ def _plan_from_row(row: PlanRecord) -> Plan:
140
+ return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
141
+ price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
142
+ indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
143
+ videos=row.videos, launch_kits=row.launch_kits, voice_cloning=bool(row.voice_cloning),
144
+ brand_kit=bool(row.brand_kit), features=tuple(row.features or ()),
145
+ video_premium=bool(row.video_premium), videos_monthly=bool(row.videos_monthly),
146
+ video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
147
+
148
+
149
+ def _read_table() -> dict[str, Plan] | None:
150
+ from app.db import SessionLocal
151
+
152
+ try:
153
+ with SessionLocal() as db:
154
+ rows = db.scalars(select(PlanRecord).order_by(PlanRecord.sort_order, PlanRecord.price_monthly_usd)).all()
155
+ plans = {r.id: _plan_from_row(r) for r in rows}
156
+ except SQLAlchemyError as e:
157
+ log.warning("plans table unreadable, using built-in plans: %s", type(e).__name__)
158
+ return None
159
+ if plans and "free" not in plans:
160
+ log.error("plans table has no 'free' row, using built-in plans")
161
+ return None
162
+ return plans or None
163
+
164
+
165
+ class PlanCatalog(Mapping[str, Plan]):
166
+ """PLANS: the plans table, cheapest first, cached for CACHE_SECONDS."""
167
+
168
+ def __init__(self) -> None:
169
+ self._plans: dict[str, Plan] | None = None
170
+ self._loaded_at = 0.0
171
+ self._lock = threading.Lock()
172
+
173
+ def _current(self) -> dict[str, Plan]:
174
+ now = time.monotonic()
175
+ if self._plans is None or now - self._loaded_at >= CACHE_SECONDS:
176
+ with self._lock:
177
+ if self._plans is None or now - self._loaded_at >= CACHE_SECONDS:
178
+ self._plans = _read_table() or DEFAULT_PLANS
179
+ self._loaded_at = now
180
+ return self._plans
181
+
182
+ def reload(self) -> None:
183
+ """Drop the cache so the next read sees table edits immediately."""
184
+ self._plans = None
185
+
186
+ def order(self) -> list[str]:
187
+ return list(self._current())
188
+
189
+ def __getitem__(self, plan_id: str) -> Plan:
190
+ return self._current()[plan_id]
191
+
192
+ def __iter__(self) -> Iterator[str]:
193
+ return iter(self._current())
194
+
195
+ def __len__(self) -> int:
196
+ return len(self._current())
197
+
198
+
199
+ PLANS = PlanCatalog()
200
+
201
+
202
  def to_99(amount: float) -> float:
203
  """Nearest price ending in .99 (18.74 -> 18.99, 36.74 -> 36.99)."""
204
  return 0.0 if amount <= 0 else round(max(0.99, round(amount + 0.01) - 0.01), 2)
 
236
  return PLANS.get(sub.plan, PLANS["free"])
237
 
238
 
239
+ def plan_order() -> list[str]:
240
+ return PLANS.order()
241
 
242
 
243
  def next_plan(plan: Plan) -> Plan | None:
244
+ order = plan_order()
245
+ i = order.index(plan.id)
246
+ return PLANS[order[i + 1]] if i + 1 < len(order) else None
247
 
248
 
249
  def upgrade_for(plan: Plan, kind: str | None = None) -> str | None:
250
  """Cheapest plan above this one that raises the limit named by kind (or simply the next tier)."""
251
+ order = plan_order()
252
+ for pid in order[order.index(plan.id) + 1:]:
253
  candidate = PLANS[pid]
254
  if kind is None:
255
  return pid
256
+ current, better = plan_value(plan, kind), plan_value(candidate, kind)
257
+ if isinstance(better, bool):
258
+ if better and not current:
259
+ return pid
260
+ elif isinstance(better, int | float) and (better < 0 or better > current):
261
  return pid
262
  return None
263
 
264
 
265
+ def plan_value(plan: Plan, kind: str):
266
+ """A plan's limit or flag by name; "video_limits.<metric>" reads the per-metric video limits."""
267
+ if kind.startswith("video_limits."):
268
+ return plan.video_limits.get(kind.removeprefix("video_limits."), 0)
269
+ return getattr(plan, kind, 0)
270
+
271
+
272
  def plan_limit_error(plan: Plan, kind: str, message: str) -> HTTPException:
273
  """402 the frontend turns into the out of fuel popup."""
274
  return HTTPException(402, {"code": "plan_limit", "message": message, "kind": kind,
backend/app/services/renderer.py CHANGED
@@ -7,8 +7,6 @@ from app.models import Artifact, Job
7
  from app.services.storage import keys, storage
8
 
9
  COMPOSITIONS = {
10
- "ShortVertical": "mp4",
11
- "ExplainerLong": "mp4",
12
  "AudiogramSquare": "mp4",
13
  "QuoteCard": "png",
14
  "CarouselSlide": "png",
 
7
  from app.services.storage import keys, storage
8
 
9
  COMPOSITIONS = {
 
 
10
  "AudiogramSquare": "mp4",
11
  "QuoteCard": "png",
12
  "CarouselSlide": "png",
backend/app/services/stripe_billing.py CHANGED
@@ -30,6 +30,7 @@ from sqlalchemy.orm import Session
30
 
31
  from app.config import settings
32
  from app.models import BillingEvent, Subscription, User, Workspace
 
33
  from app.services.plans import PLANS, Plan, annual_prices
34
 
35
  log = logging.getLogger(__name__)
@@ -150,6 +151,9 @@ def sync_subscription(db: Session, workspace_id: uuid.UUID, stripe_sub) -> Subsc
150
  sub.plan = plan_id if (plan_id in PLANS and status in LIVE) else "free"
151
  sub.current_period_end = _ts(period_end)
152
  db.commit()
 
 
 
153
  return sub
154
 
155
 
@@ -336,6 +340,11 @@ def handle_event(db: Session, event) -> str:
336
  ws = _workspace_for(db, stripe_sub, customer_id=_get(obj, "customer"))
337
  if ws and stripe_sub is not None:
338
  sync_subscription(db, ws, stripe_sub)
 
 
 
 
 
339
  if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
340
  # The first invoice of a Checkout is handled on the Checkout page itself.
341
  if _get(obj, "billing_reason") != "subscription_create":
 
30
 
31
  from app.config import settings
32
  from app.models import BillingEvent, Subscription, User, Workspace
33
+ from app.services import video_quota
34
  from app.services.plans import PLANS, Plan, annual_prices
35
 
36
  log = logging.getLogger(__name__)
 
151
  sub.plan = plan_id if (plan_id in PLANS and status in LIVE) else "free"
152
  sub.current_period_end = _ts(period_end)
153
  db.commit()
154
+ workspace = db.get(Workspace, workspace_id)
155
+ if workspace:
156
+ video_quota.sync_video_quota(db, workspace)
157
  return sub
158
 
159
 
 
340
  ws = _workspace_for(db, stripe_sub, customer_id=_get(obj, "customer"))
341
  if ws and stripe_sub is not None:
342
  sync_subscription(db, ws, stripe_sub)
343
+ # A new billing period (or a new paid plan) starts a fresh video allowance. `first` keeps a retried
344
+ # delivery of the same event from wiping videos made since.
345
+ if first and ws and kind == "invoice.paid" and \
346
+ _get(obj, "billing_reason") in {"subscription_cycle", "subscription_create"}:
347
+ video_quota.reset_videos(db, ws)
348
  if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
349
  # The first invoice of a Checkout is handled on the Checkout page itself.
350
  if _get(obj, "billing_reason") != "subscription_create":
backend/app/services/usage.py CHANGED
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
7
 
8
  from app.models import Artifact, UsageEvent, Workspace
9
  from app.services.plans import Plan, effective_plan, plan_limit_error
 
10
 
11
 
12
  def month_start(now: datetime | None = None) -> datetime:
@@ -32,7 +33,6 @@ def month_usage(db: Session, workspace_id) -> dict:
32
  ) or 0
33
  return {
34
  "audio_minutes": round(total("tts", "seconds") / 60, 1),
35
- "video_minutes": round(total("video", "seconds") / 60, 1),
36
  "launch_kits": kits,
37
  "llm_tokens": int(total("llm", "tokens")),
38
  "since": since.isoformat(),
@@ -42,10 +42,13 @@ def month_usage(db: Session, workspace_id) -> dict:
42
  def usage_report(db: Session, workspace: Workspace) -> dict:
43
  plan = effective_plan(db, workspace)
44
  used = month_usage(db, workspace.id)
 
 
45
  return {
46
  "plan": plan.id,
47
  "used": used,
48
- "limits": {"audio_minutes": plan.audio_minutes, "video_minutes": plan.video_minutes,
 
49
  "launch_kits": plan.launch_kits, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
50
  }
51
 
@@ -54,8 +57,7 @@ def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1)
54
  """Raise 402 plan_limit when this request would go over the monthly allowance."""
55
  plan = effective_plan(db, workspace)
56
  used = month_usage(db, workspace.id)
57
- limit = {"audio_minutes": plan.audio_minutes, "video_minutes": plan.video_minutes,
58
- "launch_kits": plan.launch_kits}[kind]
59
  if limit >= 0 and used[kind] + amount > limit:
60
  label = kind.replace("_", " ")
61
  raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
 
7
 
8
  from app.models import Artifact, UsageEvent, Workspace
9
  from app.services.plans import Plan, effective_plan, plan_limit_error
10
+ from app.services.video_quota import video_usage
11
 
12
 
13
  def month_start(now: datetime | None = None) -> datetime:
 
33
  ) or 0
34
  return {
35
  "audio_minutes": round(total("tts", "seconds") / 60, 1),
 
36
  "launch_kits": kits,
37
  "llm_tokens": int(total("llm", "tokens")),
38
  "since": since.isoformat(),
 
42
  def usage_report(db: Session, workspace: Workspace) -> dict:
43
  plan = effective_plan(db, workspace)
44
  used = month_usage(db, workspace.id)
45
+ videos = video_usage(db, workspace)
46
+ used["videos"] = videos["used"]
47
  return {
48
  "plan": plan.id,
49
  "used": used,
50
+ "videos_resets_at": videos["resets_at"],
51
+ "limits": {"audio_minutes": plan.audio_minutes, "videos": videos["limit"],
52
  "launch_kits": plan.launch_kits, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
53
  }
54
 
 
57
  """Raise 402 plan_limit when this request would go over the monthly allowance."""
58
  plan = effective_plan(db, workspace)
59
  used = month_usage(db, workspace.id)
60
+ limit = {"audio_minutes": plan.audio_minutes, "launch_kits": plan.launch_kits}[kind]
 
61
  if limit >= 0 and used[kind] + amount > limit:
62
  label = kind.replace("_", " ")
63
  raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
backend/app/services/video_limits.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-workspace shares of what blog2video only limits account-wide: AI edits, custom templates, template AI
2
+ operations, voice designs, voice samples, custom voices (plans.VIDEO_LIMITS), plus premium (★) video options.
3
+
4
+ blog2video checks every limit against our own (Pro) account, so every workspace passes its checks. These counters
5
+ stop a workspace before it uses up everyone's share. Videos themselves are counted in video_quota.py.
6
+
7
+ A take is one conditional UPDATE (used + n <= limit), so two requests can not both take the last unit.
8
+ """
9
+
10
+ import uuid
11
+ from datetime import UTC, datetime
12
+
13
+ from sqlalchemy import case, select, update
14
+ from sqlalchemy.exc import IntegrityError
15
+ from sqlalchemy.orm import Session
16
+
17
+ from app.models import UsageCounter, Workspace
18
+ from app.services.plans import VIDEO_LIMITS, Plan, effective_plan, plan_limit_error
19
+
20
+ LABELS = {
21
+ "ai_edits": ("AI edits", "this month"),
22
+ "templates": ("custom templates", "in total"),
23
+ "template_ai_daily": ("AI template steps", "today"),
24
+ "voice_designs_daily": ("voice designs", "today"),
25
+ "voice_samples_daily": ("voice samples", "today"),
26
+ "custom_voices": ("custom voices", "at a time"),
27
+ }
28
+
29
+
30
+ def period_key(metric: str, now: datetime | None = None) -> str:
31
+ now = now or datetime.now(UTC)
32
+ kind = VIDEO_LIMITS[metric]
33
+ return now.strftime("%Y-%m-%d") if kind == "day" else now.strftime("%Y-%m") if kind == "month" else "all"
34
+
35
+
36
+ def _ensure_row(db: Session, workspace_id: uuid.UUID, period: str, metric: str) -> None:
37
+ key = {"workspace_id": workspace_id, "period": period, "metric": metric}
38
+ if db.get(UsageCounter, key) is not None:
39
+ return
40
+ db.add(UsageCounter(**key, used=0))
41
+ try:
42
+ db.commit()
43
+ except IntegrityError: # a concurrent request made it first
44
+ db.rollback()
45
+
46
+
47
+ def used(db: Session, workspace_id: uuid.UUID, metric: str) -> int:
48
+ row = db.get(UsageCounter, {"workspace_id": workspace_id, "period": period_key(metric), "metric": metric})
49
+ return row.used if row else 0
50
+
51
+
52
+ def take(db: Session, workspace: Workspace, metric: str, n: int = 1, plan: Plan | None = None) -> None:
53
+ """Use n units of metric, or raise the 402 that opens the upgrade popup."""
54
+ plan = plan or effective_plan(db, workspace)
55
+ limit = int(plan.video_limits.get(metric, 0))
56
+ period = period_key(metric)
57
+ _ensure_row(db, workspace.id, period, metric)
58
+ where = [UsageCounter.workspace_id == workspace.id, UsageCounter.period == period, UsageCounter.metric == metric]
59
+ if limit >= 0: # -1 = unlimited, still counted
60
+ where.append(UsageCounter.used + n <= limit)
61
+ taken = db.execute(update(UsageCounter).where(*where).values(used=UsageCounter.used + n)).rowcount
62
+ db.commit()
63
+ if taken != 1:
64
+ label, when = LABELS[metric]
65
+ message = (f"Your plan does not include {label}." if limit == 0
66
+ else f"You have used all {limit} {label} {when}.")
67
+ raise plan_limit_error(plan, f"video_limits.{metric}", message)
68
+
69
+
70
+ def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> None:
71
+ """Undo a take whose blog2video call failed (or a custom voice that was deleted)."""
72
+ db.execute(update(UsageCounter)
73
+ .where(UsageCounter.workspace_id == workspace_id, UsageCounter.period == period_key(metric),
74
+ UsageCounter.metric == metric)
75
+ .values(used=case((UsageCounter.used > n, UsageCounter.used - n), else_=0)))
76
+ db.commit()
77
+
78
+
79
+ def require_premium(db: Session, workspace: Workspace, what: str, plan: Plan | None = None) -> Plan:
80
+ plan = plan or effective_plan(db, workspace)
81
+ if not plan.video_premium:
82
+ raise plan_limit_error(plan, "video_premium", f"{what} comes with a paid plan.")
83
+ return plan
84
+
85
+
86
+ def report(db: Session, workspace: Workspace) -> dict:
87
+ """What the wizard shows: premium or not, and each counter's used / limit."""
88
+ plan = effective_plan(db, workspace)
89
+ rows = {(r.period, r.metric): r.used for r in db.scalars(
90
+ select(UsageCounter).where(UsageCounter.workspace_id == workspace.id))}
91
+ return {"premium": plan.video_premium,
92
+ "limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))}
93
+ for m in VIDEO_LIMITS}}
backend/app/services/video_quota.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video allowance, kept on the workspace's subscriptions row and enforced only by us.
2
+
3
+ blog2video charges every video to our own account; it does not know our workspaces. So we:
4
+ - reserve one video (videos_used + 1, only while below video_limit) before asking blog2video for it;
5
+ - refund it exactly once if the create is refused or generation fails (the b2v_videos row records which);
6
+ - keep video_plan and video_limit equal to the effective plan, and set videos_used back to 0 each month on plans
7
+ with a monthly allowance: on Stripe renewal (invoice.paid, see stripe_billing.handle_event), and monthly for
8
+ workspaces with no renewal to wait for (billing disabled, annual plans between renewals).
9
+ - Free's allowance is a lifetime total (plan.videos_monthly False): never reset. When a workspace drops to such a
10
+ plan, videos_used becomes its lifetime count of videos that were not refunded.
11
+
12
+ videos_used is only ever written with a single SQL UPDATE here, never through the ORM, so a sync can not
13
+ overwrite a concurrent reserve or refund.
14
+ """
15
+
16
+ import calendar
17
+ import logging
18
+ import uuid
19
+ from collections.abc import Callable
20
+ from datetime import UTC, datetime, timedelta
21
+
22
+ from sqlalchemy import case, func, select, update
23
+ from sqlalchemy.orm import Session
24
+
25
+ from app.config import settings
26
+ from app.db import SessionLocal
27
+ from app.models import Artifact, B2VVideo, Subscription, Workspace
28
+ from app.services import blog2video as b2v
29
+ from app.services.plans import PLANS, effective_plan
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ LIVE = {"active", "trialing", "past_due"}
34
+ RENEWAL_WINDOW = timedelta(days=45) # a Stripe renewal this close to the period start does the reset instead
35
+ FAILED = {"failed", "error"}
36
+ FINISHED = {"generated", "done"}
37
+ SWEEP_AFTER = timedelta(minutes=30) # the sweep only looks at videos older than this (users who closed the tab)
38
+
39
+
40
+ def add_month(when: datetime) -> datetime:
41
+ year, month = (when.year + 1, 1) if when.month == 12 else (when.year, when.month + 1)
42
+ return when.replace(year=year, month=month, day=min(when.day, calendar.monthrange(year, month)[1]))
43
+
44
+
45
+ def _aware(when: datetime | None) -> datetime | None:
46
+ return when.replace(tzinfo=UTC) if when and when.tzinfo is None else when
47
+
48
+
49
+ def _row(db: Session, workspace_id: uuid.UUID) -> Subscription:
50
+ sub = db.scalar(select(Subscription).where(Subscription.workspace_id == workspace_id))
51
+ if not sub:
52
+ sub = Subscription(workspace_id=workspace_id, plan="free", status="active")
53
+ db.add(sub)
54
+ db.flush()
55
+ return sub
56
+
57
+
58
+ def sync_video_quota(db: Session, workspace: Workspace) -> Subscription:
59
+ """Make video_plan and video_limit match the workspace's effective plan. Cheap when nothing changed."""
60
+ plan = effective_plan(db, workspace)
61
+ sub = _row(db, workspace.id)
62
+ changed = False
63
+ if sub.video_plan != plan.id and not plan.videos_monthly:
64
+ # Onto a lifetime allowance: everything made so far counts (a Writer who made 10 can't make more on Free).
65
+ made = db.scalar(select(func.count(B2VVideo.id)).where(B2VVideo.workspace_id == workspace.id,
66
+ B2VVideo.quota_state != "refunded")) or 0
67
+ db.execute(update(Subscription).where(Subscription.id == sub.id).values(videos_used=made))
68
+ changed = True
69
+ if sub.video_plan != plan.id or sub.video_limit != plan.videos:
70
+ sub.video_plan, sub.video_limit = plan.id, plan.videos
71
+ changed = True
72
+ if sub.videos_period_start is None:
73
+ sub.videos_period_start = datetime.now(UTC)
74
+ changed = True
75
+ if changed:
76
+ db.commit()
77
+ return sub
78
+
79
+
80
+ def _renews_by_stripe(sub: Subscription) -> bool:
81
+ start, end = _aware(sub.videos_period_start), _aware(sub.current_period_end)
82
+ return bool(sub.provider_subscription_id and sub.status in LIVE and start and end
83
+ and end <= start + RENEWAL_WINDOW)
84
+
85
+
86
+ def monthly(sub: Subscription) -> bool:
87
+ plan = PLANS.get(sub.video_plan)
88
+ return plan.videos_monthly if plan else True
89
+
90
+
91
+ def resets_at(sub: Subscription) -> datetime | None:
92
+ start = _aware(sub.videos_period_start)
93
+ if not start or not monthly(sub):
94
+ return None
95
+ return _aware(sub.current_period_end) if _renews_by_stripe(sub) else add_month(start)
96
+
97
+
98
+ def video_usage(db: Session, workspace: Workspace) -> dict:
99
+ sub = sync_video_quota(db, workspace)
100
+ db.refresh(sub) # videos_used changes under us (reserves and refunds from other requests)
101
+ reset = resets_at(sub)
102
+ start = _aware(sub.videos_period_start)
103
+ return {"used": sub.videos_used, "limit": sub.video_limit, "plan": sub.video_plan,
104
+ "period_start": start.isoformat() if start else None, "resets_at": reset.isoformat() if reset else None}
105
+
106
+
107
+ def reset_videos(db: Session, workspace_id: uuid.UUID, at: datetime | None = None) -> None:
108
+ db.execute(update(Subscription).where(Subscription.workspace_id == workspace_id)
109
+ .values(videos_used=0, videos_period_start=at or datetime.now(UTC)))
110
+ db.commit()
111
+
112
+
113
+ def reset_due_video_periods(session_factory: Callable[[], Session] = SessionLocal) -> int:
114
+ """Monthly reset for workspaces a Stripe renewal will not reset in time. Runs from the worker's PERIODIC list."""
115
+ now = datetime.now(UTC)
116
+ count = 0
117
+ with session_factory() as db:
118
+ for sub in db.scalars(select(Subscription)):
119
+ start = _aware(sub.videos_period_start)
120
+ if start is None or _renews_by_stripe(sub) or not monthly(sub):
121
+ continue
122
+ if add_month(start) <= now:
123
+ reset_videos(db, sub.workspace_id, now)
124
+ count += 1
125
+ return count
126
+
127
+
128
+ def reserve(db: Session, workspace_id: uuid.UUID) -> bool:
129
+ """Take one video from the allowance, atomically: two requests can not both take the last one.
130
+ False = none left (do not call blog2video)."""
131
+ taken = db.execute(update(Subscription)
132
+ .where(Subscription.workspace_id == workspace_id,
133
+ Subscription.videos_used < Subscription.video_limit)
134
+ .values(videos_used=Subscription.videos_used + 1)).rowcount
135
+ db.commit()
136
+ return taken == 1
137
+
138
+
139
+ def unreserve(db: Session, workspace_id: uuid.UUID) -> None:
140
+ """Give back a reservation that never got a b2v_videos row."""
141
+ db.execute(update(Subscription).where(Subscription.workspace_id == workspace_id)
142
+ .values(videos_used=case((Subscription.videos_used > 0, Subscription.videos_used - 1), else_=0)))
143
+ db.commit()
144
+
145
+
146
+ def refund(db: Session, row: B2VVideo, status: str | None = None) -> bool:
147
+ """Give the workspace its video back, exactly once per row, however many callers race here, and never once
148
+ it finished generating ('kept'). True if this call did the refund."""
149
+ values = {"quota_state": "refunded", "updated_at": datetime.now(UTC)}
150
+ if status:
151
+ values["status"] = status
152
+ won = db.execute(update(B2VVideo).where(B2VVideo.id == row.id, B2VVideo.quota_state == "charged")
153
+ .values(**values)).rowcount == 1
154
+ if won:
155
+ db.execute(update(Subscription).where(Subscription.workspace_id == row.workspace_id)
156
+ .values(videos_used=case((Subscription.videos_used > 0, Subscription.videos_used - 1),
157
+ else_=0)))
158
+ db.commit()
159
+ db.refresh(row)
160
+ return won
161
+
162
+
163
+ def record_status(db: Session, row: B2VVideo, status: str) -> None:
164
+ """Remember blog2video's latest status, refunding when it says the video failed. Once generation finished the
165
+ charge is kept (quota_state 'kept'): a later failed edit does not give the video back."""
166
+ if status in FAILED:
167
+ refund(db, row, "failed")
168
+ elif status and row.status != status:
169
+ values = {"status": status, "updated_at": datetime.now(UTC)}
170
+ if status in FINISHED:
171
+ values["quota_state"] = case((B2VVideo.quota_state == "charged", "kept"), else_=B2VVideo.quota_state)
172
+ db.execute(update(B2VVideo).where(B2VVideo.id == row.id).values(**values))
173
+ db.commit()
174
+ db.refresh(row)
175
+
176
+
177
+ def sweep_failed_videos(session_factory: Callable[[], Session] = SessionLocal) -> int:
178
+ """Refund videos that failed while nobody was polling (the user closed the tab). Runs from PERIODIC."""
179
+ if not b2v.configured():
180
+ return 0
181
+ cutoff = datetime.now(UTC) - SWEEP_AFTER
182
+ refunded = 0
183
+ with session_factory() as db:
184
+ rows = db.scalars(select(B2VVideo).where(B2VVideo.quota_state == "charged",
185
+ B2VVideo.status.not_in(FINISHED),
186
+ B2VVideo.created_at < cutoff)).all()
187
+ for row in rows:
188
+ if row.b2v_video_id is None:
189
+ # The create never got an answer and the request died before refunding.
190
+ refunded += refund(db, row, "failed")
191
+ continue
192
+ try:
193
+ s = b2v.status(row.b2v_video_id, row.created_via)
194
+ except b2v.NotFound:
195
+ s = {"status": "failed", "error": "Video not found"}
196
+ except b2v.B2VError as e:
197
+ log.warning("video sweep: status of %s failed: %s", row.b2v_video_id, e)
198
+ continue
199
+ raw = str(s.get("status") or "")
200
+ if s.get("error") and raw not in FAILED:
201
+ raw = "failed"
202
+ if raw in FAILED:
203
+ refunded += refund(db, row, "failed")
204
+ if row.artifact_id and (a := db.get(Artifact, row.artifact_id)):
205
+ a.status = "failed"
206
+ a.content_json = {**(a.content_json or {}), "b2v_status": raw, "error": s.get("error")}
207
+ db.commit()
208
+ elif raw:
209
+ record_status(db, row, raw)
210
+ return refunded
211
+
212
+
213
+ AI_EDITS_ALERT_BELOW = 200
214
+ TEMPLATE_SLOTS_ALERT_BELOW = 3
215
+
216
+
217
+ def check_capacity() -> dict | None:
218
+ """Alert when our blog2video account runs low: our workspaces' limits added up can exceed it. Hourly."""
219
+ if not b2v.configured():
220
+ return None
221
+ try:
222
+ account = b2v.account() or {}
223
+ except b2v.B2VError as e:
224
+ log.error("ALERT blog2video capacity check failed: %s", e)
225
+ return None
226
+ low = []
227
+ limit, used = account.get("video_limit"), account.get("videos_used_this_period")
228
+ if not account.get("can_create_video") or (
229
+ limit is not None and used is not None and limit - used < settings.b2v_capacity_alert_below):
230
+ low.append(f"videos {used}/{limit} used")
231
+ ai = (account.get("ai_edit_allowance_remaining") or 0) + (account.get("ai_edit_credits") or 0)
232
+ if ai < AI_EDITS_ALERT_BELOW:
233
+ low.append(f"{ai} AI edits left")
234
+ slots, made = account.get("custom_template_limit"), account.get("custom_templates_created")
235
+ if slots is not None and made is not None and slots - made < TEMPLATE_SLOTS_ALERT_BELOW:
236
+ low.append(f"template slots {made}/{slots} used")
237
+ if low:
238
+ log.error("ALERT blog2video account (plan %s) is running low: %s", account.get("plan"), "; ".join(low))
239
+ return account
backend/app/worker.py CHANGED
@@ -44,7 +44,7 @@ from app.models import (
44
  from app.pipeline import generate, launchkit, media
45
  from app.pipeline.generate import NothingToDo
46
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
47
- from app.services import launchpad, tts
48
  from app.services.email import email_service
49
  from app.services.email_verification import purge_old_codes
50
  from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
@@ -226,7 +226,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
226
  "resurface_scan": handle_resurface,
227
  "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
228
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
229
- "video": _artifact_handler(media.make_video, "Video ready"),
230
  "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
231
  "carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
232
  "launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
@@ -364,6 +364,9 @@ PERIODIC = [
364
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
365
  Periodic("update_email_batch", 300, update_email_batch),
366
  Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
 
 
 
367
  ]
368
 
369
 
 
44
  from app.pipeline import generate, launchkit, media
45
  from app.pipeline.generate import NothingToDo
46
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
47
+ from app.services import launchpad, tts, video_quota
48
  from app.services.email import email_service
49
  from app.services.email_verification import purge_old_codes
50
  from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
 
226
  "resurface_scan": handle_resurface,
227
  "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
228
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
229
+ "video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
230
  "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
231
  "carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
232
  "launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
 
364
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
365
  Periodic("update_email_batch", 300, update_email_batch),
366
  Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
367
+ Periodic("video_period_reset", 3600, video_quota.reset_due_video_periods),
368
+ Periodic("video_refund_sweep", 600, video_quota.sweep_failed_videos),
369
+ Periodic("video_capacity_check", 3600, video_quota.check_capacity),
370
  ]
371
 
372
 
backend/requirements.txt CHANGED
@@ -13,6 +13,7 @@ requests==2.*
13
  boto3==1.35.*
14
  resend==2.*
15
  httpx==0.28.*
 
16
  dspy==2.6.*
17
  feedparser==6.*
18
  beautifulsoup4==4.*
 
13
  boto3==1.35.*
14
  resend==2.*
15
  httpx==0.28.*
16
+ python-multipart==0.0.*
17
  dspy==2.6.*
18
  feedparser==6.*
19
  beautifulsoup4==4.*
backend/tests/conftest.py CHANGED
@@ -11,6 +11,8 @@ os.environ["ELEVENLABS_API_KEY"] = ""
11
  os.environ["FIRECRAWL_API_KEY"] = ""
12
  os.environ["R2_PREFIX"] = ""
13
  os.environ["TYPESAFE_API_KEY"] = ""
 
 
14
  os.environ.setdefault("EMAIL_PROVIDER", "console")
15
  os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
16
  os.environ.setdefault("BILLING_ENABLED", "false")
 
11
  os.environ["FIRECRAWL_API_KEY"] = ""
12
  os.environ["R2_PREFIX"] = ""
13
  os.environ["TYPESAFE_API_KEY"] = ""
14
+ os.environ["B2V_API_BASE_URL"] = ""
15
+ os.environ["B2V_API_KEY"] = ""
16
  os.environ.setdefault("EMAIL_PROVIDER", "console")
17
  os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
18
  os.environ.setdefault("BILLING_ENABLED", "false")
backend/tests/test_billing.py CHANGED
@@ -23,7 +23,7 @@ def billing_on(monkeypatch):
23
  def test_status_without_billing_has_no_upgrade_nudges(client, auth):
24
  body = client.get("/api/billing/status", headers=auth).json()
25
  assert body["plan"]["id"] == "studio" and body["can_upgrade"] is False
26
- assert {m["key"] for m in body["meters"]} == {"audio_minutes", "video_minutes", "launch_kits", "sources"}
27
  assert all(n["tone"] == "action" for n in body["nudges"])
28
  assert body["nudges"][0]["id"] == "act-source"
29
 
@@ -76,3 +76,69 @@ def test_plan_limit_error_names_the_upgrade():
76
  assert upgrade_for(PLANS["studio"], "audio_minutes") is None
77
  detail = plan_limit_error(PLANS["free"], "audio_minutes", "x").detail
78
  assert detail["code"] == "plan_limit" and detail["upgrade_to"] == "writer" and detail["kind"] == "audio_minutes"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def test_status_without_billing_has_no_upgrade_nudges(client, auth):
24
  body = client.get("/api/billing/status", headers=auth).json()
25
  assert body["plan"]["id"] == "studio" and body["can_upgrade"] is False
26
+ assert {m["key"] for m in body["meters"]} == {"audio_minutes", "videos", "launch_kits", "sources"}
27
  assert all(n["tone"] == "action" for n in body["nudges"])
28
  assert body["nudges"][0]["id"] == "act-source"
29
 
 
76
  assert upgrade_for(PLANS["studio"], "audio_minutes") is None
77
  detail = plan_limit_error(PLANS["free"], "audio_minutes", "x").detail
78
  assert detail["code"] == "plan_limit" and detail["upgrade_to"] == "writer" and detail["kind"] == "audio_minutes"
79
+
80
+
81
+ @pytest.fixture()
82
+ def plans_table(monkeypatch):
83
+ """Point the plan catalog at its own database, seeded like migration 0006. (Not the client's: that one
84
+ shares a single connection, and the catalog's own session would roll back the request's work.)"""
85
+ from sqlalchemy import create_engine
86
+ from sqlalchemy.orm import sessionmaker
87
+ from sqlalchemy.pool import StaticPool
88
+
89
+ import app.db
90
+ from app.db import Base
91
+ from app.models import PlanRecord
92
+ from app.services.plans import DEFAULT_PLANS, PLANS
93
+
94
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
95
+ Base.metadata.create_all(engine)
96
+ session_factory = sessionmaker(bind=engine, expire_on_commit=False)
97
+ monkeypatch.setattr(app.db, "SessionLocal", session_factory)
98
+ with session_factory() as db:
99
+ for i, p in enumerate(DEFAULT_PLANS.values()):
100
+ db.add(PlanRecord(id=p.id, sort_order=i, name=p.name, tagline=p.tagline,
101
+ price_monthly_usd=p.price_monthly_usd, sources=p.sources, indexed_posts=p.indexed_posts,
102
+ audio_minutes=p.audio_minutes, videos=p.videos, launch_kits=p.launch_kits,
103
+ voice_cloning=p.voice_cloning, brand_kit=p.brand_kit, features=list(p.features)))
104
+ db.commit()
105
+ PLANS.reload()
106
+ yield session_factory
107
+ PLANS.reload()
108
+
109
+
110
+ def test_plans_come_from_the_table(plans_table):
111
+ from app.models import PlanRecord
112
+ from app.services.plans import PLANS, next_plan
113
+
114
+ with plans_table() as db:
115
+ db.get(PlanRecord, "studio").videos = 50
116
+ db.get(PlanRecord, "writer").features = ["Edited copy"]
117
+ db.commit()
118
+ PLANS.reload()
119
+ assert PLANS["studio"].videos == 50 and PLANS["writer"].features == ("Edited copy",)
120
+ assert list(PLANS) == ["free", "writer", "studio"] and next_plan(PLANS["free"]).id == "writer"
121
+
122
+
123
+ def test_edited_limit_reaches_video_quota(plans_table, client, auth, db_session):
124
+ from app.models import PlanRecord, Workspace
125
+ from app.services.plans import PLANS
126
+ from app.services.video_quota import sync_video_quota
127
+
128
+ with plans_table() as db:
129
+ db.get(PlanRecord, "studio").videos = 42 # billing disabled: everyone is on Studio
130
+ db.commit()
131
+ PLANS.reload()
132
+ ws = db_session.query(Workspace).one()
133
+ assert sync_video_quota(db_session, ws).video_limit == 42
134
+
135
+
136
+ def test_empty_or_broken_table_falls_back_to_defaults(plans_table):
137
+ from app.models import PlanRecord
138
+ from app.services.plans import DEFAULT_PLANS, PLANS
139
+
140
+ with plans_table() as db:
141
+ db.query(PlanRecord).filter(PlanRecord.id == "free").delete() # no free plan: unusable
142
+ db.commit()
143
+ PLANS.reload()
144
+ assert PLANS["free"] == DEFAULT_PLANS["free"] and PLANS["studio"].videos == DEFAULT_PLANS["studio"].videos
backend/tests/test_buildout.py CHANGED
@@ -404,26 +404,32 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
404
  assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit"
405
 
406
 
407
- def test_video_hands_off_and_renderer_callback(client, auth, run_jobs, feed, llm, monkeypatch):
408
- from app.pipeline import media
409
-
410
  connect(client, auth, run_jobs)
411
  doc = docs(client, auth)[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  sent = {}
413
  monkeypatch.setattr(media, "request_render", lambda job, artifact, comp, props=None, stills=None:
414
  sent.update(comp=comp, props=props, job=str(job.id)))
415
- llm["VideoStoryboard"] = {"scenes": [
416
- {"type": "title", "on_screen_text": "Charge more", "narration": "", "duration_hint_s": 2.5, "visual": ""},
417
- {"type": "section", "on_screen_text": "Readers valued it", "narration": "", "duration_hint_s": 3,
418
- "visual": ""},
419
- ]}
420
- art = client.post("/api/artifacts/generate", json={"type": "video", "document_id": doc["id"], "style": "short"},
421
- headers=auth).json()
422
  job = run_jobs()[0]
423
  assert job.status == "running" # handed off to the renderer
424
- assert sent["comp"] == "ShortVertical" and sent["props"]["hook"] == "Charge more"
425
- from app.config import settings
426
-
427
  r = client.post(f"/api/internal/jobs/{sent['job']}/progress", headers={"x-internal-token": settings.internal_token},
428
  json={"status": "done", "progress": 1, "storage_key": "ws/x/video.mp4", "render_seconds": 12})
429
  assert r.status_code == 200
 
404
  assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit"
405
 
406
 
407
+ def test_native_video_moved_to_blog2video(client, auth, run_jobs, feed):
 
 
408
  connect(client, auth, run_jobs)
409
  doc = docs(client, auth)[0]
410
+ r = client.post("/api/artifacts/generate", json={"type": "video", "document_id": doc["id"], "style": "short"},
411
+ headers=auth)
412
+ assert r.status_code == 410
413
+
414
+
415
+ def test_audiogram_hands_off_and_renderer_callback(client, auth, monkeypatch, db_session, run_jobs):
416
+ from app.config import settings
417
+ from app.models import Artifact, Workspace
418
+ from app.pipeline import media
419
+
420
+ ws = db_session.query(Workspace).one()
421
+ audio = Artifact(workspace_id=ws.id, type="audio_overview", status="ready", storage_key="ws/x/audio.mp3",
422
+ content_json={"title": "Pricing", "duration_s": 42, "segments": []})
423
+ db_session.add(audio)
424
+ db_session.commit()
425
  sent = {}
426
  monkeypatch.setattr(media, "request_render", lambda job, artifact, comp, props=None, stills=None:
427
  sent.update(comp=comp, props=props, job=str(job.id)))
428
+ art = client.post("/api/artifacts/generate", json={"type": "video", "style": "audiogram",
429
+ "audio_artifact_id": str(audio.id)}, headers=auth).json()
 
 
 
 
 
430
  job = run_jobs()[0]
431
  assert job.status == "running" # handed off to the renderer
432
+ assert sent["comp"] == "AudiogramSquare"
 
 
433
  r = client.post(f"/api/internal/jobs/{sent['job']}/progress", headers={"x-internal-token": settings.internal_token},
434
  json={"status": "done", "progress": 1, "storage_key": "ws/x/video.mp4", "render_seconds": 12})
435
  assert r.status_code == 200
backend/tests/test_simplified_flows.py CHANGED
@@ -1,9 +1,11 @@
1
  """One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
2
 
 
3
  from sqlalchemy import select
 
4
 
5
  from app.config import settings
6
- from app.models import Job
7
  from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
8
 
9
 
@@ -23,6 +25,17 @@ def test_archive_notebook_is_created_once_and_listed_first(client, auth, run_job
23
  assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
26
  def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed): # noqa: F811
27
  connect(client, auth, run_jobs)
28
  art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
 
1
  """One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
2
 
3
+ import pytest
4
  from sqlalchemy import select
5
+ from sqlalchemy.exc import IntegrityError
6
 
7
  from app.config import settings
8
+ from app.models import Job, Notebook, Workspace
9
  from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
10
 
11
 
 
25
  assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
26
 
27
 
28
+ def test_a_workspace_cannot_have_two_archive_notebooks(client, auth, db_session): # noqa: F811
29
+ client.post("/api/notebooks/archive", headers=auth)
30
+ ws = db_session.scalar(select(Workspace))
31
+ db_session.add(Notebook(workspace_id=ws.id, title="All posts", is_archive=True))
32
+ with pytest.raises(IntegrityError):
33
+ db_session.commit()
34
+ db_session.rollback()
35
+ db_session.add(Notebook(workspace_id=ws.id, title="Mine")) # ordinary notebooks are not limited
36
+ db_session.commit()
37
+
38
+
39
  def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed): # noqa: F811
40
  connect(client, auth, run_jobs)
41
  art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
backend/tests/test_stripe_billing.py CHANGED
@@ -199,3 +199,37 @@ def test_upgrade_needing_3ds_sends_user_to_invoice(client, stripe_on, owner, db_
199
  r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
200
  assert r.json()["url"] == "https://invoice.stripe.test/i_1"
201
  assert plan_of(db_session, ws) == "writer" # switches only once the payment succeeds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
200
  assert r.json()["url"] == "https://invoice.stripe.test/i_1"
201
  assert plan_of(db_session, ws) == "writer" # switches only once the payment succeeds
202
+
203
+
204
+ def videos_used(db, ws) -> int:
205
+ db.expire_all()
206
+ return db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).videos_used
207
+
208
+
209
+ def set_videos_used(db, ws, n: int) -> None:
210
+ db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).videos_used = n
211
+ db.commit()
212
+
213
+
214
+ @pytest.mark.parametrize("reason,resets", [("subscription_cycle", True), ("subscription_create", True),
215
+ ("subscription_update", False)])
216
+ def test_invoice_paid_resets_videos(client, stripe_on, owner, db_session, reason, resets):
217
+ _, ws = owner
218
+ stripe_on.add_sub("sub_v", ws.id, plan="writer")
219
+ set_videos_used(db_session, ws, 7)
220
+ paid = event("invoice.paid", {"subscription": "sub_v", "customer": "cus_1", "billing_reason": reason})
221
+ send(client, paid)
222
+ assert videos_used(db_session, ws) == (0 if resets else 7)
223
+ # A retried delivery of the same event must not wipe videos made since.
224
+ set_videos_used(db_session, ws, 3)
225
+ send(client, paid)
226
+ assert videos_used(db_session, ws) == 3
227
+
228
+
229
+ def test_subscription_sync_sets_video_limit(client, stripe_on, owner, db_session):
230
+ _, ws = owner
231
+ stripe_on.add_sub("sub_l", ws.id, plan="studio")
232
+ send(client, event("customer.subscription.created", stripe_on.subs["sub_l"]))
233
+ db_session.expire_all()
234
+ sub = db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
235
+ assert (sub.video_plan, sub.video_limit) == ("studio", 20)
backend/tests/test_videos.py ADDED
@@ -0,0 +1,960 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Videos through blog2video, with a fake blog2video: API key, denylist, the 3-step create (link, post, upload,
2
+ multi-link), our own quotas and counters, premium gating, and workspace isolation for videos, templates, voices
3
+ and styles (the integration guide's acceptance checklist)."""
4
+
5
+ import json
6
+ import json as jsonlib
7
+ import logging
8
+ import uuid
9
+ from datetime import UTC, datetime, timedelta
10
+
11
+ import httpx
12
+ import pytest
13
+ from sqlalchemy import select
14
+
15
+ from app.config import settings
16
+ from app.models import (
17
+ Artifact,
18
+ B2VCustomVoice,
19
+ B2VStyle,
20
+ B2VTemplate,
21
+ B2VVideo,
22
+ Document,
23
+ Notebook,
24
+ Subscription,
25
+ UsageCounter,
26
+ UserSavedVoice,
27
+ Workspace,
28
+ )
29
+ from app.services import blog2video, video_limits, video_quota
30
+ from tests.conftest import last_code
31
+
32
+ KEY = "b2v_live_test_key"
33
+ API = "https://b2v.test"
34
+ SARAH, BELLA = "EXAVITQu4vr4xnSDxMaL", "hpp4J3VqNfWAUOO0d1Us" # a free and a paid built-in voice
35
+
36
+ DEFAULTS: dict[str, object] = {
37
+ "GET /api/templates": [{"id": "default", "name": "Default"}, {"id": "geometric", "name": "Geometric",
38
+ "preview_colors": {"accent": "#FF0000"}}],
39
+ "GET /api/crafted-templates": [{"id": "crafted_neon", "name": "Neon"}],
40
+ "GET /api/voices/prebuilt": {"voices": [
41
+ {"voice_id": SARAH, "name": "Sarah - Mature", "preview_url": "https://cdn.test/sarah.mp3",
42
+ "labels": {"gender": "female", "accent": "american"}, "description": "Warm", "plan": "free"},
43
+ {"voice_id": BELLA, "name": "Bella - Bright", "preview_url": "https://cdn.test/bella.mp3",
44
+ "labels": {"gender": "female", "accent": "british"}, "description": "Bright", "plan": "paid"}]},
45
+ "GET /api/background-music/tracks": [{"track_id": "corporate_upbeat", "display_name": "Corporate Upbeat"}],
46
+ "GET /api/video-styles": {"styles": [
47
+ {"id": "explainer", "name": "Explainer", "kind": "builtin"},
48
+ {"id": "your_style", "name": "Your Style", "kind": "learned"},
49
+ {"id": "custom:7", "custom_id": 7, "name": "Mine", "kind": "custom", "guidance": "Short."},
50
+ {"id": "custom:9", "custom_id": 9, "name": "Theirs", "kind": "custom", "guidance": "Long."}]},
51
+ "POST /api/voices/custom": {"id": 31, "voice_id": "vc_designed", "name": "Narrator", "preview_url": None},
52
+ "POST /api/voices/clone": {"id": 32, "voice_id": "vc_clone", "name": "Me", "preview_url": None},
53
+ "POST /api/voices/design-from-prompt": {"previews": [{"generated_voice_id": "g1", "audio_base_64": "AAA",
54
+ "media_type": "audio/mpeg", "duration_secs": 3}]},
55
+ "POST /api/video-styles/custom": {"id": "custom:41", "custom_id": 41, "name": "Punchy", "guidance": "Punchy."},
56
+ "POST /api/custom-templates": {"id": 58, "name": "Brand"},
57
+ "POST /api/custom-templates/extract-theme-from-prompt": {"extractable": True, "theme": {"colors": {}}},
58
+ "GET /api/auth/me": {"plan": "pro", "video_limit": 150, "videos_used_this_period": 145, "can_create_video": True,
59
+ "ai_edit_allowance_remaining": 4000, "ai_edit_credits": 0, "custom_template_limit": 20,
60
+ "custom_templates_created": 3},
61
+ }
62
+
63
+
64
+ class FakeB2V:
65
+ """Records every call; answers from a queue of (status, body) per "METHOD path", else a sensible default."""
66
+
67
+ def __init__(self):
68
+ self.calls: list[dict] = []
69
+ self.queued: dict[str, list[tuple[int, object]]] = {}
70
+ self.next_id = 812
71
+
72
+ def queue(self, method: str, path: str, *answers: tuple[int, object]):
73
+ self.queued.setdefault(f"{method} {path}", []).extend(answers)
74
+
75
+ def paths(self, method: str | None = None) -> list[str]:
76
+ return [c["path"] for c in self.calls if method is None or c["method"] == method]
77
+
78
+ def __call__(self, method, url, json=None, params=None, data=None, files=None, timeout=None, headers=None):
79
+ if json is not None:
80
+ jsonlib.dumps(json) # what httpx does: anything that is not plain JSON fails here, as it would for real
81
+ path = url.removeprefix(API)
82
+ key = f"{method} {path}"
83
+ self.calls.append({"method": method, "path": path, "json": json, "data": data, "files": files,
84
+ "headers": headers or {}, "params": params})
85
+ answers = self.queued.get(key)
86
+ ctype = "application/json"
87
+ if answers:
88
+ status, body = answers.pop(0)
89
+ if isinstance(body, Exception):
90
+ raise body
91
+ elif key in DEFAULTS:
92
+ status, body = 200, DEFAULTS[key]
93
+ elif key in ("POST /api/v1/videos", "POST /api/projects/upload"):
94
+ vid, self.next_id = self.next_id, self.next_id + 1
95
+ status, body = (202, {"video_id": vid, "state": "queued"}) if "v1" in path else (200, {"id": vid})
96
+ elif path.startswith("/api/embed/token/"):
97
+ status, body = 200, {"embed_token": "t", "preview_url": f"{API}/embed/t"}
98
+ elif path.endswith("/status"):
99
+ status, body = 200, {"status": "scripted", "step": 2, "running": True, "ready": False, "error": None,
100
+ "video_url": None}
101
+ elif method == "GET" and path.startswith("/api/projects/") and path.count("/") == 3:
102
+ status, body = 200, {"id": int(path.rsplit("/", 1)[1]), "status": "generated", "r2_video_url": None,
103
+ "scenes": [{"id": 2, "order": 2, "title": "B"}, {"id": 1, "order": 1, "title": "A"}]}
104
+ elif path in ("/api/voice/preview",) or path.endswith("/download"):
105
+ return httpx.Response(200, content=b"ID3audio", headers={"content-type": "audio/mpeg"},
106
+ request=httpx.Request(method, url))
107
+ else:
108
+ status, body = 200, {"ok": True}
109
+ return httpx.Response(status, content=_json(body), headers={"content-type": ctype},
110
+ request=httpx.Request(method, url))
111
+
112
+
113
+ def _json(body) -> bytes:
114
+ return json.dumps(body).encode()
115
+
116
+
117
+ @pytest.fixture()
118
+ def b2v(monkeypatch):
119
+ fake = FakeB2V()
120
+ monkeypatch.setattr(settings, "b2v_api_base_url", API)
121
+ monkeypatch.setattr(settings, "b2v_api_key", KEY)
122
+ monkeypatch.setattr(blog2video.httpx, "request", fake)
123
+ monkeypatch.setattr(blog2video.time, "sleep", lambda s: None)
124
+ blog2video.clear_cache()
125
+ return fake
126
+
127
+
128
+ def register(client, email: str) -> dict:
129
+ client.post("/api/auth/email/register/start", json={"email": email, "password": "stardust-42", "name": "Ada"})
130
+ r = client.post("/api/auth/email/register/verify", json={"email": email, "code": last_code()})
131
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
132
+
133
+
134
+ @pytest.fixture()
135
+ def owner(client, db_session):
136
+ headers = register(client, "ada@example.com")
137
+ return headers, db_session.scalar(select(Workspace))
138
+
139
+
140
+ @pytest.fixture()
141
+ def other(client, db_session, owner):
142
+ headers = register(client, "grace@example.com")
143
+ ws = db_session.scalar(select(Workspace).where(Workspace.id != owner[1].id))
144
+ return headers, ws
145
+
146
+
147
+ @pytest.fixture()
148
+ def free_plan(monkeypatch):
149
+ monkeypatch.setattr(settings, "billing_enabled", True)
150
+
151
+
152
+ def add_doc(db, ws, url: str, text: str = "") -> Document:
153
+ doc = Document(workspace_id=ws.id, title="On Pricing", url=url, clean_text=text, path="sources/x/pricing.md")
154
+ db.add(doc)
155
+ db.commit()
156
+ return doc
157
+
158
+
159
+ def creates(fake: FakeB2V) -> list[dict]:
160
+ return [c for c in fake.calls if c["method"] == "POST" and c["path"] == "/api/v1/videos"]
161
+
162
+
163
+ def used(db, ws) -> int:
164
+ db.expire_all()
165
+ return db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).videos_used
166
+
167
+
168
+ def make_video(client, headers, **body) -> dict:
169
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", **body}, headers=headers)
170
+ assert r.status_code == 201, r.text
171
+ return r.json()
172
+
173
+
174
+ def age(db, row: B2VVideo) -> None:
175
+ row.created_at = datetime.now(UTC) - timedelta(hours=1)
176
+ db.commit()
177
+
178
+
179
+ # Client
180
+
181
+
182
+ def test_sends_our_api_key(b2v):
183
+ blog2video.account()
184
+ assert b2v.calls[0]["headers"]["Authorization"] == f"Bearer {KEY}"
185
+
186
+
187
+ def test_create_retries_with_the_same_idempotency_key(b2v):
188
+ b2v.queue("POST", "/api/v1/videos", (0, httpx.ConnectTimeout("slow")), (503, {"detail": "busy"}))
189
+ assert blog2video.create_video({"url": "https://a.test"}, "ws:1")["video_id"] == 812
190
+ assert [c["headers"]["Idempotency-Key"] for c in b2v.calls] == ["ws:1"] * 3
191
+
192
+
193
+ def test_other_calls_are_not_retried(b2v):
194
+ b2v.queue("GET", "/api/auth/me", (500, {"detail": "boom"}))
195
+ with pytest.raises(blog2video.Upstream):
196
+ blog2video.account()
197
+ assert len(b2v.calls) == 1
198
+
199
+
200
+ @pytest.mark.parametrize("status", [401, 402, 403])
201
+ def test_account_problems_alert_ops(b2v, caplog, status):
202
+ b2v.queue("GET", "/api/auth/me", (status, {"detail": {"error": "x"}}))
203
+ with caplog.at_level(logging.ERROR), pytest.raises(blog2video.Upstream if status != 402
204
+ else blog2video.AccountOutOfVideos):
205
+ blog2video.account()
206
+ assert "ALERT" in caplog.text and KEY not in caplog.text
207
+
208
+
209
+ @pytest.mark.parametrize("method,path", [
210
+ ("POST", "/api/projects"), ("POST", "/api/projects/bulk"), ("GET", "/api/projects"),
211
+ ("GET", "/api/projects/template-availability"), ("GET", "/api/voices/saved"), ("POST", "/api/voices/saved"),
212
+ ("DELETE", "/api/voices/saved/abc"), ("PATCH", "/api/video-styles/builtin/explainer"),
213
+ ("PUT", "/api/video-styles/pin"), ("PUT", "/api/video-styles/selection"), ("PATCH", "/api/video-styles/your-style"),
214
+ ("DELETE", "/api/auth/me/script-preferences"),
215
+ ])
216
+ def test_account_wide_endpoints_are_never_called(b2v, method, path):
217
+ with pytest.raises(blog2video.Denied):
218
+ blog2video.request(method, path)
219
+ assert b2v.calls == []
220
+
221
+
222
+ # Create: the 3 steps
223
+
224
+
225
+ def test_link_create_forwards_every_wizard_field(client, b2v, owner, db_session):
226
+ headers, ws = owner
227
+ body = {"stock_footage_enabled": False, "aspect_ratio": "portrait",
228
+ "video_length": "medium", "logo_position": "top_left", "logo_opacity": 0.5, "template": "geometric",
229
+ "video_style": "storytelling", "accent_color": "#FF5A1F", "bg_color": "#0B0B0F", "text_color": "#FFFFFF",
230
+ "content_language": "es", "voice_gender": "female", "voice_accent": "british",
231
+ "custom_voice_id": SARAH, "voice_emotion": '["0.5","1.0","calm","0","1"]',
232
+ "bgm_track_id": "corporate_upbeat", "bgm_volume": 0.2}
233
+ art = make_video(client, headers, **body)
234
+ call = creates(b2v)[0]
235
+ for k, v in body.items():
236
+ assert call["json"][k] == v, k
237
+ assert call["json"]["script_review_enabled"] is False
238
+ assert call["headers"]["Idempotency-Key"].startswith(f"{ws.id}:")
239
+ assert call["json"]["external_user_id"] == str(ws.id)
240
+ row = db_session.scalar(select(B2VVideo))
241
+ assert (row.b2v_video_id, row.quota_state, row.created_via, row.template_ref) == (812, "charged", "v1", "geometric")
242
+ assert art["content"]["b2v_video_id"] == 812 and used(db_session, ws) == 1
243
+
244
+
245
+ def test_script_review_is_always_off(client, b2v, owner):
246
+ make_video(client, owner[0], script_review_enabled=True) # an old client asking for it is ignored
247
+ assert creates(b2v)[0]["json"]["script_review_enabled"] is False
248
+
249
+
250
+ def test_create_from_feed_post_and_uploaded_post(client, b2v, owner, db_session):
251
+ headers, ws = owner
252
+ doc = add_doc(db_session, ws, "https://ada.example.com/p/pricing")
253
+ client.post("/api/videos", json={"document_id": str(doc.id)}, headers=headers)
254
+ assert creates(b2v)[-1]["json"]["url"] == "https://ada.example.com/p/pricing"
255
+ doc = add_doc(db_session, ws, "upload://notes.md", "Charging more made my readers take the work seriously. " * 3)
256
+ client.post("/api/videos", json={"document_id": str(doc.id)}, headers=headers)
257
+ sent = creates(b2v)[-1]["json"]
258
+ assert "url" not in sent and sent["content"].startswith("Charging more")
259
+
260
+
261
+ def test_notebook_posts_make_one_combined_video(client, b2v, owner, db_session):
262
+ headers, ws = owner
263
+ new = add_doc(db_session, ws, "https://ada.example.com/p/new", "Raise prices once a year, and say why. " * 3)
264
+ old = add_doc(db_session, ws, "https://ada.example.com/p/old", "Start cheaper than you think you should. " * 3)
265
+ new.title, new.published_at = "Annual raises", datetime(2026, 5, 1, tzinfo=UTC)
266
+ old.title, old.published_at = "Starting out", datetime(2025, 1, 1, tzinfo=UTC)
267
+ nb = Notebook(workspace_id=ws.id, title="Pricing series")
268
+ db_session.add(nb)
269
+ db_session.commit()
270
+ r = client.post("/api/videos", json={"document_ids": [str(new.id), str(old.id)], "notebook_id": str(nb.id)},
271
+ headers=headers)
272
+ assert r.status_code == 201 and r.json()["content"]["title"] == "Pricing series"
273
+ sent = creates(b2v)[-1]["json"]
274
+ assert len(creates(b2v)) == 1 and "url" not in sent and sent["title"] == "Pricing series"
275
+ assert not {"document_ids", "notebook_id", "document_id"} & set(sent) # the source is not a wizard option
276
+ assert sent["content"].index("# Starting out") < sent["content"].index("# Annual raises")
277
+ assert used(db_session, ws) == 1
278
+
279
+
280
+ def test_one_ticked_post_goes_by_its_link(client, b2v, owner, db_session):
281
+ headers, ws = owner
282
+ doc = add_doc(db_session, ws, "https://ada.example.com/p/pricing")
283
+ client.post("/api/videos", json={"document_ids": [str(doc.id)]}, headers=headers)
284
+ assert creates(b2v)[-1]["json"]["url"] == "https://ada.example.com/p/pricing"
285
+
286
+
287
+ def test_locked_posts_are_refused_before_charging(client, b2v, owner, db_session):
288
+ headers, ws = owner
289
+ ok = add_doc(db_session, ws, "https://ada.example.com/p/a", "Enough text to make a video from, really. " * 3)
290
+ locked = add_doc(db_session, ws, "https://ada.example.com/p/b")
291
+ locked.path, locked.metadata_json = None, {"locked": True}
292
+ db_session.commit()
293
+ for body in ({"document_id": str(locked.id)}, {"document_ids": [str(ok.id), str(locked.id)]}):
294
+ r = client.post("/api/videos", json=body, headers=headers)
295
+ assert r.status_code == 400 and "not indexed" in r.json()["detail"]
296
+ assert not creates(b2v) and used(db_session, ws) == 0
297
+
298
+
299
+ def test_another_workspaces_post_is_404(client, b2v, owner, other, db_session):
300
+ headers, ws = owner
301
+ mine = add_doc(db_session, ws, "https://ada.example.com/p/a", "Enough text to make a video from, really. " * 3)
302
+ theirs = add_doc(db_session, other[1], "https://bo.example.com/p/b")
303
+ r = client.post("/api/videos", json={"document_ids": [str(mine.id), str(theirs.id)]}, headers=headers)
304
+ assert r.status_code == 404 and not creates(b2v)
305
+
306
+
307
+ def test_upload_goes_to_blog2video_and_generates(client, b2v, owner, db_session):
308
+ headers, ws = owner
309
+ files = [("files", ("notes.md", b"# Notes\n" * 20, "text/markdown")), ("files", ("deck.pptx", b"x", "app/x"))]
310
+ r = client.post("/api/videos/upload", files=files, data={"options": json.dumps({"video_length": "medium"})},
311
+ headers=headers)
312
+ assert r.status_code == 201, r.text
313
+ upload = next(c for c in b2v.calls if c["path"] == "/api/projects/upload")
314
+ assert [f[1][0] for f in upload["files"]] == ["notes.md", "deck.pptx"]
315
+ assert upload["data"]["video_length"] == "medium" and upload["data"]["script_review_enabled"] == "false"
316
+ assert "/api/projects/812/generate" in b2v.paths("POST")
317
+ row = db_session.scalar(select(B2VVideo))
318
+ assert row.created_via == "upload" and row.idempotency_key is None
319
+ client.get(f"/api/videos/{r.json()['id']}/status", headers=headers)
320
+ assert "/api/projects/812/status" in b2v.paths("GET") # uploads are not under /api/v1
321
+
322
+
323
+ def test_upload_rejects_wrong_files_before_charging(client, b2v, owner, db_session):
324
+ headers, ws = owner
325
+ r = client.post("/api/videos/upload", files=[("files", ("a.exe", b"x", "app/x"))], headers=headers)
326
+ assert r.status_code == 400 and used(db_session, ws) == 0 and b2v.calls == []
327
+
328
+
329
+ def test_multi_link_makes_one_video_per_link(client, b2v, owner, db_session):
330
+ headers, ws = owner
331
+ r = client.post("/api/videos/batch", json={"urls": ["https://a.test/1", "https://a.test/2", "nope"],
332
+ "bgm_track_id": "corporate_upbeat"}, headers=headers)
333
+ assert r.status_code == 201 and len(r.json()) == 2
334
+ calls = creates(b2v)
335
+ assert len({c["headers"]["Idempotency-Key"] for c in calls}) == 2
336
+ assert all("bgm_track_id" not in c["json"] for c in calls) # music is single-video only
337
+ assert used(db_session, ws) == 2
338
+
339
+
340
+ def test_our_account_out_of_videos_refunds_the_user(client, b2v, owner, db_session, caplog):
341
+ headers, ws = owner
342
+ b2v.queue("POST", "/api/v1/videos", (402, {"detail": {"error": "quota_exceeded"}}))
343
+ with caplog.at_level(logging.ERROR):
344
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
345
+ assert r.status_code == 503 and "temporarily unavailable" in r.json()["detail"] and "ALERT" in caplog.text
346
+ assert db_session.scalar(select(Artifact).where(Artifact.type == "video")) is None
347
+ assert db_session.scalar(select(B2VVideo)).quota_state == "refunded" and used(db_session, ws) == 0
348
+
349
+
350
+ def test_validation_error_refunds_and_says_what_was_wrong(client, b2v, owner, db_session):
351
+ headers, ws = owner
352
+ b2v.queue("POST", "/api/v1/videos", (422, {"detail": [{"loc": ["body", "url"], "msg": "could not fetch"}]}))
353
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
354
+ assert r.status_code == 400 and "url: could not fetch" in r.json()["detail"] and used(db_session, ws) == 0
355
+
356
+
357
+ def test_slow_catalog_is_a_clean_error_not_a_500(client, b2v, owner, db_session):
358
+ headers, ws = owner
359
+ b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
360
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
361
+ headers=headers)
362
+ assert r.status_code == 502 and "not responding" in r.json()["detail"]
363
+ assert not creates(b2v) and used(db_session, ws) == 0
364
+
365
+
366
+ def test_expired_catalog_is_used_when_blog2video_is_slow(client, b2v, owner):
367
+ make_video(client, owner[0], template="geometric") # fills the catalog cache
368
+ for key, (at, value) in list(blog2video._cache.items()):
369
+ blog2video._cache[key] = (at - 2 * blog2video.CATALOG_TTL, value) # an hour and more ago
370
+ b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
371
+ make_video(client, owner[0], template="geometric")
372
+ assert len(creates(b2v)) == 2
373
+
374
+
375
+ def test_no_answer_after_retries_refunds(client, b2v, owner, db_session):
376
+ headers, ws = owner
377
+ b2v.queue("POST", "/api/v1/videos", *[(0, httpx.ConnectError("down"))] * 3)
378
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
379
+ assert r.status_code == 502 and len(creates(b2v)) == 3 and used(db_session, ws) == 0
380
+
381
+
382
+ def test_local_limit_blocks_without_calling_out(client, b2v, owner, db_session):
383
+ headers, ws = owner
384
+ client.get("/api/videos/quota", headers=headers) # syncs the row
385
+ sub = db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
386
+ sub.videos_used = sub.video_limit
387
+ db_session.commit()
388
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
389
+ assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit" and not creates(b2v)
390
+
391
+
392
+ def test_two_workspaces_same_url_get_separate_videos(client, b2v, owner, other):
393
+ make_video(client, owner[0])
394
+ make_video(client, other[0])
395
+ calls = creates(b2v)
396
+ assert calls[0]["headers"]["Idempotency-Key"].split(":")[0] != calls[1]["headers"]["Idempotency-Key"].split(":")[0]
397
+
398
+
399
+ def test_not_configured(client, owner):
400
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=owner[0])
401
+ assert r.status_code == 503
402
+
403
+
404
+ # Isolation: another workspace's ids are refused before blog2video is called
405
+
406
+
407
+ @pytest.fixture()
408
+ def theirs(db_session, other):
409
+ _, ws = other
410
+ db_session.add_all([B2VTemplate(b2v_template_id=77, workspace_id=ws.id, name="Theirs", ready=True),
411
+ B2VStyle(b2v_style_id=9, workspace_id=ws.id, name="Theirs"),
412
+ B2VCustomVoice(b2v_custom_voice_id=90, workspace_id=ws.id, voice_id="vc_theirs", name="T",
413
+ source="clone")])
414
+ db_session.commit()
415
+
416
+
417
+ @pytest.mark.parametrize("field,value", [("template", "custom_77"), ("video_style", "custom:9"),
418
+ ("custom_voice_id", "vc_theirs"), ("video_style", "your_style"),
419
+ ("template", "custom_12345"), ("template", "not-a-template"),
420
+ ("template", "crafted_other")])
421
+ def test_foreign_references_are_refused_before_calling_out(client, b2v, owner, theirs, db_session, field, value):
422
+ headers, ws = owner
423
+ r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", field: value}, headers=headers)
424
+ assert r.status_code == 404 and not creates(b2v) and used(db_session, ws) == 0
425
+
426
+
427
+ def test_own_references_pass(client, b2v, owner, db_session):
428
+ headers, ws = owner
429
+ db_session.add_all([B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True),
430
+ B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"),
431
+ B2VCustomVoice(b2v_custom_voice_id=31, workspace_id=ws.id, voice_id="vc_mine", name="M",
432
+ source="prompt")])
433
+ db_session.commit()
434
+ make_video(client, headers, template="custom_58", video_style="custom:7", custom_voice_id="vc_mine")
435
+ make_video(client, headers, template="crafted_neon")
436
+
437
+
438
+ def test_template_still_generating_is_refused(client, b2v, owner, db_session):
439
+ headers, ws = owner
440
+ db_session.add(B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=False))
441
+ db_session.commit()
442
+ r = client.post("/api/videos", json={"url": "https://a.test/x", "template": "custom_58"}, headers=headers)
443
+ assert r.status_code == 400 and not creates(b2v)
444
+
445
+
446
+ def test_other_workspace_video_is_404_everywhere(client, b2v, owner, other):
447
+ art = make_video(client, owner[0])
448
+ b2v.calls.clear()
449
+ for method, path in [("GET", ""), ("GET", "/status"), ("GET", "/script"), ("DELETE", ""),
450
+ ("PUT", "/p/scenes/1"), ("POST", "/p/render")]:
451
+ r = client.request(method, f"/api/videos/{art['id']}{path}", json={}, headers=other[0])
452
+ assert r.status_code == 404, (method, path)
453
+ assert b2v.calls == []
454
+
455
+
456
+ def test_catalog_shows_only_this_workspace(client, b2v, owner, theirs, db_session):
457
+ headers, ws = owner
458
+ db_session.add_all([B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"),
459
+ B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True),
460
+ B2VTemplate(b2v_template_id=59, workspace_id=ws.id, name="Not yet", ready=False)])
461
+ db_session.commit()
462
+ cat = client.get("/api/videos/catalog", headers=headers).json()
463
+ assert [s["id"] for s in cat["video_styles"]] == ["explainer", "custom:7"]
464
+ assert [t["id"] for t in cat["my_templates"]] == ["custom_58"]
465
+
466
+
467
+ # Premium (★) and per-workspace counters
468
+
469
+
470
+ @pytest.mark.parametrize("body", [{"video_length": "detailed"}, {"video_length": "more_detailed"},
471
+ {"custom_voice_id": BELLA}, {"voice_emotion": '["0.5","1.0","calm","0","1"]'},
472
+ {"template": "crafted_neon"}])
473
+ def test_premium_options_refused_on_free(client, b2v, owner, free_plan, db_session, body):
474
+ headers, ws = owner
475
+ r = client.post("/api/videos", json={"url": "https://a.test/x", **body}, headers=headers)
476
+ assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_premium" and not creates(b2v)
477
+ assert r.json()["detail"]["upgrade_to"] == "writer"
478
+
479
+
480
+ def test_free_plan_can_make_a_plain_video(client, b2v, owner, free_plan):
481
+ make_video(client, owner[0], custom_voice_id=SARAH)
482
+
483
+
484
+ def test_ai_edits_counted_and_given_back_on_failure(client, b2v, owner, db_session):
485
+ headers, ws = owner
486
+ art = make_video(client, headers)
487
+ r = client.post(f"/api/videos/{art['id']}/p/scenes/1/regenerate", json={"description": "x"}, headers=headers)
488
+ assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 1
489
+ b2v.queue("POST", "/api/projects/812/scenes/1/regenerate", (409, {"detail": "busy"}))
490
+ r = client.post(f"/api/videos/{art['id']}/p/scenes/1/regenerate", json={}, headers=headers)
491
+ assert r.status_code == 409 and video_limits.used(db_session, ws.id, "ai_edits") == 1
492
+
493
+
494
+ def test_ai_edits_stop_at_the_workspace_limit(client, b2v, owner, db_session):
495
+ headers, ws = owner
496
+ art = make_video(client, headers)
497
+ db_session.add(UsageCounter(workspace_id=ws.id, period=video_limits.period_key("ai_edits"), metric="ai_edits",
498
+ used=1000))
499
+ db_session.commit()
500
+ b2v.calls.clear()
501
+ r = client.post(f"/api/videos/{art['id']}/p/chat", json={"message": "shorter"}, headers=headers)
502
+ assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_limits.ai_edits" and b2v.calls == []
503
+
504
+
505
+ def test_avatar_batch_charges_ten_per_scene(client, b2v, owner, db_session):
506
+ headers, ws = owner
507
+ art = make_video(client, headers)
508
+ r = client.post(f"/api/videos/{art['id']}/p/avatar-batch/authorize", json={"scene_ids": [1, 2, 3]},
509
+ headers=headers)
510
+ assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 30
511
+
512
+
513
+ def test_template_switch_uses_a_video_and_checks_the_template(client, b2v, owner, theirs, db_session):
514
+ headers, ws = owner
515
+ art = make_video(client, headers)
516
+ r = client.post(f"/api/videos/{art['id']}/p/change-template-regenerate-layouts", json={"template": "custom_77"},
517
+ headers=headers)
518
+ assert r.status_code == 404 and used(db_session, ws) == 1
519
+ r = client.post(f"/api/videos/{art['id']}/p/change-template-regenerate-layouts", json={"template": "geometric"},
520
+ headers=headers)
521
+ assert r.status_code == 200 and used(db_session, ws) == 2
522
+
523
+
524
+ # Edit jobs keep blog2video's own status on the artifact (not "generating"), so the list and editor can tell them apart
525
+
526
+
527
+ def artifact_status(db, art) -> str:
528
+ db.expire_all()
529
+ return db.get(Artifact, uuid.UUID(art["id"])).status
530
+
531
+
532
+ @pytest.mark.parametrize("raw", ["regenerating", "voice_regenerating", "language_regenerating", "script_regenerating"])
533
+ def test_an_edit_job_keeps_blog2videos_status(client, b2v, owner, db_session, raw):
534
+ headers, _ = owner
535
+ art = make_video(client, headers)
536
+ b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": raw, "ready": False}))
537
+ assert client.get(f"/api/videos/{art['id']}/status", headers=headers).json()["artifact_status"] == raw
538
+ assert artifact_status(db_session, art) == raw
539
+
540
+
541
+ def test_starting_a_template_switch_marks_the_video(client, b2v, owner, db_session):
542
+ headers, _ = owner
543
+ art = make_video(client, headers)
544
+ r = client.post(f"/api/videos/{art['id']}/p/change-template-regenerate-layouts", json={"template": "geometric"},
545
+ headers=headers)
546
+ assert r.status_code == 200 and artifact_status(db_session, art) == "regenerating"
547
+
548
+
549
+ def test_the_list_rechecks_a_video_whose_job_ended_unwatched(client, b2v, owner, db_session):
550
+ headers, _ = owner
551
+ art = make_video(client, headers)
552
+ b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": "regenerating", "ready": False}),
553
+ (200, {"status": "generated", "ready": True}))
554
+ client.get(f"/api/videos/{art['id']}/status", headers=headers)
555
+ assert artifact_status(db_session, art) == "regenerating"
556
+ listed = client.get("/api/videos", headers=headers).json()
557
+ assert listed[0]["status"] == "ready" and listed[0]["b2v_status"] == "generated"
558
+
559
+
560
+ def test_editor_proxy_only_allows_listed_paths(client, b2v, owner):
561
+ headers, _ = owner
562
+ art = make_video(client, headers)
563
+ b2v.calls.clear()
564
+ for method, path in [("GET", "members"), ("POST", "review"), ("POST", "launch-studio"), ("GET", "../../auth/me")]:
565
+ assert client.request(method, f"/api/videos/{art['id']}/p/{path}", headers=headers).status_code == 404
566
+ assert b2v.calls == []
567
+
568
+
569
+ def test_editor_passes_downloads_through(client, b2v, owner):
570
+ headers, _ = owner
571
+ art = make_video(client, headers)
572
+ r = client.get(f"/api/videos/{art['id']}/p/download", headers=headers)
573
+ assert r.status_code == 200 and r.content == b"ID3audio" and r.headers["content-type"] == "audio/mpeg"
574
+
575
+
576
+ def test_editor_premium_features_refused_on_free(client, b2v, owner, free_plan):
577
+ headers, _ = owner
578
+ art = make_video(client, headers)
579
+ for method, path in [("POST", "chat"), ("GET", "download-studio"), ("POST", "avatar-portrait")]:
580
+ r = client.request(method, f"/api/videos/{art['id']}/p/{path}", json={}, headers=headers)
581
+ assert r.status_code == 402, path
582
+
583
+
584
+ # Script review
585
+
586
+
587
+ def test_script_review_flow(client, b2v, owner, db_session):
588
+ headers, ws = owner
589
+ art = make_video(client, headers)
590
+ scenes = client.get(f"/api/videos/{art['id']}/script", headers=headers).json()["scenes"]
591
+ assert [s["id"] for s in scenes] == [1, 2]
592
+ draft = {"title": "A", "display_text": "Text", "narration_text": "", "draft_scenes": [], "revision": 1}
593
+ client.post(f"/api/videos/{art['id']}/script/scenes/1/ai-preview", json={**draft, "instruction": "punchier"},
594
+ headers=headers)
595
+ assert video_limits.used(db_session, ws.id, "ai_edits") == 1
596
+ r = client.post(f"/api/videos/{art['id']}/script/approve",
597
+ json={"scenes": [{"id": 1, "title": "A", "narration_text": "Hi"}]}, headers=headers)
598
+ assert r.status_code == 200 and "/api/projects/812/script-review/approve" in b2v.paths("POST")
599
+
600
+
601
+ # Status, refunds, sweep
602
+
603
+
604
+ def test_failure_refunds_once_from_poller_and_sweep(client, b2v, owner, db_session, session_factory):
605
+ headers, ws = owner
606
+ art = make_video(client, headers)
607
+ failed = (200, {"status": "failed", "step": 1, "running": False, "error": "unreachable url"})
608
+ b2v.queue("GET", "/api/v1/videos/812/status", failed, failed)
609
+ assert client.get(f"/api/videos/{art['id']}/status", headers=headers).json()["artifact_status"] == "failed"
610
+ assert used(db_session, ws) == 0
611
+ client.get(f"/api/videos/{art['id']}/status", headers=headers)
612
+ age(db_session, db_session.scalar(select(B2VVideo)))
613
+ video_quota.sweep_failed_videos(session_factory)
614
+ assert used(db_session, ws) == 0
615
+
616
+
617
+ def test_sweep_refunds_videos_nobody_polled(client, b2v, owner, db_session, session_factory):
618
+ headers, ws = owner
619
+ art = make_video(client, headers)
620
+ age(db_session, db_session.scalar(select(B2VVideo)))
621
+ b2v.queue("GET", "/api/v1/videos/812/status", (404, {"detail": "Not found"}))
622
+ assert video_quota.sweep_failed_videos(session_factory) == 1
623
+ db_session.expire_all()
624
+ assert used(db_session, ws) == 0 and db_session.get(Artifact, uuid.UUID(art["id"])).status == "failed"
625
+
626
+
627
+ def test_a_finished_video_is_not_refunded_by_a_later_error(client, b2v, owner, db_session):
628
+ headers, ws = owner
629
+ art = make_video(client, headers)
630
+ b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": "generated", "ready": True}),
631
+ (200, {"status": "error", "error": "a later edit failed"}))
632
+ client.get(f"/api/videos/{art['id']}/status", headers=headers)
633
+ client.get(f"/api/videos/{art['id']}/status", headers=headers)
634
+ assert used(db_session, ws) == 1 and db_session.scalar(select(B2VVideo)).quota_state == "kept"
635
+
636
+
637
+ def test_legacy_video_shows_stored_links_and_refuses_edits(client, b2v, owner, db_session):
638
+ headers, ws = owner
639
+ a = Artifact(workspace_id=ws.id, type="video", status="ready",
640
+ content_json={"provider": "blog2video", "b2v_video_id": 99, "video_url": "https://cdn.test/old.mp4"})
641
+ db_session.add(a)
642
+ db_session.commit()
643
+ got = client.get(f"/api/videos/{a.id}", headers=headers).json()
644
+ assert got["legacy"] is True and got["video_url"] == "https://cdn.test/old.mp4"
645
+ assert client.patch(f"/api/videos/{a.id}/p/update-project", json={}, headers=headers).status_code == 410
646
+ assert b2v.calls == []
647
+
648
+
649
+ def test_get_video_mints_the_preview_link_once(client, b2v, owner):
650
+ headers, _ = owner
651
+ art = make_video(client, headers)
652
+ first = client.get(f"/api/videos/{art['id']}", headers=headers).json()
653
+ client.get(f"/api/videos/{art['id']}", headers=headers)
654
+ assert first["preview_url"] == f"{API}/embed/t" and first["project"]["id"] == 812
655
+ assert len([p for p in b2v.paths("POST") if p.startswith("/api/embed/token/")]) == 1
656
+
657
+
658
+ def test_scene_stock_clips_are_read_from_assigned_video(client, b2v, owner):
659
+ headers, _ = owner
660
+ art = make_video(client, headers)
661
+ clip = {"id": 7, "asset_type": "video", "filename": "scene_1_99.mp4", "r2_url": "https://r2.example/clip.mp4",
662
+ "excluded": False}
663
+ still = {"id": 8, "asset_type": "image", "filename": "img_a.webp", "r2_url": "https://r2.example/a.webp",
664
+ "excluded": False}
665
+ code = '{"layout": "news_headline", "layoutProps": {"assignedVideo": "scene_1_99.mp4"}}'
666
+ b2v.queue("GET", "/api/projects/812", (200, {"id": 812, "status": "generated", "assets": [clip, still],
667
+ "scenes": [{"id": 1, "order": 1, "title": "A", "remotion_code": code}]}))
668
+ project = client.get(f"/api/videos/{art['id']}", headers=headers).json()["project"]
669
+ assert project["scenes"][0]["images"] == [{"filename": "scene_1_99.mp4", "asset_id": 7, "kind": "video",
670
+ "url": "https://r2.example/clip.mp4"}]
671
+ assert project["summary"]["images"] == 1 and project["summary"]["clips"] == 1
672
+
673
+
674
+ def test_delete_removes_it_from_blog2video(client, b2v, owner):
675
+ headers, _ = owner
676
+ art = make_video(client, headers)
677
+ assert client.delete(f"/api/videos/{art['id']}", headers=headers).status_code == 200
678
+ assert "/api/projects/812" in b2v.paths("DELETE")
679
+
680
+
681
+ def test_capacity_alert(b2v, caplog):
682
+ with caplog.at_level(logging.ERROR):
683
+ video_quota.check_capacity()
684
+ assert "ALERT" in caplog.text and "videos 145/150" in caplog.text
685
+
686
+
687
+ # Voices
688
+
689
+
690
+ def test_new_workspace_gets_starter_voices_and_library(client, b2v, owner):
691
+ got = client.get("/api/video-voices", headers=owner[0]).json()
692
+ assert [v["voice_id"] for v in got["saved"]] == [SARAH] # free voices only
693
+ assert {v["voice_id"]: v["premium"] for v in got["library"]} == {SARAH: False, BELLA: True}
694
+
695
+
696
+ def test_saving_a_paid_voice_needs_premium(client, b2v, owner, free_plan):
697
+ r = client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=owner[0])
698
+ assert r.status_code == 402
699
+
700
+
701
+ def test_saved_voices_never_touch_blog2video_saved_list(client, b2v, owner, db_session):
702
+ headers, _ = owner
703
+ client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers)
704
+ client.delete(f"/api/video-voices/saved/{BELLA}", headers=headers)
705
+ assert not [p for p in b2v.paths() if "/voices/saved" in p]
706
+
707
+
708
+ def test_design_keep_and_delete_a_custom_voice(client, b2v, owner, other, db_session):
709
+ headers, ws = owner
710
+ previews = client.post("/api/video-voices/design/prompt", json={"prompt": "A calm, warm narrator voice please"},
711
+ headers=headers).json()["previews"]
712
+ kept = client.post("/api/video-voices/custom", json={"generated_voice_id": previews[0]["generated_voice_id"],
713
+ "source": "prompt", "name": "Narrator"}, headers=headers)
714
+ assert kept.status_code == 201
715
+ assert db_session.get(UserSavedVoice, {"workspace_id": ws.id, "voice_id": "vc_designed"}).is_custom
716
+ assert video_limits.used(db_session, ws.id, "voice_designs_daily") == 1
717
+ assert video_limits.used(db_session, ws.id, "custom_voices") == 1
718
+ # Another workspace can not play or delete it, nor use it on a video.
719
+ assert client.get("/api/video-voices/custom/31/preview", headers=other[0]).status_code == 404
720
+ assert client.delete("/api/video-voices/custom/31", headers=other[0]).status_code == 404
721
+ r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "vc_designed"},
722
+ headers=other[0])
723
+ assert r.status_code == 404
724
+ assert client.delete("/api/video-voices/custom/31", headers=headers).status_code == 200
725
+ assert video_limits.used(db_session, ws.id, "custom_voices") == 0
726
+
727
+
728
+ def test_clone_voice(client, b2v, owner):
729
+ r = client.post("/api/video-voices/clone", data={"name": "Me"}, files={"file": ("me.mp3", b"ID3", "audio/mpeg")},
730
+ headers=owner[0])
731
+ assert r.status_code == 201 and r.json()["voice_id"] == "vc_clone"
732
+
733
+
734
+ def test_voice_sample_returns_audio_and_counts(client, b2v, owner, db_session):
735
+ headers, ws = owner
736
+ r = client.post("/api/video-voices/sample", json={"custom_voice_id": SARAH}, headers=headers)
737
+ assert r.status_code == 200 and r.content == b"ID3audio"
738
+ assert video_limits.used(db_session, ws.id, "voice_samples_daily") == 1
739
+
740
+
741
+ # Styles
742
+
743
+
744
+ def test_styles_are_per_workspace(client, b2v, owner, other, db_session):
745
+ headers, ws = owner
746
+ made = client.post("/api/video-styles", json={"name": "Punchy", "guidance": "Punchy."}, headers=headers).json()
747
+ assert made["id"] == "custom:41" and db_session.get(B2VStyle, 41).workspace_id == ws.id
748
+ assert client.patch("/api/video-styles/41", json={"name": "x", "guidance": "y", "version": 0},
749
+ headers=other[0]).status_code == 404
750
+ assert client.delete("/api/video-styles/41", headers=other[0]).status_code == 404
751
+
752
+
753
+ # Templates
754
+
755
+
756
+ def test_custom_templates_are_switched_off(client, b2v, owner):
757
+ headers, _ = owner
758
+ assert client.post("/api/video-templates/extract/prompt", json={"prompt": "x"}, headers=headers).status_code == 404
759
+ assert client.post("/api/video-templates", json={"name": "x", "theme": {}}, headers=headers).status_code == 404
760
+ assert client.get("/api/video-templates", headers=headers).status_code == 404
761
+ assert b2v.calls == []
762
+
763
+
764
+ @pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
765
+ def test_template_create_generate_and_ready(client, b2v, owner, db_session):
766
+ headers, ws = owner
767
+ theme = client.post("/api/video-templates/extract/prompt", json={"prompt": "A bold dark brand with neon"},
768
+ headers=headers).json()
769
+ made = client.post("/api/video-templates", json={"name": "Brand", "theme": theme["theme"]}, headers=headers)
770
+ assert made.status_code == 201 and made.json()["ref"] == "custom_58"
771
+ client.post("/api/video-templates/58/generate", headers=headers)
772
+ b2v.queue("GET", "/api/custom-templates/58/generation-status", (200, {"status": "complete"}))
773
+ assert client.get("/api/video-templates/58/generation-status", headers=headers).json()["ready"] is True
774
+ assert video_limits.used(db_session, ws.id, "templates") == 1
775
+ assert video_limits.used(db_session, ws.id, "template_ai_daily") == 2
776
+
777
+
778
+ @pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
779
+ def test_free_plan_can_not_start_a_template(client, b2v, owner, free_plan):
780
+ r = client.post("/api/video-templates/extract/url", json={"url": "https://brand.test"}, headers=owner[0])
781
+ assert r.status_code == 402 and b2v.calls == []
782
+
783
+
784
+ @pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
785
+ def test_template_delete_forces_only_over_own_videos(client, b2v, owner, other, db_session):
786
+ headers, ws = owner
787
+ db_session.add(B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True))
788
+ db_session.add(B2VVideo(workspace_id=other[1].id, b2v_video_id=5, template_ref="custom_58"))
789
+ db_session.commit()
790
+ b2v.queue("DELETE", "/api/custom-templates/58", (409, {"detail": {"error": "template_in_use"}}))
791
+ assert client.delete("/api/video-templates/58", headers=headers).status_code == 409
792
+ db_session.query(B2VVideo).delete()
793
+ db_session.commit()
794
+ b2v.queue("DELETE", "/api/custom-templates/58", (409, {"detail": {"error": "template_in_use"}}))
795
+ assert client.delete("/api/video-templates/58", headers=headers).status_code == 200
796
+ assert b2v.calls[-1]["params"] == {"force": "true"}
797
+
798
+
799
+ @pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
800
+ def test_template_editor_is_per_workspace_and_allowlisted(client, b2v, owner, other, db_session):
801
+ headers, ws = owner
802
+ db_session.add(B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True))
803
+ db_session.commit()
804
+ assert client.get("/api/video-templates/58/p/versions", headers=headers).status_code == 200
805
+ assert client.get("/api/video-templates/58/p/versions", headers=other[0]).status_code == 404
806
+ assert client.get("/api/video-templates/58/p/internal/ids", headers=headers).status_code == 404
807
+ r = client.post("/api/video-templates/58/p/scenes/intro/ai-edit", json={"prompt": "bigger"}, headers=headers)
808
+ assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 1
809
+
810
+
811
+ # Quota bookkeeping
812
+
813
+
814
+ def test_sync_follows_effective_plan(client, owner, db_session, monkeypatch):
815
+ _, ws = owner
816
+ sub = video_quota.sync_video_quota(db_session, ws)
817
+ assert (sub.video_plan, sub.video_limit) == ("studio", 20) # billing disabled
818
+ monkeypatch.setattr(settings, "billing_enabled", True)
819
+ sub.plan, sub.status = "writer", "active"
820
+ db_session.commit()
821
+ assert video_quota.sync_video_quota(db_session, ws).video_limit == 10
822
+ sub.status = "canceled"
823
+ db_session.commit()
824
+ sub = video_quota.sync_video_quota(db_session, ws)
825
+ assert (sub.video_plan, sub.video_limit) == ("free", 1)
826
+
827
+
828
+ def test_monthly_fallback_reset(client, owner, db_session, session_factory):
829
+ _, ws = owner
830
+ sub = video_quota.sync_video_quota(db_session, ws) # studio (billing disabled): monthly
831
+ now = datetime.now(UTC)
832
+ # No Stripe renewal, period started 40 days ago: resets.
833
+ sub.videos_used, sub.videos_period_start = 1, now - timedelta(days=40)
834
+ db_session.commit()
835
+ assert video_quota.reset_due_video_periods(session_factory) == 1
836
+ db_session.expire_all()
837
+ assert sub.videos_used == 0
838
+
839
+ # Monthly Stripe subscription renewing soon: left to invoice.paid.
840
+ sub.videos_used, sub.videos_period_start = 3, now - timedelta(days=40)
841
+ sub.provider_subscription_id, sub.status = "sub_1", "active"
842
+ sub.current_period_end = now - timedelta(days=10)
843
+ db_session.commit()
844
+ assert video_quota.reset_due_video_periods(session_factory) == 0
845
+
846
+ # Annual subscription (renews in 11 months): still resets monthly.
847
+ sub.current_period_end = now + timedelta(days=330)
848
+ db_session.commit()
849
+ assert video_quota.reset_due_video_periods(session_factory) == 1
850
+
851
+
852
+ # The video's reference lives on its row
853
+
854
+
855
+ def test_row_holds_the_full_reference(client, b2v, owner, db_session):
856
+ headers, ws = owner
857
+ art = make_video(client, headers)
858
+ row = db_session.scalar(select(B2VVideo))
859
+ assert row.user_id == ws.owner_id and row.artifact_id == uuid.UUID(art["id"])
860
+ assert (row.title, row.source_url, row.aspect_ratio) == ("https://ada.example.com/p/a", "https://ada.example.com/p/a",
861
+ "landscape")
862
+ sent = creates(b2v)[0]["json"]["metadata"]
863
+ assert sent["user_id"] == str(ws.owner_id) and sent["workspace_id"] == str(ws.id)
864
+ assert sent["video_ref"] == str(row.id)
865
+ b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": "done", "ready": True,
866
+ "video_url": "https://cdn.test/v.mp4"}))
867
+ client.get(f"/api/videos/{art['id']}/status", headers=headers)
868
+ client.get(f"/api/videos/{art['id']}", headers=headers)
869
+ db_session.expire_all()
870
+ assert row.video_url == "https://cdn.test/v.mp4" and row.preview_url == f"{API}/embed/t"
871
+
872
+
873
+ def test_calls_use_the_row_not_the_artifact_copy(client, b2v, owner, db_session):
874
+ headers, _ = owner
875
+ art = make_video(client, headers)
876
+ a = db_session.get(Artifact, uuid.UUID(art["id"]))
877
+ a.content_json = {**a.content_json, "b2v_video_id": 999} # a stale or tampered copy
878
+ db_session.commit()
879
+ client.patch(f"/api/videos/{art['id']}/p/update-project", json={"accent_color": "#000000"}, headers=headers)
880
+ assert "/api/projects/812/update-project" in b2v.paths("PATCH")
881
+
882
+
883
+ def test_library_delete_removes_it_from_blog2video_and_keeps_the_record(client, b2v, owner, db_session):
884
+ headers, _ = owner
885
+ art = make_video(client, headers)
886
+ assert client.delete(f"/api/artifacts/{art['id']}", headers=headers).status_code == 200
887
+ assert "/api/projects/812" in b2v.paths("DELETE")
888
+ db_session.expire_all()
889
+ row = db_session.scalar(select(B2VVideo))
890
+ assert row.status == "deleted" and row.artifact_id is None
891
+
892
+
893
+ # Video limits: Free 1 in total, Writer 10 a month, Studio 20 a month
894
+
895
+
896
+ def _on_plan(db, ws, plan: str) -> None:
897
+ sub = db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
898
+ sub.plan, sub.status = plan, "active"
899
+ db.commit()
900
+
901
+
902
+ def test_plan_video_limits(client, owner, db_session, monkeypatch):
903
+ _, ws = owner
904
+ monkeypatch.setattr(settings, "billing_enabled", True)
905
+ video_quota.sync_video_quota(db_session, ws)
906
+ for plan, limit in (("free", 1), ("writer", 10), ("studio", 20)):
907
+ _on_plan(db_session, ws, plan)
908
+ assert video_quota.sync_video_quota(db_session, ws).video_limit == limit
909
+
910
+
911
+ def test_free_is_one_video_in_total(client, b2v, owner, free_plan, db_session, session_factory):
912
+ headers, ws = owner
913
+ make_video(client, headers)
914
+ r = client.post("/api/videos", json={"url": "https://a.test/2"}, headers=headers)
915
+ assert r.status_code == 402
916
+ usage = client.get("/api/videos/quota", headers=headers).json()
917
+ assert usage["resets_at"] is None
918
+ sub = db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
919
+ sub.videos_period_start = datetime.now(UTC) - timedelta(days=90)
920
+ db_session.commit()
921
+ assert video_quota.reset_due_video_periods(session_factory) == 0 # never refills
922
+ # Deleting the video does not give it back.
923
+ art_id = db_session.scalar(select(B2VVideo)).artifact_id
924
+ client.delete(f"/api/videos/{art_id}", headers=headers)
925
+ assert client.post("/api/videos", json={"url": "https://a.test/3"}, headers=headers).status_code == 402
926
+
927
+
928
+ def test_free_video_that_failed_is_given_back(client, b2v, owner, free_plan, db_session):
929
+ headers, ws = owner
930
+ b2v.queue("POST", "/api/v1/videos", (422, {"detail": "bad url"}))
931
+ assert client.post("/api/videos", json={"url": "https://a.test/1"}, headers=headers).status_code == 400
932
+ make_video(client, headers)
933
+
934
+
935
+ def test_dropping_to_free_counts_what_was_made(client, b2v, owner, db_session, monkeypatch):
936
+ headers, ws = owner
937
+ monkeypatch.setattr(settings, "billing_enabled", True)
938
+ video_quota.sync_video_quota(db_session, ws)
939
+ _on_plan(db_session, ws, "writer")
940
+ for i in range(3):
941
+ make_video(client, headers, url=f"https://a.test/{i}")
942
+ _on_plan(db_session, ws, "free")
943
+ usage = client.get("/api/videos/quota", headers=headers).json()
944
+ assert (usage["used"], usage["limit"]) == (3, 1)
945
+ assert client.post("/api/videos", json={"url": "https://a.test/x"}, headers=headers).status_code == 402
946
+
947
+
948
+ def test_list_restores_a_video_whose_library_item_went_missing(client, b2v, owner, db_session):
949
+ headers, _ = owner
950
+ art = make_video(client, headers)
951
+ row = db_session.scalar(select(B2VVideo))
952
+ row.artifact_id, row.status = None, "generated"
953
+ db_session.delete(db_session.get(Artifact, uuid.UUID(art["id"])))
954
+ db_session.commit()
955
+ listed = client.get("/api/videos", headers=headers).json()
956
+ assert len(listed) == 1 and listed[0]["status"] == "ready"
957
+ b2v.calls.clear()
958
+ client.get(f"/api/videos/{listed[0]['id']}/status", headers=headers)
959
+ assert "/api/v1/videos/812/status" in b2v.paths("GET") # the same blog2video video
960
+ assert len(client.get("/api/videos", headers=headers).json()) == 1 # restored once
docs/BLOG2VIDEO.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # blog2video integration
2
+
3
+ Videos are made and edited by blog2video. Notestack is an ordinary blog2video API customer: we own one paid
4
+ blog2video account (Pro, topped up with per-video credits) and the backend calls its public `/api/v1` API with that
5
+ account's key. Every video is charged to our account; we charge our workspaces from our own allowance.
6
+
7
+ ```
8
+ Browser ──► /api/videos/* (backend/app/routers/videos.py) ──(Bearer b2v_live_…)──► blog2video /api/v1
9
+ ├─ checks the session and workspace
10
+ ├─ reserves / refunds the workspace's allowance (subscriptions)
11
+ └─ records which video belongs to which workspace (b2v_videos)
12
+ ```
13
+
14
+ The browser never talks to blog2video, and the key never reaches the browser or the logs.
15
+
16
+ ## Settings
17
+
18
+ Backend `.env` only (never a `VITE_` variable):
19
+
20
+ | Env var | Value |
21
+ |---|---|
22
+ | `B2V_API_BASE_URL` | blog2video API host, no trailing slash (`B2V_API_URL` is still read as a fallback) |
23
+ | `B2V_API_KEY` | `b2v_live_…`, from blog2video → Account → API keys on our Pro account |
24
+ | `B2V_TIMEOUT_SECONDS` | create timeout (default 30); every other call uses 10s |
25
+ | `B2V_CAPACITY_ALERT_BELOW` | log an `ALERT` when our account has fewer videos left (default 20) |
26
+
27
+ A revoked or rotated key answers 401; a blog2video account that dropped to Free answers 403 to everything. Both
28
+ log an `ALERT` line and users see "temporarily unavailable".
29
+
30
+ ## Ownership
31
+
32
+ Everything made with the key (videos, custom templates, custom voices, styles) belongs to one blog2video account,
33
+ and blog2video's ownership checks stop at the account. Notestack is the only place each workspace's things are kept
34
+ apart (`backend/app/services/b2v_access.py`):
35
+
36
+ | Table | What | Used as |
37
+ |---|---|---|
38
+ | `b2v_videos` | videos (also the allowance ledger) | project id; `created_via` v1 or upload |
39
+ | `b2v_templates` | custom templates (`ready` once generated) | `template: "custom_<id>"` |
40
+ | `b2v_custom_voices` | designed and cloned voices | `custom_voice_id: "<voice_id>"` |
41
+ | `user_saved_voices` | "My voices" (kept only here) | the wizard's voice list |
42
+ | `b2v_styles` | custom video styles | `video_style: "custom:<id>"` |
43
+ | `usage_counters` | per-workspace counters (below) | |
44
+
45
+ Every route that takes one of these ids checks it is the workspace's (404 otherwise), and every create or edit body
46
+ has its `template`, `video_style` and `custom_voice_id` checked before it is forwarded. Lists come from these tables.
47
+ Account-wide endpoints (`POST /api/projects`, `/api/projects/bulk`, `GET /api/projects`, `/api/voices/saved*`,
48
+ built-in style overrides, style pin/selection, "Your Style", script preferences) are refused in
49
+ `blog2video.DENIED` before anything is sent.
50
+
51
+ ## Routes
52
+
53
+ | Notestack | blog2video |
54
+ |---|---|
55
+ | `POST /api/videos` (link, text, a post) | `POST /api/v1/videos` with `Idempotency-Key: <workspace>:<uuid>` |
56
+ | `POST /api/videos/upload` (≤5 files) | `POST /api/projects/upload` then `/generate` |
57
+ | `POST /api/videos/batch` (≤10 links) | one `POST /api/v1/videos` per link |
58
+ | `GET /api/videos/{id}/status` | `/api/v1/videos/{id}/status`, or `/api/projects/{id}/status` for uploads |
59
+ | `/api/videos/{id}/script*` | `/api/projects/{id}/script-review/*` |
60
+ | `/api/videos/{id}/p/<path>` | `/api/projects/{id}/<path>`, allowlisted in `routers/video_edit.py` |
61
+ | `/api/video-voices/*` | `/api/voices/design-from-*`, `/api/voices/custom*`, `/api/voices/clone`, `/api/voice/preview` |
62
+ | `/api/video-templates/*` | `/api/custom-templates/*` (editor paths allowlisted) |
63
+ | `/api/video-styles/*` | `/api/video-styles/ai-draft`, `/api/video-styles/custom*` |
64
+
65
+ The live preview link comes from `POST /api/embed/token/{id}` and is safe for the browser.
66
+
67
+ ## Premium and per-workspace limits
68
+
69
+ blog2video checks every limit against our Pro account, so every workspace passes its checks. Notestack divides them:
70
+ each plan row has `video_premium` (★: detailed lengths, paid and custom voices, advanced voice tuning, voice samples,
71
+ AI script rewrites, AI chat, avatars, designer templates, the source download) and `video_limits`:
72
+
73
+ | Metric | Period | Free / Writer / Studio | Counts |
74
+ |---|---|---|---|
75
+ | `ai_edits` | month | 20 / 300 / 1000 | scene regenerate/add, AI image, stock clip, AI chat, AI rewrites, template AI edits, avatars (10 per scene) |
76
+ | `templates` | lifetime | 0 / 2 / 5 | custom templates made (blog2video slots are never given back) |
77
+ | `template_ai_daily` | day | 0 / 3 / 5 | theme from a document or prompt, code generation |
78
+ | `voice_designs_daily` | day | 0 / 5 / 10 | voice designs |
79
+ | `voice_samples_daily` | day | 0 / 20 / 40 | voice samples |
80
+ | `custom_voices` | total | 0 / 3 / 10 | kept or cloned voices (a delete frees one) |
81
+
82
+ Edit a plan row to change them. A counter is given back when blog2video refuses the call. A template switch also
83
+ uses a video.
84
+
85
+ ## Quota
86
+
87
+ One row per workspace in `subscriptions`, enforced only by us (`backend/app/services/video_quota.py`):
88
+
89
+ | Column | Meaning |
90
+ |---|---|
91
+ | `video_plan` | effective plan id (`free`, `writer`, `studio`) |
92
+ | `video_limit` | videos per period, from the plan |
93
+ | `videos_used` | videos made this period |
94
+ | `videos_period_start` | start of the current period |
95
+
96
+ - **Reserve** before calling blog2video: `videos_used + 1` only while below `video_limit`, in one `UPDATE`. At the
97
+ limit we answer 402 `plan_limit` and blog2video is never called.
98
+ - **Refund exactly once** (conditional `UPDATE b2v_videos SET quota_state = 'refunded' WHERE quota_state = 'charged'`)
99
+ when the create is refused (402, 422, key/plan problems), gets no answer after 3 tries with the same
100
+ `Idempotency-Key`, or generation fails (status `failed`/`error`, or 404).
101
+ - The worker's `video_refund_sweep` (every 10 min) checks charged, unfinished videos older than 30 minutes, for users
102
+ who closed the tab. `video_capacity_check` (hourly) reads `GET /api/v1/me` and alerts when we run low.
103
+ - Resets: Stripe `invoice.paid` with `billing_reason` `subscription_cycle` or `subscription_create`; otherwise the
104
+ hourly `video_period_reset` resets any period older than a month (free, billing disabled, annual plans).
105
+
106
+ A 402 `quota_exceeded` from blog2video means **our** account is out of videos, not the user: they are refunded and
107
+ see "temporarily unavailable"; buy credits. Our users' limits added up can exceed the account, so watch the alert.
108
+
109
+ ## Videos made before the switch
110
+
111
+ Videos made through the old partner integration belonged to a blog2video service account that is now deactivated;
112
+ the key gets 404 for them. They have no `b2v_videos` row, so the API treats them as legacy: `GET` returns the stored
113
+ `video_url`/`preview_url` with `legacy: true`, and edits answer 410. blog2video can move them to our account on
114
+ request if we ever need them editable.
115
+
116
+ ## Clean-up after the switch
117
+
118
+ The partner-token code and settings (`B2V_PARTNER_SLUG`, `B2V_PARTNER_SECRET`) are gone. blog2video used to connect
119
+ to our database, so drop that access:
120
+
121
+ ```sql
122
+ REVOKE ALL ON public.subscriptions FROM b2v_partner;
123
+ DROP VIEW IF EXISTS b2v_quota;
124
+ DROP ROLE IF EXISTS b2v_partner;
125
+ ```
126
+
127
+ and remove blog2video's server IP from the database firewall.
128
+
129
+ ## Local development
130
+
131
+ Set `B2V_API_BASE_URL` and a test key. Unit tests (`backend/tests/test_videos.py`) use a fake blog2video.
frontend/seo/prerender.ts CHANGED
@@ -240,6 +240,16 @@ function head(r: Rendered, origin: string) {
240
  return tags.join("\n ");
241
  }
242
 
 
 
 
 
 
 
 
 
 
 
243
  export function prerenderPlugin(siteUrl: string): Plugin {
244
  const origin = siteUrl.replace(/\/+$/, "");
245
  let config: ResolvedConfig;
@@ -255,14 +265,14 @@ export function prerenderPlugin(siteUrl: string): Plugin {
255
  const all = routes();
256
  for (const r of all) {
257
  const html = template
258
- .replace("</head>", ` ${head(r, origin)}\n </head>`)
259
- .replace('<div id="root"></div>', `<div id="root">${r.body}</div>`);
260
  const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
261
  await mkdir(path.dirname(file), { recursive: true });
262
  await writeFile(file, html, "utf8");
263
  }
264
  // Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
265
- await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n </head>`), "utf8");
266
  config.logger.info(`prerendered ${all.length} public routes`);
267
  },
268
  };
 
240
  return tags.join("\n ");
241
  }
242
 
243
+ /**
244
+ * The static copy is for crawlers and screen readers, not for people: until the app's JS and CSS load it would flash
245
+ * as an unstyled page of links. Keep it in the document but visually hidden (not display:none, which some crawlers
246
+ * skip), and paint the app's black background from the first frame. React's createRoot replaces it on load.
247
+ */
248
+ const STATIC_STYLE =
249
+ "<style>html,body{background:#000;color:#fff;margin:0}" +
250
+ ".seo-static{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;" +
251
+ "clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}</style>";
252
+
253
  export function prerenderPlugin(siteUrl: string): Plugin {
254
  const origin = siteUrl.replace(/\/+$/, "");
255
  let config: ResolvedConfig;
 
265
  const all = routes();
266
  for (const r of all) {
267
  const html = template
268
+ .replace("</head>", ` ${head(r, origin)}\n ${STATIC_STYLE}\n </head>`)
269
+ .replace('<div id="root"></div>', `<div id="root"><div class="seo-static">${r.body}</div></div>`);
270
  const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
271
  await mkdir(path.dirname(file), { recursive: true });
272
  await writeFile(file, html, "utf8");
273
  }
274
  // Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
275
+ await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n ${STATIC_STYLE}\n </head>`), "utf8");
276
  config.logger.info(`prerendered ${all.length} public routes`);
277
  },
278
  };
frontend/src/App.tsx CHANGED
@@ -18,6 +18,10 @@ import Resurface from "./pages/Resurface";
18
  import Settings from "./pages/Settings";
19
  import Sources from "./pages/Sources";
20
  import Studio from "./pages/Studio";
 
 
 
 
21
  import TopicMap from "./pages/TopicMap";
22
  import { ToolPage, ToolsHub } from "./pages/Tools";
23
  import VoiceProfile from "./pages/VoiceProfile";
@@ -55,6 +59,10 @@ export default function App() {
55
  <Route index element={<MissionControl />} />
56
  <Route path="notebooks" element={<Notebooks />} />
57
  <Route path="notebooks/:id" element={<NotebookView />} />
 
 
 
 
58
  <Route path="sources" element={<Sources />} />
59
  <Route path="map" element={<TopicMap />} />
60
  <Route path="voice" element={<VoiceProfile />} />
 
18
  import Settings from "./pages/Settings";
19
  import Sources from "./pages/Sources";
20
  import Studio from "./pages/Studio";
21
+ import VideoCreate from "./pages/VideoCreate";
22
+ import VideoEditor from "./pages/VideoEditor";
23
+ import VideoVoices from "./pages/VideoVoices";
24
+ import Videos from "./pages/Videos";
25
  import TopicMap from "./pages/TopicMap";
26
  import { ToolPage, ToolsHub } from "./pages/Tools";
27
  import VoiceProfile from "./pages/VoiceProfile";
 
59
  <Route index element={<MissionControl />} />
60
  <Route path="notebooks" element={<Notebooks />} />
61
  <Route path="notebooks/:id" element={<NotebookView />} />
62
+ <Route path="videos" element={<Videos />} />
63
+ <Route path="videos/new" element={<VideoCreate />} />
64
+ <Route path="videos/voices" element={<VideoVoices />} />
65
+ <Route path="videos/:id" element={<VideoEditor />} />
66
  <Route path="sources" element={<Sources />} />
67
  <Route path="map" element={<TopicMap />} />
68
  <Route path="voice" element={<VoiceProfile />} />
frontend/src/api/client.ts CHANGED
@@ -87,16 +87,31 @@ async function tryRefresh(): Promise<boolean> {
87
  return true;
88
  }
89
 
90
- export async function api<T>(path: string, init: RequestInit = {}, retry = true): Promise<T> {
91
  const headers = new Headers(init.headers);
92
- if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
 
 
 
93
  if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`);
94
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
95
- if (res.status === 401 && retry && (await tryRefresh())) return api<T>(path, init, false);
96
  if (!res.ok) throw await parseError(res);
97
- return (await res.json()) as T;
 
 
 
 
 
 
 
 
 
98
  }
99
 
 
 
 
100
  export const post = <T>(path: string, body?: unknown) =>
101
  api<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
102
 
 
87
  return true;
88
  }
89
 
90
+ async function request(path: string, init: RequestInit, retry: boolean): Promise<Response> {
91
  const headers = new Headers(init.headers);
92
+ // FormData sets its own multipart Content-Type (with the boundary); everything else is JSON.
93
+ if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
94
+ headers.set("Content-Type", "application/json");
95
+ }
96
  if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`);
97
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
98
+ if (res.status === 401 && retry && (await tryRefresh())) return request(path, init, false);
99
  if (!res.ok) throw await parseError(res);
100
+ return res;
101
+ }
102
+
103
+ export async function api<T>(path: string, init: RequestInit = {}, retry = true): Promise<T> {
104
+ return (await (await request(path, init, retry)).json()) as T;
105
+ }
106
+
107
+ /** A binary answer (audio, a download) as a Blob. */
108
+ export async function apiBlob(path: string, init: RequestInit = {}): Promise<Blob> {
109
+ return (await request(path, init, true)).blob();
110
  }
111
 
112
+ /** Multipart upload to our API (files plus form fields). */
113
+ export const postForm = <T>(path: string, form: FormData) => api<T>(path, { method: "POST", body: form });
114
+
115
  export const post = <T>(path: string, body?: unknown) =>
116
  api<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
117
 
frontend/src/api/endpoints.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { api, del, patch, post, put, qs, uploadFile } from "./client";
2
  import type {
3
  Artifact,
4
  ArtifactType,
@@ -28,6 +28,29 @@ import type {
28
  VoiceDesignPreview,
29
  VoiceProfileData,
30
  VoiceState,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  } from "./types";
32
 
33
  type SourceJob = { source: Source; job: Job };
@@ -78,7 +101,7 @@ export type GenerateBody = {
78
  archive?: boolean;
79
  format?: "deep_dive" | "brief" | "debate";
80
  minutes?: number;
81
- style?: "short" | "explainer" | "audiogram";
82
  audio_artifact_id?: string;
83
  slides?: { heading: string; body: string }[];
84
  parent_id?: string;
@@ -198,3 +221,215 @@ export const billingApi = {
198
  portal: () => post<{ url: string }>("/api/billing/portal"),
199
  confirm: (sessionId: string) => post<BillingStatus>("/api/billing/confirm", { session_id: sessionId }),
200
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
2
  import type {
3
  Artifact,
4
  ArtifactType,
 
28
  VoiceDesignPreview,
29
  VoiceProfileData,
30
  VoiceState,
31
+ ExtractedTheme,
32
+ LegacyVideo,
33
+ MyTemplate,
34
+ ScriptPreview,
35
+ ScriptScene,
36
+ TemplateGeneration,
37
+ TemplatesResponse,
38
+ VideoCatalog,
39
+ VideoConfig,
40
+ VideoCreateBody,
41
+ VideoCustomVoice,
42
+ VideoDesignedVoice,
43
+ VideoJobState,
44
+ VideoListItem,
45
+ VideoOptions,
46
+ VideoProject,
47
+ VideoProjectData,
48
+ VideoQuota,
49
+ VideoSavedVoice,
50
+ VideoScene,
51
+ VideoStatus,
52
+ VideoStyleItem,
53
+ VideoVoicesResponse,
54
  } from "./types";
55
 
56
  type SourceJob = { source: Source; job: Job };
 
101
  archive?: boolean;
102
  format?: "deep_dive" | "brief" | "debate";
103
  minutes?: number;
104
+ style?: "audiogram";
105
  audio_artifact_id?: string;
106
  slides?: { heading: string; body: string }[];
107
  parent_id?: string;
 
221
  portal: () => post<{ url: string }>("/api/billing/portal"),
222
  confirm: (sessionId: string) => post<BillingStatus>("/api/billing/confirm", { session_id: sessionId }),
223
  };
224
+
225
+ function form(fields: Record<string, string | number | boolean | Blob | null | undefined>): FormData {
226
+ const f = new FormData();
227
+ for (const [k, v] of Object.entries(fields)) {
228
+ if (v === undefined || v === null) continue;
229
+ f.append(k, v instanceof Blob ? v : String(v));
230
+ }
231
+ return f;
232
+ }
233
+
234
+ export type ScriptDraft = { title: string; display_text: string; narration_text: string; revision: number;
235
+ draft_scenes: { id: number; title: string; display_text: string | null; narration_text: string }[] };
236
+
237
+ /** Videos are made by blog2video; the backend proxies every call. Ids here are our artifact ids. */
238
+ export const videosApi = {
239
+ config: () => api<VideoConfig>("/api/videos/config"),
240
+ quota: () => api<VideoQuota>("/api/videos/quota"),
241
+ catalog: () => api<VideoCatalog>("/api/videos/catalog"),
242
+ list: () => api<VideoListItem[]>("/api/videos"),
243
+ create: (body: VideoCreateBody) => post<Artifact>("/api/videos", body),
244
+ /** Multi-link: one video per link. Items with `error` say where it stopped (usually the allowance ran out). */
245
+ createBatch: (urls: string[], options: VideoOptions) =>
246
+ post<(Artifact | { error: string; url: string })[]>("/api/videos/batch", { ...options, urls }),
247
+ /** Up to 5 documents; blog2video reads them itself. */
248
+ createFromFiles: (files: File[], options: VideoOptions) => {
249
+ const f = new FormData();
250
+ files.forEach((file) => f.append("files", file));
251
+ f.append("options", JSON.stringify(options));
252
+ return postForm<Artifact>("/api/videos/upload", f);
253
+ },
254
+ get: (id: string) => api<VideoProject | LegacyVideo>(`/api/videos/${id}`),
255
+ status: (id: string) => api<VideoStatus>(`/api/videos/${id}/status`),
256
+ remove: (id: string) => del(`/api/videos/${id}`),
257
+ // Script review
258
+ script: (id: string) => api<{ status: string; scenes: ScriptScene[] }>(`/api/videos/${id}/script`),
259
+ narrationPreview: (id: string, sceneId: number, body: ScriptDraft) =>
260
+ post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/narration-preview`, body),
261
+ aiPreview: (id: string, sceneId: number, body: ScriptDraft & { instruction: string }) =>
262
+ post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/ai-preview`, body),
263
+ approveScript: (id: string, scenes: (ScriptScene & { source_fingerprint?: string; accepted_ai_instructions?: string[] })[]) =>
264
+ post<unknown>(`/api/videos/${id}/script/approve`, { scenes }),
265
+ };
266
+
267
+ const P = (id: string, path: string) => `/api/videos/${id}/p/${path}`;
268
+ export type LayoutSchema = Record<string, { defaults?: Record<string, unknown>; fields?: Record<string, unknown>[] }>;
269
+
270
+ /** The template's layouts, from blog2video's GET .../layouts. Variants ("news_headline__v2") are visual styles of a base
271
+ * layout: layout_variants maps a base to its variant ids, layout_variant_labels names each ("Broadsheet"). */
272
+ export type LayoutInfo = {
273
+ layouts: string[];
274
+ layout_names?: Record<string, string>;
275
+ layout_prop_schema?: LayoutSchema;
276
+ layout_variants?: Record<string, string[]>;
277
+ layout_variant_labels?: Record<string, string>;
278
+ };
279
+
280
+ export type StockClip = { provider: string; id: string; preview_url: string; thumbnail_url: string; download_url: string;
281
+ width: number; height: number; duration: number; author: string; page_url: string };
282
+
283
+ /** The editor: blog2video's project endpoints, through our allowlisted proxy. */
284
+ export const videoEditApi = {
285
+ project: (id: string) => api<VideoProjectData>(P(id, "")),
286
+ updateProject: (id: string, body: Partial<VideoProjectData>) => patch<VideoProjectData>(P(id, "update-project"), body),
287
+ // Scenes
288
+ updateScene: (id: string, sceneId: number, body: Partial<Pick<VideoScene, "title" | "display_text" | "narration_text" |
289
+ "visual_description" | "duration_seconds" | "extra_hold_seconds" | "remotion_code">>) =>
290
+ put<VideoScene>(P(id, `scenes/${sceneId}`), body),
291
+ deleteScene: (id: string, sceneId: number) => del<unknown>(P(id, `scenes/${sceneId}`)),
292
+ reorderScenes: (id: string, scene_orders: { scene_id: number; order: number }[]) =>
293
+ post<VideoScene[]>(P(id, "scenes/reorder"), { scene_orders }),
294
+ regenerateScene: (id: string, sceneId: number, body: { description?: string; narration_text?: string;
295
+ regenerate_voiceover?: boolean; voiceover_verbatim?: boolean; layout?: string; image?: File }) =>
296
+ postForm<VideoScene>(P(id, `scenes/${sceneId}/regenerate`), form(body)),
297
+ addScene: (id: string, prompt: string, position?: number) => post<VideoJobState>(P(id, "scenes/add"), { prompt, position }),
298
+ addSceneStatus: (id: string) => api<VideoJobState | null>(P(id, "scenes/add-status")),
299
+ // Images
300
+ uploadImage: (id: string, sceneId: number, image: File) => postForm<VideoScene>(P(id, `scenes/${sceneId}/image`), form({ image })),
301
+ generateImage: (id: string, sceneId: number, image_description: string) =>
302
+ // Not saved on the scene: the image comes back for the user to keep (uploadImage) or discard.
303
+ post<{ image_base64: string; refined_prompt: string }>(P(id, `scenes/${sceneId}/generate-image`), { image_description }),
304
+ imageFocus: (id: string, sceneId: number, body: { image_focus_x: number; image_focus_y: number; image_zoom?: number }) =>
305
+ patch<VideoScene>(P(id, `scenes/${sceneId}/image-focus`), body),
306
+ moveImage: (id: string, from_scene_id: number, to_scene_id: number) =>
307
+ post<unknown>(P(id, "images/move"), { from_scene_id, to_scene_id }),
308
+ swapImages: (id: string, first_scene_id: number, second_scene_id: number) =>
309
+ post<unknown>(P(id, "images/swap"), { first_scene_id, second_scene_id }),
310
+ duplicateImage: (id: string, source_scene_id: number, target_scene_id: number) =>
311
+ post<unknown>(P(id, "images/duplicate"), { source_scene_id, target_scene_id }),
312
+ /** Deletes the image or clip from the video for good (files too) and takes it off any scene. */
313
+ /** The template's layouts; layout_prop_schema[layout].defaults holds its default font sizes (meta.json). */
314
+ layouts: (id: string) => api<LayoutInfo>(P(id, "layouts")),
315
+ deleteAsset: (id: string, assetId: number) => del<unknown>(P(id, `assets/${assetId}`)),
316
+ assignImage: (id: string, scene_id: number, asset_id: number) =>
317
+ post<unknown>(P(id, "images/assign-existing"), { scene_id, asset_id }),
318
+ // Stock footage
319
+ searchStock: (id: string, q: string) => api<{ clips: StockClip[] }>(P(id, `stock-footage/search${qs({ q })}`)),
320
+ /** Downloads the clip into the project (a new asset). It is not on the scene until linkStock. */
321
+ useStock: (id: string, sceneId: number, c: StockClip) =>
322
+ post<{ asset_id: number; filename: string }>(P(id, `scenes/${sceneId}/stock-footage`), { provider: c.provider, clip_id: c.id,
323
+ download_url: c.download_url, width: c.width, height: c.height, duration: c.duration, author: c.author,
324
+ page_url: c.page_url }),
325
+ /** Put a clip that is already in the project on a scene (assignedVideo). */
326
+ linkStock: (id: string, scene_id: number, filename: string) =>
327
+ post<unknown>(P(id, "stock-footage/link"), { scene_id, filename }),
328
+ pendingStock: (id: string) => api<{ scenes?: unknown[]; [k: string]: unknown }>(P(id, "stock-footage/pending")),
329
+ approveStock: (id: string) => post<unknown>(P(id, "stock-footage/approve")),
330
+ rejectStock: (id: string) => post<unknown>(P(id, "stock-footage/reject")),
331
+ // Voice, voiceovers, language, template
332
+ uploadVoiceover: (id: string, sceneId: number, audio: File) =>
333
+ postForm<VideoScene>(P(id, `scenes/${sceneId}/voiceover`), form({ audio })),
334
+ deleteVoiceover: (id: string) => post<unknown>(P(id, "delete-voiceover")),
335
+ changeVoice: (id: string, body: { voice_gender?: string; voice_accent?: string; custom_voice_id?: string | null;
336
+ voice_emotion?: string | null }) => post<VideoJobState>(P(id, "change-voice"), body),
337
+ voiceStatus: (id: string) => api<VideoJobState>(P(id, "voice-change-status")),
338
+ changeLanguage: (id: string, content_language: string) => post<VideoJobState>(P(id, "change-language"), { content_language }),
339
+ languageStatus: (id: string) => api<VideoJobState>(P(id, "language-change-status")),
340
+ changeTemplate: (id: string, template: string) =>
341
+ post<VideoJobState>(P(id, "change-template-regenerate-layouts"), { template }),
342
+ templateStatus: (id: string) => api<VideoJobState>(P(id, "template-change-status")),
343
+ // Refresh the script (keeps narration and voiceovers, rewrites titles and layouts)
344
+ refreshScript: (id: string, user_instruction?: string) => post<VideoJobState>(P(id, "regenerate-script"), { user_instruction }),
345
+ refreshStatus: (id: string) => api<VideoJobState>(P(id, "regenerate-script-status")),
346
+ refreshPreview: (id: string) => api<Record<string, unknown>>(P(id, "regenerate-script/preview")),
347
+ refreshVerify: (id: string) => post<unknown>(P(id, "regenerate-script/verify")),
348
+ refreshRetry: (id: string, user_instruction?: string) =>
349
+ post<VideoJobState>(P(id, "regenerate-script/regenerate"), { user_instruction }),
350
+ // AI chat editing ★
351
+ chat: (id: string, message: string, conversation_id?: number) =>
352
+ post<{ reply?: string; message?: string; conversation_id?: number; [k: string]: unknown }>(P(id, "chat"), { message, conversation_id }),
353
+ chatHistory: (id: string) => api<unknown>(P(id, "chat/history")),
354
+ // Avatars ★
355
+ authorizeAvatars: (id: string, scene_ids: number[], avatar_preset?: string, avatar_motion_style?: string) =>
356
+ post<unknown>(P(id, "avatar-batch/authorize"), { scene_ids, avatar_preset, avatar_motion_style }),
357
+ makeAvatar: (id: string, sceneId: number, avatar_preset?: string) =>
358
+ postForm<unknown>(P(id, `scenes/${sceneId}/avatar`), form({ avatar_preset })),
359
+ avatarProgress: (id: string) => api<Record<string, unknown>>(P(id, "avatar-progress")),
360
+ deleteAvatar: (id: string, sceneId: number) => del<unknown>(P(id, `scenes/${sceneId}/avatar`)),
361
+ uploadPortrait: (id: string, file: File) => postForm<unknown>(P(id, "avatar-portrait"), form({ file })),
362
+ deletePortrait: (id: string) => del<unknown>(P(id, "avatar-portrait")),
363
+ avatarAppearance: (id: string, sceneId: number, body: { avatar_shape?: string; avatar_size?: number;
364
+ avatar_position?: string; avatar_opacity?: number }) => patch<unknown>(P(id, `scenes/${sceneId}/avatar-appearance`), body),
365
+ // Render, download, frames
366
+ /** force renders again even when an MP4 is already on R2 (blog2video otherwise just returns the old one). */
367
+ render: (id: string, { force = false, resolution = "1080p" }: { force?: boolean; resolution?: string } = {}) =>
368
+ post<VideoJobState>(P(id, `render${qs({ resolution, force_render: force || undefined })}`)),
369
+ renderStatus: (id: string) => api<VideoJobState>(P(id, "render-status")),
370
+ cancelRender: (id: string) => post<unknown>(P(id, "cancel-render")),
371
+ downloadUrl: (id: string) => api<{ url: string }>(P(id, "download-url")),
372
+ downloadStudio: (id: string) => apiBlob(P(id, "download-studio")),
373
+ still: (id: string, frame: number) => apiBlob(P(id, `render-still${qs({ frame })}`)),
374
+ };
375
+
376
+ export const videoVoicesApi = {
377
+ list: () => api<VideoVoicesResponse>("/api/video-voices"),
378
+ save: (voice_id: string) => post<VideoSavedVoice>("/api/video-voices/saved", { voice_id }),
379
+ unsave: (voice_id: string) => del(`/api/video-voices/saved/${encodeURIComponent(voice_id)}`),
380
+ designFromPrompt: (prompt: string) => post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/prompt", { prompt }),
381
+ designFromPreset: (body: { gender: string; age: string; persona: string; speed: string; accent: string }) =>
382
+ post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/preset", body),
383
+ keep: (body: { generated_voice_id: string; source: "prompt" | "preset"; name: string; prompt_text?: string }) =>
384
+ post<VideoCustomVoice>("/api/video-voices/custom", body),
385
+ clone: (name: string, file: File, remove_background_noise = true) =>
386
+ postForm<VideoCustomVoice>("/api/video-voices/clone", form({ name, file, remove_background_noise })),
387
+ customPreview: (customId: number) =>
388
+ api<{ preview_url: string | null; ready: boolean }>(`/api/video-voices/custom/${customId}/preview`),
389
+ removeCustom: (customId: number) => del(`/api/video-voices/custom/${customId}`),
390
+ /** ★ A few seconds of the voice with the chosen tuning (audio/mpeg). */
391
+ sample: (body: { voice_gender: string; voice_accent: string; custom_voice_id?: string; voice_emotion?: string;
392
+ video_style?: string }) => apiBlob("/api/video-voices/sample", { method: "POST", body: JSON.stringify(body) }),
393
+ };
394
+
395
+ export const videoStylesApi = {
396
+ list: () => api<VideoStyleItem[]>("/api/video-styles"),
397
+ aiDraft: (prompt: string) => post<{ name: string; guidance: string }>("/api/video-styles/ai-draft", { prompt }),
398
+ create: (body: { name: string; guidance: string; creation_method: "manual" | "ai"; source_prompt?: string }) =>
399
+ post<VideoStyleItem>("/api/video-styles", body),
400
+ update: (styleId: number, body: { name: string; guidance: string; version: number }) =>
401
+ patch<VideoStyleItem>(`/api/video-styles/${styleId}`, body),
402
+ remove: (styleId: number) => del(`/api/video-styles/${styleId}`),
403
+ };
404
+
405
+ const T = (id: number, path: string) => `/api/video-templates/${id}/p/${path}`;
406
+
407
+ export const videoTemplatesApi = {
408
+ list: () => api<TemplatesResponse>("/api/video-templates"),
409
+ extractFromUrl: (url: string) => post<ExtractedTheme>("/api/video-templates/extract/url", { url }),
410
+ extractFromPrompt: (prompt: string, name?: string) =>
411
+ post<ExtractedTheme>("/api/video-templates/extract/prompt", { prompt, name }),
412
+ extractFromDoc: (file: File, name: string) => postForm<ExtractedTheme>("/api/video-templates/extract/doc", form({ file, name })),
413
+ create: (body: { name: string; theme: Record<string, unknown>; source_url?: string; logo_urls?: string[];
414
+ og_image?: string; screenshot_url?: string }) => post<MyTemplate>("/api/video-templates", body),
415
+ generate: (id: number) => post<unknown>(`/api/video-templates/${id}/generate`),
416
+ generationStatus: (id: number) => api<TemplateGeneration>(`/api/video-templates/${id}/generation-status`),
417
+ remove: (id: number) => del(`/api/video-templates/${id}`),
418
+ // Editing
419
+ get: (id: number) => api<Record<string, unknown>>(T(id, "")),
420
+ update: (id: number, body: { name?: string; theme?: Record<string, unknown> }) => put<Record<string, unknown>>(T(id, ""), body),
421
+ uploadLogo: (id: number, file: File) => postForm<unknown>(T(id, "upload-logo"), form({ file })),
422
+ aiEdit: (id: number, sceneKey: string, prompt: string) => post<unknown>(T(id, `scenes/${sceneKey}/ai-edit`), { prompt }),
423
+ aiEditStatus: (id: number, sceneKey: string) => api<VideoJobState>(T(id, `scenes/${sceneKey}/ai-edit/status`)),
424
+ drafts: (id: number) => api<unknown>(T(id, "scene-drafts")),
425
+ applyDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/apply`)),
426
+ discardDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/discard`)),
427
+ fontDefaults: (id: number, body: Record<string, unknown>, sceneKey?: string) =>
428
+ patch<unknown>(T(id, sceneKey ? `scenes/${sceneKey}/font-defaults` : "scenes/font-defaults"), body),
429
+ chart: (id: number, sceneKey: string, body: Record<string, unknown>) => patch<unknown>(T(id, `scenes/${sceneKey}/chart`), body),
430
+ versions: (id: number) => api<unknown>(T(id, "versions")),
431
+ rollback: (id: number, versionId: number) => post<unknown>(T(id, `versions/${versionId}/rollback`)),
432
+ regenerate: (id: number) => post<unknown>(T(id, "regenerate-code")),
433
+ resume: (id: number) => post<unknown>(T(id, "resume-generation")),
434
+ rate: (id: number, body: Record<string, unknown>) => post<unknown>(T(id, "rating"), body),
435
+ };
frontend/src/api/types.ts CHANGED
@@ -114,7 +114,7 @@ export type Artifact = {
114
  type: ArtifactType;
115
  type_label: string;
116
  title: string;
117
- status: "pending" | "generating" | "rendering" | "ready" | "failed" | "draft";
118
  notebook_id: string | null;
119
  document_id: string | null;
120
  content: Record<string, any>;
@@ -124,6 +124,8 @@ export type Artifact = {
124
  slide_urls: string[];
125
  created_at: string | null;
126
  job: Job | null;
 
 
127
  };
128
 
129
  export type TopicStatus = "rising" | "steady" | "dormant";
@@ -301,7 +303,8 @@ export type Plan = {
301
  sources: number;
302
  indexed_posts: number;
303
  audio_minutes: number;
304
- video_minutes: number;
 
305
  launch_kits: number;
306
  voice_cloning: boolean;
307
  brand_kit: boolean;
@@ -309,8 +312,9 @@ export type Plan = {
309
 
310
  export type Usage = {
311
  plan: string;
312
- used: { audio_minutes: number; video_minutes: number; launch_kits: number; llm_tokens: number; since: string };
313
- limits: { audio_minutes: number; video_minutes: number; launch_kits: number; sources: number; indexed_posts: number };
 
314
  };
315
 
316
  export type Settings = {
@@ -355,5 +359,295 @@ export type BillingStatus = {
355
  period_end: string | null;
356
  meters: Meter[];
357
  since: string;
 
 
358
  nudges: Nudge[];
359
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  type: ArtifactType;
115
  type_label: string;
116
  title: string;
117
+ status: "pending" | "generating" | "rendering" | "review" | "ready" | "failed" | "draft";
118
  notebook_id: string | null;
119
  document_id: string | null;
120
  content: Record<string, any>;
 
124
  slide_urls: string[];
125
  created_at: string | null;
126
  job: Job | null;
127
+ /** "blog2video" for videos made in the Videos editor. */
128
+ provider?: string | null;
129
  };
130
 
131
  export type TopicStatus = "rising" | "steady" | "dormant";
 
303
  sources: number;
304
  indexed_posts: number;
305
  audio_minutes: number;
306
+ /** blog2video videos per period */
307
+ videos: number;
308
  launch_kits: number;
309
  voice_cloning: boolean;
310
  brand_kit: boolean;
 
312
 
313
  export type Usage = {
314
  plan: string;
315
+ used: { audio_minutes: number; videos: number; launch_kits: number; llm_tokens: number; since: string };
316
+ limits: { audio_minutes: number; videos: number; launch_kits: number; sources: number; indexed_posts: number };
317
+ videos_resets_at: string | null;
318
  };
319
 
320
  export type Settings = {
 
359
  period_end: string | null;
360
  meters: Meter[];
361
  since: string;
362
+ /** Videos reset on renewal (or monthly without one), not on the 1st like the other meters. */
363
+ videos_resets_at: string | null;
364
  nudges: Nudge[];
365
  };
366
+
367
+ // Videos (blog2video). Everything goes through our API; ids are our artifact ids unless named b2v/custom.
368
+
369
+ export type VideoLimitMetric =
370
+ | "ai_edits" | "templates" | "template_ai_daily" | "voice_designs_daily" | "voice_samples_daily" | "custom_voices";
371
+
372
+ export type VideoConfig = {
373
+ configured: boolean;
374
+ /** Premium (★) video options are included in this workspace's plan. */
375
+ premium: boolean;
376
+ limits: Record<VideoLimitMetric, { used: number; limit: number }>;
377
+ };
378
+
379
+ /** A row on the Videos page. */
380
+ export type VideoListItem = Artifact & {
381
+ source_url: string | null;
382
+ scenes: number | null;
383
+ /** blog2video's own status: generated, done, awaiting_script_review, ... */
384
+ b2v_status: string | null;
385
+ };
386
+
387
+ export type VideoQuota = { used: number; limit: number; plan: string; period_start: string | null; resets_at: string | null };
388
+
389
+ export type VideoTemplate = {
390
+ id: string;
391
+ name: string;
392
+ description?: string;
393
+ preview_url?: string | null;
394
+ /** The 9:16 poster, for portrait videos. */
395
+ preview_portrait_url?: string | null;
396
+ genres?: string[];
397
+ badge?: string | null;
398
+ popular_template?: boolean;
399
+ new_template?: boolean;
400
+ preview_colors?: { accent?: string; bg?: string; text?: string };
401
+ /** One of this workspace's own custom templates. */
402
+ custom?: boolean;
403
+ };
404
+
405
+ export type VideoStyleItem = {
406
+ /** "auto" | "explainer" | "storytelling" | "promotional" | "custom:<id>" */
407
+ id: string;
408
+ name: string;
409
+ description?: string | null;
410
+ kind: "builtin" | "custom";
411
+ custom_id?: number;
412
+ guidance?: string | null;
413
+ version?: number;
414
+ };
415
+
416
+ export type MusicTrack = { track_id: string; display_name: string; mood?: string; r2_url?: string | null };
417
+
418
+ export type VideoCatalog = {
419
+ templates: VideoTemplate[];
420
+ crafted_templates: VideoTemplate[];
421
+ my_templates: VideoTemplate[];
422
+ video_styles: VideoStyleItem[];
423
+ music: MusicTrack[];
424
+ languages: { code: string; name: string }[];
425
+ };
426
+
427
+ /** A voice in the workspace's "My voices" list (kept only in Notestack). */
428
+ export type VideoSavedVoice = {
429
+ voice_id: string;
430
+ name: string;
431
+ preview_url: string | null;
432
+ gender: string | null;
433
+ accent: string | null;
434
+ premium: boolean;
435
+ is_custom: boolean;
436
+ };
437
+
438
+ export type VideoLibraryVoice = {
439
+ voice_id: string;
440
+ name: string;
441
+ description: string;
442
+ preview_url: string | null;
443
+ gender: string | null;
444
+ accent: string | null;
445
+ age?: string | null;
446
+ premium: boolean;
447
+ saved: boolean;
448
+ };
449
+
450
+ export type VideoCustomVoice = {
451
+ id: number;
452
+ voice_id: string;
453
+ name: string;
454
+ source: "prompt" | "preset" | "clone";
455
+ preview_url: string | null;
456
+ saved: boolean;
457
+ };
458
+
459
+ export type VideoVoicesResponse = { saved: VideoSavedVoice[]; library: VideoLibraryVoice[]; custom: VideoCustomVoice[] };
460
+
461
+ export type VideoDesignedVoice = {
462
+ generated_voice_id: string;
463
+ audio_base_64: string;
464
+ media_type: string;
465
+ duration_secs: number;
466
+ };
467
+
468
+ export type VideoLength = "short" | "medium" | "detailed" | "more_detailed";
469
+ export type LogoPosition = "bottom_right" | "bottom_left" | "top_left" | "top_right";
470
+
471
+ /** Every field of the 3-step wizard, named as blog2video names them. */
472
+ export type VideoOptions = {
473
+ // Step 1: Project
474
+ stock_footage_enabled: boolean;
475
+ aspect_ratio: "landscape" | "portrait";
476
+ video_length: VideoLength;
477
+ logo_upload_id?: string;
478
+ logo_position: LogoPosition;
479
+ logo_opacity: number;
480
+ // Step 2: Template
481
+ template: string;
482
+ video_style: string;
483
+ accent_color?: string;
484
+ bg_color?: string;
485
+ text_color?: string;
486
+ // Step 3: Voice
487
+ content_language?: string | null;
488
+ voice_gender: "female" | "male" | "none";
489
+ voice_accent: string;
490
+ custom_voice_id?: string;
491
+ voice_emotion?: string;
492
+ bgm_track_id?: string;
493
+ bgm_volume: number;
494
+ };
495
+
496
+ export type VideoCreateBody = VideoOptions & {
497
+ document_id?: string;
498
+ /** A notebook's ticked posts, combined into one video. */
499
+ document_ids?: string[];
500
+ /** Names a combined video after its notebook. */
501
+ notebook_id?: string;
502
+ url?: string;
503
+ content?: string;
504
+ title?: string;
505
+ };
506
+
507
+ export type VideoStatus = {
508
+ video_id: number;
509
+ status: string;
510
+ step: number | null;
511
+ running: boolean;
512
+ ready: boolean;
513
+ error: string | null;
514
+ video_url: string | null;
515
+ artifact_status: Artifact["status"];
516
+ /** Made through the old partner integration: blog2video can no longer reach it, so only stored links remain. */
517
+ legacy?: true;
518
+ };
519
+
520
+ export type VideoScene = {
521
+ id: number;
522
+ order: number;
523
+ title: string;
524
+ display_text: string | null;
525
+ narration_text: string;
526
+ visual_description?: string;
527
+ preferred_layout?: string | null;
528
+ duration_seconds: number;
529
+ voiceover_path?: string | null;
530
+ avatar_video_path?: string | null;
531
+ bgm_volume?: number | null;
532
+ extra_hold_seconds?: number | null;
533
+ /** JSON: {"layout": ..., "layoutProps": {...}} (layoutConfig on custom templates). */
534
+ remotion_code?: string | null;
535
+ // Worked out by our backend (routers/videos.py _enrich)
536
+ audio_url?: string | null;
537
+ images?: { filename: string; asset_id: number | null; kind: string; url: string }[];
538
+ layout?: string | null;
539
+ title_font_size?: number | null;
540
+ desc_font_size?: number | null;
541
+ [key: string]: unknown;
542
+ };
543
+
544
+ export type VideoAsset = {
545
+ id: number;
546
+ asset_type: string;
547
+ r2_url: string | null;
548
+ filename: string;
549
+ excluded: boolean;
550
+ duration_seconds?: number | null;
551
+ source_provider?: string | null;
552
+ };
553
+
554
+ /** blog2video's project, as its editor sees it. */
555
+ export type VideoProjectData = {
556
+ id: number;
557
+ name: string;
558
+ status: string;
559
+ template: string;
560
+ aspect_ratio: "landscape" | "portrait";
561
+ accent_color: string;
562
+ bg_color: string;
563
+ text_color: string;
564
+ font_family: string | null;
565
+ voice_gender: string;
566
+ voice_accent: string;
567
+ custom_voice_id: string | null;
568
+ content_language: string | null;
569
+ logo_r2_url: string | null;
570
+ logo_position: string;
571
+ logo_opacity: number;
572
+ logo_size: number;
573
+ bgm_track_id: string | null;
574
+ bgm_volume: number;
575
+ playback_speed: number;
576
+ captions_enabled: boolean;
577
+ caption_position: string;
578
+ caption_font_family?: string | null;
579
+ caption_font_size?: number | string | null;
580
+ /** -100 (down) to 100 (up); 0 is the template's default place. */
581
+ caption_offset?: number | null;
582
+ r2_video_url: string | null;
583
+ scenes: VideoScene[];
584
+ assets: VideoAsset[];
585
+ summary?: { scenes: number; images: number; clips?: number; duration_seconds: number };
586
+ [key: string]: unknown;
587
+ };
588
+
589
+ /** A video made before the switch to our API key: stored links only, no edits. */
590
+ export type LegacyVideo = {
591
+ legacy: true;
592
+ status: string | null;
593
+ ready: boolean;
594
+ preview_url: string | null;
595
+ video_url: string | null;
596
+ artifact: Artifact;
597
+ };
598
+
599
+ export type VideoProject = {
600
+ legacy?: undefined;
601
+ video_id: number;
602
+ status: string;
603
+ preview_url: string | null;
604
+ video_url: string | null;
605
+ project: VideoProjectData;
606
+ artifact: Artifact;
607
+ };
608
+
609
+ /** Background jobs (template, voice, language, add scene, render, script refresh) report progress in roughly this
610
+ * shape; each names things slightly differently. */
611
+ export type VideoJobState = { status?: string; running?: boolean; done?: boolean; error?: string | null;
612
+ progress?: number; video_url?: string | null; [key: string]: unknown };
613
+
614
+ export type ScriptScene = {
615
+ id: number;
616
+ order: number;
617
+ title: string;
618
+ display_text: string | null;
619
+ narration_text: string;
620
+ preferred_layout?: string | null;
621
+ };
622
+
623
+ export type ScriptPreview = { revision: number; title: string; display_text: string; narration_text: string;
624
+ source_fingerprint: string };
625
+
626
+ // Custom templates
627
+
628
+ export type MyTemplate = { id: number; ref: string; name: string; ready: boolean; created_at: string | null };
629
+
630
+ export type TemplatesResponse = {
631
+ templates: MyTemplate[];
632
+ limits: Record<"templates" | "template_ai_daily", { used: number; limit: number }>;
633
+ };
634
+
635
+ export type ExtractedTheme = {
636
+ extractable: boolean;
637
+ reason: string;
638
+ theme: Record<string, unknown> | null;
639
+ template_name: string;
640
+ logo_urls: string[];
641
+ og_image: string;
642
+ screenshot_url: string;
643
+ };
644
+
645
+ export type TemplateGeneration = {
646
+ status: string;
647
+ step?: string | null;
648
+ running: boolean;
649
+ error: string | null;
650
+ scenes_done?: number;
651
+ scenes_total?: number;
652
+ ready: boolean;
653
+ };
frontend/src/components/AppShell.tsx CHANGED
@@ -8,6 +8,7 @@ import SkyCanvas from "./SkyCanvas";
8
  import UpgradeModal from "./UpgradeModal";
9
  import {
10
  AsteroidIcon,
 
11
  HelmetIcon,
12
  LaunchpadIcon,
13
  LaunchWindowIcon,
@@ -18,12 +19,13 @@ import {
18
  } from "./icons/Icons";
19
 
20
  // Theme in the icon, clarity in the label: the plain label always shows.
21
- // Seven stops: related pages share one entry (`also` keeps it lit on them).
22
  const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [
23
  { to: "/app", label: "Home", icon: RadarIcon, end: true },
24
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
25
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
26
  { to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] },
 
27
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
28
  { to: "/app/archive", label: "Library", icon: AsteroidIcon },
29
  { to: "/app/voice", label: "Voice", icon: HelmetIcon },
 
8
  import UpgradeModal from "./UpgradeModal";
9
  import {
10
  AsteroidIcon,
11
+ CometIcon,
12
  HelmetIcon,
13
  LaunchpadIcon,
14
  LaunchWindowIcon,
 
19
  } from "./icons/Icons";
20
 
21
  // Theme in the icon, clarity in the label: the plain label always shows.
22
+ // Eight stops: related pages share one entry (`also` keeps it lit on them).
23
  const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [
24
  { to: "/app", label: "Home", icon: RadarIcon, end: true },
25
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
26
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
27
  { to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] },
28
+ { to: "/app/videos", label: "Videos", icon: CometIcon },
29
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
30
  { to: "/app/archive", label: "Library", icon: AsteroidIcon },
31
  { to: "/app/voice", label: "Voice", icon: HelmetIcon },