Muhammad Mehdi commited on
Commit
ebe8d3a
·
2 Parent(s): 52ec2791dc40e1

Merge pull request #2 from FireBird-Technologies/fix/b2v-integration

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/alembic/versions/{0005_workspace_memory.py → 0013_workspace_memory.py} +4 -4
  11. backend/alembic/versions/{0006_chat_citations.py → 0014_chat_citations.py} +4 -4
  12. backend/alembic/versions/{0007_message_feedback.py → 0015_message_feedback.py} +4 -4
  13. backend/alembic/versions/{0008_support_chat.py → 0016_support_chat.py} +4 -4
  14. backend/app/config.py +9 -0
  15. backend/app/llm/provider.py +12 -0
  16. backend/app/llm/run.py +47 -6
  17. backend/app/llm/signatures.py +27 -16
  18. backend/app/main.py +6 -1
  19. backend/app/models/__init__.py +14 -1
  20. backend/app/models/content.py +100 -1
  21. backend/app/models/user.py +48 -2
  22. backend/app/pipeline/generate.py +55 -12
  23. backend/app/pipeline/media.py +6 -90
  24. backend/app/routers/artifacts.py +11 -10
  25. backend/app/routers/billing.py +3 -2
  26. backend/app/routers/notebooks.py +29 -5
  27. backend/app/routers/topics.py +2 -2
  28. backend/app/routers/video_edit.py +212 -0
  29. backend/app/routers/video_styles.py +88 -0
  30. backend/app/routers/video_templates.py +277 -0
  31. backend/app/routers/video_voices.py +297 -0
  32. backend/app/routers/videos.py +949 -0
  33. backend/app/routers/voice.py +11 -3
  34. backend/app/services/artifacts.py +5 -2
  35. backend/app/services/auth_identity.py +4 -1
  36. backend/app/services/b2v_access.py +219 -0
  37. backend/app/services/billing.py +8 -2
  38. backend/app/services/blog2video.py +326 -0
  39. backend/app/services/jobs.py +6 -0
  40. backend/app/services/notestack_voices.py +37 -0
  41. backend/app/services/plans.py +136 -19
  42. backend/app/services/renderer.py +0 -2
  43. backend/app/services/stripe_billing.py +9 -0
  44. backend/app/services/usage.py +6 -4
  45. backend/app/services/video_focus.py +156 -0
  46. backend/app/services/video_limits.py +93 -0
  47. backend/app/services/video_quota.py +239 -0
  48. backend/app/support/corpus.py +7 -4
  49. backend/app/worker.py +31 -10
  50. backend/requirements.txt +1 -0
.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=10000
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/alembic/versions/{0005_workspace_memory.py → 0013_workspace_memory.py} RENAMED
@@ -1,14 +1,14 @@
1
  """workspace_memory: one row of standing notes per workspace, stored as JSON
2
 
3
- Revision ID: 0005_workspace_memory
4
- Revises: 0004_archive_notebook
5
  Create Date: 2026-09-30
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
- revision = "0005_workspace_memory"
11
- down_revision = "0004_archive_notebook"
12
  branch_labels = None
13
  depends_on = None
14
 
 
1
  """workspace_memory: one row of standing notes per workspace, stored as JSON
2
 
3
+ Revision ID: 0013_workspace_memory
4
+ Revises: 0012_artifact_status_len
5
  Create Date: 2026-09-30
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
+ revision = "0013_workspace_memory"
11
+ down_revision = "0012_artifact_status_len"
12
  branch_labels = None
13
  depends_on = None
14
 
backend/alembic/versions/{0006_chat_citations.py → 0014_chat_citations.py} RENAMED
@@ -1,14 +1,14 @@
1
  """citations.kind: a citation can point at an earlier chat (no document) as well as a post
2
 
3
- Revision ID: 0006_chat_citations
4
- Revises: 0005_workspace_memory
5
  Create Date: 2026-10-01
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
- revision = "0006_chat_citations"
11
- down_revision = "0005_workspace_memory"
12
  branch_labels = None
13
  depends_on = None
14
 
 
1
  """citations.kind: a citation can point at an earlier chat (no document) as well as a post
2
 
3
+ Revision ID: 0014_chat_citations
4
+ Revises: 0013_workspace_memory
5
  Create Date: 2026-10-01
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
+ revision = "0014_chat_citations"
11
+ down_revision = "0013_workspace_memory"
12
  branch_labels = None
13
  depends_on = None
14
 
backend/alembic/versions/{0007_message_feedback.py → 0015_message_feedback.py} RENAMED
@@ -1,14 +1,14 @@
1
  """message_feedback: thumbs up/down on chat answers, plus messages.recall_json (how the answer was produced)
2
 
3
- Revision ID: 0007_message_feedback
4
- Revises: 0006_chat_citations
5
  Create Date: 2026-10-02
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
- revision = "0007_message_feedback"
11
- down_revision = "0006_chat_citations"
12
  branch_labels = None
13
  depends_on = None
14
 
 
1
  """message_feedback: thumbs up/down on chat answers, plus messages.recall_json (how the answer was produced)
2
 
3
+ Revision ID: 0015_message_feedback
4
+ Revises: 0014_chat_citations
5
  Create Date: 2026-10-02
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
+ revision = "0015_message_feedback"
11
+ down_revision = "0014_chat_citations"
12
  branch_labels = None
13
  depends_on = None
14
 
backend/alembic/versions/{0008_support_chat.py → 0016_support_chat.py} RENAMED
@@ -1,14 +1,14 @@
1
  """support_chat: conversations and messages for the in app help bot
2
 
3
- Revision ID: 0008_support_chat
4
- Revises: 0007_message_feedback
5
  Create Date: 2026-10-06
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
- revision = "0008_support_chat"
11
- down_revision = "0007_message_feedback"
12
  branch_labels = None
13
  depends_on = None
14
 
 
1
  """support_chat: conversations and messages for the in app help bot
2
 
3
+ Revision ID: 0016_support_chat
4
+ Revises: 0015_message_feedback
5
  Create Date: 2026-10-06
6
  """
7
  import sqlalchemy as sa
8
  from alembic import op
9
 
10
+ revision = "0016_support_chat"
11
+ down_revision = "0015_message_feedback"
12
  branch_labels = None
13
  depends_on = None
14
 
backend/app/config.py CHANGED
@@ -121,6 +121,15 @@ class Settings(BaseSettings):
121
  linkedin_client_id: str = ""
122
  linkedin_client_secret: str = ""
123
 
 
 
 
 
 
 
 
 
 
124
  # Renderer
125
  renderer_url: str = "http://localhost:3100"
126
  internal_token: str = "internal-change-me"
 
121
  linkedin_client_id: str = ""
122
  linkedin_client_secret: str = ""
123
 
124
+ # blog2video: makes the videos. We call its public API with our own account's key (b2v_live_...), so every
125
+ # video is charged to that account. Backend only: the key must never reach the browser or the logs.
126
+ b2v_api_base_url: str = Field("", validation_alias=AliasChoices("B2V_API_BASE_URL", "B2V_API_URL"))
127
+ b2v_api_key: str = ""
128
+ # blog2video's web app: serves the template poster images (<app>/template-posters/<id>.webp).
129
+ b2v_app_url: str = "https://blog2video.app"
130
+ b2v_timeout_seconds: float = 30 # creates, uploads and full-project reads; other calls also wait 30s (STATUS_TIMEOUT)
131
+ b2v_capacity_alert_below: int = 20 # log an error when our blog2video account has fewer videos left
132
+
133
  # Renderer
134
  renderer_url: str = "http://localhost:3100"
135
  internal_token: str = "internal-change-me"
backend/app/llm/provider.py CHANGED
@@ -60,6 +60,18 @@ def triage_lm() -> dspy.LM:
60
  return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def configure_default() -> None:
64
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
65
 
 
60
  return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
61
 
62
 
63
+ FOCUS_MAX_TOKENS = 2000 # three titles and two-line descriptions plus GLM's low-effort reasoning
64
+
65
+
66
+ @lru_cache
67
+ def focus_lm(retry: bool = False) -> dspy.LM:
68
+ """The video wizard's focus suggestions: a little material in, three short topics out, so a small cap keeps it
69
+ quick. Only for a source with no topics to show. retry=True is the second try after an unusable answer: a
70
+ different temperature, so it is a fresh call and not that answer again from the cache."""
71
+ return _build(settings.llm_fast_model, effort="low", temperature=0.8 if retry else 0.4,
72
+ max_tokens=FOCUS_MAX_TOKENS)
73
+
74
+
75
  def configure_default() -> None:
76
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
77
 
backend/app/llm/run.py CHANGED
@@ -1,7 +1,9 @@
1
  """One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
2
  em dashes from the outputs. Tests replace `predict` to return canned outputs."""
3
 
 
4
  import uuid
 
5
 
6
  import dspy
7
  from sqlalchemy.orm import Session
@@ -12,6 +14,8 @@ from app.llm.provider import main_lm, provider_name, track_usage
12
  from app.models import DspyTrace, Job, Workspace
13
  from app.services.jobs import record_usage
14
 
 
 
15
 
16
  def _dump(value):
17
  if hasattr(value, "model_dump"):
@@ -35,14 +39,51 @@ def predict(
35
  with track_usage(lm) as usage, dspy.context(lm=lm):
36
  pred = dspy.Predict(signature)(**inputs)
37
  outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  if usage.total:
39
  record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
40
  quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
41
  workspace = db.get(Workspace, workspace_id)
42
- db.add(DspyTrace(
43
- workspace_id=workspace_id, module=signature.__name__, model=lm.model,
44
- inputs={k: (v if len(str(v)) < 4000 else str(v)[:4000]) for k, v in inputs.items()},
45
- outputs=outputs, training_allowed=bool(workspace and workspace.training_opt_in),
46
- ))
 
47
  db.commit()
48
- return outputs
 
1
  """One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
2
  em dashes from the outputs. Tests replace `predict` to return canned outputs."""
3
 
4
+ import logging
5
  import uuid
6
+ from concurrent.futures import ThreadPoolExecutor
7
 
8
  import dspy
9
  from sqlalchemy.orm import Session
 
14
  from app.models import DspyTrace, Job, Workspace
15
  from app.services.jobs import record_usage
16
 
17
+ log = logging.getLogger(__name__)
18
+
19
 
20
  def _dump(value):
21
  if hasattr(value, "model_dump"):
 
39
  with track_usage(lm) as usage, dspy.context(lm=lm):
40
  pred = dspy.Predict(signature)(**inputs)
41
  outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
42
+ _record(db, workspace_id, job, signature, lm, usage, [(inputs, outputs)])
43
+ return outputs
44
+
45
+
46
+ def predict_many(
47
+ signature: type[dspy.Signature],
48
+ inputs_list: list[dict],
49
+ *,
50
+ db: Session,
51
+ workspace_id: uuid.UUID,
52
+ lm: dspy.LM | None = None,
53
+ workers: int = 5,
54
+ ) -> list[dict | None]:
55
+ """`predict` once per inputs, in parallel. Only the model calls run in threads: usage, traces and the commit
56
+ happen here, on the caller's session. A call that fails is None in its place."""
57
+ if not inputs_list:
58
+ return []
59
+ lm = lm or main_lm()
60
+
61
+ def call(inputs: dict) -> dict | None:
62
+ try:
63
+ with dspy.context(lm=lm):
64
+ pred = dspy.Predict(signature)(**inputs)
65
+ return {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
66
+ except Exception:
67
+ log.warning("%s failed", signature.__name__, exc_info=True)
68
+ return None
69
+
70
+ with track_usage(lm) as usage, ThreadPoolExecutor(max_workers=min(workers, len(inputs_list))) as pool:
71
+ results = list(pool.map(call, inputs_list))
72
+ _record(db, workspace_id, None, signature, lm, usage,
73
+ [(i, o) for i, o in zip(inputs_list, results, strict=True) if o is not None])
74
+ return results
75
+
76
+
77
+ def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
78
+ usage, calls: list[tuple[dict, dict]]) -> None:
79
  if usage.total:
80
  record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
81
  quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
82
  workspace = db.get(Workspace, workspace_id)
83
+ for inputs, outputs in calls:
84
+ db.add(DspyTrace(
85
+ workspace_id=workspace_id, module=signature.__name__, model=lm.model,
86
+ inputs={k: (v if len(str(v)) < 4000 else str(v)[:4000]) for k, v in inputs.items()},
87
+ outputs=outputs, training_allowed=bool(workspace and workspace.training_opt_in),
88
+ ))
89
  db.commit()
 
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)
@@ -278,14 +270,6 @@ class PodcastScript(dspy.Signature):
278
  lines: list[ScriptLine] = dspy.OutputField()
279
 
280
 
281
- class VideoStoryboard(dspy.Signature):
282
- """Turn the post into a scene list for a Remotion composition."""
283
-
284
- passages: list[str] = dspy.InputField()
285
- aspect: Literal["16:9", "9:16", "1:1"] = dspy.InputField()
286
- scenes: list[Scene] = dspy.OutputField()
287
-
288
-
289
  class HookGenerator(dspy.Signature):
290
  """Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
291
 
@@ -458,3 +442,30 @@ class TriageWithMemory(TriageMessage):
458
  "`slug | label | last active | gist`")
459
  topic: str = dspy.OutputField(desc="The slug of the topic the message belongs to, or none")
460
  memory: Literal["none", "lookup", "replay", "compose"] = dspy.OutputField()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
 
270
  lines: list[ScriptLine] = dspy.OutputField()
271
 
272
 
 
 
 
 
 
 
 
 
273
  class HookGenerator(dspy.Signature):
274
  """Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
275
 
 
442
  "`slug | label | last active | gist`")
443
  topic: str = dspy.OutputField(desc="The slug of the topic the message belongs to, or none")
444
  memory: Literal["none", "lookup", "replay", "compose"] = dspy.OutputField()
445
+
446
+
447
+ class FocusTopic(BaseModel):
448
+ # Not "title"/"description": with those names GLM sometimes wrote the word "Description" as every title.
449
+ topic: str = Field(description="The angle itself in 3 to 7 words, e.g. \"Why tokens break software margins\". "
450
+ "Never a label such as Title, Topic or Description. No trailing period")
451
+ summary: str = Field(description="Two short sentences (at most 40 words): what the video would cover and the "
452
+ "angle it takes, using only this material")
453
+
454
+
455
+ class FocusAngle(FocusTopic):
456
+ tag: str = Field(description="The candidate tag this angle is built on, copied exactly (its name only), or an "
457
+ "empty string when it comes from the material alone")
458
+
459
+
460
+ class VideoFocusAngles(dspy.Signature):
461
+ """Suggest three distinct angles a short explainer video could focus on, drawn only from this material. Build
462
+ each angle on a different candidate tag when tags are given (a tag shared by several items makes a good angle that
463
+ connects them). Each has a short, specific topic (different for each of the three) and a two sentence summary of
464
+ what the video covers about that angle. Never reuse or closely reword an avoided title: find a new angle instead.
465
+ Never use em dashes."""
466
+
467
+ items: list[str] = dspy.InputField(desc="Each item: a title (when there is one), then the start of its text")
468
+ candidate_tags: list[str] = dspy.InputField(
469
+ desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
470
+ avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
471
+ topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
backend/app/main.py CHANGED
@@ -23,6 +23,10 @@ from app.routers import (
23
  support,
24
  topics,
25
  unsubscribe,
 
 
 
 
26
  voice,
27
  )
28
  from app.routers import settings as settings_router
@@ -88,8 +92,9 @@ def health():
88
  return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
89
 
90
 
 
91
  for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
92
- launchpad, settings_router, memory, support):
93
  app.include_router(r.router)
94
  app.include_router(sources.documents_router)
95
  app.include_router(launchpad.links_router)
 
23
  support,
24
  topics,
25
  unsubscribe,
26
+ video_edit,
27
+ video_styles,
28
+ video_voices,
29
+ videos,
30
  voice,
31
  )
32
  from app.routers import settings as settings_router
 
92
  return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
93
 
94
 
95
+ # video_templates (custom templates) is switched off: users pick only from the built-in templates.
96
  for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
97
+ launchpad, videos, video_edit, video_voices, video_styles, settings_router, memory, support):
98
  app.include_router(r.router)
99
  app.include_router(sources.documents_router)
100
  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,
@@ -17,20 +21,26 @@ from app.models.content import (
17
  Topic,
18
  TrackedLink,
19
  Upload,
 
20
  UsageEvent,
 
21
  VoiceConsent,
22
  VoiceProfile,
23
  WorkspaceMemory,
24
  )
25
  from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
26
  from app.models.support import SupportConversation, SupportMessage
27
- from app.models.user import AuthProvider, BillingEvent, Subscription, User, Workspace, WorkspaceMember
28
 
29
  __all__ = [
30
  "SupportConversation",
31
  "SupportMessage",
32
  "Artifact",
33
  "AuthProvider",
 
 
 
 
34
  "BillingEvent",
35
  "CalendarItem",
36
  "Chat",
@@ -44,6 +54,7 @@ __all__ = [
44
  "Message",
45
  "MessageFeedback",
46
  "Notebook",
 
47
  "NotebookDocument",
48
  "SocialAccount",
49
  "Source",
@@ -53,7 +64,9 @@ __all__ = [
53
  "UpdateEmail",
54
  "UpdateEmailSend",
55
  "Upload",
 
56
  "UsageEvent",
 
57
  "User",
58
  "VerificationPurpose",
59
  "VoiceConsent",
 
1
  from app.models.content import (
2
  Artifact,
3
+ B2VCustomVoice,
4
+ B2VStyle,
5
+ B2VTemplate,
6
+ B2VVideo,
7
  CalendarItem,
8
  Chat,
9
  Citation,
 
21
  Topic,
22
  TrackedLink,
23
  Upload,
24
+ UsageCounter,
25
  UsageEvent,
26
+ UserSavedVoice,
27
  VoiceConsent,
28
  VoiceProfile,
29
  WorkspaceMemory,
30
  )
31
  from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
32
  from app.models.support import SupportConversation, SupportMessage
33
+ from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
34
 
35
  __all__ = [
36
  "SupportConversation",
37
  "SupportMessage",
38
  "Artifact",
39
  "AuthProvider",
40
+ "B2VCustomVoice",
41
+ "B2VStyle",
42
+ "B2VTemplate",
43
+ "B2VVideo",
44
  "BillingEvent",
45
  "CalendarItem",
46
  "Chat",
 
54
  "Message",
55
  "MessageFeedback",
56
  "Notebook",
57
+ "PlanRecord",
58
  "NotebookDocument",
59
  "SocialAccount",
60
  "Source",
 
64
  "UpdateEmail",
65
  "UpdateEmailSend",
66
  "Upload",
67
+ "UsageCounter",
68
  "UsageEvent",
69
+ "UserSavedVoice",
70
  "User",
71
  "VerificationPurpose",
72
  "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))
@@ -184,11 +188,106 @@ class Artifact(IdMixin, TimestampMixin, Base):
184
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
185
  # summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
186
  type: Mapped[str] = mapped_column(String(30))
187
- status: Mapped[str] = mapped_column(String(20), default="pending")
 
 
188
  content_json: Mapped[dict] = mapped_column(JSON, default=dict)
189
  storage_key: Mapped[str | None] = mapped_column(String(500))
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  class Job(IdMixin, TimestampMixin, Base):
193
  """Covers ingest, tts and render jobs. kind decides which fields matter.
194
 
 
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))
 
188
  document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
189
  # summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
190
  type: Mapped[str] = mapped_column(String(30))
191
+ # pending | generating | ready | failed | rendering | review; a video also takes blog2video's job statuses as they
192
+ # are: regenerating (template switch) | voice_regenerating | language_regenerating | script_regenerating
193
+ status: Mapped[str] = mapped_column(String(40), default="pending")
194
  content_json: Mapped[dict] = mapped_column(JSON, default=dict)
195
  storage_key: Mapped[str | None] = mapped_column(String(500))
196
 
197
 
198
+ class B2VVideo(IdMixin, TimestampMixin, Base):
199
+ """One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
200
+ blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
201
+ and whether the workspace's video allowance is still charged for it.
202
+
203
+ Our API key reaches every video it created, for every workspace, so this table (with the artifact) is what
204
+ ties a blog2video id to one workspace. quota_state moves charged -> refunded (or -> kept once generated), with a
205
+ conditional UPDATE (see video_quota.refund), so the status poller and the sweep can not refund twice."""
206
+
207
+ __tablename__ = "b2v_videos"
208
+ __table_args__ = (Index("ix_b2v_videos_ws_created", "workspace_id", "created_at"),)
209
+
210
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
211
+ user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"),
212
+ index=True) # who made it
213
+ artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"),
214
+ index=True)
215
+ # blog2video's video id, which is also its project id. Null until blog2video accepts the create.
216
+ b2v_video_id: Mapped[int | None] = mapped_column(Integer, unique=True)
217
+ idempotency_key: Mapped[str | None] = mapped_column(String(100), unique=True) # "<workspace_id>:<uuid>"; v1 only
218
+ created_via: Mapped[str] = mapped_column(String(10), default="v1", server_default="v1") # v1 | upload
219
+ template_ref: Mapped[str | None] = mapped_column(String(100)) # e.g. custom_58: blocks deleting that template
220
+ title: Mapped[str | None] = mapped_column(String(300))
221
+ source_url: Mapped[str | None] = mapped_column(String(2000)) # the link it was made from (null for files, text)
222
+ aspect_ratio: Mapped[str | None] = mapped_column(String(20))
223
+ preview_url: Mapped[str | None] = mapped_column(String(1000)) # public live preview (embed token)
224
+ video_url: Mapped[str | None] = mapped_column(String(1000)) # the rendered MP4
225
+ quota_state: Mapped[str] = mapped_column(String(20), default="charged") # charged | refunded | kept
226
+ status: Mapped[str] = mapped_column(String(40), default="queued") # last known blog2video status
227
+
228
+
229
+ class B2VTemplate(TimestampMixin, Base):
230
+ """A custom template this workspace made. Used on videos as custom_<b2v_template_id>; only when ready."""
231
+
232
+ __tablename__ = "b2v_templates"
233
+
234
+ b2v_template_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
235
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
236
+ name: Mapped[str] = mapped_column(String(255))
237
+ ready: Mapped[bool] = mapped_column(Boolean, default=False) # code generation finished ('complete')
238
+
239
+
240
+ class B2VCustomVoice(TimestampMixin, Base):
241
+ """A designed or cloned voice this workspace made. Videos reference it by voice_id (custom_voice_id)."""
242
+
243
+ __tablename__ = "b2v_custom_voices"
244
+
245
+ b2v_custom_voice_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) # preview/delete
246
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
247
+ voice_id: Mapped[str] = mapped_column(String(100), unique=True) # the ElevenLabs id sent as custom_voice_id
248
+ name: Mapped[str] = mapped_column(String(255))
249
+ source: Mapped[str] = mapped_column(String(20)) # prompt | preset | clone
250
+ preview_url: Mapped[str | None] = mapped_column(String(1000))
251
+
252
+
253
+ class UserSavedVoice(TimestampMixin, Base):
254
+ """The workspace's "My voices" list. Kept only here: blog2video's saved-voice list is shared by everyone."""
255
+
256
+ __tablename__ = "user_saved_voices"
257
+
258
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
259
+ primary_key=True)
260
+ voice_id: Mapped[str] = mapped_column(String(100), primary_key=True)
261
+ name: Mapped[str] = mapped_column(String(255))
262
+ preview_url: Mapped[str | None] = mapped_column(String(1000))
263
+ gender: Mapped[str | None] = mapped_column(String(20))
264
+ accent: Mapped[str | None] = mapped_column(String(50))
265
+ premium: Mapped[bool] = mapped_column(Boolean, default=False) # a paid built-in voice, or any custom voice
266
+ is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
267
+
268
+
269
+ class B2VStyle(TimestampMixin, Base):
270
+ """A custom video style this workspace made. Used on videos as custom:<b2v_style_id>."""
271
+
272
+ __tablename__ = "b2v_styles"
273
+
274
+ b2v_style_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
275
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
276
+ name: Mapped[str] = mapped_column(String(80))
277
+
278
+
279
+ class UsageCounter(Base):
280
+ """Per-workspace counters for the video features blog2video only limits account-wide (video_limits.py)."""
281
+
282
+ __tablename__ = "usage_counters"
283
+
284
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
285
+ primary_key=True)
286
+ period: Mapped[str] = mapped_column(String(10), primary_key=True) # 2026-10 | 2026-10-02 | all
287
+ metric: Mapped[str] = mapped_column(String(30), primary_key=True)
288
+ used: Mapped[int] = mapped_column(Integer, default=0)
289
+
290
+
291
  class Job(IdMixin, TimestampMixin, Base):
292
  """Covers ingest, tts and render jobs. kind decides which fields matter.
293
 
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/generate.py CHANGED
@@ -1,5 +1,7 @@
1
  """Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
2
 
 
 
3
  import uuid
4
  from collections import Counter, defaultdict
5
  from datetime import UTC, datetime
@@ -19,11 +21,13 @@ from app.llm.signatures import (
19
  ExtractTopics,
20
  SummarizeNotebook,
21
  )
22
- from app.models import Artifact, DocumentTopic, Job, Notebook, Topic, VoiceProfile
23
  from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
24
  from app.pipeline.research import verify_citations
25
  from app.services.jobs import update_job
26
 
 
 
27
 
28
  class NothingToDo(ValueError):
29
  pass
@@ -244,26 +248,65 @@ def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
244
  # Topic map
245
 
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
248
  """Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
249
  docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
250
- todo = [d for d in docs if doc_ids or "topics" not in (d.metadata_json or {})]
251
  known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
252
- lm = fast_lm()
253
  for i, d in enumerate(todo):
254
  update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
255
- try:
256
- out = run.predict(ExtractTopics, db=db, workspace_id=workspace_id, job=job, lm=lm,
257
- title=d.title, text=(d.clean_text or "")[:12000], known_topics=known)
258
- except Exception:
259
- continue
260
- tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
261
- d.metadata_json = {**(d.metadata_json or {}), "topics": tags}
262
- known = sorted(set(known) | {t["name"] for t in tags})[:200]
263
  db.commit()
264
  update_job(db, job, progress=0.85, message="Drawing constellations")
265
  count = rebuild_topics(db, job, workspace_id)
266
- return {"tagged": len(todo), "topics": count}
267
 
268
 
269
  def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
 
1
  """Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
2
 
3
+ import logging
4
+ import time
5
  import uuid
6
  from collections import Counter, defaultdict
7
  from datetime import UTC, datetime
 
21
  ExtractTopics,
22
  SummarizeNotebook,
23
  )
24
+ from app.models import Artifact, Document, DocumentTopic, Job, Notebook, Topic, VoiceProfile
25
  from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
26
  from app.pipeline.research import verify_citations
27
  from app.services.jobs import update_job
28
 
29
+ log = logging.getLogger(__name__)
30
+
31
 
32
  class NothingToDo(ValueError):
33
  pass
 
248
  # Topic map
249
 
250
 
251
+ TAG_TRIES = 3 # per post per job: the model times out or rate limits now and then
252
+ TAG_BACKOFF = (2.0, 6.0) # seconds before the second and third try
253
+ MAX_TAG_FAILURES = 5 # jobs in a row that could not tag a post: the sweep stops asking for it (and logs it)
254
+
255
+
256
+ def needs_topics(meta: dict | None) -> bool:
257
+ """A post without topics yet that is still worth another try (see worker.topics_backfill)."""
258
+ meta = meta or {}
259
+ return not meta.get("topics") and int(meta.get("topics_failures") or 0) < MAX_TAG_FAILURES
260
+
261
+
262
+ def save_topic_tags(doc: Document, out: dict) -> list[dict]:
263
+ """ExtractTopics' answer for a post (at most 8 named topics), kept in metadata_json["topics"]. An answer with no
264
+ topics is not kept, so the post still counts as untagged. Also used by the video wizard, which tags a post on
265
+ demand when it has none yet."""
266
+ tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
267
+ if tags:
268
+ meta = {k: v for k, v in (doc.metadata_json or {}).items() if k != "topics_failures"}
269
+ doc.metadata_json = {**meta, "topics": tags}
270
+ return tags
271
+
272
+
273
+ def tag_post(db: Session, job: Job | None, workspace_id: uuid.UUID, doc: Document, known: list[str]) -> list[dict]:
274
+ """One post's topics, tried a few times. Out of tries, the post is counted as failed once more (the sweep asks
275
+ again later) and [] returned."""
276
+ error: object = None
277
+ for attempt in range(TAG_TRIES):
278
+ if attempt:
279
+ time.sleep(TAG_BACKOFF[attempt - 1])
280
+ try:
281
+ out = run.predict(ExtractTopics, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(),
282
+ title=doc.title, text=(doc.clean_text or "")[:12000], known_topics=known)
283
+ except Exception as exc:
284
+ error = exc
285
+ continue
286
+ if tags := save_topic_tags(doc, out):
287
+ return tags
288
+ error = "no topics in the answer"
289
+ meta = doc.metadata_json or {}
290
+ doc.metadata_json = {**meta, "topics_failures": int(meta.get("topics_failures") or 0) + 1}
291
+ log.warning("could not tag post %s (%s): %s", doc.id, doc.title[:60], error)
292
+ return []
293
+
294
+
295
  def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
296
  """Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
297
  docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
298
+ todo = [d for d in docs if doc_ids or needs_topics(d.metadata_json)]
299
  known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
300
+ tagged = 0
301
  for i, d in enumerate(todo):
302
  update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
303
+ if tags := tag_post(db, job, workspace_id, d, known):
304
+ tagged += 1
305
+ known = sorted(set(known) | {t["name"] for t in tags})[:200]
 
 
 
 
 
306
  db.commit()
307
  update_job(db, job, progress=0.85, message="Drawing constellations")
308
  count = rebuild_topics(db, job, workspace_id)
309
+ return {"tagged": tagged, "failed": len(todo) - tagged, "topics": count}
310
 
311
 
312
  def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
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
@@ -84,12 +84,12 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
84
  body.notebook_id = ensure_archive_notebook(ctx).id
85
  params: dict = {}
86
  content: dict = {}
87
- if body.type == "video" and body.style == "audiogram":
 
 
88
  if not body.audio_artifact_id:
89
  raise HTTPException(400, "Pick an audio overview for the audiogram")
90
  source = get_artifact_or_404(ctx, body.audio_artifact_id)
91
- seconds = float((source.content_json or {}).get("duration_s", 60))
92
- check_limit(ctx.db, ctx.workspace, "video_minutes", seconds / 60)
93
  title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
94
  params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
95
  artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
@@ -104,10 +104,6 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
104
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
105
  params = {"format": body.format, "minutes": body.minutes}
106
  title = f"Audio overview: {target}"
107
- elif body.type == "video":
108
- check_limit(ctx.db, ctx.workspace, "video_minutes", 1 if body.style == "short" else 3)
109
- params = {"style": body.style}
110
- title = f"{'Short' if body.style == 'short' else 'Explainer'} video: {target}"
111
  elif body.type == "mind_map":
112
  if not body.notebook_id:
113
  raise HTTPException(400, "A Mind Constellation is made from a notebook")
@@ -182,6 +178,11 @@ def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get
182
  @router.delete("/{artifact_id}")
183
  def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
184
  a = get_artifact_or_404(ctx, artifact_id)
 
 
 
 
 
185
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
186
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
187
  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
 
84
  body.notebook_id = ensure_archive_notebook(ctx).id
85
  params: dict = {}
86
  content: dict = {}
87
+ if body.type == "video" and body.style != "audiogram":
88
+ raise HTTPException(410, "Videos moved to /app/videos")
89
+ if body.type == "video":
90
  if not body.audio_artifact_id:
91
  raise HTTPException(400, "Pick an audio overview for the audiogram")
92
  source = get_artifact_or_404(ctx, body.audio_artifact_id)
 
 
93
  title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
94
  params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
95
  artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
 
104
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
105
  params = {"format": body.format, "minutes": body.minutes}
106
  title = f"Audio overview: {target}"
 
 
 
 
107
  elif body.type == "mind_map":
108
  if not body.notebook_id:
109
  raise HTTPException(400, "A Mind Constellation is made from a notebook")
 
178
  @router.delete("/{artifact_id}")
179
  def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
180
  a = get_artifact_or_404(ctx, artifact_id)
181
+ if a.type == "video" and (a.content_json or {}).get("provider") == "blog2video":
182
+ from app.routers.videos import remove_video # also removes it from blog2video
183
+
184
+ remove_video(ctx, a)
185
+ return {"ok": True}
186
  storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
187
  ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
188
  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
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException
10
  from fastapi.responses import StreamingResponse
11
  from pydantic import BaseModel, Field
12
  from sqlalchemy import delete, func, select
 
13
 
14
  from app.auth import Ctx, get_ctx
15
  from app.chat_memory.context import load_chat_memory
@@ -103,12 +104,20 @@ ARCHIVE_TITLE = "All posts"
103
 
104
  def ensure_archive_notebook(ctx: Ctx) -> Notebook:
105
  """The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
106
- nb = ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id, Notebook.is_archive.is_(True)))
 
 
 
 
107
  if not nb:
108
- nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
109
- description="Every indexed post in your archive, kept up to date.")
110
- ctx.db.add(nb)
111
- ctx.db.flush()
 
 
 
 
112
  ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
113
  _add_docs(ctx, nb, ids)
114
  ctx.db.commit()
@@ -161,6 +170,21 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
161
  ]
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  @router.get("/{notebook_id}")
165
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
166
  nb = _get(ctx, notebook_id)
 
10
  from fastapi.responses import StreamingResponse
11
  from pydantic import BaseModel, Field
12
  from sqlalchemy import delete, func, select
13
+ from sqlalchemy.exc import IntegrityError
14
 
15
  from app.auth import Ctx, get_ctx
16
  from app.chat_memory.context import load_chat_memory
 
104
 
105
  def ensure_archive_notebook(ctx: Ctx) -> Notebook:
106
  """The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
107
+ def find() -> Notebook | None:
108
+ return ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id,
109
+ Notebook.is_archive.is_(True)))
110
+
111
+ nb = find()
112
  if not nb:
113
+ try:
114
+ nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
115
+ description="Every indexed post in your archive, kept up to date.")
116
+ ctx.db.add(nb)
117
+ ctx.db.flush()
118
+ except IntegrityError: # a concurrent request made it first (uq_notebooks_one_archive)
119
+ ctx.db.rollback()
120
+ nb = find()
121
  ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
122
  _add_docs(ctx, nb, ids)
123
  ctx.db.commit()
 
170
  ]
171
 
172
 
173
+ # Above /{notebook_id}, so "chats" is not read as a notebook id.
174
+ @router.get("/chats")
175
+ def list_all_chats(ctx: Ctx = Depends(get_ctx)):
176
+ """Every chat in the workspace that has an answer, newest first (the video wizard's "Made from" menu)."""
177
+ answered = select(Message.chat_id).where(Message.role == "assistant")
178
+ rows = ctx.db.execute(
179
+ select(Chat, Notebook.title).join(Notebook, Notebook.id == Chat.notebook_id)
180
+ .where(Chat.workspace_id == ctx.workspace.id, Chat.id.in_(answered))
181
+ .order_by(Chat.updated_at.desc())
182
+ ).all()
183
+ return [{"id": str(c.id), "title": c.title, "notebook_id": str(c.notebook_id), "notebook_title": nb_title,
184
+ "updated_at": c.updated_at.isoformat() if c.updated_at else None}
185
+ for c, nb_title in rows]
186
+
187
+
188
  @router.get("/{notebook_id}")
189
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
190
  nb = _get(ctx, notebook_id)
backend/app/routers/topics.py CHANGED
@@ -234,8 +234,8 @@ def rebuild(ctx: Ctx = Depends(get_ctx), full: bool = False):
234
  if full:
235
  # Clear per post tags so every post is mapped again.
236
  for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
237
- if d.metadata_json and "topics" in d.metadata_json:
238
- d.metadata_json = {k: v for k, v in d.metadata_json.items() if k != "topics"}
239
  ctx.db.commit()
240
  job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
241
  return serialize_job(job)
 
234
  if full:
235
  # Clear per post tags so every post is mapped again.
236
  for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
237
+ if d.metadata_json and ({"topics", "topics_failures"} & d.metadata_json.keys()):
238
+ d.metadata_json = {k: v for k, v in d.metadata_json.items() if k not in ("topics", "topics_failures")}
239
  ctx.db.commit()
240
  job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
241
  return serialize_job(job)
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,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
2
+ Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
3
+
4
+ "My voices" (user_saved_voices, at most MAX_SAVED_VOICES) lives only here: blog2video's saved-voice list is one list
5
+ for the whole account.
6
+ Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
7
+ only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
8
+ of the workspace's own daily and total counters (video_limits).
9
+ """
10
+
11
+ import logging
12
+ from typing import Literal
13
+
14
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
15
+ from pydantic import BaseModel, Field
16
+ from sqlalchemy import select
17
+
18
+ from app.auth import Ctx, get_ctx
19
+ from app.models import B2VCustomVoice, UserSavedVoice
20
+ from app.routers.videos import b2v_ready, upstream
21
+ from app.services import b2v_access, video_limits
22
+ from app.services import blog2video as b2v
23
+ from app.services.notestack_voices import notestack_voices
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ router = APIRouter(prefix="/api/video-voices", tags=["videos"])
28
+
29
+ CLONE_MAX_BYTES = 50 * 1024 * 1024
30
+ STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
31
+ MAX_SAVED_VOICES = 5 # "My voices": what step 3 of a new video offers
32
+
33
+
34
+ def _labels(v: dict) -> dict:
35
+ return v.get("labels") or {}
36
+
37
+
38
+ def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
39
+ return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
40
+ "accent": v.accent, "premium": v.premium, "is_custom": v.is_custom,
41
+ "source": "notestack" if v.voice_id in ours else "blog2video"}
42
+
43
+
44
+ def _room(ctx: Ctx) -> bool:
45
+ return len(_saved(ctx)) < MAX_SAVED_VOICES
46
+
47
+
48
+ def _full() -> HTTPException:
49
+ return HTTPException(409, f"You can keep {MAX_SAVED_VOICES} voices. Remove one first.")
50
+
51
+
52
+ def _library_out(v: dict, saved: set[str]) -> dict:
53
+ labels = _labels(v)
54
+ return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
55
+ "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
56
+ "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
57
+ "premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved}
58
+
59
+
60
+ def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
61
+ labels = _labels(v)
62
+ row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
63
+ name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
64
+ preview_url=v.get("preview_url"),
65
+ gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid")
66
+ ctx.db.merge(row)
67
+ return row
68
+
69
+
70
+ def _saved(ctx: Ctx) -> list[UserSavedVoice]:
71
+ return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
72
+ .order_by(UserSavedVoice.created_at)))
73
+
74
+
75
+ @router.get("")
76
+ def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
77
+ """My voices (for the wizard), the built-in library (to save from) and my custom voices."""
78
+ with upstream():
79
+ library = b2v.prebuilt_voices()
80
+ saved = _saved(ctx)
81
+ if not saved:
82
+ for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]:
83
+ _save_builtin(ctx, v)
84
+ ctx.db.commit()
85
+ saved = _saved(ctx)
86
+ ids = {v.voice_id for v in saved}
87
+ custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
88
+ .order_by(B2VCustomVoice.created_at.desc()))
89
+ mine = notestack_voices(ctx.db, ctx.workspace.id)
90
+ ours = {v["voice_id"] for v in mine}
91
+ return {"saved": [_voice_out(v, ours) for v in saved], "max_saved": MAX_SAVED_VOICES,
92
+ "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
93
+ "library": [_library_out(v, ids) for v in library if v.get("voice_id")],
94
+ "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
95
+ "preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
96
+
97
+
98
+ class SaveIn(BaseModel):
99
+ voice_id: str = Field(min_length=1, max_length=100)
100
+
101
+
102
+ @router.post("/saved", status_code=201)
103
+ def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
104
+ if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
105
+ return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
106
+ if not _room(ctx):
107
+ raise _full()
108
+ custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
109
+ B2VCustomVoice.workspace_id == ctx.workspace.id))
110
+ mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
111
+ if mine:
112
+ # A Notestack voice: premium, like a custom voice. No preview link (the Voice page plays it).
113
+ video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
114
+ row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=mine["voice_id"],
115
+ name=mine["name"][:255], premium=True, is_custom=True))
116
+ ctx.db.commit()
117
+ return _voice_out(row, {row.voice_id})
118
+ if custom:
119
+ row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
120
+ preview_url=custom.preview_url, premium=True, is_custom=True))
121
+ else:
122
+ with upstream():
123
+ v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
124
+ if not v:
125
+ raise HTTPException(404, "Voice not found")
126
+ if v.get("plan") == "paid":
127
+ video_limits.require_premium(ctx.db, ctx.workspace, "This voice")
128
+ row = _save_builtin(ctx, v)
129
+ ctx.db.commit()
130
+ return _voice_out(row)
131
+
132
+
133
+ @router.delete("/saved/{voice_id}")
134
+ def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
135
+ row = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": voice_id})
136
+ if not row:
137
+ raise HTTPException(404, "Voice not found")
138
+ ctx.db.delete(row)
139
+ ctx.db.commit()
140
+ return {"ok": True}
141
+
142
+
143
+ # Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice
144
+
145
+
146
+ class DesignPromptIn(BaseModel):
147
+ prompt: str = Field(min_length=20, max_length=1000)
148
+
149
+
150
+ class DesignPresetIn(BaseModel):
151
+ gender: str = Field("", max_length=30)
152
+ age: str = Field("", max_length=30)
153
+ persona: str = Field("", max_length=100)
154
+ speed: str = Field("", max_length=30)
155
+ accent: str = Field("", max_length=60)
156
+
157
+
158
+ def _design(ctx: Ctx, path: str, body: dict) -> dict:
159
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice")
160
+ video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan)
161
+ try:
162
+ with upstream():
163
+ data = b2v.request("POST", path, json=body, timeout=90) or {}
164
+ except HTTPException:
165
+ video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily")
166
+ raise
167
+ return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")}
168
+ for p in data.get("previews") or []]}
169
+
170
+
171
+ @router.post("/design/prompt")
172
+ def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
173
+ return _design(ctx, "/api/voices/design-from-prompt", body.model_dump())
174
+
175
+
176
+ @router.post("/design/preset")
177
+ def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
178
+ return _design(ctx, "/api/voices/design-from-preset", body.model_dump())
179
+
180
+
181
+ class KeepIn(BaseModel):
182
+ generated_voice_id: str = Field(min_length=1, max_length=200)
183
+ source: Literal["prompt", "preset"]
184
+ name: str = Field(min_length=1, max_length=100)
185
+ prompt_text: str | None = Field(None, max_length=1000)
186
+ preview_url: str | None = Field(None, max_length=1000)
187
+
188
+
189
+ def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
190
+ row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id,
191
+ voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
192
+ preview_url=made.get("preview_url"))
193
+ ctx.db.add(row)
194
+ saved = _room(ctx) # kept in My voices only while there is room; otherwise added from the list later
195
+ if saved:
196
+ ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
197
+ preview_url=row.preview_url, premium=True, is_custom=True))
198
+ ctx.db.commit()
199
+ return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
200
+ "preview_url": row.preview_url, "saved": saved}
201
+
202
+
203
+ def _make_custom(ctx: Ctx, source: str, send) -> dict:
204
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
205
+ video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan)
206
+ try:
207
+ with upstream():
208
+ made = send()
209
+ except HTTPException:
210
+ video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
211
+ raise
212
+ return _record_custom(ctx, made, source)
213
+
214
+
215
+ @router.post("/custom", status_code=201)
216
+ def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
217
+ """Keep one designed preview as a permanent custom voice."""
218
+ payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form",
219
+ "name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url}
220
+ return _make_custom(ctx, body.source,
221
+ lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60))
222
+
223
+
224
+ @router.post("/clone", status_code=201)
225
+ async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True),
226
+ file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
227
+ """Clone a voice from one audio or video file (up to 50 MB)."""
228
+ data = await file.read(CLONE_MAX_BYTES + 1)
229
+ if not data:
230
+ raise HTTPException(400, "The file is empty.")
231
+ if len(data) > CLONE_MAX_BYTES:
232
+ raise HTTPException(400, "The file must be under 50 MB.")
233
+ form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"}
234
+ files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")}
235
+ return _make_custom(ctx, "clone",
236
+ lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180))
237
+
238
+
239
+ @router.get("/custom/{custom_id}/preview")
240
+ def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
241
+ row = b2v_access.owned_custom_voice(ctx, custom_id)
242
+ if row.preview_url:
243
+ return {"preview_url": row.preview_url, "ready": True}
244
+ with upstream():
245
+ result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {}
246
+ if result.get("preview_url"):
247
+ row.preview_url = result["preview_url"]
248
+ saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
249
+ if saved:
250
+ saved.preview_url = row.preview_url
251
+ ctx.db.commit()
252
+ return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))}
253
+
254
+
255
+ @router.delete("/custom/{custom_id}")
256
+ def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
257
+ row = b2v_access.owned_custom_voice(ctx, custom_id)
258
+ try:
259
+ with upstream():
260
+ b2v.request("DELETE", f"/api/voices/custom/{custom_id}")
261
+ except HTTPException as e:
262
+ if e.status_code != 404:
263
+ raise
264
+ saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
265
+ if saved:
266
+ ctx.db.delete(saved)
267
+ ctx.db.delete(row)
268
+ ctx.db.commit()
269
+ video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
270
+ return {"ok": True}
271
+
272
+
273
+ # Voice sample ★: a few seconds of the chosen voice with the chosen tuning
274
+
275
+
276
+ class SampleIn(BaseModel):
277
+ voice_gender: Literal["female", "male"] = "female"
278
+ voice_accent: str = Field("american", max_length=30)
279
+ custom_voice_id: str | None = Field(None, max_length=100)
280
+ voice_emotion: str | None = Field(None, max_length=200)
281
+ video_style: str | None = Field(None, max_length=40)
282
+
283
+
284
+ @router.post("/sample")
285
+ def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
286
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples")
287
+ payload = body.model_dump(exclude_none=True)
288
+ with upstream(): # checking the voice may need blog2video's catalog
289
+ b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan)
290
+ video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan)
291
+ try:
292
+ with upstream():
293
+ audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True)
294
+ except HTTPException:
295
+ video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily")
296
+ raise
297
+ return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"})
backend/app/routers/videos.py ADDED
@@ -0,0 +1,949 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.llm import run
30
+ from app.llm.provider import focus_lm
31
+ from app.llm.signatures import VideoFocusAngles
32
+ from app.models import Artifact, B2VVideo, Chat, Document, Message, Notebook, Upload
33
+ from app.routers.sources import is_locked
34
+ from app.services import b2v_access, video_focus, video_limits, video_quota
35
+ from app.services import blog2video as b2v
36
+ from app.services.artifacts import serialize_artifact
37
+ from app.services.plans import Plan, plan_limit_error
38
+ from app.services.storage import storage
39
+ from app.services.video_quota import sync_video_quota, video_usage
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ router = APIRouter(prefix="/api/videos", tags=["videos"])
44
+
45
+ MIN_CONTENT = 50
46
+ MAX_CONTENT = 100_000
47
+ MAX_LINKS = 10
48
+ MAX_POSTS = 200 # a notebook's posts, combined into one video (the ingest SCAN_CAP)
49
+ MAX_CHATS = 5 # notebook chats, combined into one video (the wizard's cap, as for posts)
50
+ MAX_FILES = 5
51
+ # Sent ahead of chat transcripts: blog2video only takes content, so this tells its script writer what it is reading.
52
+ CHAT_CONTEXT = ("The text below is one or more conversations between a user and an AI assistant. Make the video about "
53
+ "the subject of these conversations: the questions the user asked and the answers and conclusions "
54
+ "reached. Present it "
55
+ "as a clear explainer for a general audience, in your own narration. Do not read it out as a dialogue, "
56
+ "and do not mention the chat, the user or the AI assistant.")
57
+ FILE_MAX_BYTES = 5 * 1024 * 1024
58
+ FILE_TYPES = {".pdf", ".docx", ".pptx", ".md", ".txt", ".vtt"}
59
+ LOGO_MAX_BYTES = 2 * 1024 * 1024
60
+ LOGO_TYPES = {"image/png", "image/jpeg", "image/webp", "image/svg+xml"}
61
+ LANGUAGES = [("en", "English"), ("es", "Spanish"), ("fr", "French"), ("de", "German"), ("it", "Italian"),
62
+ ("pt", "Portuguese"), ("nl", "Dutch"), ("pl", "Polish"), ("hi", "Hindi"), ("ar", "Arabic"),
63
+ ("ja", "Japanese"), ("ko", "Korean"), ("zh", "Chinese"), ("tr", "Turkish"), ("ru", "Russian"),
64
+ ("id", "Indonesian")]
65
+
66
+ # blog2video status -> our artifact status
67
+ STATUS_MAP = {"failed": "failed", "error": "failed", "awaiting_stock_footage_review": "review",
68
+ "awaiting_script_review": "review", "rendering": "rendering"}
69
+ # blog2video's statuses while an edit job runs on a finished video, kept on the artifact as they are so the list and
70
+ # the editor can tell them from first generation: regenerating is a template switch (its relayout).
71
+ JOB_STATUSES = {"regenerating", "voice_regenerating", "language_regenerating", "script_regenerating"}
72
+ # Statuses the video list re-checks with blog2video, in case the work ended while nobody was watching.
73
+ BUSY_STATUSES = JOB_STATUSES | {"generating", "rendering"}
74
+
75
+
76
+ def b2v_ready(ctx: Ctx = Depends(get_ctx)) -> None:
77
+ if not b2v.configured():
78
+ raise HTTPException(503, {"code": "video_not_configured", "message": "Video is not set up yet."})
79
+ sync_video_quota(ctx.db, ctx.workspace)
80
+
81
+
82
+ @contextmanager
83
+ def upstream():
84
+ """blog2video errors -> our HTTP errors. Problems with our own blog2video account (out of videos, key or plan)
85
+ are not the user's: they get "temporarily unavailable" and ops get an ALERT log line."""
86
+ try:
87
+ yield
88
+ except b2v.Denied:
89
+ raise HTTPException(404, "Not found") from None
90
+ except b2v.NotFound as e:
91
+ raise HTTPException(404, str(e)) from None # e.g. "Scene not found": the editor reloads
92
+ except (b2v.Busy, b2v.TemplateInUse) as e:
93
+ raise HTTPException(e.status, str(e)) from None
94
+ except b2v.BadRequest as e:
95
+ raise HTTPException(400, str(e)) from None
96
+ except b2v.B2VError as e:
97
+ raise HTTPException(e.status if e.status == 503 else 502, str(e)) from None
98
+
99
+
100
+ ROW_FIELDS = {"video_url", "preview_url", "title"}
101
+
102
+
103
+ def store(ctx: Ctx, a: Artifact, row: B2VVideo | None = None, **fields) -> None:
104
+ """Merge fields into content_json (and status), and the reference fields into the video's row, committing only
105
+ when something changed."""
106
+ if row is not None:
107
+ for key in ROW_FIELDS & fields.keys():
108
+ if fields[key] is not None and getattr(row, key) != fields[key]:
109
+ setattr(row, key, fields[key])
110
+ ctx.db.commit()
111
+ content = dict(a.content_json or {})
112
+ status = fields.pop("status", None)
113
+ changed = {k: v for k, v in fields.items() if v is not None and content.get(k) != v}
114
+ if changed or (status and status != a.status):
115
+ a.content_json = {**content, **changed}
116
+ if status:
117
+ a.status = status
118
+ ctx.db.commit()
119
+
120
+
121
+ def sync_status(ctx: Ctx, a: Artifact, row: B2VVideo, s: dict) -> None:
122
+ raw = str(s.get("status") or "")
123
+ video_quota.record_status(ctx.db, row, "failed" if s.get("error") and raw not in STATUS_MAP else raw)
124
+ if s.get("error") or raw in STATUS_MAP:
125
+ status = STATUS_MAP.get(raw, "failed")
126
+ elif raw in JOB_STATUSES:
127
+ status = raw
128
+ elif s.get("ready") or raw in {"generated", "done"}:
129
+ status = "ready"
130
+ else:
131
+ status = "generating"
132
+ store(ctx, a, row, status=status, b2v_status=raw or None, step=s.get("step"), video_url=s.get("video_url"),
133
+ error=s.get("error"))
134
+
135
+
136
+ # The wizard's options (every field of blog2video's 3 steps)
137
+
138
+
139
+ class VideoOptions(BaseModel):
140
+ # Step 1: Project (script review is always off: blog2video goes straight to the video)
141
+ stock_footage_enabled: bool = True
142
+ aspect_ratio: Literal["landscape", "portrait"] = "landscape"
143
+ video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short"
144
+ logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
145
+ logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
146
+ logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
147
+ # Step 2: Template
148
+ template: str = Field("default", max_length=100)
149
+ video_style: str = Field("auto", max_length=40)
150
+ accent_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
151
+ bg_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
152
+ text_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
153
+ # Step 3: Voice
154
+ content_language: str | None = Field(None, max_length=10) # None = detect from the content
155
+ voice_gender: Literal["female", "male", "none"] = "female"
156
+ voice_accent: str = Field("american", max_length=30)
157
+ custom_voice_id: str | None = Field(None, max_length=100)
158
+ voice_emotion: str | None = Field(None, max_length=200) # ["<expressiveness>","<speed>","<emotion>",...]
159
+ bgm_track_id: str | None = Field(None, max_length=100)
160
+ bgm_volume: float = Field(0.10, ge=0, le=1)
161
+
162
+ def payload(self) -> dict:
163
+ """The fields blog2video takes, as it names them."""
164
+ # Only the wizard's own fields: a subclass (VideoCreateIn) adds the source, which is sent separately.
165
+ data = self.model_dump(include=set(VideoOptions.model_fields) - {"logo_upload_id"}, exclude_none=True,
166
+ mode="json")
167
+ data["script_review_enabled"] = False
168
+ if self.voice_gender == "none":
169
+ data.pop("custom_voice_id", None)
170
+ data.pop("voice_emotion", None)
171
+ if not self.bgm_track_id:
172
+ data.pop("bgm_volume", None)
173
+ return data
174
+
175
+
176
+ class FocusIn(BaseModel):
177
+ # A source from the workspace: exactly one of these. document_ids are a notebook's ticked posts, combined into one
178
+ # video; chat_ids are notebook chats, sent as their transcripts (combined too).
179
+ document_id: uuid.UUID | None = None
180
+ document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
181
+ chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_CHATS)
182
+ notebook_id: uuid.UUID | None = None # names a combined video after its notebook
183
+
184
+ @model_validator(mode="after")
185
+ def one_source(self):
186
+ if sum(x is not None for x in (self.document_id, self.document_ids, self.chat_ids)) != 1:
187
+ raise ValueError("Pick one source: a notebook, a post or chats")
188
+ return self
189
+
190
+
191
+ class VideoCreateIn(VideoOptions):
192
+ # Source: exactly one of these (the first three as in FocusIn).
193
+ document_id: uuid.UUID | None = None
194
+ document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
195
+ chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_CHATS)
196
+ url: str | None = Field(None, max_length=2000)
197
+ content: str | None = Field(None, max_length=MAX_CONTENT)
198
+ title: str | None = Field(None, max_length=300)
199
+ notebook_id: uuid.UUID | None = None # names a combined video after its notebook
200
+ # One of the suggested focus topics (/focus-topics): the video is mainly about it. None = the material as a whole.
201
+ focus: str | None = Field(None, max_length=200)
202
+ focus_detail: str | None = Field(None, max_length=400) # the topic's description: what the video should cover
203
+ # Or the user's own topic, in their words: followed as far as the material supports it (instead of `focus`).
204
+ focus_prompt: str | None = Field(None, max_length=500)
205
+
206
+ @model_validator(mode="after")
207
+ def one_source(self):
208
+ if sum(x is not None for x in (self.document_id, self.document_ids, self.url, self.content, self.chat_ids)) != 1:
209
+ raise ValueError("Pick one source: a notebook, a post, chats, a link or pasted text")
210
+ if self.document_ids and len(self.document_ids) == 1:
211
+ self.document_id, self.document_ids = self.document_ids[0], None
212
+ return self
213
+
214
+ @model_validator(mode="after")
215
+ def one_focus(self):
216
+ self.focus_prompt = re.sub(r"\s+", " ", self.focus_prompt or "").strip() or None
217
+ if self.focus_prompt and (self.focus or "").strip():
218
+ raise ValueError("Pick a suggested topic or write your own, not both")
219
+ return self
220
+
221
+
222
+ class VideoBatchIn(VideoOptions):
223
+ urls: list[str] = Field(min_length=1, max_length=MAX_LINKS)
224
+
225
+
226
+ def _not_indexed(count: int) -> HTTPException:
227
+ return HTTPException(400, f"{count} of these posts {'is' if count == 1 else 'are'} not indexed on your plan. "
228
+ "Untick them or upgrade to index your whole archive.")
229
+
230
+
231
+ def _combined(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int) -> tuple[str, str]:
232
+ """Several posts as one text, oldest first, each given an equal share so none is cut off entirely."""
233
+ ids = set(body.document_ids or [])
234
+ docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
235
+ Document.workspace_id == ctx.workspace.id)))
236
+ if len(docs) != len(ids):
237
+ raise HTTPException(404, "Post not found")
238
+ locked = sum(is_locked(d) for d in docs)
239
+ if locked:
240
+ raise _not_indexed(locked)
241
+ docs.sort(key=lambda d: (d.published_at is None, d.published_at.timestamp() if d.published_at else 0))
242
+ share = limit // len(docs)
243
+ text = "\n\n".join(f"# {d.title}\n\n{(d.clean_text or '').strip()}"[:share] for d in docs)
244
+ nb = ctx.db.scalar(select(Notebook).where(Notebook.id == body.notebook_id,
245
+ Notebook.workspace_id == ctx.workspace.id)) if body.notebook_id else None
246
+ return (nb.title if nb else f"{docs[0].title} + {len(docs) - 1} more"), text
247
+
248
+
249
+ def _cite_free(text: str) -> str:
250
+ """Citation markers ([1], [2]) point at the notebook's posts, which blog2video never sees."""
251
+ return re.sub(r"\s*\[\d+\]", "", text).strip()
252
+
253
+
254
+ def _load_chats(ctx: Ctx, ids: list[uuid.UUID]) -> list[tuple[Chat, list[Message]]]:
255
+ """This workspace's chats, oldest first, each with its messages in order. 404 if any is not ours."""
256
+ chats = list(ctx.db.scalars(select(Chat).where(Chat.id.in_(set(ids)), Chat.workspace_id == ctx.workspace.id)
257
+ .order_by(Chat.created_at)))
258
+ if len(chats) != len(set(ids)):
259
+ raise HTTPException(404, "Chat not found")
260
+ msgs = ctx.db.scalars(select(Message).where(Message.chat_id.in_([c.id for c in chats]))
261
+ .order_by(Message.created_at)).all()
262
+ return [(c, [m for m in msgs if m.chat_id == c.id]) for c in chats]
263
+
264
+
265
+ def _chat_title(chat: Chat, msgs: list[Message]) -> str:
266
+ return chat.title or next((m.content for m in msgs if m.role == "user"), "")[:80] or "Chat"
267
+
268
+
269
+ def _chats(ctx: Ctx, ids: list[uuid.UUID], limit: int) -> tuple[str, str]:
270
+ """Chats' turns as one text, each chat given an equal share. Over its share, a chat's oldest turns go so its
271
+ conclusion stays. Several chats are headed by their titles."""
272
+ loaded = _load_chats(ctx, ids)
273
+ share = limit // len(loaded)
274
+ parts = []
275
+ for chat, msgs in loaded:
276
+ if not any(m.role == "assistant" for m in msgs):
277
+ raise HTTPException(400, f'"{_chat_title(chat, msgs)}" has no answers yet')
278
+ turns = [f"{'User' if m.role == 'user' else 'Assistant'}: {t}" for m in msgs if (t := _cite_free(m.content))]
279
+ head = f"# {_chat_title(chat, msgs)}\n\n" if len(loaded) > 1 else ""
280
+ size = len(head) + sum(len(t) + 2 for t in turns)
281
+ while len(turns) > 1 and size > share:
282
+ size -= len(turns.pop(0)) + 2
283
+ parts.append(head + "\n\n".join(turns))
284
+ first = _chat_title(*loaded[0])
285
+ return (first if len(loaded) == 1 else f"{first} + {len(loaded) - 1} more"), "\n\n".join(parts)
286
+
287
+
288
+ def _doc(ctx: Ctx, document_id: uuid.UUID) -> Document:
289
+ doc = ctx.db.scalar(select(Document).where(Document.id == document_id, Document.workspace_id == ctx.workspace.id))
290
+ if not doc:
291
+ raise HTTPException(404, "Post not found")
292
+ if is_locked(doc):
293
+ raise _not_indexed(1)
294
+ return doc
295
+
296
+
297
+ def _source_text(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int = MAX_CONTENT) -> tuple[str, str]:
298
+ """A workspace source (posts, a chat, one post) or pasted text as a title and one text."""
299
+ if body.document_ids:
300
+ return _combined(ctx, body, limit)
301
+ if body.chat_ids:
302
+ return _chats(ctx, body.chat_ids, limit)
303
+ if body.document_id:
304
+ doc = _doc(ctx, body.document_id)
305
+ return doc.title, (doc.clean_text or "")[:limit]
306
+ return getattr(body, "title", None) or "", getattr(body, "content", None) or ""
307
+
308
+
309
+ def _instructions(body: VideoCreateIn) -> str:
310
+ """What blog2video's script writer is told ahead of the material: what a chat is, and the focus picked or written
311
+ by the user (worded for the house style's depth). Empty when neither applies."""
312
+ parts = [CHAT_CONTEXT] if body.chat_ids else []
313
+ if prompt := (body.focus_prompt or "").replace('"', "'"):
314
+ depth = {"short": " Make it a brief overview.", "medium": " Make it a deep dive."}
315
+ parts.append(f'The user wants this video to focus on: "{prompt}". Follow it only as far as the material below '
316
+ "supports it: use what the material says about it and leave the rest out. If the material does "
317
+ "not cover it, ignore this request and make a general video about the material."
318
+ + depth.get(b2v_access.house_length(body.video_style), ""))
319
+ elif focus := (body.focus or "").strip():
320
+ detail = re.sub(r"\s+", " ", body.focus_detail or "").strip().rstrip(".")
321
+ cover = f" What to cover: {detail}." if detail else ""
322
+ length = b2v_access.house_length(body.video_style)
323
+ if length == "short":
324
+ parts.append(f'The main focus of this video is one topic only: "{focus}".{cover} Make it an overview of '
325
+ "that topic: cover just the parts of the material about it, briefly, and leave the rest out.")
326
+ elif length == "medium":
327
+ parts.append(f'The main focus of this video is one topic only: "{focus}".{cover} Make it a deep dive into '
328
+ "that topic: explore it in depth using the parts of the material about it, and leave the rest "
329
+ "out.")
330
+ else:
331
+ parts.append(f'The main focus of this video is: "{focus}".{cover} Leave out parts of the material not '
332
+ "about it.")
333
+ return " ".join(parts)
334
+
335
+
336
+ def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
337
+ """The blog2video source fields and a title for our artifact."""
338
+ if body.url:
339
+ if not re.match(r"https?://", body.url):
340
+ raise HTTPException(400, "Enter a link that starts with http:// or https://")
341
+ return {"url": body.url}, body.title or body.url
342
+ if body.document_id and not (body.focus or body.focus_prompt):
343
+ doc = _doc(ctx, body.document_id)
344
+ if re.match(r"https?://", doc.url or ""):
345
+ return {"url": doc.url, "title": doc.title[:255]}, doc.title
346
+ # Uploaded files have no public link (upload://name): their text is sent instead (below). So is any post
347
+ # given a focus, which goes in front of the text.
348
+ with upstream(): # the focus wording needs the house styles
349
+ head = _instructions(body)
350
+ title, text = _source_text(ctx, body, MAX_CONTENT - len(head) - 10)
351
+ if body.content is not None and not title.strip():
352
+ raise HTTPException(400, "Give the pasted text a title")
353
+ text = text.strip()
354
+ if len(text) < MIN_CONTENT:
355
+ raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
356
+ content = f"{head}\n\n---\n\n{text}" if head else text
357
+ return {"content": content[:MAX_CONTENT], "title": title[:255]}, title
358
+
359
+
360
+ def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
361
+ """Sent with every create, and returned by blog2video: ties the video back to us from its side too."""
362
+ return {"workspace_id": str(ctx.workspace.id), "user_id": str(ctx.user.id), "video_ref": str(row.id), **extra}
363
+
364
+
365
+ def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
366
+ payload = options.payload()
367
+ with upstream(): # checking a template or voice may need blog2video's catalog
368
+ # A house style decides the length, whatever the client sent (before the checks, which gate some lengths).
369
+ if length := b2v_access.house_length(payload.get("video_style")):
370
+ payload["video_length"] = length
371
+ plan = b2v_access.check_refs(ctx, payload)
372
+ accent = (ctx.workspace.brand_json or {}).get("accent")
373
+ if plan.brand_kit and accent and "accent_color" not in payload:
374
+ payload["accent_color"] = accent
375
+ return payload, plan
376
+
377
+
378
+ def _logo(ctx: Ctx, upload_id: uuid.UUID | None) -> tuple[str, str, bytes] | None:
379
+ if not upload_id:
380
+ return None
381
+ upload = ctx.db.scalar(select(Upload).where(Upload.id == upload_id, Upload.workspace_id == ctx.workspace.id))
382
+ if not upload or upload.status != "complete" or upload.content_type not in LOGO_TYPES:
383
+ raise HTTPException(400, "The logo must be a PNG, JPG, WebP or SVG.")
384
+ data = storage.get_bytes(upload.key)
385
+ if len(data) > LOGO_MAX_BYTES:
386
+ raise HTTPException(400, "The logo must be under 2 MB.")
387
+ return upload.filename, upload.content_type, data
388
+
389
+
390
+ def _start(ctx: Ctx, plan: Plan, title: str, aspect_ratio: str, template: str, created_via: str,
391
+ call: Callable[[B2VVideo], int], logo: tuple[str, str, bytes] | None = None,
392
+ document_id: uuid.UUID | None = None, source_url: str | None = None) -> Artifact:
393
+ """Reserve one video, record it, ask blog2video for it (call returns its id), refund if that fails."""
394
+ if not video_quota.reserve(ctx.db, ctx.workspace.id):
395
+ usage = video_usage(ctx.db, ctx.workspace)
396
+ raise plan_limit_error(plan, "videos",
397
+ f"You have made {usage['used']} of {usage['limit']} videos this period.")
398
+ try:
399
+ artifact = Artifact(workspace_id=ctx.workspace.id, type="video", status="generating", document_id=document_id,
400
+ content_json={"provider": "blog2video", "title": title[:300],
401
+ "aspect_ratio": aspect_ratio})
402
+ ctx.db.add(artifact)
403
+ ctx.db.flush()
404
+ row = B2VVideo(workspace_id=ctx.workspace.id, user_id=ctx.user.id, artifact_id=artifact.id,
405
+ created_via=created_via, template_ref=template, title=title[:300], source_url=source_url,
406
+ aspect_ratio=aspect_ratio,
407
+ # Keys are unique across our whole blog2video account, so they start with the workspace id.
408
+ idempotency_key=f"{ctx.workspace.id}:{uuid.uuid4()}" if created_via == "v1" else None)
409
+ ctx.db.add(row)
410
+ ctx.db.commit()
411
+ except Exception:
412
+ ctx.db.rollback()
413
+ video_quota.unreserve(ctx.db, ctx.workspace.id)
414
+ raise
415
+ try:
416
+ with upstream():
417
+ vid = call(row)
418
+ except HTTPException:
419
+ # Refused, or no answer after every retry: the user gets the video back.
420
+ video_quota.refund(ctx.db, row, "failed")
421
+ ctx.db.delete(artifact)
422
+ ctx.db.commit()
423
+ raise
424
+ row.b2v_video_id, row.status = vid, "queued"
425
+ ctx.db.commit()
426
+ store(ctx, artifact, row, b2v_video_id=vid, b2v_status="queued")
427
+ if logo:
428
+ try:
429
+ b2v.upload_logo(vid, *logo)
430
+ except b2v.B2VError:
431
+ log.warning("logo upload failed for video artifact %s", artifact.id) # the video is made without it
432
+ return artifact
433
+
434
+
435
+ @router.post("", status_code=201)
436
+ def create_video(body: VideoCreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
437
+ """From a link, pasted text or one of the workspace's posts: POST /api/v1/videos, which starts generating."""
438
+ source, title = _source(ctx, body)
439
+ payload, plan = _check(ctx, body)
440
+ logo = _logo(ctx, body.logo_upload_id)
441
+
442
+ def call(row: B2VVideo) -> int:
443
+ # Retried with the same key on no answer: blog2video returns the same video, charged once.
444
+ created = b2v.create_video({**source, **payload, "external_user_id": str(ctx.workspace.id),
445
+ "metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id),
446
+ document_id=str(body.document_id) if body.document_id
447
+ else None,
448
+ document_ids=[str(d) for d in body.document_ids or []]
449
+ or None,
450
+ chat_ids=[str(c) for c in body.chat_ids or []] or None)},
451
+ row.idempotency_key)
452
+ return int(created["video_id"])
453
+
454
+ a = _start(ctx, plan, title, body.aspect_ratio, body.template, "v1", call, logo, body.document_id,
455
+ source_url=source.get("url"))
456
+ return serialize_artifact(a)
457
+
458
+
459
+ def _excerpt(text: str | None, limit: int) -> str:
460
+ """The start of a text, cut on a word boundary."""
461
+ text = re.sub(r"\s+", " ", text or "").strip()
462
+ if len(text) <= limit:
463
+ return text
464
+ return text[:limit].rsplit(" ", 1)[0] + "..."
465
+
466
+
467
+ def _focus_material(ctx: Ctx, body: FocusIn) -> tuple[dict[str, str], list[Document]]:
468
+ """What the focus suggestions read, per post or chat (by id): each post's title and the start of its text, or a
469
+ chat's title and first exchange. A little text, so a vague title or an untitled chat still says what it is about.
470
+ Also the posts themselves (none for chats). Checked like a create (another workspace's source 404s, locked posts
471
+ 400)."""
472
+ if body.chat_ids:
473
+ loaded = _load_chats(ctx, body.chat_ids)
474
+ answer_len = 600 if len(loaded) == 1 else 300
475
+ material = {}
476
+ for chat, msgs in loaded:
477
+ question = next((m.content for m in msgs if m.role == "user"), "")
478
+ answer = next((m.content for m in msgs if m.role == "assistant"), "")
479
+ parts = [chat.title or "", _excerpt(question, 300), _excerpt(_cite_free(answer), answer_len)]
480
+ if item := "\n".join(p for p in parts if p):
481
+ material[str(chat.id)] = item
482
+ return material, []
483
+ ids = set(body.document_ids or [body.document_id])
484
+ docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
485
+ Document.workspace_id == ctx.workspace.id)))
486
+ if len(docs) != len(ids):
487
+ raise HTTPException(404, "Post not found")
488
+ if locked := sum(is_locked(d) for d in docs):
489
+ raise _not_indexed(locked)
490
+ docs.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
491
+ each = 800 if len(docs) == 1 else 400
492
+ items = {str(d.id): "\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs}
493
+ return {k: v for k, v in items.items() if v}, docs
494
+
495
+
496
+ @router.post("/focus-topics")
497
+ def focus_topics(body: FocusIn, ctx: Ctx = Depends(get_ctx)):
498
+ """Up to three topics a video from this source could focus on: the topics the workspace already has for it,
499
+ shown as they are (untagged posts are tagged on demand; a chat's are the cited posts' topics). The AI writes them
500
+ only when there are none. Asked for and shown on the wizard's step 1, once the source is chosen."""
501
+ material, docs = _focus_material(ctx, body)
502
+ if not material:
503
+ return {"topics": []}
504
+ ws = ctx.workspace.id
505
+ if body.chat_ids:
506
+ cited, docs = video_focus.chat_citations(ctx.db, ws, body.chat_ids)
507
+ sources = {chat: cited.get(chat, {}) for chat in material}
508
+ else:
509
+ sources = {str(d.id): {d.id: 1.0} for d in docs}
510
+ video_focus.ensure_tags(ctx.db, ws, docs)
511
+ if cards := video_focus.pick_cards(video_focus.build_pool(ctx.db, ws, sources, docs)):
512
+ return {"topics": [{"title": c.name, "description": c.summary} for c in cards]}
513
+ items = list(material.values())[:FOCUS_ITEMS]
514
+ topics: list[dict] = []
515
+ # One retry when the answer has fewer than 3 usable topics (a placeholder for every title, repeats).
516
+ for retry in (False, True):
517
+ out = run.predict(VideoFocusAngles, db=ctx.db, workspace_id=ws, lm=focus_lm(retry), items=items,
518
+ candidate_tags=[], avoid_titles=[])
519
+ _add_topics(topics, out.get("topics"))
520
+ if len(topics) >= 3:
521
+ break
522
+ return {"topics": topics[:3]}
523
+
524
+
525
+ FOCUS_ITEMS = 5 # posts or chats the AI reads when there are no topics to show
526
+ FOCUS_PLACEHOLDERS = {"title", "topic", "description", "summary", "name", "angle", "focus"}
527
+
528
+
529
+ def _add_topics(topics: list[dict], raw) -> None:
530
+ """Add the model's usable topics to `topics` (as {title, description}): a real title, not seen before."""
531
+ for t in raw or []:
532
+ if not isinstance(t, dict):
533
+ continue
534
+ title = str(t.get("topic") or t.get("title") or "").strip().strip(".").strip()[:120]
535
+ description = re.sub(r"\s+", " ", str(t.get("summary") or t.get("description") or "")).strip()[:300]
536
+ if not title or title.lower() in FOCUS_PLACEHOLDERS:
537
+ continue
538
+ if title.lower() not in {x["title"].lower() for x in topics}:
539
+ topics.append({"title": title, "description": description})
540
+
541
+
542
+ @router.post("/batch", status_code=201)
543
+ def create_videos(body: VideoBatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
544
+ """Multi-link: one video per link, each charged and keyed on its own. Background music is single-video only."""
545
+ urls = [u.strip() for u in body.urls if re.match(r"https?://\S+", u.strip())]
546
+ if not urls:
547
+ raise HTTPException(400, "Enter at least one link that starts with http:// or https://")
548
+ options = body.model_copy(update={"bgm_track_id": None})
549
+ payload, plan = _check(ctx, options)
550
+ logo = _logo(ctx, body.logo_upload_id)
551
+ made: list[dict] = []
552
+ for url in urls:
553
+ def call(row: B2VVideo, url=url) -> int:
554
+ created = b2v.create_video({"url": url, **payload, "external_user_id": str(ctx.workspace.id),
555
+ "metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id))},
556
+ row.idempotency_key)
557
+ return int(created["video_id"])
558
+
559
+ try:
560
+ made.append(serialize_artifact(_start(ctx, plan, url, body.aspect_ratio, body.template, "v1", call,
561
+ logo, source_url=url)))
562
+ except HTTPException as e:
563
+ if not made:
564
+ raise
565
+ # Keep what was made; say where it stopped (usually the allowance ran out).
566
+ detail = e.detail if isinstance(e.detail, str) else (e.detail or {}).get("message")
567
+ made.append({"error": detail, "url": url})
568
+ break
569
+ return made
570
+
571
+
572
+ @router.post("/upload", status_code=201)
573
+ async def create_from_files(files: list[UploadFile] = File(...), options: str = Form("{}"),
574
+ ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
575
+ """From up to 5 documents (pdf, docx, pptx, md, txt, vtt; 5 MB each): blog2video parses them itself.
576
+ `options` is the wizard's VideoOptions as JSON."""
577
+ try:
578
+ opts = VideoOptions.model_validate(json.loads(options or "{}"))
579
+ except (ValueError, ValidationError) as e:
580
+ raise HTTPException(422, str(e)[:500]) from None
581
+ if not files or len(files) > MAX_FILES:
582
+ raise HTTPException(400, f"Add 1 to {MAX_FILES} files.")
583
+ parts: list[tuple[str, bytes, str]] = []
584
+ for f in files:
585
+ name = f.filename or "document"
586
+ if not any(name.lower().endswith(ext) for ext in FILE_TYPES):
587
+ raise HTTPException(400, f"{name}: use PDF, DOCX, PPTX, MD, TXT or VTT.")
588
+ data = await f.read(FILE_MAX_BYTES + 1)
589
+ if len(data) > FILE_MAX_BYTES:
590
+ raise HTTPException(400, f"{name} is over 5 MB.")
591
+ parts.append((name, data, f.content_type or "application/octet-stream"))
592
+ payload, plan = _check(ctx, opts)
593
+ logo = _logo(ctx, opts.logo_upload_id)
594
+ title = parts[0][0].rsplit(".", 1)[0] if len(parts) == 1 else f"{len(parts)} documents"
595
+ form = {k: (str(v).lower() if isinstance(v, bool) else str(v)) for k, v in payload.items()}
596
+
597
+ def call(row: B2VVideo) -> int:
598
+ project = b2v.upload_project({**form, "name": title[:255]}, parts)
599
+ pid = int(project["id"])
600
+ try:
601
+ b2v.generate(pid)
602
+ except b2v.B2VError:
603
+ try:
604
+ b2v.delete_project(pid)
605
+ except b2v.B2VError:
606
+ log.warning("could not remove blog2video project %s after generate failed", pid)
607
+ raise
608
+ return pid
609
+
610
+ a = _start(ctx, plan, title, opts.aspect_ratio, opts.template, "upload", call, logo)
611
+ return serialize_artifact(a)
612
+
613
+
614
+ # Read
615
+
616
+
617
+ @router.get("/config")
618
+ def video_config(ctx: Ctx = Depends(get_ctx)):
619
+ return {"configured": b2v.configured(), **video_limits.report(ctx.db, ctx.workspace)}
620
+
621
+
622
+ @router.get("/quota")
623
+ def quota(ctx: Ctx = Depends(get_ctx)):
624
+ return video_usage(ctx.db, ctx.workspace)
625
+
626
+
627
+ def _with_posters(t: dict) -> dict:
628
+ """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
629
+ have none; the picker then shows the template's colors); designer templates carry their own image."""
630
+ if t.get("preview_image_url") and not t.get("preview_url"):
631
+ return {**t, "preview_url": t["preview_image_url"]}
632
+ if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"):
633
+ return t
634
+ base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
635
+ return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
636
+
637
+
638
+ @router.get("/catalog")
639
+ def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
640
+ """What the wizard offers this workspace: built-in and designer templates, its own ready custom templates,
641
+ built-in and its own styles, music, languages. Nothing another workspace made."""
642
+ with upstream():
643
+ builtin = b2v.builtin_templates()
644
+ try:
645
+ crafted = b2v.crafted_templates()
646
+ except b2v.B2VError:
647
+ crafted = []
648
+ mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
649
+ for t in b2v_access.my_templates(ctx, ready_only=True)]
650
+ styles = b2v_access.visible_styles(ctx)
651
+ music = b2v.music_tracks()
652
+ return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
653
+ "crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)],
654
+ "my_templates": mine, "video_styles": styles,
655
+ "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
656
+
657
+
658
+ def _restore_missing_artifacts(ctx: Ctx) -> None:
659
+ """A video whose Library item is gone but which was never deleted (b2v_videos is the record) gets its item back,
660
+ rebuilt from the row. E.g. items removed by the Library's delete before it also removed the blog2video video."""
661
+ orphans = ctx.db.scalars(select(B2VVideo).where(
662
+ B2VVideo.workspace_id == ctx.workspace.id, B2VVideo.artifact_id.is_(None),
663
+ B2VVideo.b2v_video_id.is_not(None), B2VVideo.status != "deleted",
664
+ B2VVideo.quota_state != "refunded")).all()
665
+ for row in orphans:
666
+ status = ("ready" if row.status in video_quota.FINISHED else
667
+ "failed" if row.status in video_quota.FAILED else
668
+ row.status if row.status in JOB_STATUSES else STATUS_MAP.get(row.status, "generating"))
669
+ a = Artifact(workspace_id=ctx.workspace.id, type="video", status=status,
670
+ content_json={k: v for k, v in {
671
+ "provider": "blog2video", "b2v_video_id": row.b2v_video_id, "b2v_status": row.status,
672
+ "title": row.title or "Video", "aspect_ratio": row.aspect_ratio,
673
+ "preview_url": row.preview_url, "video_url": row.video_url}.items() if v is not None})
674
+ ctx.db.add(a)
675
+ ctx.db.flush()
676
+ row.artifact_id = a.id
677
+ if orphans:
678
+ ctx.db.commit()
679
+
680
+
681
+ def _needs_title(row: B2VVideo) -> bool:
682
+ return not row.title or row.title == row.source_url or bool(re.match(r"https?://", row.title))
683
+
684
+
685
+ def _set_title(ctx: Ctx, row: B2VVideo, a: Artifact | None, name: str | None) -> None:
686
+ """blog2video names a video after reading its source; until then we only had the link."""
687
+ name = (name or "").strip()[:300]
688
+ if not name or re.match(r"https?://", name) or row.title == name:
689
+ return
690
+ row.title = name
691
+ if a is not None:
692
+ a.content_json = {**(a.content_json or {}), "title": name}
693
+ ctx.db.commit()
694
+
695
+
696
+ def _refresh_titles(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]]) -> None:
697
+ """Real titles for videos still titled by their link, from blog2video's video list (one call)."""
698
+ todo = {row.b2v_video_id: (a, row) for a, row in pairs
699
+ if row.created_via == "v1" and row.b2v_video_id and _needs_title(row)}
700
+ if not todo or not b2v.configured():
701
+ return
702
+ try:
703
+ listed = b2v.request("GET", "/api/v1/videos", params={"external_user_id": str(ctx.workspace.id), "limit": 100})
704
+ except b2v.B2VError as e:
705
+ log.warning("could not refresh video titles: %s", e)
706
+ return
707
+ for item in (listed or {}).get("items") or []:
708
+ if item.get("video_id") in todo:
709
+ a, row = todo[item["video_id"]]
710
+ _set_title(ctx, row, a, item.get("name"))
711
+
712
+
713
+ @router.get("")
714
+ def list_videos(ctx: Ctx = Depends(get_ctx), limit: int = 30, offset: int = 0):
715
+ """The workspace's videos, newest first, with what the list rows show: title, status, source, scenes, date."""
716
+ _restore_missing_artifacts(ctx)
717
+ arts = [a for a in ctx.db.scalars(
718
+ select(Artifact).where(Artifact.workspace_id == ctx.workspace.id, Artifact.type == "video")
719
+ .order_by(Artifact.created_at.desc()).limit(min(limit, 100)).offset(offset)
720
+ ).all() if (a.content_json or {}).get("provider") == "blog2video"]
721
+ rows = {r.artifact_id: r for r in ctx.db.scalars(
722
+ select(B2VVideo).where(B2VVideo.artifact_id.in_([a.id for a in arts])))} if arts else {}
723
+ _refresh_titles(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
724
+ _refresh_busy(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
725
+ out = []
726
+ for a in arts:
727
+ row, content = rows.get(a.id), a.content_json or {}
728
+ out.append({**serialize_artifact(a), "source_url": row.source_url if row else None,
729
+ "scenes": content.get("scenes"), "b2v_status": (row.status if row else content.get("b2v_status"))})
730
+ return out
731
+
732
+
733
+ def _refresh_busy(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]], limit: int = 10) -> None:
734
+ """Ask blog2video about videos still generating, rendering or in an edit job, so one that finished (or failed)
735
+ while nobody had it open shows its real state. A few per page; a blog2video hiccup leaves the stored status."""
736
+ busy = [(a, r) for a, r in pairs if a.status in BUSY_STATUSES and r.b2v_video_id is not None][:limit]
737
+ for a, row in busy:
738
+ try:
739
+ sync_status(ctx, a, row, b2v.status(row.b2v_video_id, row.created_via))
740
+ except b2v.B2VError as e:
741
+ log.info("status refresh for video %s skipped: %s", row.b2v_video_id, e)
742
+
743
+
744
+ def _legacy(a: Artifact) -> dict:
745
+ content = a.content_json or {}
746
+ return {"legacy": True, "status": content.get("b2v_status"), "ready": bool(content.get("video_url")),
747
+ "video_url": content.get("video_url"), "preview_url": content.get("preview_url"),
748
+ "artifact_status": a.status, "artifact": serialize_artifact(a)}
749
+
750
+
751
+ @router.get("/{artifact_id}/status")
752
+ def video_status(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
753
+ a, vid, row = b2v_access.owned_video(ctx, artifact_id)
754
+ if row is None:
755
+ return _legacy(a)
756
+ try:
757
+ with upstream():
758
+ s = b2v.status(vid, row.created_via)
759
+ except HTTPException as e:
760
+ if e.status_code != 404:
761
+ raise
762
+ s = {"status": "failed", "error": "The video service lost this video."}
763
+ sync_status(ctx, a, row, s)
764
+ return {**s, "video_id": vid, "artifact_status": a.status}
765
+
766
+
767
+ def preview_url(ctx: Ctx, a: Artifact, vid: int, row: B2VVideo | None = None) -> str | None:
768
+ """The public live-preview link (no key needed, safe for the browser). Minted once and kept."""
769
+ url = (row.preview_url if row else None) or (a.content_json or {}).get("preview_url")
770
+ if url:
771
+ return url
772
+ try:
773
+ url = (b2v.preview_token(vid) or {}).get("preview_url")
774
+ except b2v.B2VError as e:
775
+ log.warning("preview token for video %s failed: %s", vid, e)
776
+ return None
777
+ store(ctx, a, row, preview_url=url)
778
+ return url
779
+
780
+
781
+ def _media_url(project_id: int, asset: dict | None, subdir: str, filename: str) -> str:
782
+ if asset and asset.get("r2_url"):
783
+ return asset["r2_url"]
784
+ return f"{settings.b2v_api_base_url.rstrip('/')}/media/projects/{project_id}/{subdir}/{filename}"
785
+
786
+
787
+ def _enrich(project: dict) -> dict:
788
+ """What the scene rows show, worked out once here: each scene's audio and image links (blog2video only gives
789
+ file names), its layout and font sizes, and totals for the preview caption."""
790
+ pid = project.get("id")
791
+ assets = [a for a in project.get("assets") or [] if not a.get("excluded")]
792
+ latest: dict[tuple[str, str], dict] = {}
793
+ for a in sorted(assets, key=lambda a: a.get("id") or 0): # a regenerated file is a newer asset, same name
794
+ latest[(a.get("asset_type"), a.get("filename"))] = a
795
+ total, images = 0.0, 0
796
+ scenes = []
797
+ for sc in sorted(project.get("scenes") or [], key=lambda s: s.get("order") or 0):
798
+ try:
799
+ code = json.loads(sc.get("remotion_code") or "{}")
800
+ except ValueError:
801
+ code = {}
802
+ custom = str(project.get("template") or "").startswith("custom_")
803
+ props = (code.get("layoutConfig") if custom else code.get("layoutProps")) or {}
804
+ audio = None
805
+ if sc.get("voiceover_path"):
806
+ name = re.split(r"[/\\]", sc["voiceover_path"])[-1]
807
+ asset = latest.get(("audio", name))
808
+ audio = _media_url(pid, asset, "audio", name) + (f"?v={asset['id']}" if asset else "")
809
+ pics = []
810
+ # Stills sit in assignedImage(s); a stock clip in assignedVideo(s). Both fill the scene's visual slot.
811
+ for key in ("assignedImage", "assignedImages", "image", "images", "assignedVideo", "assignedVideos"):
812
+ vals = props.get(key)
813
+ clip = key.startswith("assignedVideo")
814
+ for name in ([vals] if isinstance(vals, str) else vals if isinstance(vals, list) else []):
815
+ if isinstance(name, str) and name and not name.startswith("http"):
816
+ first, second = ("video", "image") if clip else ("image", "video")
817
+ asset = latest.get((first, name)) or latest.get((second, name))
818
+ kind = asset.get("asset_type") if asset else first
819
+ pics.append({"filename": name, "asset_id": asset.get("id") if asset else None, "kind": kind,
820
+ "url": _media_url(pid, asset, "videos" if kind == "video" else "images", name)})
821
+ images += len(pics)
822
+ total += float(sc.get("duration_seconds") or 0) + float(sc.get("extra_hold_seconds") or 0)
823
+ scenes.append({**sc, "audio_url": audio, "images": pics,
824
+ "layout": code.get("layout") or sc.get("preferred_layout"),
825
+ "title_font_size": props.get("titleFontSize"),
826
+ "desc_font_size": props.get("descriptionFontSize")})
827
+ return {**project, "scenes": scenes,
828
+ "summary": {"scenes": len(scenes), "duration_seconds": round(total),
829
+ "images": sum(1 for a in assets if a.get("asset_type") == "image") or images,
830
+ "clips": sum(1 for a in assets if a.get("asset_type") == "video")}}
831
+
832
+
833
+ @router.get("/{artifact_id}")
834
+ def get_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
835
+ a, vid, row = b2v_access.owned_video(ctx, artifact_id)
836
+ if row is None:
837
+ return _legacy(a)
838
+ with upstream():
839
+ project = b2v.get_project(vid)
840
+ if row is not None:
841
+ _set_title(ctx, row, a, project.get("name"))
842
+ store(ctx, a, row, video_url=project.get("r2_video_url"), scenes=len(project.get("scenes") or []) or None)
843
+ return {"video_id": vid, "status": project.get("status"), "project": _enrich(project),
844
+ "preview_url": preview_url(ctx, a, vid, row), "video_url": project.get("r2_video_url"),
845
+ "artifact": serialize_artifact(a)}
846
+
847
+
848
+ @router.delete("/{artifact_id}")
849
+ def delete_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
850
+ """Removes it from Notestack and from blog2video."""
851
+ a, _, _ = b2v_access.owned_video(ctx, artifact_id)
852
+ remove_video(ctx, a)
853
+ return {"ok": True}
854
+
855
+
856
+ def remove_video(ctx: Ctx, a: Artifact) -> None:
857
+ """Delete the video on blog2video, then the artifact. The b2v_videos row stays as the record that it was made
858
+ (status "deleted"): a lifetime allowance (Free) must not get a video back because one was deleted."""
859
+ row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id, B2VVideo.workspace_id == ctx.workspace.id))
860
+ if row is not None and row.b2v_video_id is not None and b2v.configured():
861
+ try:
862
+ b2v.delete_project(row.b2v_video_id)
863
+ except b2v.NotFound:
864
+ pass
865
+ except b2v.B2VError as e:
866
+ raise HTTPException(502, str(e)) from None
867
+ if row is not None:
868
+ row.status, row.artifact_id = "deleted", None
869
+ ctx.db.delete(a)
870
+ ctx.db.commit()
871
+
872
+
873
+ # Script review (status awaiting_script_review): read, rewrite with AI, approve
874
+
875
+
876
+ class DraftScene(BaseModel):
877
+ id: int
878
+ title: str = Field(max_length=255)
879
+ display_text: str | None = Field(None, max_length=3000)
880
+ narration_text: str = Field(max_length=6000)
881
+
882
+
883
+ class ReviewPreviewIn(BaseModel):
884
+ title: str = Field(min_length=1, max_length=255)
885
+ display_text: str = Field(min_length=1, max_length=3000)
886
+ narration_text: str = Field("", max_length=6000)
887
+ draft_scenes: list[DraftScene] = Field(max_length=200)
888
+ revision: int = Field(0, ge=0)
889
+
890
+
891
+ class ReviewAiIn(ReviewPreviewIn):
892
+ instruction: str = Field(min_length=2, max_length=1000)
893
+
894
+
895
+ class ReviewApproveScene(DraftScene):
896
+ preferred_layout: str | None = Field(None, max_length=100)
897
+ source_fingerprint: str | None = Field(None, max_length=200)
898
+ accepted_ai_instructions: list[str] = Field(default_factory=list, max_length=10)
899
+
900
+
901
+ class ReviewApproveIn(BaseModel):
902
+ scenes: list[ReviewApproveScene] = Field(max_length=200)
903
+
904
+
905
+ @router.get("/{artifact_id}/script")
906
+ def get_script(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
907
+ _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
908
+ with upstream():
909
+ project = b2v.get_project(vid)
910
+ scenes = sorted(project.get("scenes") or [], key=lambda s: s.get("order", 0))
911
+ return {"status": project.get("status"), "scenes": [
912
+ {k: s.get(k) for k in ("id", "order", "title", "display_text", "narration_text", "preferred_layout")}
913
+ for s in scenes]}
914
+
915
+
916
+ @router.post("/{artifact_id}/script/scenes/{scene_id}/narration-preview")
917
+ def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreviewIn, ctx: Ctx = Depends(get_ctx),
918
+ _: None = Depends(b2v_ready)):
919
+ """Rewrite one scene's narration to match its edited title and text."""
920
+ _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
921
+ with upstream():
922
+ return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/narration-preview",
923
+ json=body.model_dump(), timeout=60)
924
+
925
+
926
+ @router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview")
927
+ def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx),
928
+ _: None = Depends(b2v_ready)):
929
+ """★ Rewrite one scene from an instruction. Uses one AI edit."""
930
+ _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
931
+ plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites")
932
+ video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
933
+ try:
934
+ with upstream():
935
+ return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview",
936
+ json=body.model_dump(), timeout=60)
937
+ except HTTPException:
938
+ video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits")
939
+ raise
940
+
941
+
942
+ @router.post("/{artifact_id}/script/approve")
943
+ def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
944
+ _: None = Depends(b2v_ready)):
945
+ a, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
946
+ with upstream():
947
+ result = b2v.project_call("POST", vid, "script-review/approve", json=body.model_dump(), timeout=60)
948
+ store(ctx, a, status="generating")
949
+ return result
backend/app/routers/voice.py CHANGED
@@ -12,6 +12,7 @@ from app.config import settings
12
  from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
13
  from app.services import tts
14
  from app.services.jobs import create_job, record_usage, serialize_job
 
15
  from app.services.plans import effective_plan, plan_limit_error
16
  from app.services.storage import storage
17
 
@@ -206,10 +207,15 @@ async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
206
  voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
207
  except tts.TTSError as exc:
208
  raise HTTPException(502, str(exc)) from exc
 
 
 
 
 
209
  if body.use_as in ("host_a", "host_b"):
210
- vp = _ensure_profile(ctx)
211
- vp.host_voices = {**(vp.host_voices or {}), body.use_as: voice_id}
212
- ctx.db.commit()
213
  return {"voice_id": voice_id, **_serialize(ctx)}
214
 
215
 
@@ -369,5 +375,7 @@ async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
369
  vp = _profile(ctx)
370
  if vp and vp.host_voices:
371
  vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
 
 
372
  ctx.db.commit()
373
  return _serialize(ctx)
 
12
  from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
13
  from app.services import tts
14
  from app.services.jobs import create_job, record_usage, serialize_job
15
+ from app.services.notestack_voices import forget_video_voice
16
  from app.services.plans import effective_plan, plan_limit_error
17
  from app.services.storage import storage
18
 
 
207
  voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
208
  except tts.TTSError as exc:
209
  raise HTTPException(502, str(exc)) from exc
210
+ vp = _ensure_profile(ctx)
211
+ voices = dict(vp.host_voices or {})
212
+ # Listed, so it can be added to the workspace's video voices too.
213
+ added = [v for v in voices.get("added") or [] if v.get("voice_id") != voice_id]
214
+ voices["added"] = [*added, {"voice_id": voice_id, "name": body.name[:100]}]
215
  if body.use_as in ("host_a", "host_b"):
216
+ voices[body.use_as] = voice_id
217
+ vp.host_voices = voices
218
+ ctx.db.commit()
219
  return {"voice_id": voice_id, **_serialize(ctx)}
220
 
221
 
 
375
  vp = _profile(ctx)
376
  if vp and vp.host_voices:
377
  vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
378
+ if consent.elevenlabs_voice_id:
379
+ forget_video_voice(ctx.db, ctx.workspace.id, consent.elevenlabs_voice_id)
380
  ctx.db.commit()
381
  return _serialize(ctx)
backend/app/services/artifacts.py CHANGED
@@ -63,8 +63,11 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
63
  "document_id": str(a.document_id) if a.document_id else None,
64
  "content": content,
65
  "storage_key": a.storage_key,
66
- "url": storage.presign_get(a.storage_key) if a.storage_key else None,
67
- "download_url": storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key else None,
 
 
 
68
  "slide_urls": [storage.presign_get(k) for k in slides],
69
  "created_at": a.created_at.isoformat() if a.created_at else None,
70
  "job": serialize_job(job) if job else None,
 
63
  "document_id": str(a.document_id) if a.document_id else None,
64
  "content": content,
65
  "storage_key": a.storage_key,
66
+ # blog2video videos live on blog2video's storage (video_url once rendered)
67
+ "url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
68
+ "download_url": (storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key
69
+ else content.get("video_url")),
70
+ "provider": content.get("provider"),
71
  "slide_urls": [storage.presign_get(k) for k in slides],
72
  "created_at": a.created_at.isoformat() if a.created_at else None,
73
  "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,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.notestack_voices import is_notestack_voice
18
+ from app.services.plans import Plan, effective_plan
19
+ from app.services.video_limits import require_premium
20
+
21
+ BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
22
+ # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
23
+ # exist (matched by name, the first is the default). Each sets the video length; the wizard has no length picker and
24
+ # only says roughly how long the video comes out.
25
+ HOUSE_STYLES = [ # (name on blog2video, video length, what the wizard says it is: about three lines)
26
+ ("Overview", "short", "A clear, neutral rundown of the key points. It gets straight to the main idea, one point per "
27
+ "scene in plain words. Best for quick social posts and teasers. Usually under a minute long."),
28
+ ("Deep dive", "medium", "Sets up the context first, then works through the reasoning, examples and trade-offs, each "
29
+ "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
30
+ "2 minutes long."),
31
+ ]
32
+ PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
33
+
34
+
35
+ def not_found(what: str = "Not found") -> HTTPException:
36
+ return HTTPException(404, what)
37
+
38
+
39
+ # Videos
40
+
41
+
42
+ def owned_video(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo | None]:
43
+ """The artifact, blog2video's id, and our ledger row (None for videos made before the API key).
44
+
45
+ The row is the reference: its b2v_video_id (and created_via) is what every blog2video call uses. The artifact's
46
+ content_json only keeps a copy for the Library, and is read only for legacy videos, which have no row."""
47
+ a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id,
48
+ Artifact.type == "video"))
49
+ content = (a.content_json or {}) if a else {}
50
+ if not a or content.get("provider") != "blog2video":
51
+ raise not_found("Video not found")
52
+ row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id,
53
+ B2VVideo.workspace_id == ctx.workspace.id))
54
+ if row is not None and row.b2v_video_id is not None:
55
+ return a, row.b2v_video_id, row
56
+ if row is None and content.get("b2v_video_id"):
57
+ return a, int(content["b2v_video_id"]), None # legacy
58
+ raise not_found("Video not found") # the create never got an id from blog2video
59
+
60
+
61
+ def video_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo]:
62
+ """A video this workspace owns and blog2video can still reach with our key."""
63
+ a, vid, row = owned_video(ctx, artifact_id)
64
+ if row is None:
65
+ raise HTTPException(410, {"code": "video_legacy",
66
+ "message": "This video was made before the video service changed and can not be "
67
+ "edited any more. Its links still work."})
68
+ return a, vid, row
69
+
70
+
71
+ # Templates, voices, styles
72
+
73
+
74
+ def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate:
75
+ row = ctx.db.scalar(select(B2VTemplate).where(B2VTemplate.b2v_template_id == template_id,
76
+ B2VTemplate.workspace_id == ctx.workspace.id))
77
+ if not row:
78
+ raise not_found("Template not found")
79
+ return row
80
+
81
+
82
+ def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice:
83
+ row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id,
84
+ B2VCustomVoice.workspace_id == ctx.workspace.id))
85
+ if not row:
86
+ raise not_found("Voice not found")
87
+ return row
88
+
89
+
90
+ def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
91
+ row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
92
+ B2VStyle.workspace_id == ctx.workspace.id))
93
+ if not row:
94
+ raise not_found("Style not found")
95
+ return row
96
+
97
+
98
+ def _custom_id(ref: str, prefix: str) -> int | None:
99
+ raw = ref.removeprefix(prefix)
100
+ return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
101
+
102
+
103
+ def crafted_ids() -> set[str]:
104
+ out = set()
105
+ for t in b2v.crafted_templates():
106
+ tid = t.get("id") if isinstance(t, dict) else t
107
+ if isinstance(tid, str) and tid.startswith("crafted_"):
108
+ out.add(tid)
109
+ return out
110
+
111
+
112
+ def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
113
+ if template == "default":
114
+ return
115
+ if (cid := _custom_id(template, "custom_")) is not None:
116
+ row = owned_template(ctx, cid)
117
+ if not row.ready:
118
+ raise HTTPException(400, "That template is still being made.")
119
+ return
120
+ if template.startswith("crafted_"):
121
+ # blog2video silently falls back to "default" for an unknown id, so check it exists.
122
+ if template not in crafted_ids():
123
+ raise not_found("Template not found")
124
+ require_premium(ctx.db, ctx.workspace, "Designer templates", plan)
125
+ return
126
+ if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
127
+ raise not_found("Template not found")
128
+
129
+
130
+ def house_styles() -> list[dict]:
131
+ """HOUSE_STYLES found among the account's custom styles, in HOUSE_STYLES order, with the length each sets."""
132
+ by_name = {str(s.get("name") or "").strip().lower(): s for s in (b2v.video_styles() or {}).get("styles") or []
133
+ if s.get("kind") == "custom" and s.get("custom_id") is not None}
134
+ out = []
135
+ for name, length, blurb in HOUSE_STYLES:
136
+ if s := by_name.get(name.lower()):
137
+ out.append({"id": f"custom:{s['custom_id']}", "custom_id": s["custom_id"], "name": s.get("name"),
138
+ "description": blurb, "kind": "house", "length": length})
139
+ return out
140
+
141
+
142
+ def house_length(style: str | None) -> str | None:
143
+ """The video length a house style sets; None for any other style."""
144
+ if not style or not style.startswith("custom:"):
145
+ return None
146
+ return next((s["length"] for s in house_styles() if s["id"] == style), None)
147
+
148
+
149
+ def check_style(ctx: Ctx, style: str) -> None:
150
+ if style in BUILTIN_STYLES:
151
+ return
152
+ if (sid := _custom_id(style, "custom:")) is not None:
153
+ if any(s["custom_id"] == sid for s in house_styles()):
154
+ return
155
+ owned_style(ctx, sid)
156
+ return
157
+ raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
158
+
159
+
160
+ def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
161
+ """A built-in voice (premium if blog2video marks it paid), or one of this workspace's custom or Notestack
162
+ voices."""
163
+ prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
164
+ if voice_id in prebuilt:
165
+ if prebuilt[voice_id].get("plan") == "paid":
166
+ require_premium(ctx.db, ctx.workspace, "This voice", plan)
167
+ return
168
+ if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id,
169
+ B2VCustomVoice.workspace_id == ctx.workspace.id)):
170
+ require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
171
+ return
172
+ # One of this workspace's Notestack voices (clone, designed, added): same ElevenLabs account, premium like custom.
173
+ if is_notestack_voice(ctx.db, ctx.workspace.id, voice_id):
174
+ require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
175
+ return
176
+ raise not_found("Voice not found")
177
+
178
+
179
+ def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
180
+ """Every id in a create or edit body must be this workspace's, and premium options need a paid plan."""
181
+ plan = plan or effective_plan(ctx.db, ctx.workspace)
182
+ if body.get("template"):
183
+ check_template(ctx, str(body["template"]), plan)
184
+ if body.get("video_style"):
185
+ check_style(ctx, str(body["video_style"]))
186
+ if body.get("custom_voice_id") and body.get("voice_gender") != "none":
187
+ check_voice(ctx, str(body["custom_voice_id"]), plan)
188
+ if str(body.get("video_length") or "") in PREMIUM_LENGTHS:
189
+ require_premium(ctx.db, ctx.workspace, "Longer videos", plan)
190
+ if body.get("voice_emotion"):
191
+ require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan)
192
+ return plan
193
+
194
+
195
+ # Catalog, filtered to what this workspace may see
196
+
197
+
198
+ def visible_styles(ctx: Ctx) -> list[dict]:
199
+ """The house styles when the account has them; otherwise built-in styles (read-only) and this workspace's custom
200
+ styles. Never "Your Style", never others'."""
201
+ if house := house_styles():
202
+ return [{k: v for k, v in s.items() if k != "length"} for s in house]
203
+ mine = set(ctx.db.scalars(select(B2VStyle.b2v_style_id).where(B2VStyle.workspace_id == ctx.workspace.id)))
204
+ out = []
205
+ for s in (b2v.video_styles() or {}).get("styles") or []:
206
+ if s.get("kind") == "builtin" and s.get("id") in BUILTIN_STYLES:
207
+ out.append({"id": s["id"], "name": s.get("name"), "description": s.get("description"), "kind": "builtin"})
208
+ elif s.get("kind") == "custom" and s.get("custom_id") in mine:
209
+ out.append({"id": s["id"], "custom_id": s["custom_id"], "name": s.get("name"),
210
+ "description": s.get("description"), "guidance": s.get("guidance"),
211
+ "version": s.get("version"), "kind": "custom"})
212
+ return out
213
+
214
+
215
+ def my_templates(ctx: Ctx, ready_only: bool = False) -> list[B2VTemplate]:
216
+ q = select(B2VTemplate).where(B2VTemplate.workspace_id == ctx.workspace.id)
217
+ if ready_only:
218
+ q = q.where(B2VTemplate.ready.is_(True))
219
+ 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/jobs.py CHANGED
@@ -46,6 +46,12 @@ def serialize_job(job: Job) -> dict:
46
  # Producer side
47
 
48
 
 
 
 
 
 
 
49
  def create_job(
50
  db: Session,
51
  workspace_id: uuid.UUID,
 
46
  # Producer side
47
 
48
 
49
+ def pending_job(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
50
+ """A job of this kind is already queued or running for the workspace."""
51
+ return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
52
+ Job.status.in_(("queued", "running"))).limit(1)) is not None
53
+
54
+
55
  def create_job(
56
  db: Session,
57
  workspace_id: uuid.UUID,
backend/app/services/notestack_voices.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
2
+ from the ElevenLabs library. blog2video speaks with the same ElevenLabs account, so these can be used in videos too
3
+ (as `custom_voice_id`); video_voices lists them and b2v_access accepts them."""
4
+
5
+ import uuid
6
+
7
+ from sqlalchemy import delete, select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.models import UserSavedVoice, VoiceConsent, VoiceProfile
11
+
12
+
13
+ def notestack_voices(db: Session, workspace_id: uuid.UUID) -> list[dict]:
14
+ """[{voice_id, name, kind}], kind: clone | designed | library. The clone first, no repeats."""
15
+ out: list[dict] = []
16
+ consent = db.scalar(select(VoiceConsent).where(VoiceConsent.workspace_id == workspace_id,
17
+ VoiceConsent.revoked_at.is_(None))
18
+ .order_by(VoiceConsent.created_at.desc()))
19
+ if consent and consent.elevenlabs_voice_id:
20
+ out.append({"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "kind": "clone"})
21
+ vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
22
+ voices = (vp.host_voices if vp else None) or {}
23
+ for kind, key in (("designed", "custom"), ("library", "added")):
24
+ for v in voices.get(key) or []:
25
+ if v.get("voice_id") and all(o["voice_id"] != v["voice_id"] for o in out):
26
+ out.append({"voice_id": v["voice_id"], "name": v.get("name") or "Voice", "kind": kind})
27
+ return out
28
+
29
+
30
+ def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> bool:
31
+ return any(v["voice_id"] == voice_id for v in notestack_voices(db, workspace_id))
32
+
33
+
34
+ def forget_video_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> None:
35
+ """A Notestack voice that is gone (clone revoked) leaves the workspace's saved video voices too."""
36
+ db.execute(delete(UserSavedVoice).where(UserSavedVoice.workspace_id == workspace_id,
37
+ UserSavedVoice.voice_id == voice_id))
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_focus.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The video wizard's focus cards: the topics the workspace already has for the source, shown as they are.
2
+
3
+ Each post is tagged by the background `topics` job (generate.extract_topics); a post still without tags is tagged
4
+ here on demand. A source's tags make its pool, each scored by weight and by how much of the source shares it (a
5
+ chat's tags are those of the posts its answers cite). The cards are the best merged topics (the Topic Map's, with a
6
+ summary), spread over the source's posts, then the posts' own tags (a name only) when there are fewer than three.
7
+ """
8
+
9
+ import uuid
10
+ from collections import Counter, defaultdict
11
+ from dataclasses import dataclass
12
+
13
+ from sqlalchemy import func, select
14
+ from sqlalchemy.orm import Session
15
+
16
+ from app.config import settings
17
+ from app.corpus import slugify
18
+ from app.llm import run
19
+ from app.llm.provider import fast_lm
20
+ from app.llm.signatures import ExtractTopics
21
+ from app.models import Citation, Document, DocumentTopic, Message, Topic
22
+ from app.pipeline.generate import save_topic_tags
23
+ from app.routers.sources import is_locked
24
+ from app.services.jobs import create_job, pending_job
25
+
26
+ INLINE_TAG_CAP = 5 # untagged posts tagged while the wizard waits; the rest go to the background job
27
+ TAG_TEXT = 6000 # characters of a post read when tagging it on demand (the background job reads 12000)
28
+ CARDS = 3
29
+
30
+
31
+ @dataclass
32
+ class Candidate:
33
+ name: str
34
+ summary: str
35
+ score: float
36
+ sources: set[str] # the posts (or chats) that carry it
37
+ merged: bool # a topic of the topic table (has a summary), not only a post's own tag
38
+
39
+
40
+ # Tags
41
+
42
+
43
+ def ensure_tags(db: Session, workspace_id: uuid.UUID, docs: list[Document]) -> None:
44
+ """Tag the newest few untagged posts now, in parallel, and leave the rest to the background job, which also
45
+ merges these new tags into the topic table. A failed call leaves that post untagged: never an error here."""
46
+ untagged = [d for d in docs if not (d.metadata_json or {}).get("topics") and not is_locked(d)]
47
+ if not untagged or not settings.llm_api_key:
48
+ return
49
+ untagged.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
50
+ now = untagged[:INLINE_TAG_CAP]
51
+ known = sorted(db.scalars(select(Topic.name).where(Topic.workspace_id == workspace_id)))[:200]
52
+ outs = run.predict_many(ExtractTopics, [{"title": d.title, "text": (d.clean_text or "")[:TAG_TEXT],
53
+ "known_topics": known} for d in now],
54
+ db=db, workspace_id=workspace_id, lm=fast_lm())
55
+ for doc, out in zip(now, outs, strict=True):
56
+ if out is not None:
57
+ save_topic_tags(doc, out)
58
+ db.commit()
59
+ # With no ids the job tags every post still untagged (the rest of these too) and rebuilds the topic table.
60
+ if not pending_job(db, workspace_id, "topics"):
61
+ create_job(db, workspace_id, "topics", {}, max_attempts=2)
62
+
63
+
64
+ def chat_citations(db: Session, workspace_id: uuid.UUID,
65
+ chat_ids: list[uuid.UUID]) -> tuple[dict[str, dict[uuid.UUID, float]], list[Document]]:
66
+ """For each chat, the posts its answers cite (weighted by how often, the most cited 1.0), and those posts.
67
+ Locked posts are left out: the chat itself is fine to use."""
68
+ rows = db.execute(
69
+ select(Message.chat_id, Citation.document_id, func.count())
70
+ .join(Citation, Citation.message_id == Message.id)
71
+ .where(Message.chat_id.in_(chat_ids))
72
+ .group_by(Message.chat_id, Citation.document_id)
73
+ ).all()
74
+ docs = {d.id: d for d in db.scalars(select(Document).where(
75
+ Document.id.in_({doc_id for _, doc_id, _ in rows}), Document.workspace_id == workspace_id)) if not is_locked(d)}
76
+ counts: dict[str, dict[uuid.UUID, int]] = defaultdict(dict)
77
+ for chat_id, doc_id, n in rows:
78
+ if doc_id in docs:
79
+ counts[str(chat_id)][doc_id] = n
80
+ cited = {chat: {d: n / max(per.values()) for d, n in per.items()} for chat, per in counts.items()}
81
+ return cited, list(docs.values())
82
+
83
+
84
+ def build_pool(db: Session, workspace_id: uuid.UUID, sources: dict[str, dict[uuid.UUID, float]],
85
+ docs: list[Document]) -> list[Candidate]:
86
+ """Every tag of the source's posts, best first. A source is a post (itself, weight 1) or a chat (the posts it
87
+ cites). Score: the tag's weight summed over the sources that carry it, times the share of sources that do, so a
88
+ theme running through the selection beats one post's strongest tag."""
89
+ tags = _doc_tags(db, workspace_id, docs)
90
+ score: dict[str, float] = defaultdict(float)
91
+ carried: dict[str, set[str]] = defaultdict(set)
92
+ summaries: dict[str, str] = {}
93
+ merged: dict[str, bool] = defaultdict(bool)
94
+ for source, weights in sources.items():
95
+ best: dict[str, float] = {}
96
+ for doc_id, w in weights.items():
97
+ for name, (tag_weight, summary, is_merged) in tags.get(doc_id, {}).items():
98
+ best[name] = max(best.get(name, 0.0), tag_weight * w)
99
+ summaries.setdefault(name, summary)
100
+ merged[name] |= is_merged
101
+ for name, v in best.items():
102
+ score[name] += v
103
+ carried[name].add(source)
104
+ total = max(len(sources), 1)
105
+ pool = [Candidate(name, summaries[name], score[name] * len(carried[name]) / total, carried[name], merged[name])
106
+ for name in score]
107
+ return sorted(pool, key=lambda c: (-c.score, c.name))
108
+
109
+
110
+ def _doc_tags(db: Session, workspace_id: uuid.UUID,
111
+ docs: list[Document]) -> dict[uuid.UUID, dict[str, tuple[float, str, bool]]]:
112
+ """Post -> {topic name: (weight, summary, merged)}: the topic table's (merged) topics and the post's own tags. The
113
+ merge folds a post's specific tags into a few broad workspace topics ("Content Repurposing" into "Finance
114
+ Substacks & Audience Growth"), which alone leaves a single post one or two angles; the merged names are what lets
115
+ posts share a tag. An own tag is named as the topic table's matching topic if any, and skipped if already there."""
116
+ out: dict[uuid.UUID, dict[str, tuple[float, str, bool]]] = defaultdict(dict)
117
+ rows = db.execute(
118
+ select(DocumentTopic.document_id, DocumentTopic.weight, Topic.name, Topic.summary)
119
+ .join(Topic, Topic.id == DocumentTopic.topic_id)
120
+ .where(DocumentTopic.document_id.in_([d.id for d in docs]), Topic.workspace_id == workspace_id)
121
+ ).all()
122
+ for doc_id, weight, name, summary in rows:
123
+ out[doc_id][name] = (float(weight or 0.5), summary or "", True)
124
+ own = [d for d in docs if (d.metadata_json or {}).get("topics")]
125
+ if own:
126
+ known = {t.slug: t for t in db.scalars(select(Topic).where(Topic.workspace_id == workspace_id))}
127
+ for d in own:
128
+ have = {slugify(n, 120) for n in out[d.id]}
129
+ for t in d.metadata_json["topics"]:
130
+ topic = known.get(slugify(t["name"], 120))
131
+ name = topic.name if topic else t["name"]
132
+ if slugify(name, 120) in have:
133
+ continue
134
+ have.add(slugify(name, 120))
135
+ summary = (topic.summary or "") if topic else ""
136
+ out[d.id][name] = (float(t.get("weight") or 0.5), summary, topic is not None)
137
+ return out
138
+
139
+
140
+ def pick_cards(pool: list[Candidate], n: int = CARDS) -> list[Candidate]:
141
+ """The cards: merged topics first, then own tags. Within each, best score first, halved for each card already on
142
+ every post the topic has, so several posts' cards do not all come from one post."""
143
+ chosen: list[Candidate] = []
144
+ covered: Counter[str] = Counter() # source -> cards on it so far
145
+
146
+ def worth(c: Candidate) -> float:
147
+ return c.score * 0.5 ** min(covered[s] for s in c.sources)
148
+
149
+ for group in ([c for c in pool if c.merged], [c for c in pool if not c.merged]):
150
+ rest = list(enumerate(group)) # the pool's order breaks ties
151
+ while rest and len(chosen) < n:
152
+ i, best = max(rest, key=lambda ic: (worth(ic[1]), -ic[0]))
153
+ chosen.append(best)
154
+ covered.update(best.sources)
155
+ rest.remove((i, best))
156
+ return chosen
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/support/corpus.py CHANGED
@@ -61,10 +61,13 @@ def _parse(path: Path) -> Doc:
61
 
62
  def _plans_doc() -> Doc:
63
  """Built from services/plans.py so prices and limits can never drift from what the app enforces."""
64
- from app.services.plans import PLAN_ORDER, PLANS, annual_prices
65
 
66
- lines = ["# Plans", "Notestack has three plans: Free, Writer and Studio. Prices are in US dollars."]
67
- for pid in PLAN_ORDER:
 
 
 
68
  p = PLANS[pid]
69
  price = "free" if not p.price_monthly_usd else f"${p.price_monthly_usd:g} a month"
70
  if p.price_monthly_usd:
@@ -76,7 +79,7 @@ def _plans_doc() -> Doc:
76
  f"{p.tagline}. Price: {price}.",
77
  f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
78
  f"{p.audio_minutes} minutes of audio a month, "
79
- f"{p.video_minutes} minutes of video a month, {kits} Launch Kits a month. "
80
  f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
81
  "Includes: " + "; ".join(p.features) + ".",
82
  ]
 
61
 
62
  def _plans_doc() -> Doc:
63
  """Built from services/plans.py so prices and limits can never drift from what the app enforces."""
64
+ from app.services.plans import PLANS, annual_prices, plan_order
65
 
66
+ order = plan_order() # the plans table's (editable without a deploy), so the names and count come from it too
67
+ names = [PLANS[pid].name for pid in order]
68
+ listed = ", ".join(names[:-1]) + f" and {names[-1]}" if len(names) > 1 else "".join(names)
69
+ lines = ["# Plans", f"Notestack has {len(names)} plans: {listed}. Prices are in US dollars."]
70
+ for pid in order:
71
  p = PLANS[pid]
72
  price = "free" if not p.price_monthly_usd else f"${p.price_monthly_usd:g} a month"
73
  if p.price_monthly_usd:
 
79
  f"{p.tagline}. Price: {price}.",
80
  f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
81
  f"{p.audio_minutes} minutes of audio a month, "
82
+ f"{p.videos} videos {'a month' if p.videos_monthly else 'in total'}, {kits} Launch Kits a month. "
83
  f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
84
  "Includes: " + "; ".join(p.features) + ".",
85
  ]
backend/app/worker.py CHANGED
@@ -31,6 +31,7 @@ from app.db import SessionLocal, engine
31
  from app.models import (
32
  Artifact,
33
  Chat,
 
34
  Job,
35
  Message,
36
  Source,
@@ -48,10 +49,18 @@ from app.pipeline import generate, launchkit, media
48
  from app.pipeline.generate import NothingToDo
49
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
50
  from app.pipeline.memory import learn_from_message
51
- from app.services import launchpad, tts
52
  from app.services.email import email_service
53
  from app.services.email_verification import purge_old_codes
54
- from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
 
 
 
 
 
 
 
 
55
  from app.services.plans import effective_plan
56
  from app.services.renderer import PermanentJobError, request_render
57
  from app.services.storage import keys, storage
@@ -71,11 +80,6 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
71
  # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
72
 
73
 
74
- def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
75
- return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
76
- Job.status.in_(("queued", "running"))).limit(1)) is not None
77
-
78
-
79
  def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
80
  """New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
81
  first import builds the writing voice, so none of these need a button press."""
@@ -83,10 +87,10 @@ def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[
83
  return
84
  create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
85
  create_job(db, workspace_id, "ideas", {"document_ids": changed}, max_attempts=2)
86
- if not _pending(db, workspace_id, "resurface_scan"):
87
  create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
88
  vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
89
- if not (vp and vp.profile_json) and not _pending(db, workspace_id, "voice_profile"):
90
  create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
91
 
92
 
@@ -276,7 +280,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
276
  "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
277
  "mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"),
278
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
279
- "video": _artifact_handler(media.make_video, "Video ready"),
280
  "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
281
  "carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
282
  "launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
@@ -357,6 +361,19 @@ def single_flight(name: str):
357
  conn.commit()
358
 
359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
361
  """Sends one daily batch per scheduled campaign at its send hour (UTC)."""
362
  now = datetime.now(UTC)
@@ -413,7 +430,11 @@ PERIODIC = [
413
  Periodic("publish_due", 30, launchpad.publish_due),
414
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
415
  Periodic("update_email_batch", 300, update_email_batch),
 
416
  Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
 
 
 
417
  ]
418
 
419
 
 
31
  from app.models import (
32
  Artifact,
33
  Chat,
34
+ Document,
35
  Job,
36
  Message,
37
  Source,
 
49
  from app.pipeline.generate import NothingToDo
50
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
51
  from app.pipeline.memory import learn_from_message
52
+ from app.services import launchpad, tts, video_quota
53
  from app.services.email import email_service
54
  from app.services.email_verification import purge_old_codes
55
+ from app.services.jobs import (
56
+ claim_next,
57
+ create_job,
58
+ pending_job,
59
+ record_usage,
60
+ retry_or_fail,
61
+ stale_jobs,
62
+ update_job,
63
+ )
64
  from app.services.plans import effective_plan
65
  from app.services.renderer import PermanentJobError, request_render
66
  from app.services.storage import keys, storage
 
80
  # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
81
 
82
 
 
 
 
 
 
83
  def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
84
  """New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
85
  first import builds the writing voice, so none of these need a button press."""
 
87
  return
88
  create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
89
  create_job(db, workspace_id, "ideas", {"document_ids": changed}, max_attempts=2)
90
+ if not pending_job(db, workspace_id, "resurface_scan"):
91
  create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
92
  vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
93
+ if not (vp and vp.profile_json) and not pending_job(db, workspace_id, "voice_profile"):
94
  create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
95
 
96
 
 
280
  "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
281
  "mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"),
282
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
283
+ "video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
284
  "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
285
  "carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
286
  "launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
 
361
  conn.commit()
362
 
363
 
364
+ def topics_backfill(session_factory: Callable[[], Session] = SessionLocal) -> None:
365
+ """Every indexed post gets topics: one whose tagging failed (the model timed out, rate limited, answered nothing)
366
+ or that came before posts were tagged on import is asked for again here, one topics job per workspace that has
367
+ some, unless one is already queued. A post that keeps failing stops being asked (generate.MAX_TAG_FAILURES)."""
368
+ if not settings.llm_api_key:
369
+ return
370
+ with session_factory() as db:
371
+ rows = db.execute(select(Document.workspace_id, Document.metadata_json).where(Document.path.is_not(None)))
372
+ for workspace_id in {ws for ws, meta in rows if generate.needs_topics(meta)}:
373
+ if not pending_job(db, workspace_id, "topics"):
374
+ create_job(db, workspace_id, "topics", {}, max_attempts=2)
375
+
376
+
377
  def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
378
  """Sends one daily batch per scheduled campaign at its send hour (UTC)."""
379
  now = datetime.now(UTC)
 
430
  Periodic("publish_due", 30, launchpad.publish_due),
431
  Periodic("sync_engagement", 3600, launchpad.sync_engagement),
432
  Periodic("update_email_batch", 300, update_email_batch),
433
+ Periodic("topics_backfill", 900, topics_backfill),
434
  Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
435
+ Periodic("video_period_reset", 3600, video_quota.reset_due_video_periods),
436
+ Periodic("video_refund_sweep", 600, video_quota.sweep_failed_videos),
437
+ Periodic("video_capacity_check", 3600, video_quota.check_capacity),
438
  ]
439
 
440
 
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
  rank-bm25==0.2.*
18
  feedparser==6.*
 
13
  boto3==1.35.*
14
  resend==2.*
15
  httpx==0.28.*
16
+ python-multipart==0.0.*
17
  dspy==2.6.*
18
  rank-bm25==0.2.*
19
  feedparser==6.*