Spaces:
Runtime error
Runtime error
Commit ·
070c931
1
Parent(s): 0f62e9d
wired blog2video api phase 1
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +0 -131
- backend/alembic/versions/0005_video_quota.py +48 -0
- backend/alembic/versions/0006_plans.py +71 -0
- backend/alembic/versions/0007_subscription_plan_fk.py +36 -0
- backend/alembic/versions/0008_b2v_videos.py +40 -0
- backend/alembic/versions/0009_b2v_resources.py +111 -0
- backend/alembic/versions/0010_b2v_video_refs.py +96 -0
- backend/alembic/versions/0011_one_archive_notebook.py +48 -0
- backend/alembic/versions/0012_artifact_status_len.py +26 -0
- backend/app/config.py +9 -0
- backend/app/llm/signatures.py +0 -16
- backend/app/main.py +6 -1
- backend/app/models/__init__.py +14 -1
- backend/app/models/content.py +100 -1
- backend/app/models/user.py +48 -2
- backend/app/pipeline/media.py +6 -90
- backend/app/routers/artifacts.py +11 -10
- backend/app/routers/billing.py +3 -2
- backend/app/routers/notebooks.py +14 -5
- backend/app/routers/video_edit.py +212 -0
- backend/app/routers/video_styles.py +88 -0
- backend/app/routers/video_templates.py +277 -0
- backend/app/routers/video_voices.py +267 -0
- backend/app/routers/videos.py +742 -0
- backend/app/services/artifacts.py +5 -2
- backend/app/services/auth_identity.py +4 -1
- backend/app/services/b2v_access.py +179 -0
- backend/app/services/billing.py +8 -2
- backend/app/services/blog2video.py +326 -0
- backend/app/services/plans.py +136 -19
- backend/app/services/renderer.py +0 -2
- backend/app/services/stripe_billing.py +9 -0
- backend/app/services/usage.py +6 -4
- backend/app/services/video_limits.py +93 -0
- backend/app/services/video_quota.py +239 -0
- backend/app/worker.py +5 -2
- backend/requirements.txt +1 -0
- backend/tests/conftest.py +2 -0
- backend/tests/test_billing.py +67 -1
- backend/tests/test_buildout.py +19 -13
- backend/tests/test_simplified_flows.py +14 -1
- backend/tests/test_stripe_billing.py +34 -0
- backend/tests/test_videos.py +960 -0
- docs/BLOG2VIDEO.md +131 -0
- frontend/seo/prerender.ts +13 -3
- frontend/src/App.tsx +8 -0
- frontend/src/api/client.ts +19 -4
- frontend/src/api/endpoints.ts +237 -2
- frontend/src/api/types.ts +298 -4
- frontend/src/components/AppShell.tsx +3 -1
.env.example
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
# ---- App
|
| 2 |
-
ENV=development
|
| 3 |
-
APP_NAME=Notestack
|
| 4 |
-
FRONTEND_URL=http://localhost:5173
|
| 5 |
-
API_URL=http://localhost:8000
|
| 6 |
-
CORS_ORIGINS=http://localhost:5173
|
| 7 |
-
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
-
BILLING_ENABLED=false
|
| 9 |
-
# Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
|
| 10 |
-
# (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
|
| 11 |
-
# Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
|
| 12 |
-
# checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
|
| 13 |
-
# customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
|
| 14 |
-
# customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
|
| 15 |
-
# invoice.payment_action_required, charge.dispute.created.
|
| 16 |
-
STRIPE_SECRET_KEY=
|
| 17 |
-
STRIPE_WEBHOOK_SECRET=
|
| 18 |
-
STRIPE_PRICE_WRITER_MONTHLY=
|
| 19 |
-
STRIPE_PRICE_WRITER_ANNUAL=
|
| 20 |
-
STRIPE_PRICE_STUDIO_MONTHLY=
|
| 21 |
-
STRIPE_PRICE_STUDIO_ANNUAL=
|
| 22 |
-
|
| 23 |
-
# ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
|
| 24 |
-
JWT_SECRET=replace-with-64-random-chars
|
| 25 |
-
JWT_ALGORITHM=HS256
|
| 26 |
-
JWT_EXPIRATION_HOURS=72
|
| 27 |
-
JWT_REFRESH_EXPIRATION_DAYS=30
|
| 28 |
-
|
| 29 |
-
# ---- Database (also backs the job queue)
|
| 30 |
-
# Local: a SQLite file, created by `alembic upgrade head` run from backend/.
|
| 31 |
-
# Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
|
| 32 |
-
# DATABASE_URL=postgresql+psycopg://user:pass@host:5432/dbname?sslmode=require
|
| 33 |
-
# docker-compose ignores this and points at its own Postgres container.
|
| 34 |
-
DATABASE_URL=sqlite:///./notestack.db
|
| 35 |
-
|
| 36 |
-
# ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
|
| 37 |
-
# Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
|
| 38 |
-
# Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
|
| 39 |
-
# (default {API_URL}/api/auth/google/callback, e.g. http://localhost:8000/api/auth/google/callback)
|
| 40 |
-
# to "Authorized redirect URIs" on the OAuth client. Never prefix the secret with VITE_.
|
| 41 |
-
GOOGLE_CLIENT_ID=
|
| 42 |
-
GOOGLE_CLIENT_SECRET=
|
| 43 |
-
GOOGLE_REDIRECT_URI=
|
| 44 |
-
|
| 45 |
-
# ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
|
| 46 |
-
EMAIL_PROVIDER=console
|
| 47 |
-
RESEND_API_KEY=
|
| 48 |
-
FROM_EMAIL=Notestack <hello@notestack.ai>
|
| 49 |
-
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 50 |
-
UNSUBSCRIBE_SECRET=replace-me
|
| 51 |
-
# Hour (UTC, 0 to 23) at which scheduled update email campaigns send their daily batch.
|
| 52 |
-
UPDATE_EMAIL_SEND_HOUR=9
|
| 53 |
-
|
| 54 |
-
# ---- Local dev conveniences
|
| 55 |
-
# The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
|
| 56 |
-
RUN_WORKER_IN_API=
|
| 57 |
-
# auto = local disk (LOCAL_STORAGE_DIR) when no R2 account or endpoint is set, else R2
|
| 58 |
-
STORAGE_BACKEND=auto
|
| 59 |
-
LOCAL_STORAGE_DIR=.storage
|
| 60 |
-
|
| 61 |
-
# ---- Storage: Cloudflare R2
|
| 62 |
-
# Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
|
| 63 |
-
# Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
|
| 64 |
-
R2_ACCOUNT_ID=
|
| 65 |
-
R2_ACCESS_KEY_ID=minioadmin
|
| 66 |
-
R2_SECRET_ACCESS_KEY=minioadmin
|
| 67 |
-
R2_BUCKET=notestack-assets
|
| 68 |
-
R2_ENDPOINT_URL=
|
| 69 |
-
R2_PUBLIC_BASE_URL=
|
| 70 |
-
# Folder inside the bucket when it is shared with other apps, e.g. notestack. Empty = bucket root.
|
| 71 |
-
R2_PREFIX=
|
| 72 |
-
# How long presigned download links stay valid, in seconds.
|
| 73 |
-
R2_PRESIGN_TTL_SECONDS=3600
|
| 74 |
-
# Largest file a browser may upload (bytes). 209715200 = 200 MB.
|
| 75 |
-
MAX_UPLOAD_BYTES=209715200
|
| 76 |
-
|
| 77 |
-
# ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
|
| 78 |
-
# GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
|
| 79 |
-
# Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
|
| 80 |
-
LLM_MODEL=openai/glm-5.3
|
| 81 |
-
LLM_FAST_MODEL=openai/glm-5.3-flash
|
| 82 |
-
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 83 |
-
LLM_API_KEY=
|
| 84 |
-
LLM_TEMPERATURE=1.0
|
| 85 |
-
LLM_MAX_TOKENS=16000
|
| 86 |
-
LLM_REASONING_EFFORT=low
|
| 87 |
-
# Fallback chat triage model when Jev is not configured. Empty = LLM_FAST_MODEL.
|
| 88 |
-
LLM_TRIAGE_MODEL=
|
| 89 |
-
|
| 90 |
-
# ---- Chat triage: TypeSafe Jev (https://typesafe.ai)
|
| 91 |
-
# A fast classifier (typed choices, no text generation) that decides what each chat message needs
|
| 92 |
-
# before any research runs. With a key set, greetings, off topic and app questions never reach the
|
| 93 |
-
# LLM, and archive questions get a quick or deep research budget. Empty = LLM triage above.
|
| 94 |
-
TYPESAFE_API_KEY=
|
| 95 |
-
TYPESAFE_MODEL=jev-latest
|
| 96 |
-
TYPESAFE_URL=https://api.typesafe.ai
|
| 97 |
-
|
| 98 |
-
# Firecrawl (firecrawl.dev): lets Connect read sites with no RSS feed, including JavaScript sites
|
| 99 |
-
# (Next.js, Framer, Webflow), and makes single URL imports reliable. Empty = plain HTTP fetching only.
|
| 100 |
-
FIRECRAWL_API_KEY=
|
| 101 |
-
|
| 102 |
-
# ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
|
| 103 |
-
CORPUS_CACHE_DIR=.corpus-cache
|
| 104 |
-
RESEARCH_MAX_STEPS=10
|
| 105 |
-
|
| 106 |
-
# ---- Worker (jobs queue lives in the database; no Redis)
|
| 107 |
-
WORKER_CONCURRENCY=4
|
| 108 |
-
WORKER_POLL_SECONDS=1
|
| 109 |
-
JOB_STALE_SECONDS=900
|
| 110 |
-
|
| 111 |
-
# ---- ElevenLabs (audio overviews, video narration, voice cloning)
|
| 112 |
-
ELEVENLABS_API_KEY=
|
| 113 |
-
ELEVENLABS_MODEL=eleven_multilingual_v2
|
| 114 |
-
ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
|
| 115 |
-
ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
|
| 116 |
-
|
| 117 |
-
# ---- Renderer (Remotion service for videos, quote cards and carousels: cd renderer && npm start)
|
| 118 |
-
RENDERER_URL=http://localhost:3100
|
| 119 |
-
|
| 120 |
-
# ---- Launchpad social publishing
|
| 121 |
-
# Bluesky needs nothing here (users connect with an app password).
|
| 122 |
-
# X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
|
| 123 |
-
# Callback URI: {API_URL}/api/social/x/callback e.g. http://localhost:8000/api/social/x/callback
|
| 124 |
-
# LinkedIn: app with the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products.
|
| 125 |
-
# Redirect URL: {API_URL}/api/social/linkedin/callback
|
| 126 |
-
X_CLIENT_ID=
|
| 127 |
-
X_CLIENT_SECRET=
|
| 128 |
-
LINKEDIN_CLIENT_ID=
|
| 129 |
-
LINKEDIN_CLIENT_SECRET=
|
| 130 |
-
# Fernet key for stored social tokens; derived from JWT_SECRET when empty.
|
| 131 |
-
SOCIAL_TOKEN_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/alembic/versions/0005_video_quota.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""subscriptions.video_plan / videos_used / video_limit / videos_period_start: the video allowance
|
| 2 |
+
|
| 3 |
+
Revision ID: 0005_video_quota
|
| 4 |
+
Revises: 0004_archive_notebook
|
| 5 |
+
Create Date: 2026-09-29
|
| 6 |
+
"""
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from alembic import op
|
| 9 |
+
|
| 10 |
+
revision = "0005_video_quota"
|
| 11 |
+
down_revision = "0004_archive_notebook"
|
| 12 |
+
branch_labels = None
|
| 13 |
+
depends_on = None
|
| 14 |
+
|
| 15 |
+
COLUMNS = ("video_plan", "videos_used", "video_limit", "videos_period_start")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
# 0001 builds from the live models, so a fresh database may already have the columns.
|
| 20 |
+
existing = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("subscriptions")}
|
| 21 |
+
new = {
|
| 22 |
+
"video_plan": sa.Column("video_plan", sa.String(20), nullable=False, server_default="free"),
|
| 23 |
+
"videos_used": sa.Column("videos_used", sa.Integer(), nullable=False, server_default="0"),
|
| 24 |
+
"video_limit": sa.Column("video_limit", sa.Integer(), nullable=False, server_default="0"),
|
| 25 |
+
"videos_period_start": sa.Column("videos_period_start", sa.DateTime(timezone=True), nullable=True),
|
| 26 |
+
}
|
| 27 |
+
missing = [new[name] for name in COLUMNS if name not in existing]
|
| 28 |
+
if missing:
|
| 29 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 30 |
+
for column in missing:
|
| 31 |
+
batch.add_column(column)
|
| 32 |
+
# Same limits as app/services/plans.py; the app re-syncs on first use (billing disabled -> studio).
|
| 33 |
+
op.execute("""
|
| 34 |
+
UPDATE subscriptions SET
|
| 35 |
+
video_plan = CASE WHEN status IN ('active', 'trialing', 'past_due') AND plan IN ('writer', 'studio')
|
| 36 |
+
THEN plan ELSE 'free' END
|
| 37 |
+
""")
|
| 38 |
+
op.execute("""
|
| 39 |
+
UPDATE subscriptions SET
|
| 40 |
+
video_limit = CASE video_plan WHEN 'studio' THEN 30 WHEN 'writer' THEN 10 ELSE 1 END,
|
| 41 |
+
videos_period_start = COALESCE(videos_period_start, CURRENT_TIMESTAMP)
|
| 42 |
+
""")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def downgrade() -> None:
|
| 46 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 47 |
+
for name in reversed(COLUMNS):
|
| 48 |
+
batch.drop_column(name)
|
backend/alembic/versions/0006_plans.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""plans: one row per plan (limits, price, pricing-page copy), editable without a deploy
|
| 2 |
+
|
| 3 |
+
Seeded with the plans that were hard-coded in app/services/plans.py. Rows that already exist are left alone.
|
| 4 |
+
|
| 5 |
+
Revision ID: 0006_plans
|
| 6 |
+
Revises: 0005_video_quota
|
| 7 |
+
Create Date: 2026-09-29
|
| 8 |
+
"""
|
| 9 |
+
from datetime import UTC, datetime
|
| 10 |
+
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from alembic import op
|
| 13 |
+
|
| 14 |
+
revision = "0006_plans"
|
| 15 |
+
down_revision = "0005_video_quota"
|
| 16 |
+
branch_labels = None
|
| 17 |
+
depends_on = None
|
| 18 |
+
|
| 19 |
+
SEED = [
|
| 20 |
+
{"id": "free", "sort_order": 0, "name": "Free", "tagline": "For trying Notestack on your archive",
|
| 21 |
+
"price_monthly_usd": 0, "sources": 1, "indexed_posts": 5, "audio_minutes": 3, "videos": 1, "launch_kits": 2,
|
| 22 |
+
"voice_cloning": False, "brand_kit": False,
|
| 23 |
+
"features": ["1 source, your latest 5 posts indexed", "Grounded research chat with citations",
|
| 24 |
+
"3 min of audio overviews a month", "1 video a month", "2 Launch Kits a month"]},
|
| 25 |
+
{"id": "writer", "sort_order": 1, "name": "Writer", "tagline": "For writers publishing every week",
|
| 26 |
+
"price_monthly_usd": 24.99, "sources": 3, "indexed_posts": 500, "audio_minutes": 60, "videos": 10,
|
| 27 |
+
"launch_kits": 50, "voice_cloning": True, "brand_kit": True,
|
| 28 |
+
"features": ["3 sources, 500 indexed posts", "60 min of audio overviews a month", "10 videos a month",
|
| 29 |
+
"50 Launch Kits a month", "Voice cloning with consent", "Your brand colors and logo"]},
|
| 30 |
+
{"id": "studio", "sort_order": 2, "name": "Studio", "tagline": "For publications and power users",
|
| 31 |
+
"price_monthly_usd": 48.99, "sources": 10, "indexed_posts": 5000, "audio_minutes": 240, "videos": 30,
|
| 32 |
+
"launch_kits": -1, "voice_cloning": True, "brand_kit": True,
|
| 33 |
+
"features": ["10 sources, 5,000 indexed posts", "240 min of audio overviews a month", "30 videos a month",
|
| 34 |
+
"Unlimited Launch Kits", "Launchpad calendar and resurfacing", "Priority rendering"]},
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def upgrade() -> None:
|
| 39 |
+
bind = op.get_bind()
|
| 40 |
+
# 0001 builds from the live models, so a fresh database may already have the table (empty).
|
| 41 |
+
if "plans" not in sa.inspect(bind).get_table_names():
|
| 42 |
+
op.create_table(
|
| 43 |
+
"plans",
|
| 44 |
+
sa.Column("id", sa.String(20), primary_key=True),
|
| 45 |
+
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
| 46 |
+
sa.Column("name", sa.String(50), nullable=False),
|
| 47 |
+
sa.Column("tagline", sa.String(200), nullable=False, server_default=""),
|
| 48 |
+
sa.Column("price_monthly_usd", sa.Float(), nullable=False, server_default="0"),
|
| 49 |
+
sa.Column("sources", sa.Integer(), nullable=False, server_default="1"),
|
| 50 |
+
sa.Column("indexed_posts", sa.Integer(), nullable=False, server_default="5"),
|
| 51 |
+
sa.Column("audio_minutes", sa.Integer(), nullable=False, server_default="0"),
|
| 52 |
+
sa.Column("videos", sa.Integer(), nullable=False, server_default="0"),
|
| 53 |
+
sa.Column("launch_kits", sa.Integer(), nullable=False, server_default="0"),
|
| 54 |
+
sa.Column("voice_cloning", sa.Boolean(), nullable=False, server_default=sa.false()),
|
| 55 |
+
sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false()),
|
| 56 |
+
sa.Column("features", sa.JSON(), nullable=False),
|
| 57 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 58 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 59 |
+
)
|
| 60 |
+
types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
|
| 61 |
+
"created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
|
| 62 |
+
plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*SEED[0], "created_at", "updated_at"]))
|
| 63 |
+
existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
|
| 64 |
+
now = datetime.now(UTC)
|
| 65 |
+
missing = [{**p, "created_at": now, "updated_at": now} for p in SEED if p["id"] not in existing]
|
| 66 |
+
if missing:
|
| 67 |
+
op.bulk_insert(plans, missing)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def downgrade() -> None:
|
| 71 |
+
op.drop_table("plans")
|
backend/alembic/versions/0007_subscription_plan_fk.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""subscriptions.plan -> plans.id: the plan is picked from the plans table, not typed as free text
|
| 2 |
+
|
| 3 |
+
Unknown plan values (typos, removed plans) become 'free' first so the constraint can be added.
|
| 4 |
+
|
| 5 |
+
Revision ID: 0007_subscription_plan_fk
|
| 6 |
+
Revises: 0006_plans
|
| 7 |
+
Create Date: 2026-09-29
|
| 8 |
+
"""
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from alembic import op
|
| 11 |
+
|
| 12 |
+
revision = "0007_subscription_plan_fk"
|
| 13 |
+
down_revision = "0006_plans"
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
NAME = "fk_subscriptions_plan_plans"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def upgrade() -> None:
|
| 21 |
+
bind = op.get_bind()
|
| 22 |
+
# 0001 builds from the live models, so a fresh database may already have the constraint.
|
| 23 |
+
if any(fk["referred_table"] == "plans" for fk in sa.inspect(bind).get_foreign_keys("subscriptions")):
|
| 24 |
+
return
|
| 25 |
+
op.execute("UPDATE subscriptions SET plan = 'free' WHERE plan IS NULL OR plan NOT IN (SELECT id FROM plans)")
|
| 26 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 27 |
+
batch.create_foreign_key(NAME, "plans", ["plan"], ["id"], onupdate="CASCADE")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
fks = sa.inspect(op.get_bind()).get_foreign_keys("subscriptions")
|
| 32 |
+
name = next((fk["name"] for fk in fks if fk["referred_table"] == "plans"), None)
|
| 33 |
+
if not name:
|
| 34 |
+
return # unnamed (built by 0001 on SQLite): nothing we can drop by name
|
| 35 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 36 |
+
batch.drop_constraint(name, type_="foreignkey")
|
backend/alembic/versions/0008_b2v_videos.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""b2v_videos: which workspace owns each blog2video video, and whether its allowance is still charged
|
| 2 |
+
|
| 3 |
+
blog2video now only takes our account's API key, so this table is our access control and refund ledger.
|
| 4 |
+
blog2video no longer connects to this database: drop the role it used (see docs/BLOG2VIDEO.md).
|
| 5 |
+
|
| 6 |
+
Revision ID: 0008_b2v_videos
|
| 7 |
+
Revises: 0007_subscription_plan_fk
|
| 8 |
+
Create Date: 2026-09-30
|
| 9 |
+
"""
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from alembic import op
|
| 12 |
+
|
| 13 |
+
revision = "0008_b2v_videos"
|
| 14 |
+
down_revision = "0007_subscription_plan_fk"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
# 0001 builds from the live models, so a fresh database may already have the table.
|
| 21 |
+
if sa.inspect(op.get_bind()).has_table("b2v_videos"):
|
| 22 |
+
return
|
| 23 |
+
op.create_table(
|
| 24 |
+
"b2v_videos",
|
| 25 |
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
| 26 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
| 27 |
+
sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL"), nullable=True),
|
| 28 |
+
sa.Column("b2v_video_id", sa.Integer(), nullable=True, unique=True),
|
| 29 |
+
sa.Column("idempotency_key", sa.String(100), nullable=False, unique=True),
|
| 30 |
+
sa.Column("quota_state", sa.String(20), nullable=False, server_default="charged"),
|
| 31 |
+
sa.Column("status", sa.String(40), nullable=False, server_default="queued"),
|
| 32 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
| 33 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
| 34 |
+
)
|
| 35 |
+
op.create_index("ix_b2v_videos_ws_created", "b2v_videos", ["workspace_id", "created_at"])
|
| 36 |
+
op.create_index("ix_b2v_videos_artifact_id", "b2v_videos", ["artifact_id"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def downgrade() -> None:
|
| 40 |
+
op.drop_table("b2v_videos")
|
backend/alembic/versions/0009_b2v_resources.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""blog2video resources per workspace: templates, custom voices, saved voices, styles, usage counters;
|
| 2 |
+
b2v_videos.created_via / template_ref; plans.video_premium / video_limits
|
| 3 |
+
|
| 4 |
+
Everything made with our blog2video key belongs to one account, so these tables are what keeps each workspace's
|
| 5 |
+
templates, voices and styles its own (app/services/b2v_access.py).
|
| 6 |
+
|
| 7 |
+
Revision ID: 0009_b2v_resources
|
| 8 |
+
Revises: 0008_b2v_videos
|
| 9 |
+
Create Date: 2026-09-30
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
+
from alembic import op
|
| 15 |
+
|
| 16 |
+
revision = "0009_b2v_resources"
|
| 17 |
+
down_revision = "0008_b2v_videos"
|
| 18 |
+
branch_labels = None
|
| 19 |
+
depends_on = None
|
| 20 |
+
|
| 21 |
+
# Same as app/services/plans.py
|
| 22 |
+
VIDEO_LIMITS = {
|
| 23 |
+
"free": {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
|
| 24 |
+
"voice_samples_daily": 0, "custom_voices": 0},
|
| 25 |
+
"writer": {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
|
| 26 |
+
"voice_samples_daily": 20, "custom_voices": 3},
|
| 27 |
+
"studio": {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
|
| 28 |
+
"voice_samples_daily": 40, "custom_voices": 10},
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _ws() -> sa.Column:
|
| 33 |
+
return sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False,
|
| 34 |
+
index=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _times() -> list[sa.Column]:
|
| 38 |
+
return [sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
| 39 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def upgrade() -> None:
|
| 43 |
+
bind = op.get_bind()
|
| 44 |
+
inspector = sa.inspect(bind)
|
| 45 |
+
|
| 46 |
+
# 0001 builds from the live models, so a fresh database may already have all of this.
|
| 47 |
+
video_cols = {c["name"] for c in inspector.get_columns("b2v_videos")}
|
| 48 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 49 |
+
if "created_via" not in video_cols:
|
| 50 |
+
batch.add_column(sa.Column("created_via", sa.String(10), nullable=False, server_default="v1"))
|
| 51 |
+
if "template_ref" not in video_cols:
|
| 52 |
+
batch.add_column(sa.Column("template_ref", sa.String(100), nullable=True))
|
| 53 |
+
batch.alter_column("idempotency_key", existing_type=sa.String(100), nullable=True)
|
| 54 |
+
|
| 55 |
+
if not inspector.has_table("b2v_templates"):
|
| 56 |
+
op.create_table("b2v_templates",
|
| 57 |
+
sa.Column("b2v_template_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
|
| 58 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 59 |
+
sa.Column("ready", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
|
| 60 |
+
if not inspector.has_table("b2v_custom_voices"):
|
| 61 |
+
op.create_table("b2v_custom_voices",
|
| 62 |
+
sa.Column("b2v_custom_voice_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
|
| 63 |
+
sa.Column("voice_id", sa.String(100), nullable=False, unique=True),
|
| 64 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 65 |
+
sa.Column("source", sa.String(20), nullable=False),
|
| 66 |
+
sa.Column("preview_url", sa.String(1000), nullable=True), *_times())
|
| 67 |
+
if not inspector.has_table("user_saved_voices"):
|
| 68 |
+
op.create_table("user_saved_voices",
|
| 69 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 70 |
+
primary_key=True),
|
| 71 |
+
sa.Column("voice_id", sa.String(100), primary_key=True),
|
| 72 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 73 |
+
sa.Column("preview_url", sa.String(1000), nullable=True),
|
| 74 |
+
sa.Column("gender", sa.String(20), nullable=True),
|
| 75 |
+
sa.Column("accent", sa.String(50), nullable=True),
|
| 76 |
+
sa.Column("premium", sa.Boolean(), nullable=False, server_default=sa.false()),
|
| 77 |
+
sa.Column("is_custom", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
|
| 78 |
+
if not inspector.has_table("b2v_styles"):
|
| 79 |
+
op.create_table("b2v_styles",
|
| 80 |
+
sa.Column("b2v_style_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
|
| 81 |
+
sa.Column("name", sa.String(80), nullable=False), *_times())
|
| 82 |
+
if not inspector.has_table("usage_counters"):
|
| 83 |
+
op.create_table("usage_counters",
|
| 84 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 85 |
+
primary_key=True),
|
| 86 |
+
sa.Column("period", sa.String(10), primary_key=True),
|
| 87 |
+
sa.Column("metric", sa.String(30), primary_key=True),
|
| 88 |
+
sa.Column("used", sa.Integer(), nullable=False, server_default="0"))
|
| 89 |
+
|
| 90 |
+
plan_cols = {c["name"] for c in inspector.get_columns("plans")}
|
| 91 |
+
with op.batch_alter_table("plans") as batch:
|
| 92 |
+
if "video_premium" not in plan_cols:
|
| 93 |
+
batch.add_column(sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false()))
|
| 94 |
+
if "video_limits" not in plan_cols:
|
| 95 |
+
batch.add_column(sa.Column("video_limits", sa.JSON(), nullable=True))
|
| 96 |
+
plans = sa.table("plans", sa.column("id", sa.String), sa.column("video_premium", sa.Boolean),
|
| 97 |
+
sa.column("video_limits", sa.JSON))
|
| 98 |
+
for plan_id, limits in VIDEO_LIMITS.items():
|
| 99 |
+
op.execute(plans.update().where(plans.c.id == plan_id)
|
| 100 |
+
.values(video_premium=plan_id != "free", video_limits=json.loads(json.dumps(limits))))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def downgrade() -> None:
|
| 104 |
+
with op.batch_alter_table("plans") as batch:
|
| 105 |
+
batch.drop_column("video_limits")
|
| 106 |
+
batch.drop_column("video_premium")
|
| 107 |
+
for table in ("usage_counters", "b2v_styles", "user_saved_voices", "b2v_custom_voices", "b2v_templates"):
|
| 108 |
+
op.drop_table(table)
|
| 109 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 110 |
+
batch.drop_column("template_ref")
|
| 111 |
+
batch.drop_column("created_via")
|
backend/alembic/versions/0010_b2v_video_refs.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""b2v_videos holds each video's full reference (who made it, title, source, links); video limits 1 total / 10 / 20
|
| 2 |
+
|
| 3 |
+
- b2v_videos: user_id, title, source_url, aspect_ratio, preview_url, video_url, backfilled from the workspace owner
|
| 4 |
+
and the linked artifact.
|
| 5 |
+
- plans: videos_monthly (False on Free: 1 video in total, never refilled); Studio 20 videos a month.
|
| 6 |
+
|
| 7 |
+
Revision ID: 0010_b2v_video_refs
|
| 8 |
+
Revises: 0009_b2v_resources
|
| 9 |
+
Create Date: 2026-09-30
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
+
from alembic import op
|
| 15 |
+
|
| 16 |
+
revision = "0010_b2v_video_refs"
|
| 17 |
+
down_revision = "0009_b2v_resources"
|
| 18 |
+
branch_labels = None
|
| 19 |
+
depends_on = None
|
| 20 |
+
|
| 21 |
+
VIDEO_COLUMNS = {
|
| 22 |
+
"user_id": lambda: sa.Column("user_id", sa.Uuid(), nullable=True),
|
| 23 |
+
"title": lambda: sa.Column("title", sa.String(300), nullable=True),
|
| 24 |
+
"source_url": lambda: sa.Column("source_url", sa.String(2000), nullable=True),
|
| 25 |
+
"aspect_ratio": lambda: sa.Column("aspect_ratio", sa.String(20), nullable=True),
|
| 26 |
+
"preview_url": lambda: sa.Column("preview_url", sa.String(1000), nullable=True),
|
| 27 |
+
"video_url": lambda: sa.Column("video_url", sa.String(1000), nullable=True),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Same as app/services/plans.py
|
| 31 |
+
PLAN_VIDEOS = {"free": (1, False, "1 video a month", "1 video to try it"),
|
| 32 |
+
"writer": (10, True, None, None),
|
| 33 |
+
"studio": (20, True, "30 videos a month", "20 videos a month")}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def upgrade() -> None:
|
| 37 |
+
bind = op.get_bind()
|
| 38 |
+
inspector = sa.inspect(bind)
|
| 39 |
+
|
| 40 |
+
# 0001 builds from the live models, so a fresh database may already have these columns.
|
| 41 |
+
existing = {c["name"] for c in inspector.get_columns("b2v_videos")}
|
| 42 |
+
missing = [make() for name, make in VIDEO_COLUMNS.items() if name not in existing]
|
| 43 |
+
if missing:
|
| 44 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 45 |
+
for column in missing:
|
| 46 |
+
batch.add_column(column)
|
| 47 |
+
if "user_id" not in existing:
|
| 48 |
+
batch.create_foreign_key("fk_b2v_videos_user_id_users", "users", ["user_id"], ["id"],
|
| 49 |
+
ondelete="SET NULL")
|
| 50 |
+
batch.create_index("ix_b2v_videos_user_id", ["user_id"])
|
| 51 |
+
|
| 52 |
+
# Backfill: the workspace owner made it; title and links come from the artifact.
|
| 53 |
+
rows = bind.execute(sa.text("""
|
| 54 |
+
SELECT v.id, w.owner_id, a.content_json FROM b2v_videos v
|
| 55 |
+
JOIN workspaces w ON w.id = v.workspace_id
|
| 56 |
+
LEFT JOIN artifacts a ON a.id = v.artifact_id
|
| 57 |
+
WHERE v.user_id IS NULL
|
| 58 |
+
""")).fetchall()
|
| 59 |
+
for vid, owner_id, content in rows:
|
| 60 |
+
c = content if isinstance(content, dict) else json.loads(content or "{}")
|
| 61 |
+
bind.execute(sa.text("""
|
| 62 |
+
UPDATE b2v_videos SET user_id = :u, title = COALESCE(title, :t), aspect_ratio = COALESCE(aspect_ratio, :ar),
|
| 63 |
+
preview_url = COALESCE(preview_url, :p), video_url = COALESCE(video_url, :v) WHERE id = :id
|
| 64 |
+
"""), {"u": owner_id, "t": (c.get("title") or None), "ar": c.get("aspect_ratio"), "p": c.get("preview_url"),
|
| 65 |
+
"v": c.get("video_url"), "id": vid})
|
| 66 |
+
|
| 67 |
+
plan_cols = {c["name"] for c in inspector.get_columns("plans")}
|
| 68 |
+
if "videos_monthly" not in plan_cols:
|
| 69 |
+
with op.batch_alter_table("plans") as batch:
|
| 70 |
+
batch.add_column(sa.Column("videos_monthly", sa.Boolean(), nullable=False, server_default=sa.true()))
|
| 71 |
+
for plan_id, (videos, monthly, old, new) in PLAN_VIDEOS.items():
|
| 72 |
+
row = bind.execute(sa.text("SELECT features FROM plans WHERE id = :id"), {"id": plan_id}).fetchone()
|
| 73 |
+
if row is None:
|
| 74 |
+
continue
|
| 75 |
+
features = row[0] if isinstance(row[0], list) else json.loads(row[0] or "[]")
|
| 76 |
+
if old:
|
| 77 |
+
features = [new if f == old else f for f in features]
|
| 78 |
+
bind.execute(sa.text("UPDATE plans SET videos = :v, videos_monthly = :m, features = :f WHERE id = :id"),
|
| 79 |
+
{"v": videos, "m": monthly, "f": json.dumps(features), "id": plan_id})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def downgrade() -> None:
|
| 83 |
+
with op.batch_alter_table("plans") as batch:
|
| 84 |
+
batch.drop_column("videos_monthly")
|
| 85 |
+
# A database built from the models (0001) names these differently, so look them up.
|
| 86 |
+
inspector = sa.inspect(op.get_bind())
|
| 87 |
+
fks = [fk["name"] for fk in inspector.get_foreign_keys("b2v_videos")
|
| 88 |
+
if fk["constrained_columns"] == ["user_id"] and fk.get("name")]
|
| 89 |
+
indexes = [ix["name"] for ix in inspector.get_indexes("b2v_videos") if ix["column_names"] == ["user_id"]]
|
| 90 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 91 |
+
for name in indexes:
|
| 92 |
+
batch.drop_index(name)
|
| 93 |
+
for name in fks:
|
| 94 |
+
batch.drop_constraint(name, type_="foreignkey")
|
| 95 |
+
for name in reversed(list(VIDEO_COLUMNS)):
|
| 96 |
+
batch.drop_column(name)
|
backend/alembic/versions/0011_one_archive_notebook.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One "All posts" notebook per workspace
|
| 2 |
+
|
| 3 |
+
Two requests opening the Notebooks page at once could each create the archive notebook. This merges the copies into
|
| 4 |
+
the oldest one (its posts, chats and artifacts move over) and adds a unique index so it cannot happen again.
|
| 5 |
+
|
| 6 |
+
Revision ID: 0011_one_archive_notebook
|
| 7 |
+
Revises: 0010_b2v_video_refs
|
| 8 |
+
Create Date: 2026-10-01
|
| 9 |
+
"""
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from alembic import op
|
| 12 |
+
|
| 13 |
+
revision = "0011_one_archive_notebook"
|
| 14 |
+
down_revision = "0010_b2v_video_refs"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
INDEX = "uq_notebooks_one_archive"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
conn = op.get_bind()
|
| 23 |
+
rows = conn.execute(sa.text(
|
| 24 |
+
"SELECT workspace_id, id FROM notebooks WHERE is_archive ORDER BY workspace_id, created_at, id"
|
| 25 |
+
)).all()
|
| 26 |
+
keep: dict = {}
|
| 27 |
+
for ws, nb in rows:
|
| 28 |
+
if ws not in keep:
|
| 29 |
+
keep[ws] = nb
|
| 30 |
+
continue
|
| 31 |
+
ids = {"keep": keep[ws], "dup": nb}
|
| 32 |
+
conn.execute(sa.text(
|
| 33 |
+
"INSERT INTO notebook_documents (notebook_id, document_id) "
|
| 34 |
+
"SELECT :keep, document_id FROM notebook_documents WHERE notebook_id = :dup AND document_id NOT IN "
|
| 35 |
+
"(SELECT document_id FROM notebook_documents WHERE notebook_id = :keep)"
|
| 36 |
+
), ids)
|
| 37 |
+
conn.execute(sa.text("UPDATE chats SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
|
| 38 |
+
conn.execute(sa.text("UPDATE artifacts SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
|
| 39 |
+
conn.execute(sa.text("DELETE FROM notebooks WHERE id = :dup"), ids)
|
| 40 |
+
|
| 41 |
+
# 0001 builds from the live models, so a fresh database may already have the index.
|
| 42 |
+
if INDEX not in {i["name"] for i in sa.inspect(conn).get_indexes("notebooks")}:
|
| 43 |
+
op.create_index(INDEX, "notebooks", ["workspace_id"], unique=True,
|
| 44 |
+
postgresql_where=sa.text("is_archive"), sqlite_where=sa.text("is_archive"))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def downgrade() -> None:
|
| 48 |
+
op.drop_index(INDEX, table_name="notebooks")
|
backend/alembic/versions/0012_artifact_status_len.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Longer artifact status
|
| 2 |
+
|
| 3 |
+
A video's artifact now takes blog2video's job statuses as they are (e.g. "language_regenerating", 21 characters),
|
| 4 |
+
which do not fit the old 20.
|
| 5 |
+
|
| 6 |
+
Revision ID: 0012_artifact_status_len
|
| 7 |
+
Revises: 0011_one_archive_notebook
|
| 8 |
+
Create Date: 2026-10-02
|
| 9 |
+
"""
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from alembic import op
|
| 12 |
+
|
| 13 |
+
revision = "0012_artifact_status_len"
|
| 14 |
+
down_revision = "0011_one_archive_notebook"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
with op.batch_alter_table("artifacts") as batch:
|
| 21 |
+
batch.alter_column("status", existing_type=sa.String(20), type_=sa.String(40), existing_nullable=False)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def downgrade() -> None:
|
| 25 |
+
with op.batch_alter_table("artifacts") as batch:
|
| 26 |
+
batch.alter_column("status", existing_type=sa.String(40), type_=sa.String(20), existing_nullable=False)
|
backend/app/config.py
CHANGED
|
@@ -101,6 +101,15 @@ class Settings(BaseSettings):
|
|
| 101 |
linkedin_client_id: str = ""
|
| 102 |
linkedin_client_secret: str = ""
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
# Renderer
|
| 105 |
renderer_url: str = "http://localhost:3100"
|
| 106 |
internal_token: str = "internal-change-me"
|
|
|
|
| 101 |
linkedin_client_id: str = ""
|
| 102 |
linkedin_client_secret: str = ""
|
| 103 |
|
| 104 |
+
# blog2video: makes the videos. We call its public API with our own account's key (b2v_live_...), so every
|
| 105 |
+
# video is charged to that account. Backend only: the key must never reach the browser or the logs.
|
| 106 |
+
b2v_api_base_url: str = Field("", validation_alias=AliasChoices("B2V_API_BASE_URL", "B2V_API_URL"))
|
| 107 |
+
b2v_api_key: str = ""
|
| 108 |
+
# blog2video's web app: serves the template poster images (<app>/template-posters/<id>.webp).
|
| 109 |
+
b2v_app_url: str = "https://blog2video.app"
|
| 110 |
+
b2v_timeout_seconds: float = 30 # creates, uploads and full-project reads; other calls also wait 30s (STATUS_TIMEOUT)
|
| 111 |
+
b2v_capacity_alert_below: int = 20 # log an error when our blog2video account has fewer videos left
|
| 112 |
+
|
| 113 |
# Renderer
|
| 114 |
renderer_url: str = "http://localhost:3100"
|
| 115 |
internal_token: str = "internal-change-me"
|
backend/app/llm/signatures.py
CHANGED
|
@@ -47,14 +47,6 @@ class ScriptLine(BaseModel):
|
|
| 47 |
sources: list[SourceRef] = Field(default_factory=list)
|
| 48 |
|
| 49 |
|
| 50 |
-
class Scene(BaseModel):
|
| 51 |
-
type: Literal["title", "section", "pull_quote", "number", "outro"]
|
| 52 |
-
on_screen_text: str
|
| 53 |
-
narration: str
|
| 54 |
-
duration_hint_s: float
|
| 55 |
-
visual: str = ""
|
| 56 |
-
|
| 57 |
-
|
| 58 |
class Hook(BaseModel):
|
| 59 |
text: str
|
| 60 |
strength: float = Field(ge=0, le=1)
|
|
@@ -165,14 +157,6 @@ class PodcastScript(dspy.Signature):
|
|
| 165 |
lines: list[ScriptLine] = dspy.OutputField()
|
| 166 |
|
| 167 |
|
| 168 |
-
class VideoStoryboard(dspy.Signature):
|
| 169 |
-
"""Turn the post into a scene list for a Remotion composition."""
|
| 170 |
-
|
| 171 |
-
passages: list[str] = dspy.InputField()
|
| 172 |
-
aspect: Literal["16:9", "9:16", "1:1"] = dspy.InputField()
|
| 173 |
-
scenes: list[Scene] = dspy.OutputField()
|
| 174 |
-
|
| 175 |
-
|
| 176 |
class HookGenerator(dspy.Signature):
|
| 177 |
"""Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
|
| 178 |
|
|
|
|
| 47 |
sources: list[SourceRef] = Field(default_factory=list)
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
class Hook(BaseModel):
|
| 51 |
text: str
|
| 52 |
strength: float = Field(ge=0, le=1)
|
|
|
|
| 157 |
lines: list[ScriptLine] = dspy.OutputField()
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
class HookGenerator(dspy.Signature):
|
| 161 |
"""Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
|
| 162 |
|
backend/app/main.py
CHANGED
|
@@ -21,6 +21,10 @@ from app.routers import (
|
|
| 21 |
storage,
|
| 22 |
topics,
|
| 23 |
unsubscribe,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
voice,
|
| 25 |
)
|
| 26 |
from app.routers import settings as settings_router
|
|
@@ -86,8 +90,9 @@ def health():
|
|
| 86 |
return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
|
| 87 |
|
| 88 |
|
|
|
|
| 89 |
for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
|
| 90 |
-
launchpad, settings_router):
|
| 91 |
app.include_router(r.router)
|
| 92 |
app.include_router(sources.documents_router)
|
| 93 |
app.include_router(launchpad.links_router)
|
|
|
|
| 21 |
storage,
|
| 22 |
topics,
|
| 23 |
unsubscribe,
|
| 24 |
+
video_edit,
|
| 25 |
+
video_styles,
|
| 26 |
+
video_voices,
|
| 27 |
+
videos,
|
| 28 |
voice,
|
| 29 |
)
|
| 30 |
from app.routers import settings as settings_router
|
|
|
|
| 90 |
return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
|
| 91 |
|
| 92 |
|
| 93 |
+
# video_templates (custom templates) is switched off: users pick only from the built-in templates.
|
| 94 |
for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
|
| 95 |
+
launchpad, videos, video_edit, video_voices, video_styles, settings_router):
|
| 96 |
app.include_router(r.router)
|
| 97 |
app.include_router(sources.documents_router)
|
| 98 |
app.include_router(launchpad.links_router)
|
backend/app/models/__init__.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
from app.models.content import (
|
| 2 |
Artifact,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
CalendarItem,
|
| 4 |
Chat,
|
| 5 |
Citation,
|
|
@@ -16,16 +20,22 @@ from app.models.content import (
|
|
| 16 |
Topic,
|
| 17 |
TrackedLink,
|
| 18 |
Upload,
|
|
|
|
| 19 |
UsageEvent,
|
|
|
|
| 20 |
VoiceConsent,
|
| 21 |
VoiceProfile,
|
| 22 |
)
|
| 23 |
from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
|
| 24 |
-
from app.models.user import AuthProvider, BillingEvent, Subscription, User, Workspace, WorkspaceMember
|
| 25 |
|
| 26 |
__all__ = [
|
| 27 |
"Artifact",
|
| 28 |
"AuthProvider",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
"BillingEvent",
|
| 30 |
"CalendarItem",
|
| 31 |
"Chat",
|
|
@@ -38,6 +48,7 @@ __all__ = [
|
|
| 38 |
"Job",
|
| 39 |
"Message",
|
| 40 |
"Notebook",
|
|
|
|
| 41 |
"NotebookDocument",
|
| 42 |
"SocialAccount",
|
| 43 |
"Source",
|
|
@@ -47,7 +58,9 @@ __all__ = [
|
|
| 47 |
"UpdateEmail",
|
| 48 |
"UpdateEmailSend",
|
| 49 |
"Upload",
|
|
|
|
| 50 |
"UsageEvent",
|
|
|
|
| 51 |
"User",
|
| 52 |
"VerificationPurpose",
|
| 53 |
"VoiceConsent",
|
|
|
|
| 1 |
from app.models.content import (
|
| 2 |
Artifact,
|
| 3 |
+
B2VCustomVoice,
|
| 4 |
+
B2VStyle,
|
| 5 |
+
B2VTemplate,
|
| 6 |
+
B2VVideo,
|
| 7 |
CalendarItem,
|
| 8 |
Chat,
|
| 9 |
Citation,
|
|
|
|
| 20 |
Topic,
|
| 21 |
TrackedLink,
|
| 22 |
Upload,
|
| 23 |
+
UsageCounter,
|
| 24 |
UsageEvent,
|
| 25 |
+
UserSavedVoice,
|
| 26 |
VoiceConsent,
|
| 27 |
VoiceProfile,
|
| 28 |
)
|
| 29 |
from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
|
| 30 |
+
from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
|
| 31 |
|
| 32 |
__all__ = [
|
| 33 |
"Artifact",
|
| 34 |
"AuthProvider",
|
| 35 |
+
"B2VCustomVoice",
|
| 36 |
+
"B2VStyle",
|
| 37 |
+
"B2VTemplate",
|
| 38 |
+
"B2VVideo",
|
| 39 |
"BillingEvent",
|
| 40 |
"CalendarItem",
|
| 41 |
"Chat",
|
|
|
|
| 48 |
"Job",
|
| 49 |
"Message",
|
| 50 |
"Notebook",
|
| 51 |
+
"PlanRecord",
|
| 52 |
"NotebookDocument",
|
| 53 |
"SocialAccount",
|
| 54 |
"Source",
|
|
|
|
| 58 |
"UpdateEmail",
|
| 59 |
"UpdateEmailSend",
|
| 60 |
"Upload",
|
| 61 |
+
"UsageCounter",
|
| 62 |
"UsageEvent",
|
| 63 |
+
"UserSavedVoice",
|
| 64 |
"User",
|
| 65 |
"VerificationPurpose",
|
| 66 |
"VoiceConsent",
|
backend/app/models/content.py
CHANGED
|
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
|
| 14 |
Text,
|
| 15 |
UniqueConstraint,
|
| 16 |
Uuid,
|
|
|
|
| 17 |
)
|
| 18 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 19 |
|
|
@@ -60,6 +61,9 @@ class Document(IdMixin, TimestampMixin, Base):
|
|
| 60 |
|
| 61 |
class Notebook(IdMixin, TimestampMixin, Base):
|
| 62 |
__tablename__ = "notebooks"
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 65 |
title: Mapped[str] = mapped_column(String(300))
|
|
@@ -143,11 +147,106 @@ class Artifact(IdMixin, TimestampMixin, Base):
|
|
| 143 |
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
|
| 144 |
# summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
|
| 145 |
type: Mapped[str] = mapped_column(String(30))
|
| 146 |
-
|
|
|
|
|
|
|
| 147 |
content_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 148 |
storage_key: Mapped[str | None] = mapped_column(String(500))
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
class Job(IdMixin, TimestampMixin, Base):
|
| 152 |
"""Covers ingest, tts and render jobs. kind decides which fields matter.
|
| 153 |
|
|
|
|
| 14 |
Text,
|
| 15 |
UniqueConstraint,
|
| 16 |
Uuid,
|
| 17 |
+
text,
|
| 18 |
)
|
| 19 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 20 |
|
|
|
|
| 61 |
|
| 62 |
class Notebook(IdMixin, TimestampMixin, Base):
|
| 63 |
__tablename__ = "notebooks"
|
| 64 |
+
# One "All posts" notebook per workspace: two requests opening it at once cannot both create one.
|
| 65 |
+
__table_args__ = (Index("uq_notebooks_one_archive", "workspace_id", unique=True,
|
| 66 |
+
postgresql_where=text("is_archive"), sqlite_where=text("is_archive")),)
|
| 67 |
|
| 68 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 69 |
title: Mapped[str] = mapped_column(String(300))
|
|
|
|
| 147 |
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
|
| 148 |
# summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
|
| 149 |
type: Mapped[str] = mapped_column(String(30))
|
| 150 |
+
# pending | generating | ready | failed | rendering | review; a video also takes blog2video's job statuses as they
|
| 151 |
+
# are: regenerating (template switch) | voice_regenerating | language_regenerating | script_regenerating
|
| 152 |
+
status: Mapped[str] = mapped_column(String(40), default="pending")
|
| 153 |
content_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 154 |
storage_key: Mapped[str | None] = mapped_column(String(500))
|
| 155 |
|
| 156 |
|
| 157 |
+
class B2VVideo(IdMixin, TimestampMixin, Base):
|
| 158 |
+
"""One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
|
| 159 |
+
blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
|
| 160 |
+
and whether the workspace's video allowance is still charged for it.
|
| 161 |
+
|
| 162 |
+
Our API key reaches every video it created, for every workspace, so this table (with the artifact) is what
|
| 163 |
+
ties a blog2video id to one workspace. quota_state moves charged -> refunded (or -> kept once generated), with a
|
| 164 |
+
conditional UPDATE (see video_quota.refund), so the status poller and the sweep can not refund twice."""
|
| 165 |
+
|
| 166 |
+
__tablename__ = "b2v_videos"
|
| 167 |
+
__table_args__ = (Index("ix_b2v_videos_ws_created", "workspace_id", "created_at"),)
|
| 168 |
+
|
| 169 |
+
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
|
| 170 |
+
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"),
|
| 171 |
+
index=True) # who made it
|
| 172 |
+
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"),
|
| 173 |
+
index=True)
|
| 174 |
+
# blog2video's video id, which is also its project id. Null until blog2video accepts the create.
|
| 175 |
+
b2v_video_id: Mapped[int | None] = mapped_column(Integer, unique=True)
|
| 176 |
+
idempotency_key: Mapped[str | None] = mapped_column(String(100), unique=True) # "<workspace_id>:<uuid>"; v1 only
|
| 177 |
+
created_via: Mapped[str] = mapped_column(String(10), default="v1", server_default="v1") # v1 | upload
|
| 178 |
+
template_ref: Mapped[str | None] = mapped_column(String(100)) # e.g. custom_58: blocks deleting that template
|
| 179 |
+
title: Mapped[str | None] = mapped_column(String(300))
|
| 180 |
+
source_url: Mapped[str | None] = mapped_column(String(2000)) # the link it was made from (null for files, text)
|
| 181 |
+
aspect_ratio: Mapped[str | None] = mapped_column(String(20))
|
| 182 |
+
preview_url: Mapped[str | None] = mapped_column(String(1000)) # public live preview (embed token)
|
| 183 |
+
video_url: Mapped[str | None] = mapped_column(String(1000)) # the rendered MP4
|
| 184 |
+
quota_state: Mapped[str] = mapped_column(String(20), default="charged") # charged | refunded | kept
|
| 185 |
+
status: Mapped[str] = mapped_column(String(40), default="queued") # last known blog2video status
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class B2VTemplate(TimestampMixin, Base):
|
| 189 |
+
"""A custom template this workspace made. Used on videos as custom_<b2v_template_id>; only when ready."""
|
| 190 |
+
|
| 191 |
+
__tablename__ = "b2v_templates"
|
| 192 |
+
|
| 193 |
+
b2v_template_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
| 194 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 195 |
+
name: Mapped[str] = mapped_column(String(255))
|
| 196 |
+
ready: Mapped[bool] = mapped_column(Boolean, default=False) # code generation finished ('complete')
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class B2VCustomVoice(TimestampMixin, Base):
|
| 200 |
+
"""A designed or cloned voice this workspace made. Videos reference it by voice_id (custom_voice_id)."""
|
| 201 |
+
|
| 202 |
+
__tablename__ = "b2v_custom_voices"
|
| 203 |
+
|
| 204 |
+
b2v_custom_voice_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) # preview/delete
|
| 205 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 206 |
+
voice_id: Mapped[str] = mapped_column(String(100), unique=True) # the ElevenLabs id sent as custom_voice_id
|
| 207 |
+
name: Mapped[str] = mapped_column(String(255))
|
| 208 |
+
source: Mapped[str] = mapped_column(String(20)) # prompt | preset | clone
|
| 209 |
+
preview_url: Mapped[str | None] = mapped_column(String(1000))
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class UserSavedVoice(TimestampMixin, Base):
|
| 213 |
+
"""The workspace's "My voices" list. Kept only here: blog2video's saved-voice list is shared by everyone."""
|
| 214 |
+
|
| 215 |
+
__tablename__ = "user_saved_voices"
|
| 216 |
+
|
| 217 |
+
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 218 |
+
primary_key=True)
|
| 219 |
+
voice_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
| 220 |
+
name: Mapped[str] = mapped_column(String(255))
|
| 221 |
+
preview_url: Mapped[str | None] = mapped_column(String(1000))
|
| 222 |
+
gender: Mapped[str | None] = mapped_column(String(20))
|
| 223 |
+
accent: Mapped[str | None] = mapped_column(String(50))
|
| 224 |
+
premium: Mapped[bool] = mapped_column(Boolean, default=False) # a paid built-in voice, or any custom voice
|
| 225 |
+
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class B2VStyle(TimestampMixin, Base):
|
| 229 |
+
"""A custom video style this workspace made. Used on videos as custom:<b2v_style_id>."""
|
| 230 |
+
|
| 231 |
+
__tablename__ = "b2v_styles"
|
| 232 |
+
|
| 233 |
+
b2v_style_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
| 234 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 235 |
+
name: Mapped[str] = mapped_column(String(80))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class UsageCounter(Base):
|
| 239 |
+
"""Per-workspace counters for the video features blog2video only limits account-wide (video_limits.py)."""
|
| 240 |
+
|
| 241 |
+
__tablename__ = "usage_counters"
|
| 242 |
+
|
| 243 |
+
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 244 |
+
primary_key=True)
|
| 245 |
+
period: Mapped[str] = mapped_column(String(10), primary_key=True) # 2026-10 | 2026-10-02 | all
|
| 246 |
+
metric: Mapped[str] = mapped_column(String(30), primary_key=True)
|
| 247 |
+
used: Mapped[int] = mapped_column(Integer, default=0)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
class Job(IdMixin, TimestampMixin, Base):
|
| 251 |
"""Covers ingest, tts and render jobs. kind decides which fields matter.
|
| 252 |
|
backend/app/models/user.py
CHANGED
|
@@ -2,7 +2,20 @@ import enum
|
|
| 2 |
import uuid
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
-
from sqlalchemy import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 68 |
status: Mapped[str] = mapped_column(String(20), default="active")
|
| 69 |
provider: Mapped[str | None] = mapped_column(String(20))
|
| 70 |
provider_customer_id: Mapped[str | None] = mapped_column(String(100))
|
| 71 |
provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
|
| 72 |
current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
class BillingEvent(Base):
|
|
|
|
| 2 |
import uuid
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
+
from sqlalchemy import (
|
| 6 |
+
JSON,
|
| 7 |
+
Boolean,
|
| 8 |
+
DateTime,
|
| 9 |
+
Enum,
|
| 10 |
+
Float,
|
| 11 |
+
ForeignKey,
|
| 12 |
+
Integer,
|
| 13 |
+
String,
|
| 14 |
+
UniqueConstraint,
|
| 15 |
+
Uuid,
|
| 16 |
+
false,
|
| 17 |
+
true,
|
| 18 |
+
)
|
| 19 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 20 |
|
| 21 |
from app.db import Base
|
|
|
|
| 77 |
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
| 78 |
Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
|
| 79 |
)
|
| 80 |
+
# A row of the plans table. Only kept while status is active/trialing/past_due (see plans.effective_plan).
|
| 81 |
+
plan: Mapped[str] = mapped_column(String(20), ForeignKey("plans.id", onupdate="CASCADE"), default="free")
|
| 82 |
status: Mapped[str] = mapped_column(String(20), default="active")
|
| 83 |
provider: Mapped[str | None] = mapped_column(String(20))
|
| 84 |
provider_customer_id: Mapped[str | None] = mapped_column(String(100))
|
| 85 |
provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
|
| 86 |
current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 87 |
+
# Video allowance, enforced by us (see app/services/video_quota.py): +1 per video before we call blog2video,
|
| 88 |
+
# -1 when it fails; video_plan/video_limit follow the effective plan and videos_used resets each period.
|
| 89 |
+
video_plan: Mapped[str] = mapped_column(String(20), default="free", server_default="free")
|
| 90 |
+
videos_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
| 91 |
+
video_limit: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
| 92 |
+
videos_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class PlanRecord(TimestampMixin, Base):
|
| 96 |
+
"""One row per plan: limits, price and pricing-page copy. Edit a row to change a plan without a deploy
|
| 97 |
+
(app/services/plans.py reloads within a minute). Ids are fixed: Stripe prices and subscriptions use them."""
|
| 98 |
+
|
| 99 |
+
__tablename__ = "plans"
|
| 100 |
+
|
| 101 |
+
id: Mapped[str] = mapped_column(String(20), primary_key=True) # free | writer | studio
|
| 102 |
+
sort_order: Mapped[int] = mapped_column(Integer, default=0) # cheapest first; upgrades go up this order
|
| 103 |
+
name: Mapped[str] = mapped_column(String(50))
|
| 104 |
+
tagline: Mapped[str] = mapped_column(String(200), default="")
|
| 105 |
+
price_monthly_usd: Mapped[float] = mapped_column(Float, default=0)
|
| 106 |
+
sources: Mapped[int] = mapped_column(Integer, default=1)
|
| 107 |
+
indexed_posts: Mapped[int] = mapped_column(Integer, default=5)
|
| 108 |
+
audio_minutes: Mapped[int] = mapped_column(Integer, default=0)
|
| 109 |
+
videos: Mapped[int] = mapped_column(Integer, default=0)
|
| 110 |
+
launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
|
| 111 |
+
voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 112 |
+
brand_kit: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 113 |
+
features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
|
| 114 |
+
# Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
|
| 115 |
+
video_premium: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) # premium options
|
| 116 |
+
# False: `videos` is a lifetime total that never refills (Free). True: per month.
|
| 117 |
+
videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
|
| 118 |
+
video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS
|
| 119 |
|
| 120 |
|
| 121 |
class BillingEvent(Base):
|
backend/app/pipeline/media.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Audio overviews (two hosts, ElevenLabs),
|
| 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
|
| 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 |
-
|
| 127 |
-
if style == "audiogram":
|
| 128 |
return make_audiogram(db, job, artifact)
|
| 129 |
-
|
| 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="
|
| 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["
|
| 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:
|
| 38 |
audio_artifact_id: uuid.UUID | None = None
|
| 39 |
# carousel
|
| 40 |
slides: list[dict] | None = None
|
|
@@ -81,12 +81,12 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 81 |
body.notebook_id = ensure_archive_notebook(ctx).id
|
| 82 |
params: dict = {}
|
| 83 |
content: dict = {}
|
| 84 |
-
if body.type == "video" and body.style =
|
|
|
|
|
|
|
| 85 |
if not body.audio_artifact_id:
|
| 86 |
raise HTTPException(400, "Pick an audio overview for the audiogram")
|
| 87 |
source = get_artifact_or_404(ctx, body.audio_artifact_id)
|
| 88 |
-
seconds = float((source.content_json or {}).get("duration_s", 60))
|
| 89 |
-
check_limit(ctx.db, ctx.workspace, "video_minutes", seconds / 60)
|
| 90 |
title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
|
| 91 |
params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
|
| 92 |
artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
|
|
@@ -101,10 +101,6 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 101 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 102 |
params = {"format": body.format, "minutes": body.minutes}
|
| 103 |
title = f"Audio overview: {target}"
|
| 104 |
-
elif body.type == "video":
|
| 105 |
-
check_limit(ctx.db, ctx.workspace, "video_minutes", 1 if body.style == "short" else 3)
|
| 106 |
-
params = {"style": body.style}
|
| 107 |
-
title = f"{'Short' if body.style == 'short' else 'Explainer'} video: {target}"
|
| 108 |
elif body.type == "launch_kit":
|
| 109 |
if not body.document_id:
|
| 110 |
raise HTTPException(400, "A Launch Kit is made from one post")
|
|
@@ -174,6 +170,11 @@ def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get
|
|
| 174 |
@router.delete("/{artifact_id}")
|
| 175 |
def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 176 |
a = get_artifact_or_404(ctx, artifact_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
|
| 178 |
ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
|
| 179 |
ctx.db.delete(a)
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class RenderIn(BaseModel):
|
| 24 |
+
composition: Literal["AudiogramSquare", "QuoteCard", "CarouselSlide"]
|
| 25 |
props: dict
|
| 26 |
|
| 27 |
|
|
|
|
| 33 |
# audio_overview
|
| 34 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 35 |
minutes: int = Field(6, ge=1, le=30)
|
| 36 |
+
# video: only audiograms here; other videos are made by blog2video (/api/videos)
|
| 37 |
+
style: str = "audiogram"
|
| 38 |
audio_artifact_id: uuid.UUID | None = None
|
| 39 |
# carousel
|
| 40 |
slides: list[dict] | None = None
|
|
|
|
| 81 |
body.notebook_id = ensure_archive_notebook(ctx).id
|
| 82 |
params: dict = {}
|
| 83 |
content: dict = {}
|
| 84 |
+
if body.type == "video" and body.style != "audiogram":
|
| 85 |
+
raise HTTPException(410, "Videos moved to /app/videos")
|
| 86 |
+
if body.type == "video":
|
| 87 |
if not body.audio_artifact_id:
|
| 88 |
raise HTTPException(400, "Pick an audio overview for the audiogram")
|
| 89 |
source = get_artifact_or_404(ctx, body.audio_artifact_id)
|
|
|
|
|
|
|
| 90 |
title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
|
| 91 |
params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
|
| 92 |
artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
|
|
|
|
| 101 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 102 |
params = {"format": body.format, "minutes": body.minutes}
|
| 103 |
title = f"Audio overview: {target}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
elif body.type == "launch_kit":
|
| 105 |
if not body.document_id:
|
| 106 |
raise HTTPException(400, "A Launch Kit is made from one post")
|
|
|
|
| 170 |
@router.delete("/{artifact_id}")
|
| 171 |
def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 172 |
a = get_artifact_or_404(ctx, artifact_id)
|
| 173 |
+
if a.type == "video" and (a.content_json or {}).get("provider") == "blog2video":
|
| 174 |
+
from app.routers.videos import remove_video # also removes it from blog2video
|
| 175 |
+
|
| 176 |
+
remove_video(ctx, a)
|
| 177 |
+
return {"ok": True}
|
| 178 |
storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
|
| 179 |
ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
|
| 180 |
ctx.db.delete(a)
|
backend/app/routers/billing.py
CHANGED
|
@@ -8,7 +8,7 @@ from app.auth import Ctx, get_ctx
|
|
| 8 |
from app.config import settings
|
| 9 |
from app.db import get_db
|
| 10 |
from app.services.billing import billing_status, create_checkout_url, create_portal_url
|
| 11 |
-
from app.services.plans import
|
| 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 |
-
|
|
|
|
| 44 |
raise HTTPException(409, {"code": "already_on_plan", "message": f"You are already on {current.name}."})
|
| 45 |
return {"url": create_checkout_url(ctx.db, ctx.user, ctx.workspace, PLANS[body.plan], body.cycle)}
|
| 46 |
|
|
|
|
| 8 |
from app.config import settings
|
| 9 |
from app.db import get_db
|
| 10 |
from app.services.billing import billing_status, create_checkout_url, create_portal_url
|
| 11 |
+
from app.services.plans import PLANS, effective_plan, plan_dict, plan_order
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
| 14 |
|
|
|
|
| 40 |
if not settings.billing_enabled:
|
| 41 |
raise HTTPException(409, {"code": "billing_disabled", "message": "Checkout is not available yet."})
|
| 42 |
current = effective_plan(ctx.db, ctx.workspace)
|
| 43 |
+
order = plan_order()
|
| 44 |
+
if body.plan not in PLANS or order.index(body.plan) <= order.index(current.id):
|
| 45 |
raise HTTPException(409, {"code": "already_on_plan", "message": f"You are already on {current.name}."})
|
| 46 |
return {"url": create_checkout_url(ctx.db, ctx.user, ctx.workspace, PLANS[body.plan], body.cycle)}
|
| 47 |
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|
| 7 |
from fastapi.responses import StreamingResponse
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
from sqlalchemy import delete, func, select
|
|
|
|
| 10 |
|
| 11 |
from app.auth import Ctx, get_ctx
|
| 12 |
from app.config import settings
|
|
@@ -77,12 +78,20 @@ ARCHIVE_TITLE = "All posts"
|
|
| 77 |
|
| 78 |
def ensure_archive_notebook(ctx: Ctx) -> Notebook:
|
| 79 |
"""The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if not nb:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
|
| 87 |
_add_docs(ctx, nb, ids)
|
| 88 |
ctx.db.commit()
|
|
|
|
| 7 |
from fastapi.responses import StreamingResponse
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
from sqlalchemy import delete, func, select
|
| 10 |
+
from sqlalchemy.exc import IntegrityError
|
| 11 |
|
| 12 |
from app.auth import Ctx, get_ctx
|
| 13 |
from app.config import settings
|
|
|
|
| 78 |
|
| 79 |
def ensure_archive_notebook(ctx: Ctx) -> Notebook:
|
| 80 |
"""The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
|
| 81 |
+
def find() -> Notebook | None:
|
| 82 |
+
return ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id,
|
| 83 |
+
Notebook.is_archive.is_(True)))
|
| 84 |
+
|
| 85 |
+
nb = find()
|
| 86 |
if not nb:
|
| 87 |
+
try:
|
| 88 |
+
nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
|
| 89 |
+
description="Every indexed post in your archive, kept up to date.")
|
| 90 |
+
ctx.db.add(nb)
|
| 91 |
+
ctx.db.flush()
|
| 92 |
+
except IntegrityError: # a concurrent request made it first (uq_notebooks_one_archive)
|
| 93 |
+
ctx.db.rollback()
|
| 94 |
+
nb = find()
|
| 95 |
ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
|
| 96 |
_add_docs(ctx, nb, ids)
|
| 97 |
ctx.db.commit()
|
backend/app/routers/video_edit.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The video editor: one allowlisted proxy onto blog2video's /api/projects/{id}/... endpoints.
|
| 2 |
+
|
| 3 |
+
<METHOD> /api/videos/{artifact_id}/p/<path> -> <METHOD> /api/projects/{blog2video id}/<path>
|
| 4 |
+
|
| 5 |
+
Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
|
| 6 |
+
(b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
|
| 7 |
+
premium (★) features need a paid plan, and AI edits come out of the workspace's own counter (given back if
|
| 8 |
+
blog2video refuses). A template switch also uses one video, as it does on blog2video.
|
| 9 |
+
|
| 10 |
+
JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
import uuid
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
| 19 |
+
from starlette.concurrency import run_in_threadpool
|
| 20 |
+
from starlette.datastructures import UploadFile
|
| 21 |
+
|
| 22 |
+
from app.auth import Ctx, get_ctx
|
| 23 |
+
from app.routers.videos import b2v_ready, store, upstream
|
| 24 |
+
from app.services import b2v_access, video_limits, video_quota
|
| 25 |
+
from app.services import blog2video as b2v
|
| 26 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/api/videos", tags=["videos"])
|
| 29 |
+
|
| 30 |
+
MAX_JSON_BYTES = 512 * 1024
|
| 31 |
+
MAX_FILE_BYTES = 50 * 1024 * 1024
|
| 32 |
+
S = r"\d+" # a scene id
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class Rule:
|
| 37 |
+
ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
|
| 38 |
+
per_scene: bool = False # ai_edits is per scene_ids entry (avatar batches)
|
| 39 |
+
premium: str | None = None # ★: what to call it in the upgrade message
|
| 40 |
+
uses_video: bool = False # a template switch: one video from the allowance
|
| 41 |
+
raw: bool = False # binary answer (download, still)
|
| 42 |
+
timeout: float = 30
|
| 43 |
+
status: str | None = None # artifact status to show once it started
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
|
| 47 |
+
# Project settings, logo, assets
|
| 48 |
+
("GET", "", Rule(timeout=60)), # the whole project: slow on a far-away database
|
| 49 |
+
("PATCH", "update-project", Rule()),
|
| 50 |
+
("PATCH", "", Rule()),
|
| 51 |
+
("POST", "logo", Rule()),
|
| 52 |
+
("DELETE", "logo", Rule()),
|
| 53 |
+
("PATCH", rf"assets/{S}/exclude", Rule()),
|
| 54 |
+
("DELETE", rf"assets/{S}", Rule()),
|
| 55 |
+
("GET", "layouts", Rule()),
|
| 56 |
+
("GET", "status", Rule()),
|
| 57 |
+
# Scenes
|
| 58 |
+
("PUT", rf"scenes/{S}", Rule()),
|
| 59 |
+
("DELETE", rf"scenes/{S}", Rule()),
|
| 60 |
+
("PUT", "bulk-update-scenes", Rule()),
|
| 61 |
+
("POST", "scenes/reorder", Rule()),
|
| 62 |
+
("POST", rf"scenes/{S}/regenerate", Rule(ai_edits=1, timeout=120)),
|
| 63 |
+
("POST", "scenes/add", Rule(ai_edits=1)),
|
| 64 |
+
("GET", "scenes/add-status", Rule()),
|
| 65 |
+
# Images
|
| 66 |
+
("POST", rf"scenes/{S}/image", Rule(timeout=60)),
|
| 67 |
+
("POST", rf"scenes/{S}/generate-image", Rule(ai_edits=1, timeout=120)),
|
| 68 |
+
("PATCH", rf"scenes/{S}/image-focus", Rule()),
|
| 69 |
+
("POST", "images/move", Rule()),
|
| 70 |
+
("POST", "images/swap", Rule()),
|
| 71 |
+
("POST", "images/duplicate", Rule()),
|
| 72 |
+
("POST", "images/assign-existing", Rule()),
|
| 73 |
+
# Stock footage
|
| 74 |
+
("GET", "stock-footage/search", Rule()),
|
| 75 |
+
("POST", rf"scenes/{S}/stock-footage", Rule(ai_edits=1, timeout=60)),
|
| 76 |
+
("GET", "stock-footage/pending", Rule()),
|
| 77 |
+
("POST", "stock-footage/link", Rule()),
|
| 78 |
+
("POST", "stock-footage/approve", Rule(status="generating")),
|
| 79 |
+
("POST", "stock-footage/reject", Rule(status="generating")),
|
| 80 |
+
# Voice, voiceovers, language, template
|
| 81 |
+
("POST", rf"scenes/{S}/voiceover", Rule(timeout=60)),
|
| 82 |
+
# blog2video keeps the project's status during a voiceover delete; marking ours lets a reload resume its progress
|
| 83 |
+
# (voice-change-status reports it, kind "delete"), and the next status check sets it back.
|
| 84 |
+
("POST", "delete-voiceover", Rule(status="voice_regenerating")),
|
| 85 |
+
("POST", "change-voice", Rule(status="voice_regenerating")),
|
| 86 |
+
("GET", "voice-change-status", Rule()),
|
| 87 |
+
("POST", "change-language", Rule(status="language_regenerating")),
|
| 88 |
+
("GET", "language-change-status", Rule()),
|
| 89 |
+
("POST", "change-template-regenerate-layouts", Rule(uses_video=True, status="regenerating")),
|
| 90 |
+
("GET", "template-change-status", Rule()),
|
| 91 |
+
# Refresh the script
|
| 92 |
+
("POST", "regenerate-script", Rule(status="script_regenerating")),
|
| 93 |
+
("GET", "regenerate-script-status", Rule()),
|
| 94 |
+
("GET", "regenerate-script/preview", Rule()),
|
| 95 |
+
("POST", "regenerate-script/verify", Rule()),
|
| 96 |
+
("POST", "regenerate-script/regenerate", Rule()),
|
| 97 |
+
# AI chat editing ★
|
| 98 |
+
("POST", "chat", Rule(ai_edits=1, premium="AI chat editing", timeout=120)),
|
| 99 |
+
("GET", "chat/history", Rule(premium="AI chat editing")),
|
| 100 |
+
# Avatars ★ (blog2video charges the whole batch at authorize: 10 AI edits per scene)
|
| 101 |
+
("POST", "avatar-batch/authorize", Rule(ai_edits=10, per_scene=True, premium="Avatars")),
|
| 102 |
+
("POST", rf"scenes/{S}/avatar", Rule(premium="Avatars")),
|
| 103 |
+
("GET", rf"scenes/{S}/avatar-status", Rule()),
|
| 104 |
+
("DELETE", rf"scenes/{S}/avatar", Rule()),
|
| 105 |
+
("POST", rf"scenes/{S}/avatar-matte", Rule(premium="Avatars")),
|
| 106 |
+
("POST", "avatar-matte-all", Rule(premium="Avatars")),
|
| 107 |
+
("POST", "avatar-retry-failed", Rule(premium="Avatars")),
|
| 108 |
+
("GET", "avatar-progress", Rule()),
|
| 109 |
+
("POST", "avatar-portrait", Rule(premium="Avatars", timeout=60)),
|
| 110 |
+
("DELETE", "avatar-portrait", Rule()),
|
| 111 |
+
("PATCH", rf"scenes/{S}/avatar-focus", Rule()),
|
| 112 |
+
("PATCH", rf"scenes/{S}/avatar-appearance", Rule()),
|
| 113 |
+
# Render, download, frames
|
| 114 |
+
("POST", "render", Rule(status="rendering")),
|
| 115 |
+
("GET", "render-status", Rule()),
|
| 116 |
+
("POST", "cancel-render", Rule()),
|
| 117 |
+
("GET", "download-url", Rule()),
|
| 118 |
+
("GET", "download", Rule(raw=True, timeout=120)),
|
| 119 |
+
("GET", "download-studio", Rule(raw=True, premium="The Remotion source download", timeout=120)),
|
| 120 |
+
("GET", "render-still", Rule(raw=True, timeout=60)),
|
| 121 |
+
("POST", "render-stills", Rule(timeout=120)),
|
| 122 |
+
]]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def rule_for(method: str, path: str) -> Rule | None:
|
| 126 |
+
for m, pattern, rule in RULES:
|
| 127 |
+
if m == method and pattern.match(path):
|
| 128 |
+
return rule
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def _read_body(request: Request) -> tuple[dict | None, dict | None, list | None]:
|
| 133 |
+
"""(json, form fields, files) from the browser's request."""
|
| 134 |
+
ctype = request.headers.get("content-type", "")
|
| 135 |
+
if ctype.startswith("multipart/form-data"):
|
| 136 |
+
form = await request.form()
|
| 137 |
+
data, files = {}, []
|
| 138 |
+
for key, value in form.multi_items():
|
| 139 |
+
if isinstance(value, UploadFile):
|
| 140 |
+
content = await value.read(MAX_FILE_BYTES + 1)
|
| 141 |
+
if len(content) > MAX_FILE_BYTES:
|
| 142 |
+
raise HTTPException(413, "That file is too large.")
|
| 143 |
+
files.append((key, (value.filename or "file", content, value.content_type or
|
| 144 |
+
"application/octet-stream")))
|
| 145 |
+
else:
|
| 146 |
+
data[key] = value
|
| 147 |
+
return None, data, files
|
| 148 |
+
raw = await request.body()
|
| 149 |
+
if not raw:
|
| 150 |
+
return None, None, None
|
| 151 |
+
if len(raw) > MAX_JSON_BYTES:
|
| 152 |
+
raise HTTPException(413, "Request too large")
|
| 153 |
+
try:
|
| 154 |
+
return json.loads(raw), None, None
|
| 155 |
+
except ValueError:
|
| 156 |
+
raise HTTPException(400, "Send JSON") from None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
|
| 160 |
+
"""Premium, references and counters, all before blog2video is called. Returns AI edits taken."""
|
| 161 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 162 |
+
if rule.premium:
|
| 163 |
+
video_limits.require_premium(ctx.db, ctx.workspace, rule.premium, plan)
|
| 164 |
+
refs = body if isinstance(body, dict) else (form or {})
|
| 165 |
+
with upstream(): # checking a template or voice may need blog2video's catalog
|
| 166 |
+
b2v_access.check_refs(ctx, refs, plan)
|
| 167 |
+
edits = rule.ai_edits
|
| 168 |
+
if rule.per_scene:
|
| 169 |
+
scenes = (body or {}).get("scene_ids") if isinstance(body, dict) else None
|
| 170 |
+
if not isinstance(scenes, list) or not scenes:
|
| 171 |
+
raise HTTPException(400, "Pick at least one scene")
|
| 172 |
+
edits *= len(scenes)
|
| 173 |
+
if edits:
|
| 174 |
+
video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
|
| 175 |
+
if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
|
| 176 |
+
if edits:
|
| 177 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
|
| 178 |
+
raise plan_limit_error(plan, "videos", "Switching the template uses a video, and you have none left.")
|
| 179 |
+
return edits
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@router.api_route("/{artifact_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
| 183 |
+
async def project_proxy(artifact_id: uuid.UUID, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
|
| 184 |
+
_: None = Depends(b2v_ready)):
|
| 185 |
+
path = path.strip("/")
|
| 186 |
+
rule = rule_for(request.method, path)
|
| 187 |
+
if rule is None:
|
| 188 |
+
raise HTTPException(404, "Not found")
|
| 189 |
+
a, vid, _row = b2v_access.video_or_404(ctx, artifact_id)
|
| 190 |
+
body, form, files = await _read_body(request)
|
| 191 |
+
edits = await run_in_threadpool(_charge, ctx, rule, body, form)
|
| 192 |
+
params = dict(request.query_params)
|
| 193 |
+
|
| 194 |
+
def call():
|
| 195 |
+
return b2v.project_call(request.method, vid, path, json=body, data=form, files=files or None,
|
| 196 |
+
params=params or None, timeout=rule.timeout, raw=rule.raw)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
with upstream():
|
| 200 |
+
result = await run_in_threadpool(call)
|
| 201 |
+
except HTTPException:
|
| 202 |
+
if edits:
|
| 203 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
|
| 204 |
+
if rule.uses_video:
|
| 205 |
+
video_quota.unreserve(ctx.db, ctx.workspace.id)
|
| 206 |
+
raise
|
| 207 |
+
if rule.status:
|
| 208 |
+
store(ctx, a, status=rule.status)
|
| 209 |
+
if rule.raw:
|
| 210 |
+
content, ctype = result
|
| 211 |
+
return Response(content, media_type=ctype, headers={"Cache-Control": "no-store"})
|
| 212 |
+
return result
|
backend/app/routers/video_styles.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom video styles: writing guidance for the script, used on videos as custom:<id>.
|
| 2 |
+
|
| 3 |
+
Styles are made on our one blog2video account, so b2v_styles records which workspace made each; only that
|
| 4 |
+
workspace sees, uses, edits or deletes it. Built-in styles are read-only here, and "Your Style" (which learns from
|
| 5 |
+
every workspace's edits) is never offered. The account-wide style selection, pin and built-in overrides are never
|
| 6 |
+
touched (blog2video.DENIED).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from app.auth import Ctx, get_ctx
|
| 15 |
+
from app.models import B2VStyle
|
| 16 |
+
from app.routers.videos import b2v_ready, upstream
|
| 17 |
+
from app.services import b2v_access
|
| 18 |
+
from app.services import blog2video as b2v
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/video-styles", tags=["videos"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class DraftIn(BaseModel):
|
| 24 |
+
prompt: str = Field(min_length=20, max_length=1000)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class StyleIn(BaseModel):
|
| 28 |
+
name: str = Field(min_length=1, max_length=80)
|
| 29 |
+
guidance: str = Field(min_length=1, max_length=2000)
|
| 30 |
+
creation_method: Literal["manual", "ai"] = "manual"
|
| 31 |
+
source_prompt: str | None = Field(None, max_length=1000)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class StylePatchIn(BaseModel):
|
| 35 |
+
name: str = Field(min_length=1, max_length=80)
|
| 36 |
+
guidance: str = Field(min_length=1, max_length=2000)
|
| 37 |
+
version: int = Field(ge=0)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("")
|
| 41 |
+
def list_styles(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 42 |
+
with upstream():
|
| 43 |
+
return b2v_access.visible_styles(ctx)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/ai-draft")
|
| 47 |
+
def ai_draft(body: DraftIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 48 |
+
"""A name and guidance written from a description; nothing is saved."""
|
| 49 |
+
with upstream():
|
| 50 |
+
return b2v.request("POST", "/api/video-styles/ai-draft", json=body.model_dump(), timeout=90)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _out(s: dict) -> dict:
|
| 54 |
+
return {"id": s.get("id"), "custom_id": s.get("custom_id"), "name": s.get("name"), "guidance": s.get("guidance"),
|
| 55 |
+
"description": s.get("description"), "version": s.get("version"), "kind": "custom"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("", status_code=201)
|
| 59 |
+
def create_style(body: StyleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 60 |
+
with upstream():
|
| 61 |
+
made = b2v.request("POST", "/api/video-styles/custom", json=body.model_dump())
|
| 62 |
+
ctx.db.add(B2VStyle(b2v_style_id=int(made["custom_id"]), workspace_id=ctx.workspace.id, name=body.name))
|
| 63 |
+
ctx.db.commit()
|
| 64 |
+
return _out(made)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.patch("/{style_id}")
|
| 68 |
+
def update_style(style_id: int, body: StylePatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 69 |
+
row = b2v_access.owned_style(ctx, style_id)
|
| 70 |
+
with upstream():
|
| 71 |
+
made = b2v.request("PATCH", f"/api/video-styles/custom/{style_id}", json=body.model_dump())
|
| 72 |
+
row.name = body.name
|
| 73 |
+
ctx.db.commit()
|
| 74 |
+
return _out(made)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.delete("/{style_id}")
|
| 78 |
+
def delete_style(style_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 79 |
+
row = b2v_access.owned_style(ctx, style_id)
|
| 80 |
+
try:
|
| 81 |
+
with upstream():
|
| 82 |
+
b2v.request("DELETE", f"/api/video-styles/custom/{style_id}")
|
| 83 |
+
except HTTPException as e:
|
| 84 |
+
if e.status_code != 404:
|
| 85 |
+
raise
|
| 86 |
+
ctx.db.delete(row)
|
| 87 |
+
ctx.db.commit()
|
| 88 |
+
return {"ok": True}
|
backend/app/routers/video_templates.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom video templates: made from a website, a document or a description, then generated and edited.
|
| 2 |
+
|
| 3 |
+
Templates are made on our one blog2video account, which has a small lifetime number of template slots (a delete
|
| 4 |
+
does not give one back). b2v_templates records which workspace made each template; only that workspace sees, uses,
|
| 5 |
+
edits or deletes it, and each workspace has its own share of slots and daily AI steps (video_limits).
|
| 6 |
+
|
| 7 |
+
Create: extract a theme (/extract/url | /extract/doc | /extract/prompt) -> POST "" (uses a slot) -> /{id}/generate
|
| 8 |
+
-> poll /{id}/generation-status until complete (the template becomes usable in the wizard) or error.
|
| 9 |
+
Edit: /{id}/p/<path> -> /api/custom-templates/{id}/<path>, allowlisted in RULES.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
|
| 17 |
+
from pydantic import BaseModel, Field
|
| 18 |
+
from sqlalchemy import select
|
| 19 |
+
from starlette.concurrency import run_in_threadpool
|
| 20 |
+
from starlette.datastructures import UploadFile as StarletteUpload
|
| 21 |
+
|
| 22 |
+
from app.auth import Ctx, get_ctx
|
| 23 |
+
from app.models import B2VTemplate, B2VVideo
|
| 24 |
+
from app.routers.videos import b2v_ready, upstream
|
| 25 |
+
from app.services import b2v_access, video_limits
|
| 26 |
+
from app.services import blog2video as b2v
|
| 27 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/api/video-templates", tags=["videos"])
|
| 30 |
+
|
| 31 |
+
DOC_MAX_BYTES = 10 * 1024 * 1024
|
| 32 |
+
LOGO_MAX_BYTES = 2 * 1024 * 1024
|
| 33 |
+
AI_TIMEOUT = 120
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _out(t: B2VTemplate) -> dict:
|
| 37 |
+
return {"id": t.b2v_template_id, "ref": f"custom_{t.b2v_template_id}", "name": t.name, "ready": t.ready,
|
| 38 |
+
"created_at": t.created_at.isoformat() if t.created_at else None}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _can_make(ctx: Ctx) -> None:
|
| 42 |
+
"""Refuse early (before any AI work) when the workspace has no template slots left."""
|
| 43 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 44 |
+
limit = int(plan.video_limits.get("templates", 0))
|
| 45 |
+
if limit >= 0 and video_limits.used(ctx.db, ctx.workspace.id, "templates") >= limit:
|
| 46 |
+
raise plan_limit_error(plan, "video_limits.templates",
|
| 47 |
+
"Your plan does not include custom templates." if limit == 0
|
| 48 |
+
else f"You have made all {limit} custom templates your plan includes.")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _counted(ctx: Ctx, metric: str, call):
|
| 52 |
+
video_limits.take(ctx.db, ctx.workspace, metric)
|
| 53 |
+
try:
|
| 54 |
+
with upstream():
|
| 55 |
+
return call()
|
| 56 |
+
except HTTPException:
|
| 57 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, metric)
|
| 58 |
+
raise
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("")
|
| 62 |
+
def list_templates(ctx: Ctx = Depends(get_ctx)):
|
| 63 |
+
limits = video_limits.report(ctx.db, ctx.workspace)["limits"]
|
| 64 |
+
return {"templates": [_out(t) for t in b2v_access.my_templates(ctx)],
|
| 65 |
+
"limits": {k: limits[k] for k in ("templates", "template_ai_daily")}}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# 1. Extract a theme
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ExtractUrlIn(BaseModel):
|
| 72 |
+
url: str = Field(min_length=1, max_length=2048)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ExtractPromptIn(BaseModel):
|
| 76 |
+
prompt: str = Field(min_length=15, max_length=5000)
|
| 77 |
+
name: str | None = Field(None, max_length=255)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/extract/url")
|
| 81 |
+
def extract_from_url(body: ExtractUrlIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 82 |
+
if not re.match(r"https?://", body.url):
|
| 83 |
+
raise HTTPException(400, "Enter a link that starts with http:// or https://")
|
| 84 |
+
_can_make(ctx)
|
| 85 |
+
with upstream():
|
| 86 |
+
return b2v.request("POST", "/api/custom-templates/extract-theme", json=body.model_dump(), timeout=AI_TIMEOUT)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.post("/extract/prompt")
|
| 90 |
+
def extract_from_prompt(body: ExtractPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 91 |
+
_can_make(ctx)
|
| 92 |
+
return _counted(ctx, "template_ai_daily", lambda: b2v.request(
|
| 93 |
+
"POST", "/api/custom-templates/extract-theme-from-prompt", json=body.model_dump(), timeout=AI_TIMEOUT))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.post("/extract/doc")
|
| 97 |
+
async def extract_from_doc(file: UploadFile = File(...), name: str = Form(""), ctx: Ctx = Depends(get_ctx),
|
| 98 |
+
_: None = Depends(b2v_ready)):
|
| 99 |
+
data = await file.read(DOC_MAX_BYTES + 1)
|
| 100 |
+
if len(data) > DOC_MAX_BYTES:
|
| 101 |
+
raise HTTPException(400, "Keep the document under 10 MB.")
|
| 102 |
+
_can_make(ctx)
|
| 103 |
+
files = {"file": (file.filename or "brand.pdf", data, file.content_type or "application/octet-stream")}
|
| 104 |
+
return await run_in_threadpool(_counted, ctx, "template_ai_daily", lambda: b2v.request(
|
| 105 |
+
"POST", "/api/custom-templates/extract-theme-from-doc", data={"name": name[:255]}, files=files,
|
| 106 |
+
timeout=AI_TIMEOUT))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# 2. Create (uses a slot) and 3. generate
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class CreateIn(BaseModel):
|
| 113 |
+
name: str = Field(min_length=1, max_length=255)
|
| 114 |
+
theme: dict
|
| 115 |
+
source_url: str | None = Field(None, max_length=2048)
|
| 116 |
+
logo_urls: list[str] | None = Field(None, max_length=20)
|
| 117 |
+
og_image: str | None = Field(None, max_length=2048)
|
| 118 |
+
screenshot_url: str | None = Field(None, max_length=2048)
|
| 119 |
+
reason: str | None = Field(None, max_length=2000)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.post("", status_code=201)
|
| 123 |
+
def create_template(body: CreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 124 |
+
made = _counted(ctx, "templates", lambda: b2v.request("POST", "/api/custom-templates",
|
| 125 |
+
json=body.model_dump(exclude_none=True), timeout=60))
|
| 126 |
+
row = B2VTemplate(b2v_template_id=int(made["id"]), workspace_id=ctx.workspace.id, name=body.name[:255])
|
| 127 |
+
ctx.db.add(row)
|
| 128 |
+
ctx.db.commit()
|
| 129 |
+
return _out(row)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.post("/{template_id}/generate")
|
| 133 |
+
def generate(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 134 |
+
b2v_access.owned_template(ctx, template_id)
|
| 135 |
+
return _counted(ctx, "template_ai_daily", lambda: b2v.request(
|
| 136 |
+
"POST", f"/api/custom-templates/{template_id}/generate-code", timeout=60))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/{template_id}/generation-status")
|
| 140 |
+
def generation_status(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 141 |
+
row = b2v_access.owned_template(ctx, template_id)
|
| 142 |
+
with upstream():
|
| 143 |
+
s = b2v.request("GET", f"/api/custom-templates/{template_id}/generation-status") or {}
|
| 144 |
+
ready = s.get("status") == "complete"
|
| 145 |
+
if ready != row.ready:
|
| 146 |
+
row.ready = ready
|
| 147 |
+
ctx.db.commit()
|
| 148 |
+
return {**s, "ready": ready}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.delete("/{template_id}")
|
| 152 |
+
def delete_template(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 153 |
+
"""blog2video refuses (template_in_use) while any video on the account uses the template. We force it only
|
| 154 |
+
when every such video is this workspace's: another workspace's video must never lose its template."""
|
| 155 |
+
row = b2v_access.owned_template(ctx, template_id)
|
| 156 |
+
ref = f"custom_{template_id}"
|
| 157 |
+
try:
|
| 158 |
+
with upstream():
|
| 159 |
+
b2v.request("DELETE", f"/api/custom-templates/{template_id}")
|
| 160 |
+
except HTTPException as e:
|
| 161 |
+
if e.status_code == 404:
|
| 162 |
+
pass
|
| 163 |
+
elif e.status_code == 409:
|
| 164 |
+
others = ctx.db.scalar(select(B2VVideo.id).where(B2VVideo.template_ref == ref,
|
| 165 |
+
B2VVideo.workspace_id != ctx.workspace.id).limit(1))
|
| 166 |
+
if others is not None:
|
| 167 |
+
raise HTTPException(409, "This template is in use and can not be deleted.") from None
|
| 168 |
+
with upstream():
|
| 169 |
+
b2v.request("DELETE", f"/api/custom-templates/{template_id}", params={"force": "true"})
|
| 170 |
+
else:
|
| 171 |
+
raise
|
| 172 |
+
ctx.db.delete(row)
|
| 173 |
+
ctx.db.commit()
|
| 174 |
+
return {"ok": True} # the slot is not given back: blog2video's slots are lifetime
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# Editing: allowlisted proxy
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@dataclass(frozen=True)
|
| 181 |
+
class Rule:
|
| 182 |
+
ai_edit: bool = False # ★ one AI edit
|
| 183 |
+
metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
|
| 184 |
+
raw: bool = False
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
K = r"(intro|outro|content_\d+)" # scene keys
|
| 188 |
+
RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
|
| 189 |
+
("GET", "", Rule()),
|
| 190 |
+
("PUT", "", Rule()),
|
| 191 |
+
("GET", "code", Rule()),
|
| 192 |
+
("POST", "upload-logo", Rule()),
|
| 193 |
+
("POST", f"scenes/{K}/ai-edit", Rule(ai_edit=True)),
|
| 194 |
+
("GET", f"scenes/{K}/ai-edit/status", Rule()),
|
| 195 |
+
("GET", "scene-drafts", Rule()),
|
| 196 |
+
("GET", f"scenes/{K}/draft", Rule()),
|
| 197 |
+
("POST", f"scenes/{K}/draft/apply", Rule()),
|
| 198 |
+
("POST", f"scenes/{K}/draft/discard", Rule()),
|
| 199 |
+
("PATCH", f"scenes/{K}/chart", Rule()),
|
| 200 |
+
("PATCH", f"scenes/{K}/font-defaults", Rule()),
|
| 201 |
+
("PATCH", "scenes/font-defaults", Rule()),
|
| 202 |
+
("GET", "versions", Rule()),
|
| 203 |
+
("POST", r"versions/\d+/rollback", Rule()),
|
| 204 |
+
("POST", "regenerate-code", Rule(metric="templates")), # uses a slot, like creating one
|
| 205 |
+
("POST", "resume-generation", Rule()),
|
| 206 |
+
("POST", "rating", Rule()),
|
| 207 |
+
]]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _rule(method: str, path: str) -> Rule | None:
|
| 211 |
+
return next((r for m, p, r in RULES if m == method and p.match(path)), None)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _take(ctx: Ctx, rule: Rule) -> list[str]:
|
| 215 |
+
taken: list[str] = []
|
| 216 |
+
try:
|
| 217 |
+
if rule.ai_edit:
|
| 218 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI template editing")
|
| 219 |
+
video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
|
| 220 |
+
taken.append("ai_edits")
|
| 221 |
+
if rule.metric:
|
| 222 |
+
video_limits.take(ctx.db, ctx.workspace, rule.metric)
|
| 223 |
+
taken.append(rule.metric)
|
| 224 |
+
except HTTPException:
|
| 225 |
+
for metric in taken:
|
| 226 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, metric)
|
| 227 |
+
raise
|
| 228 |
+
return taken
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.api_route("/{template_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH"])
|
| 232 |
+
async def template_proxy(template_id: int, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
|
| 233 |
+
_: None = Depends(b2v_ready)):
|
| 234 |
+
path = path.strip("/")
|
| 235 |
+
rule = _rule(request.method, path)
|
| 236 |
+
if rule is None:
|
| 237 |
+
raise HTTPException(404, "Not found")
|
| 238 |
+
row = b2v_access.owned_template(ctx, template_id)
|
| 239 |
+
body = form = files = None
|
| 240 |
+
if request.headers.get("content-type", "").startswith("multipart/form-data"):
|
| 241 |
+
parsed = await request.form()
|
| 242 |
+
form, files = {}, []
|
| 243 |
+
for key, value in parsed.multi_items():
|
| 244 |
+
if isinstance(value, StarletteUpload):
|
| 245 |
+
content = await value.read(LOGO_MAX_BYTES + 1)
|
| 246 |
+
if len(content) > LOGO_MAX_BYTES:
|
| 247 |
+
raise HTTPException(413, "Keep the file under 2 MB.")
|
| 248 |
+
files.append((key, (value.filename or "logo.png", content, value.content_type or "image/png")))
|
| 249 |
+
else:
|
| 250 |
+
form[key] = value
|
| 251 |
+
elif raw := await request.body():
|
| 252 |
+
try:
|
| 253 |
+
body = json.loads(raw)
|
| 254 |
+
except ValueError:
|
| 255 |
+
raise HTTPException(400, "Send JSON") from None
|
| 256 |
+
taken = await run_in_threadpool(_take, ctx, rule)
|
| 257 |
+
|
| 258 |
+
def call():
|
| 259 |
+
return b2v.request(request.method, f"/api/custom-templates/{template_id}/{path}".rstrip("/"), json=body,
|
| 260 |
+
data=form, files=files or None, params=dict(request.query_params) or None,
|
| 261 |
+
timeout=AI_TIMEOUT, raw=rule.raw)
|
| 262 |
+
|
| 263 |
+
try:
|
| 264 |
+
with upstream():
|
| 265 |
+
result = await run_in_threadpool(call)
|
| 266 |
+
except HTTPException:
|
| 267 |
+
for metric in taken:
|
| 268 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, metric)
|
| 269 |
+
raise
|
| 270 |
+
if request.method == "PUT" and path == "" and isinstance(body, dict) and body.get("name"):
|
| 271 |
+
row.name = str(body["name"])[:255]
|
| 272 |
+
ctx.db.commit()
|
| 273 |
+
if rule.raw:
|
| 274 |
+
content, ctype = result
|
| 275 |
+
return Response(content, media_type=ctype)
|
| 276 |
+
return result
|
| 277 |
+
|
backend/app/routers/video_voices.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voices for videos: the workspace's saved voices, the built-in library, and custom voices (design, clone).
|
| 2 |
+
|
| 3 |
+
"My voices" (user_saved_voices) lives only here: blog2video's saved-voice list is one list for the whole account.
|
| 4 |
+
Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
|
| 5 |
+
only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
|
| 6 |
+
of the workspace's own daily and total counters (video_limits).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Literal
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
from sqlalchemy import select
|
| 15 |
+
|
| 16 |
+
from app.auth import Ctx, get_ctx
|
| 17 |
+
from app.models import B2VCustomVoice, UserSavedVoice
|
| 18 |
+
from app.routers.videos import b2v_ready, upstream
|
| 19 |
+
from app.services import b2v_access, video_limits
|
| 20 |
+
from app.services import blog2video as b2v
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/video-voices", tags=["videos"])
|
| 25 |
+
|
| 26 |
+
CLONE_MAX_BYTES = 50 * 1024 * 1024
|
| 27 |
+
STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _labels(v: dict) -> dict:
|
| 31 |
+
return v.get("labels") or {}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _voice_out(v: UserSavedVoice) -> dict:
|
| 35 |
+
return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
|
| 36 |
+
"accent": v.accent, "premium": v.premium, "is_custom": v.is_custom}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _library_out(v: dict, saved: set[str]) -> dict:
|
| 40 |
+
labels = _labels(v)
|
| 41 |
+
return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
|
| 42 |
+
"description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
|
| 43 |
+
"gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
|
| 44 |
+
"premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
|
| 48 |
+
labels = _labels(v)
|
| 49 |
+
row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
|
| 50 |
+
name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
|
| 51 |
+
preview_url=v.get("preview_url"),
|
| 52 |
+
gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid")
|
| 53 |
+
ctx.db.merge(row)
|
| 54 |
+
return row
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _saved(ctx: Ctx) -> list[UserSavedVoice]:
|
| 58 |
+
return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
|
| 59 |
+
.order_by(UserSavedVoice.created_at)))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.get("")
|
| 63 |
+
def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 64 |
+
"""My voices (for the wizard), the built-in library (to save from) and my custom voices."""
|
| 65 |
+
with upstream():
|
| 66 |
+
library = b2v.prebuilt_voices()
|
| 67 |
+
saved = _saved(ctx)
|
| 68 |
+
if not saved:
|
| 69 |
+
for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]:
|
| 70 |
+
_save_builtin(ctx, v)
|
| 71 |
+
ctx.db.commit()
|
| 72 |
+
saved = _saved(ctx)
|
| 73 |
+
ids = {v.voice_id for v in saved}
|
| 74 |
+
custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
|
| 75 |
+
.order_by(B2VCustomVoice.created_at.desc()))
|
| 76 |
+
return {"saved": [_voice_out(v) for v in saved],
|
| 77 |
+
"library": [_library_out(v, ids) for v in library if v.get("voice_id")],
|
| 78 |
+
"custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
|
| 79 |
+
"preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class SaveIn(BaseModel):
|
| 83 |
+
voice_id: str = Field(min_length=1, max_length=100)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.post("/saved", status_code=201)
|
| 87 |
+
def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 88 |
+
custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
|
| 89 |
+
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
| 90 |
+
if custom:
|
| 91 |
+
row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
|
| 92 |
+
preview_url=custom.preview_url, premium=True, is_custom=True))
|
| 93 |
+
else:
|
| 94 |
+
with upstream():
|
| 95 |
+
v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
|
| 96 |
+
if not v:
|
| 97 |
+
raise HTTPException(404, "Voice not found")
|
| 98 |
+
if v.get("plan") == "paid":
|
| 99 |
+
video_limits.require_premium(ctx.db, ctx.workspace, "This voice")
|
| 100 |
+
row = _save_builtin(ctx, v)
|
| 101 |
+
ctx.db.commit()
|
| 102 |
+
return _voice_out(row)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.delete("/saved/{voice_id}")
|
| 106 |
+
def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
|
| 107 |
+
row = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": voice_id})
|
| 108 |
+
if not row:
|
| 109 |
+
raise HTTPException(404, "Voice not found")
|
| 110 |
+
ctx.db.delete(row)
|
| 111 |
+
ctx.db.commit()
|
| 112 |
+
return {"ok": True}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class DesignPromptIn(BaseModel):
|
| 119 |
+
prompt: str = Field(min_length=20, max_length=1000)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class DesignPresetIn(BaseModel):
|
| 123 |
+
gender: str = Field("", max_length=30)
|
| 124 |
+
age: str = Field("", max_length=30)
|
| 125 |
+
persona: str = Field("", max_length=100)
|
| 126 |
+
speed: str = Field("", max_length=30)
|
| 127 |
+
accent: str = Field("", max_length=60)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _design(ctx: Ctx, path: str, body: dict) -> dict:
|
| 131 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice")
|
| 132 |
+
video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan)
|
| 133 |
+
try:
|
| 134 |
+
with upstream():
|
| 135 |
+
data = b2v.request("POST", path, json=body, timeout=90) or {}
|
| 136 |
+
except HTTPException:
|
| 137 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily")
|
| 138 |
+
raise
|
| 139 |
+
return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")}
|
| 140 |
+
for p in data.get("previews") or []]}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.post("/design/prompt")
|
| 144 |
+
def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 145 |
+
return _design(ctx, "/api/voices/design-from-prompt", body.model_dump())
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post("/design/preset")
|
| 149 |
+
def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 150 |
+
return _design(ctx, "/api/voices/design-from-preset", body.model_dump())
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class KeepIn(BaseModel):
|
| 154 |
+
generated_voice_id: str = Field(min_length=1, max_length=200)
|
| 155 |
+
source: Literal["prompt", "preset"]
|
| 156 |
+
name: str = Field(min_length=1, max_length=100)
|
| 157 |
+
prompt_text: str | None = Field(None, max_length=1000)
|
| 158 |
+
preview_url: str | None = Field(None, max_length=1000)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
|
| 162 |
+
row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id,
|
| 163 |
+
voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
|
| 164 |
+
preview_url=made.get("preview_url"))
|
| 165 |
+
ctx.db.add(row)
|
| 166 |
+
ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
|
| 167 |
+
preview_url=row.preview_url, premium=True, is_custom=True))
|
| 168 |
+
ctx.db.commit()
|
| 169 |
+
return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
|
| 170 |
+
"preview_url": row.preview_url, "saved": True}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _make_custom(ctx: Ctx, source: str, send) -> dict:
|
| 174 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
|
| 175 |
+
video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan)
|
| 176 |
+
try:
|
| 177 |
+
with upstream():
|
| 178 |
+
made = send()
|
| 179 |
+
except HTTPException:
|
| 180 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
|
| 181 |
+
raise
|
| 182 |
+
return _record_custom(ctx, made, source)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@router.post("/custom", status_code=201)
|
| 186 |
+
def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 187 |
+
"""Keep one designed preview as a permanent custom voice."""
|
| 188 |
+
payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form",
|
| 189 |
+
"name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url}
|
| 190 |
+
return _make_custom(ctx, body.source,
|
| 191 |
+
lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@router.post("/clone", status_code=201)
|
| 195 |
+
async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True),
|
| 196 |
+
file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 197 |
+
"""Clone a voice from one audio or video file (up to 50 MB)."""
|
| 198 |
+
data = await file.read(CLONE_MAX_BYTES + 1)
|
| 199 |
+
if not data:
|
| 200 |
+
raise HTTPException(400, "The file is empty.")
|
| 201 |
+
if len(data) > CLONE_MAX_BYTES:
|
| 202 |
+
raise HTTPException(400, "The file must be under 50 MB.")
|
| 203 |
+
form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"}
|
| 204 |
+
files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")}
|
| 205 |
+
return _make_custom(ctx, "clone",
|
| 206 |
+
lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.get("/custom/{custom_id}/preview")
|
| 210 |
+
def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 211 |
+
row = b2v_access.owned_custom_voice(ctx, custom_id)
|
| 212 |
+
if row.preview_url:
|
| 213 |
+
return {"preview_url": row.preview_url, "ready": True}
|
| 214 |
+
with upstream():
|
| 215 |
+
result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {}
|
| 216 |
+
if result.get("preview_url"):
|
| 217 |
+
row.preview_url = result["preview_url"]
|
| 218 |
+
saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
|
| 219 |
+
if saved:
|
| 220 |
+
saved.preview_url = row.preview_url
|
| 221 |
+
ctx.db.commit()
|
| 222 |
+
return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.delete("/custom/{custom_id}")
|
| 226 |
+
def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 227 |
+
row = b2v_access.owned_custom_voice(ctx, custom_id)
|
| 228 |
+
try:
|
| 229 |
+
with upstream():
|
| 230 |
+
b2v.request("DELETE", f"/api/voices/custom/{custom_id}")
|
| 231 |
+
except HTTPException as e:
|
| 232 |
+
if e.status_code != 404:
|
| 233 |
+
raise
|
| 234 |
+
saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
|
| 235 |
+
if saved:
|
| 236 |
+
ctx.db.delete(saved)
|
| 237 |
+
ctx.db.delete(row)
|
| 238 |
+
ctx.db.commit()
|
| 239 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
|
| 240 |
+
return {"ok": True}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# Voice sample ★: a few seconds of the chosen voice with the chosen tuning
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class SampleIn(BaseModel):
|
| 247 |
+
voice_gender: Literal["female", "male"] = "female"
|
| 248 |
+
voice_accent: str = Field("american", max_length=30)
|
| 249 |
+
custom_voice_id: str | None = Field(None, max_length=100)
|
| 250 |
+
voice_emotion: str | None = Field(None, max_length=200)
|
| 251 |
+
video_style: str | None = Field(None, max_length=40)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.post("/sample")
|
| 255 |
+
def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 256 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples")
|
| 257 |
+
payload = body.model_dump(exclude_none=True)
|
| 258 |
+
with upstream(): # checking the voice may need blog2video's catalog
|
| 259 |
+
b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan)
|
| 260 |
+
video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan)
|
| 261 |
+
try:
|
| 262 |
+
with upstream():
|
| 263 |
+
audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True)
|
| 264 |
+
except HTTPException:
|
| 265 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily")
|
| 266 |
+
raise
|
| 267 |
+
return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"})
|
backend/app/routers/videos.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Videos, made by blog2video: the 3-step wizard (Project, Template, Voice), script review, status and delete.
|
| 2 |
+
Editing after creation is in video_edit.py; voices, templates and styles have their own routers.
|
| 3 |
+
|
| 4 |
+
The browser only talks to these routes; the backend calls blog2video with our account's API key, which reaches
|
| 5 |
+
every video we ever made. So every route that takes a video first checks it belongs to the current workspace
|
| 6 |
+
(b2v_access.owned_video: 404 otherwise), and every create has its template, style and voice checked (check_refs).
|
| 7 |
+
|
| 8 |
+
A video is an Artifact(type="video") with content_json.provider == "blog2video", so it shows up in the Library and
|
| 9 |
+
Studio like everything else, plus a b2v_videos row: the ownership and allowance ledger. Routes take our artifact id.
|
| 10 |
+
|
| 11 |
+
Videos made through the old partner integration have no b2v_videos row. blog2video answers 404 for them now, so
|
| 12 |
+
they are "legacy": we keep showing the preview/video links we stored and refuse edits.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import re
|
| 18 |
+
import uuid
|
| 19 |
+
from collections.abc import Callable
|
| 20 |
+
from contextlib import contextmanager
|
| 21 |
+
from typing import Literal
|
| 22 |
+
|
| 23 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 24 |
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
| 25 |
+
from sqlalchemy import select
|
| 26 |
+
|
| 27 |
+
from app.auth import Ctx, get_ctx
|
| 28 |
+
from app.config import settings
|
| 29 |
+
from app.models import Artifact, B2VVideo, Document, Notebook, Upload
|
| 30 |
+
from app.routers.sources import is_locked
|
| 31 |
+
from app.services import b2v_access, video_limits, video_quota
|
| 32 |
+
from app.services import blog2video as b2v
|
| 33 |
+
from app.services.artifacts import serialize_artifact
|
| 34 |
+
from app.services.plans import Plan, plan_limit_error
|
| 35 |
+
from app.services.storage import storage
|
| 36 |
+
from app.services.video_quota import sync_video_quota, video_usage
|
| 37 |
+
|
| 38 |
+
log = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
router = APIRouter(prefix="/api/videos", tags=["videos"])
|
| 41 |
+
|
| 42 |
+
MIN_CONTENT = 50
|
| 43 |
+
MAX_CONTENT = 100_000
|
| 44 |
+
MAX_LINKS = 10
|
| 45 |
+
MAX_POSTS = 200 # a notebook's posts, combined into one video (the ingest SCAN_CAP)
|
| 46 |
+
MAX_FILES = 5
|
| 47 |
+
FILE_MAX_BYTES = 5 * 1024 * 1024
|
| 48 |
+
FILE_TYPES = {".pdf", ".docx", ".pptx", ".md", ".txt", ".vtt"}
|
| 49 |
+
LOGO_MAX_BYTES = 2 * 1024 * 1024
|
| 50 |
+
LOGO_TYPES = {"image/png", "image/jpeg", "image/webp", "image/svg+xml"}
|
| 51 |
+
LANGUAGES = [("en", "English"), ("es", "Spanish"), ("fr", "French"), ("de", "German"), ("it", "Italian"),
|
| 52 |
+
("pt", "Portuguese"), ("nl", "Dutch"), ("pl", "Polish"), ("hi", "Hindi"), ("ar", "Arabic"),
|
| 53 |
+
("ja", "Japanese"), ("ko", "Korean"), ("zh", "Chinese"), ("tr", "Turkish"), ("ru", "Russian"),
|
| 54 |
+
("id", "Indonesian")]
|
| 55 |
+
|
| 56 |
+
# blog2video status -> our artifact status
|
| 57 |
+
STATUS_MAP = {"failed": "failed", "error": "failed", "awaiting_stock_footage_review": "review",
|
| 58 |
+
"awaiting_script_review": "review", "rendering": "rendering"}
|
| 59 |
+
# blog2video's statuses while an edit job runs on a finished video, kept on the artifact as they are so the list and
|
| 60 |
+
# the editor can tell them from first generation: regenerating is a template switch (its relayout).
|
| 61 |
+
JOB_STATUSES = {"regenerating", "voice_regenerating", "language_regenerating", "script_regenerating"}
|
| 62 |
+
# Statuses the video list re-checks with blog2video, in case the work ended while nobody was watching.
|
| 63 |
+
BUSY_STATUSES = JOB_STATUSES | {"generating", "rendering"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def b2v_ready(ctx: Ctx = Depends(get_ctx)) -> None:
|
| 67 |
+
if not b2v.configured():
|
| 68 |
+
raise HTTPException(503, {"code": "video_not_configured", "message": "Video is not set up yet."})
|
| 69 |
+
sync_video_quota(ctx.db, ctx.workspace)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@contextmanager
|
| 73 |
+
def upstream():
|
| 74 |
+
"""blog2video errors -> our HTTP errors. Problems with our own blog2video account (out of videos, key or plan)
|
| 75 |
+
are not the user's: they get "temporarily unavailable" and ops get an ALERT log line."""
|
| 76 |
+
try:
|
| 77 |
+
yield
|
| 78 |
+
except b2v.Denied:
|
| 79 |
+
raise HTTPException(404, "Not found") from None
|
| 80 |
+
except b2v.NotFound as e:
|
| 81 |
+
raise HTTPException(404, str(e)) from None # e.g. "Scene not found": the editor reloads
|
| 82 |
+
except (b2v.Busy, b2v.TemplateInUse) as e:
|
| 83 |
+
raise HTTPException(e.status, str(e)) from None
|
| 84 |
+
except b2v.BadRequest as e:
|
| 85 |
+
raise HTTPException(400, str(e)) from None
|
| 86 |
+
except b2v.B2VError as e:
|
| 87 |
+
raise HTTPException(e.status if e.status == 503 else 502, str(e)) from None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
ROW_FIELDS = {"video_url", "preview_url", "title"}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def store(ctx: Ctx, a: Artifact, row: B2VVideo | None = None, **fields) -> None:
|
| 94 |
+
"""Merge fields into content_json (and status), and the reference fields into the video's row, committing only
|
| 95 |
+
when something changed."""
|
| 96 |
+
if row is not None:
|
| 97 |
+
for key in ROW_FIELDS & fields.keys():
|
| 98 |
+
if fields[key] is not None and getattr(row, key) != fields[key]:
|
| 99 |
+
setattr(row, key, fields[key])
|
| 100 |
+
ctx.db.commit()
|
| 101 |
+
content = dict(a.content_json or {})
|
| 102 |
+
status = fields.pop("status", None)
|
| 103 |
+
changed = {k: v for k, v in fields.items() if v is not None and content.get(k) != v}
|
| 104 |
+
if changed or (status and status != a.status):
|
| 105 |
+
a.content_json = {**content, **changed}
|
| 106 |
+
if status:
|
| 107 |
+
a.status = status
|
| 108 |
+
ctx.db.commit()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def sync_status(ctx: Ctx, a: Artifact, row: B2VVideo, s: dict) -> None:
|
| 112 |
+
raw = str(s.get("status") or "")
|
| 113 |
+
video_quota.record_status(ctx.db, row, "failed" if s.get("error") and raw not in STATUS_MAP else raw)
|
| 114 |
+
if s.get("error") or raw in STATUS_MAP:
|
| 115 |
+
status = STATUS_MAP.get(raw, "failed")
|
| 116 |
+
elif raw in JOB_STATUSES:
|
| 117 |
+
status = raw
|
| 118 |
+
elif s.get("ready") or raw in {"generated", "done"}:
|
| 119 |
+
status = "ready"
|
| 120 |
+
else:
|
| 121 |
+
status = "generating"
|
| 122 |
+
store(ctx, a, row, status=status, b2v_status=raw or None, step=s.get("step"), video_url=s.get("video_url"),
|
| 123 |
+
error=s.get("error"))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# The wizard's options (every field of blog2video's 3 steps)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class VideoOptions(BaseModel):
|
| 130 |
+
# Step 1: Project (script review is always off: blog2video goes straight to the video)
|
| 131 |
+
stock_footage_enabled: bool = True
|
| 132 |
+
aspect_ratio: Literal["landscape", "portrait"] = "landscape"
|
| 133 |
+
video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short"
|
| 134 |
+
logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
|
| 135 |
+
logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
|
| 136 |
+
logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
|
| 137 |
+
# Step 2: Template
|
| 138 |
+
template: str = Field("default", max_length=100)
|
| 139 |
+
video_style: str = Field("auto", max_length=40)
|
| 140 |
+
accent_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
| 141 |
+
bg_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
| 142 |
+
text_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
| 143 |
+
# Step 3: Voice
|
| 144 |
+
content_language: str | None = Field(None, max_length=10) # None = detect from the content
|
| 145 |
+
voice_gender: Literal["female", "male", "none"] = "female"
|
| 146 |
+
voice_accent: str = Field("american", max_length=30)
|
| 147 |
+
custom_voice_id: str | None = Field(None, max_length=100)
|
| 148 |
+
voice_emotion: str | None = Field(None, max_length=200) # ["<expressiveness>","<speed>","<emotion>",...]
|
| 149 |
+
bgm_track_id: str | None = Field(None, max_length=100)
|
| 150 |
+
bgm_volume: float = Field(0.10, ge=0, le=1)
|
| 151 |
+
|
| 152 |
+
def payload(self) -> dict:
|
| 153 |
+
"""The fields blog2video takes, as it names them."""
|
| 154 |
+
# Only the wizard's own fields: a subclass (VideoCreateIn) adds the source, which is sent separately.
|
| 155 |
+
data = self.model_dump(include=set(VideoOptions.model_fields) - {"logo_upload_id"}, exclude_none=True,
|
| 156 |
+
mode="json")
|
| 157 |
+
data["script_review_enabled"] = False
|
| 158 |
+
if self.voice_gender == "none":
|
| 159 |
+
data.pop("custom_voice_id", None)
|
| 160 |
+
data.pop("voice_emotion", None)
|
| 161 |
+
if not self.bgm_track_id:
|
| 162 |
+
data.pop("bgm_volume", None)
|
| 163 |
+
return data
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class VideoCreateIn(VideoOptions):
|
| 167 |
+
# Source: exactly one of these. document_ids are a notebook's ticked posts, combined into one video.
|
| 168 |
+
document_id: uuid.UUID | None = None
|
| 169 |
+
document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
|
| 170 |
+
url: str | None = Field(None, max_length=2000)
|
| 171 |
+
content: str | None = Field(None, max_length=MAX_CONTENT)
|
| 172 |
+
title: str | None = Field(None, max_length=300)
|
| 173 |
+
notebook_id: uuid.UUID | None = None # names a combined video after its notebook
|
| 174 |
+
|
| 175 |
+
@model_validator(mode="after")
|
| 176 |
+
def one_source(self):
|
| 177 |
+
if sum(x is not None for x in (self.document_id, self.document_ids, self.url, self.content)) != 1:
|
| 178 |
+
raise ValueError("Pick one source: a notebook, a post, a link or pasted text")
|
| 179 |
+
if self.document_ids and len(self.document_ids) == 1:
|
| 180 |
+
self.document_id, self.document_ids = self.document_ids[0], None
|
| 181 |
+
return self
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class VideoBatchIn(VideoOptions):
|
| 185 |
+
urls: list[str] = Field(min_length=1, max_length=MAX_LINKS)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _not_indexed(count: int) -> HTTPException:
|
| 189 |
+
return HTTPException(400, f"{count} of these posts {'is' if count == 1 else 'are'} not indexed on your plan. "
|
| 190 |
+
"Untick them or upgrade to index your whole archive.")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _combined(ctx: Ctx, body: VideoCreateIn) -> tuple[str, str]:
|
| 194 |
+
"""Several posts as one text, oldest first, each given an equal share so none is cut off entirely."""
|
| 195 |
+
ids = set(body.document_ids or [])
|
| 196 |
+
docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
|
| 197 |
+
Document.workspace_id == ctx.workspace.id)))
|
| 198 |
+
if len(docs) != len(ids):
|
| 199 |
+
raise HTTPException(404, "Post not found")
|
| 200 |
+
locked = sum(is_locked(d) for d in docs)
|
| 201 |
+
if locked:
|
| 202 |
+
raise _not_indexed(locked)
|
| 203 |
+
docs.sort(key=lambda d: (d.published_at is None, d.published_at.timestamp() if d.published_at else 0))
|
| 204 |
+
share = MAX_CONTENT // len(docs)
|
| 205 |
+
text = "\n\n".join(f"# {d.title}\n\n{(d.clean_text or '').strip()}"[:share] for d in docs)
|
| 206 |
+
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == body.notebook_id,
|
| 207 |
+
Notebook.workspace_id == ctx.workspace.id)) if body.notebook_id else None
|
| 208 |
+
return (nb.title if nb else f"{docs[0].title} + {len(docs) - 1} more"), text
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
|
| 212 |
+
"""The blog2video source fields and a title for our artifact."""
|
| 213 |
+
if body.document_ids:
|
| 214 |
+
title, text = _combined(ctx, body)
|
| 215 |
+
elif body.document_id:
|
| 216 |
+
doc = ctx.db.scalar(select(Document).where(Document.id == body.document_id,
|
| 217 |
+
Document.workspace_id == ctx.workspace.id))
|
| 218 |
+
if not doc:
|
| 219 |
+
raise HTTPException(404, "Post not found")
|
| 220 |
+
if is_locked(doc):
|
| 221 |
+
raise _not_indexed(1)
|
| 222 |
+
if re.match(r"https?://", doc.url or ""):
|
| 223 |
+
return {"url": doc.url, "title": doc.title[:255]}, doc.title
|
| 224 |
+
# Uploaded files have no public link (upload://name): send their text instead.
|
| 225 |
+
title, text = doc.title, doc.clean_text or ""
|
| 226 |
+
elif body.url:
|
| 227 |
+
if not re.match(r"https?://", body.url):
|
| 228 |
+
raise HTTPException(400, "Enter a link that starts with http:// or https://")
|
| 229 |
+
return {"url": body.url}, body.title or body.url
|
| 230 |
+
else:
|
| 231 |
+
title, text = body.title or "", body.content or ""
|
| 232 |
+
if not title.strip():
|
| 233 |
+
raise HTTPException(400, "Give the pasted text a title")
|
| 234 |
+
text = text.strip()
|
| 235 |
+
if len(text) < MIN_CONTENT:
|
| 236 |
+
raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
|
| 237 |
+
return {"content": text[:MAX_CONTENT], "title": title[:255]}, title
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
|
| 241 |
+
"""Sent with every create, and returned by blog2video: ties the video back to us from its side too."""
|
| 242 |
+
return {"workspace_id": str(ctx.workspace.id), "user_id": str(ctx.user.id), "video_ref": str(row.id), **extra}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
|
| 246 |
+
payload = options.payload()
|
| 247 |
+
with upstream(): # checking a template or voice may need blog2video's catalog
|
| 248 |
+
plan = b2v_access.check_refs(ctx, payload)
|
| 249 |
+
accent = (ctx.workspace.brand_json or {}).get("accent")
|
| 250 |
+
if plan.brand_kit and accent and "accent_color" not in payload:
|
| 251 |
+
payload["accent_color"] = accent
|
| 252 |
+
return payload, plan
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _logo(ctx: Ctx, upload_id: uuid.UUID | None) -> tuple[str, str, bytes] | None:
|
| 256 |
+
if not upload_id:
|
| 257 |
+
return None
|
| 258 |
+
upload = ctx.db.scalar(select(Upload).where(Upload.id == upload_id, Upload.workspace_id == ctx.workspace.id))
|
| 259 |
+
if not upload or upload.status != "complete" or upload.content_type not in LOGO_TYPES:
|
| 260 |
+
raise HTTPException(400, "The logo must be a PNG, JPG, WebP or SVG.")
|
| 261 |
+
data = storage.get_bytes(upload.key)
|
| 262 |
+
if len(data) > LOGO_MAX_BYTES:
|
| 263 |
+
raise HTTPException(400, "The logo must be under 2 MB.")
|
| 264 |
+
return upload.filename, upload.content_type, data
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _start(ctx: Ctx, plan: Plan, title: str, aspect_ratio: str, template: str, created_via: str,
|
| 268 |
+
call: Callable[[B2VVideo], int], logo: tuple[str, str, bytes] | None = None,
|
| 269 |
+
document_id: uuid.UUID | None = None, source_url: str | None = None) -> Artifact:
|
| 270 |
+
"""Reserve one video, record it, ask blog2video for it (call returns its id), refund if that fails."""
|
| 271 |
+
if not video_quota.reserve(ctx.db, ctx.workspace.id):
|
| 272 |
+
usage = video_usage(ctx.db, ctx.workspace)
|
| 273 |
+
raise plan_limit_error(plan, "videos",
|
| 274 |
+
f"You have made {usage['used']} of {usage['limit']} videos this period.")
|
| 275 |
+
try:
|
| 276 |
+
artifact = Artifact(workspace_id=ctx.workspace.id, type="video", status="generating", document_id=document_id,
|
| 277 |
+
content_json={"provider": "blog2video", "title": title[:300],
|
| 278 |
+
"aspect_ratio": aspect_ratio})
|
| 279 |
+
ctx.db.add(artifact)
|
| 280 |
+
ctx.db.flush()
|
| 281 |
+
row = B2VVideo(workspace_id=ctx.workspace.id, user_id=ctx.user.id, artifact_id=artifact.id,
|
| 282 |
+
created_via=created_via, template_ref=template, title=title[:300], source_url=source_url,
|
| 283 |
+
aspect_ratio=aspect_ratio,
|
| 284 |
+
# Keys are unique across our whole blog2video account, so they start with the workspace id.
|
| 285 |
+
idempotency_key=f"{ctx.workspace.id}:{uuid.uuid4()}" if created_via == "v1" else None)
|
| 286 |
+
ctx.db.add(row)
|
| 287 |
+
ctx.db.commit()
|
| 288 |
+
except Exception:
|
| 289 |
+
ctx.db.rollback()
|
| 290 |
+
video_quota.unreserve(ctx.db, ctx.workspace.id)
|
| 291 |
+
raise
|
| 292 |
+
try:
|
| 293 |
+
with upstream():
|
| 294 |
+
vid = call(row)
|
| 295 |
+
except HTTPException:
|
| 296 |
+
# Refused, or no answer after every retry: the user gets the video back.
|
| 297 |
+
video_quota.refund(ctx.db, row, "failed")
|
| 298 |
+
ctx.db.delete(artifact)
|
| 299 |
+
ctx.db.commit()
|
| 300 |
+
raise
|
| 301 |
+
row.b2v_video_id, row.status = vid, "queued"
|
| 302 |
+
ctx.db.commit()
|
| 303 |
+
store(ctx, artifact, row, b2v_video_id=vid, b2v_status="queued")
|
| 304 |
+
if logo:
|
| 305 |
+
try:
|
| 306 |
+
b2v.upload_logo(vid, *logo)
|
| 307 |
+
except b2v.B2VError:
|
| 308 |
+
log.warning("logo upload failed for video artifact %s", artifact.id) # the video is made without it
|
| 309 |
+
return artifact
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@router.post("", status_code=201)
|
| 313 |
+
def create_video(body: VideoCreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 314 |
+
"""From a link, pasted text or one of the workspace's posts: POST /api/v1/videos, which starts generating."""
|
| 315 |
+
source, title = _source(ctx, body)
|
| 316 |
+
payload, plan = _check(ctx, body)
|
| 317 |
+
logo = _logo(ctx, body.logo_upload_id)
|
| 318 |
+
|
| 319 |
+
def call(row: B2VVideo) -> int:
|
| 320 |
+
# Retried with the same key on no answer: blog2video returns the same video, charged once.
|
| 321 |
+
created = b2v.create_video({**source, **payload, "external_user_id": str(ctx.workspace.id),
|
| 322 |
+
"metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id),
|
| 323 |
+
document_id=str(body.document_id) if body.document_id
|
| 324 |
+
else None,
|
| 325 |
+
document_ids=[str(d) for d in body.document_ids or []]
|
| 326 |
+
or None)},
|
| 327 |
+
row.idempotency_key)
|
| 328 |
+
return int(created["video_id"])
|
| 329 |
+
|
| 330 |
+
a = _start(ctx, plan, title, body.aspect_ratio, body.template, "v1", call, logo, body.document_id,
|
| 331 |
+
source_url=source.get("url"))
|
| 332 |
+
return serialize_artifact(a)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@router.post("/batch", status_code=201)
|
| 336 |
+
def create_videos(body: VideoBatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 337 |
+
"""Multi-link: one video per link, each charged and keyed on its own. Background music is single-video only."""
|
| 338 |
+
urls = [u.strip() for u in body.urls if re.match(r"https?://\S+", u.strip())]
|
| 339 |
+
if not urls:
|
| 340 |
+
raise HTTPException(400, "Enter at least one link that starts with http:// or https://")
|
| 341 |
+
options = body.model_copy(update={"bgm_track_id": None})
|
| 342 |
+
payload, plan = _check(ctx, options)
|
| 343 |
+
logo = _logo(ctx, body.logo_upload_id)
|
| 344 |
+
made: list[dict] = []
|
| 345 |
+
for url in urls:
|
| 346 |
+
def call(row: B2VVideo, url=url) -> int:
|
| 347 |
+
created = b2v.create_video({"url": url, **payload, "external_user_id": str(ctx.workspace.id),
|
| 348 |
+
"metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id))},
|
| 349 |
+
row.idempotency_key)
|
| 350 |
+
return int(created["video_id"])
|
| 351 |
+
|
| 352 |
+
try:
|
| 353 |
+
made.append(serialize_artifact(_start(ctx, plan, url, body.aspect_ratio, body.template, "v1", call,
|
| 354 |
+
logo, source_url=url)))
|
| 355 |
+
except HTTPException as e:
|
| 356 |
+
if not made:
|
| 357 |
+
raise
|
| 358 |
+
# Keep what was made; say where it stopped (usually the allowance ran out).
|
| 359 |
+
detail = e.detail if isinstance(e.detail, str) else (e.detail or {}).get("message")
|
| 360 |
+
made.append({"error": detail, "url": url})
|
| 361 |
+
break
|
| 362 |
+
return made
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@router.post("/upload", status_code=201)
|
| 366 |
+
async def create_from_files(files: list[UploadFile] = File(...), options: str = Form("{}"),
|
| 367 |
+
ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 368 |
+
"""From up to 5 documents (pdf, docx, pptx, md, txt, vtt; 5 MB each): blog2video parses them itself.
|
| 369 |
+
`options` is the wizard's VideoOptions as JSON."""
|
| 370 |
+
try:
|
| 371 |
+
opts = VideoOptions.model_validate(json.loads(options or "{}"))
|
| 372 |
+
except (ValueError, ValidationError) as e:
|
| 373 |
+
raise HTTPException(422, str(e)[:500]) from None
|
| 374 |
+
if not files or len(files) > MAX_FILES:
|
| 375 |
+
raise HTTPException(400, f"Add 1 to {MAX_FILES} files.")
|
| 376 |
+
parts: list[tuple[str, bytes, str]] = []
|
| 377 |
+
for f in files:
|
| 378 |
+
name = f.filename or "document"
|
| 379 |
+
if not any(name.lower().endswith(ext) for ext in FILE_TYPES):
|
| 380 |
+
raise HTTPException(400, f"{name}: use PDF, DOCX, PPTX, MD, TXT or VTT.")
|
| 381 |
+
data = await f.read(FILE_MAX_BYTES + 1)
|
| 382 |
+
if len(data) > FILE_MAX_BYTES:
|
| 383 |
+
raise HTTPException(400, f"{name} is over 5 MB.")
|
| 384 |
+
parts.append((name, data, f.content_type or "application/octet-stream"))
|
| 385 |
+
payload, plan = _check(ctx, opts)
|
| 386 |
+
logo = _logo(ctx, opts.logo_upload_id)
|
| 387 |
+
title = parts[0][0].rsplit(".", 1)[0] if len(parts) == 1 else f"{len(parts)} documents"
|
| 388 |
+
form = {k: (str(v).lower() if isinstance(v, bool) else str(v)) for k, v in payload.items()}
|
| 389 |
+
|
| 390 |
+
def call(row: B2VVideo) -> int:
|
| 391 |
+
project = b2v.upload_project({**form, "name": title[:255]}, parts)
|
| 392 |
+
pid = int(project["id"])
|
| 393 |
+
try:
|
| 394 |
+
b2v.generate(pid)
|
| 395 |
+
except b2v.B2VError:
|
| 396 |
+
try:
|
| 397 |
+
b2v.delete_project(pid)
|
| 398 |
+
except b2v.B2VError:
|
| 399 |
+
log.warning("could not remove blog2video project %s after generate failed", pid)
|
| 400 |
+
raise
|
| 401 |
+
return pid
|
| 402 |
+
|
| 403 |
+
a = _start(ctx, plan, title, opts.aspect_ratio, opts.template, "upload", call, logo)
|
| 404 |
+
return serialize_artifact(a)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# Read
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
@router.get("/config")
|
| 411 |
+
def video_config(ctx: Ctx = Depends(get_ctx)):
|
| 412 |
+
return {"configured": b2v.configured(), **video_limits.report(ctx.db, ctx.workspace)}
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
@router.get("/quota")
|
| 416 |
+
def quota(ctx: Ctx = Depends(get_ctx)):
|
| 417 |
+
return video_usage(ctx.db, ctx.workspace)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _with_posters(t: dict) -> dict:
|
| 421 |
+
"""Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
|
| 422 |
+
have none; the picker then shows the template's colors); designer templates carry their own image."""
|
| 423 |
+
if t.get("preview_image_url") and not t.get("preview_url"):
|
| 424 |
+
return {**t, "preview_url": t["preview_image_url"]}
|
| 425 |
+
if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"):
|
| 426 |
+
return t
|
| 427 |
+
base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
|
| 428 |
+
return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
@router.get("/catalog")
|
| 432 |
+
def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 433 |
+
"""What the wizard offers this workspace: built-in and designer templates, its own ready custom templates,
|
| 434 |
+
built-in and its own styles, music, languages. Nothing another workspace made."""
|
| 435 |
+
with upstream():
|
| 436 |
+
builtin = b2v.builtin_templates()
|
| 437 |
+
try:
|
| 438 |
+
crafted = b2v.crafted_templates()
|
| 439 |
+
except b2v.B2VError:
|
| 440 |
+
crafted = []
|
| 441 |
+
mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
|
| 442 |
+
for t in b2v_access.my_templates(ctx, ready_only=True)]
|
| 443 |
+
styles = b2v_access.visible_styles(ctx)
|
| 444 |
+
music = b2v.music_tracks()
|
| 445 |
+
return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
|
| 446 |
+
"crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)],
|
| 447 |
+
"my_templates": mine, "video_styles": styles,
|
| 448 |
+
"music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _restore_missing_artifacts(ctx: Ctx) -> None:
|
| 452 |
+
"""A video whose Library item is gone but which was never deleted (b2v_videos is the record) gets its item back,
|
| 453 |
+
rebuilt from the row. E.g. items removed by the Library's delete before it also removed the blog2video video."""
|
| 454 |
+
orphans = ctx.db.scalars(select(B2VVideo).where(
|
| 455 |
+
B2VVideo.workspace_id == ctx.workspace.id, B2VVideo.artifact_id.is_(None),
|
| 456 |
+
B2VVideo.b2v_video_id.is_not(None), B2VVideo.status != "deleted",
|
| 457 |
+
B2VVideo.quota_state != "refunded")).all()
|
| 458 |
+
for row in orphans:
|
| 459 |
+
status = ("ready" if row.status in video_quota.FINISHED else
|
| 460 |
+
"failed" if row.status in video_quota.FAILED else
|
| 461 |
+
row.status if row.status in JOB_STATUSES else STATUS_MAP.get(row.status, "generating"))
|
| 462 |
+
a = Artifact(workspace_id=ctx.workspace.id, type="video", status=status,
|
| 463 |
+
content_json={k: v for k, v in {
|
| 464 |
+
"provider": "blog2video", "b2v_video_id": row.b2v_video_id, "b2v_status": row.status,
|
| 465 |
+
"title": row.title or "Video", "aspect_ratio": row.aspect_ratio,
|
| 466 |
+
"preview_url": row.preview_url, "video_url": row.video_url}.items() if v is not None})
|
| 467 |
+
ctx.db.add(a)
|
| 468 |
+
ctx.db.flush()
|
| 469 |
+
row.artifact_id = a.id
|
| 470 |
+
if orphans:
|
| 471 |
+
ctx.db.commit()
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _needs_title(row: B2VVideo) -> bool:
|
| 475 |
+
return not row.title or row.title == row.source_url or bool(re.match(r"https?://", row.title))
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _set_title(ctx: Ctx, row: B2VVideo, a: Artifact | None, name: str | None) -> None:
|
| 479 |
+
"""blog2video names a video after reading its source; until then we only had the link."""
|
| 480 |
+
name = (name or "").strip()[:300]
|
| 481 |
+
if not name or re.match(r"https?://", name) or row.title == name:
|
| 482 |
+
return
|
| 483 |
+
row.title = name
|
| 484 |
+
if a is not None:
|
| 485 |
+
a.content_json = {**(a.content_json or {}), "title": name}
|
| 486 |
+
ctx.db.commit()
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def _refresh_titles(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]]) -> None:
|
| 490 |
+
"""Real titles for videos still titled by their link, from blog2video's video list (one call)."""
|
| 491 |
+
todo = {row.b2v_video_id: (a, row) for a, row in pairs
|
| 492 |
+
if row.created_via == "v1" and row.b2v_video_id and _needs_title(row)}
|
| 493 |
+
if not todo or not b2v.configured():
|
| 494 |
+
return
|
| 495 |
+
try:
|
| 496 |
+
listed = b2v.request("GET", "/api/v1/videos", params={"external_user_id": str(ctx.workspace.id), "limit": 100})
|
| 497 |
+
except b2v.B2VError as e:
|
| 498 |
+
log.warning("could not refresh video titles: %s", e)
|
| 499 |
+
return
|
| 500 |
+
for item in (listed or {}).get("items") or []:
|
| 501 |
+
if item.get("video_id") in todo:
|
| 502 |
+
a, row = todo[item["video_id"]]
|
| 503 |
+
_set_title(ctx, row, a, item.get("name"))
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
@router.get("")
|
| 507 |
+
def list_videos(ctx: Ctx = Depends(get_ctx), limit: int = 30, offset: int = 0):
|
| 508 |
+
"""The workspace's videos, newest first, with what the list rows show: title, status, source, scenes, date."""
|
| 509 |
+
_restore_missing_artifacts(ctx)
|
| 510 |
+
arts = [a for a in ctx.db.scalars(
|
| 511 |
+
select(Artifact).where(Artifact.workspace_id == ctx.workspace.id, Artifact.type == "video")
|
| 512 |
+
.order_by(Artifact.created_at.desc()).limit(min(limit, 100)).offset(offset)
|
| 513 |
+
).all() if (a.content_json or {}).get("provider") == "blog2video"]
|
| 514 |
+
rows = {r.artifact_id: r for r in ctx.db.scalars(
|
| 515 |
+
select(B2VVideo).where(B2VVideo.artifact_id.in_([a.id for a in arts])))} if arts else {}
|
| 516 |
+
_refresh_titles(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
|
| 517 |
+
_refresh_busy(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
|
| 518 |
+
out = []
|
| 519 |
+
for a in arts:
|
| 520 |
+
row, content = rows.get(a.id), a.content_json or {}
|
| 521 |
+
out.append({**serialize_artifact(a), "source_url": row.source_url if row else None,
|
| 522 |
+
"scenes": content.get("scenes"), "b2v_status": (row.status if row else content.get("b2v_status"))})
|
| 523 |
+
return out
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _refresh_busy(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]], limit: int = 10) -> None:
|
| 527 |
+
"""Ask blog2video about videos still generating, rendering or in an edit job, so one that finished (or failed)
|
| 528 |
+
while nobody had it open shows its real state. A few per page; a blog2video hiccup leaves the stored status."""
|
| 529 |
+
busy = [(a, r) for a, r in pairs if a.status in BUSY_STATUSES and r.b2v_video_id is not None][:limit]
|
| 530 |
+
for a, row in busy:
|
| 531 |
+
try:
|
| 532 |
+
sync_status(ctx, a, row, b2v.status(row.b2v_video_id, row.created_via))
|
| 533 |
+
except b2v.B2VError as e:
|
| 534 |
+
log.info("status refresh for video %s skipped: %s", row.b2v_video_id, e)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def _legacy(a: Artifact) -> dict:
|
| 538 |
+
content = a.content_json or {}
|
| 539 |
+
return {"legacy": True, "status": content.get("b2v_status"), "ready": bool(content.get("video_url")),
|
| 540 |
+
"video_url": content.get("video_url"), "preview_url": content.get("preview_url"),
|
| 541 |
+
"artifact_status": a.status, "artifact": serialize_artifact(a)}
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
@router.get("/{artifact_id}/status")
|
| 545 |
+
def video_status(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 546 |
+
a, vid, row = b2v_access.owned_video(ctx, artifact_id)
|
| 547 |
+
if row is None:
|
| 548 |
+
return _legacy(a)
|
| 549 |
+
try:
|
| 550 |
+
with upstream():
|
| 551 |
+
s = b2v.status(vid, row.created_via)
|
| 552 |
+
except HTTPException as e:
|
| 553 |
+
if e.status_code != 404:
|
| 554 |
+
raise
|
| 555 |
+
s = {"status": "failed", "error": "The video service lost this video."}
|
| 556 |
+
sync_status(ctx, a, row, s)
|
| 557 |
+
return {**s, "video_id": vid, "artifact_status": a.status}
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def preview_url(ctx: Ctx, a: Artifact, vid: int, row: B2VVideo | None = None) -> str | None:
|
| 561 |
+
"""The public live-preview link (no key needed, safe for the browser). Minted once and kept."""
|
| 562 |
+
url = (row.preview_url if row else None) or (a.content_json or {}).get("preview_url")
|
| 563 |
+
if url:
|
| 564 |
+
return url
|
| 565 |
+
try:
|
| 566 |
+
url = (b2v.preview_token(vid) or {}).get("preview_url")
|
| 567 |
+
except b2v.B2VError as e:
|
| 568 |
+
log.warning("preview token for video %s failed: %s", vid, e)
|
| 569 |
+
return None
|
| 570 |
+
store(ctx, a, row, preview_url=url)
|
| 571 |
+
return url
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def _media_url(project_id: int, asset: dict | None, subdir: str, filename: str) -> str:
|
| 575 |
+
if asset and asset.get("r2_url"):
|
| 576 |
+
return asset["r2_url"]
|
| 577 |
+
return f"{settings.b2v_api_base_url.rstrip('/')}/media/projects/{project_id}/{subdir}/{filename}"
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def _enrich(project: dict) -> dict:
|
| 581 |
+
"""What the scene rows show, worked out once here: each scene's audio and image links (blog2video only gives
|
| 582 |
+
file names), its layout and font sizes, and totals for the preview caption."""
|
| 583 |
+
pid = project.get("id")
|
| 584 |
+
assets = [a for a in project.get("assets") or [] if not a.get("excluded")]
|
| 585 |
+
latest: dict[tuple[str, str], dict] = {}
|
| 586 |
+
for a in sorted(assets, key=lambda a: a.get("id") or 0): # a regenerated file is a newer asset, same name
|
| 587 |
+
latest[(a.get("asset_type"), a.get("filename"))] = a
|
| 588 |
+
total, images = 0.0, 0
|
| 589 |
+
scenes = []
|
| 590 |
+
for sc in sorted(project.get("scenes") or [], key=lambda s: s.get("order") or 0):
|
| 591 |
+
try:
|
| 592 |
+
code = json.loads(sc.get("remotion_code") or "{}")
|
| 593 |
+
except ValueError:
|
| 594 |
+
code = {}
|
| 595 |
+
custom = str(project.get("template") or "").startswith("custom_")
|
| 596 |
+
props = (code.get("layoutConfig") if custom else code.get("layoutProps")) or {}
|
| 597 |
+
audio = None
|
| 598 |
+
if sc.get("voiceover_path"):
|
| 599 |
+
name = re.split(r"[/\\]", sc["voiceover_path"])[-1]
|
| 600 |
+
asset = latest.get(("audio", name))
|
| 601 |
+
audio = _media_url(pid, asset, "audio", name) + (f"?v={asset['id']}" if asset else "")
|
| 602 |
+
pics = []
|
| 603 |
+
# Stills sit in assignedImage(s); a stock clip in assignedVideo(s). Both fill the scene's visual slot.
|
| 604 |
+
for key in ("assignedImage", "assignedImages", "image", "images", "assignedVideo", "assignedVideos"):
|
| 605 |
+
vals = props.get(key)
|
| 606 |
+
clip = key.startswith("assignedVideo")
|
| 607 |
+
for name in ([vals] if isinstance(vals, str) else vals if isinstance(vals, list) else []):
|
| 608 |
+
if isinstance(name, str) and name and not name.startswith("http"):
|
| 609 |
+
first, second = ("video", "image") if clip else ("image", "video")
|
| 610 |
+
asset = latest.get((first, name)) or latest.get((second, name))
|
| 611 |
+
kind = asset.get("asset_type") if asset else first
|
| 612 |
+
pics.append({"filename": name, "asset_id": asset.get("id") if asset else None, "kind": kind,
|
| 613 |
+
"url": _media_url(pid, asset, "videos" if kind == "video" else "images", name)})
|
| 614 |
+
images += len(pics)
|
| 615 |
+
total += float(sc.get("duration_seconds") or 0) + float(sc.get("extra_hold_seconds") or 0)
|
| 616 |
+
scenes.append({**sc, "audio_url": audio, "images": pics,
|
| 617 |
+
"layout": code.get("layout") or sc.get("preferred_layout"),
|
| 618 |
+
"title_font_size": props.get("titleFontSize"),
|
| 619 |
+
"desc_font_size": props.get("descriptionFontSize")})
|
| 620 |
+
return {**project, "scenes": scenes,
|
| 621 |
+
"summary": {"scenes": len(scenes), "duration_seconds": round(total),
|
| 622 |
+
"images": sum(1 for a in assets if a.get("asset_type") == "image") or images,
|
| 623 |
+
"clips": sum(1 for a in assets if a.get("asset_type") == "video")}}
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
@router.get("/{artifact_id}")
|
| 627 |
+
def get_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 628 |
+
a, vid, row = b2v_access.owned_video(ctx, artifact_id)
|
| 629 |
+
if row is None:
|
| 630 |
+
return _legacy(a)
|
| 631 |
+
with upstream():
|
| 632 |
+
project = b2v.get_project(vid)
|
| 633 |
+
if row is not None:
|
| 634 |
+
_set_title(ctx, row, a, project.get("name"))
|
| 635 |
+
store(ctx, a, row, video_url=project.get("r2_video_url"), scenes=len(project.get("scenes") or []) or None)
|
| 636 |
+
return {"video_id": vid, "status": project.get("status"), "project": _enrich(project),
|
| 637 |
+
"preview_url": preview_url(ctx, a, vid, row), "video_url": project.get("r2_video_url"),
|
| 638 |
+
"artifact": serialize_artifact(a)}
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
@router.delete("/{artifact_id}")
|
| 642 |
+
def delete_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 643 |
+
"""Removes it from Notestack and from blog2video."""
|
| 644 |
+
a, _, _ = b2v_access.owned_video(ctx, artifact_id)
|
| 645 |
+
remove_video(ctx, a)
|
| 646 |
+
return {"ok": True}
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def remove_video(ctx: Ctx, a: Artifact) -> None:
|
| 650 |
+
"""Delete the video on blog2video, then the artifact. The b2v_videos row stays as the record that it was made
|
| 651 |
+
(status "deleted"): a lifetime allowance (Free) must not get a video back because one was deleted."""
|
| 652 |
+
row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id, B2VVideo.workspace_id == ctx.workspace.id))
|
| 653 |
+
if row is not None and row.b2v_video_id is not None and b2v.configured():
|
| 654 |
+
try:
|
| 655 |
+
b2v.delete_project(row.b2v_video_id)
|
| 656 |
+
except b2v.NotFound:
|
| 657 |
+
pass
|
| 658 |
+
except b2v.B2VError as e:
|
| 659 |
+
raise HTTPException(502, str(e)) from None
|
| 660 |
+
if row is not None:
|
| 661 |
+
row.status, row.artifact_id = "deleted", None
|
| 662 |
+
ctx.db.delete(a)
|
| 663 |
+
ctx.db.commit()
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
# Script review (status awaiting_script_review): read, rewrite with AI, approve
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
class DraftScene(BaseModel):
|
| 670 |
+
id: int
|
| 671 |
+
title: str = Field(max_length=255)
|
| 672 |
+
display_text: str | None = Field(None, max_length=3000)
|
| 673 |
+
narration_text: str = Field(max_length=6000)
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
class ReviewPreviewIn(BaseModel):
|
| 677 |
+
title: str = Field(min_length=1, max_length=255)
|
| 678 |
+
display_text: str = Field(min_length=1, max_length=3000)
|
| 679 |
+
narration_text: str = Field("", max_length=6000)
|
| 680 |
+
draft_scenes: list[DraftScene] = Field(max_length=200)
|
| 681 |
+
revision: int = Field(0, ge=0)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
class ReviewAiIn(ReviewPreviewIn):
|
| 685 |
+
instruction: str = Field(min_length=2, max_length=1000)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
class ReviewApproveScene(DraftScene):
|
| 689 |
+
preferred_layout: str | None = Field(None, max_length=100)
|
| 690 |
+
source_fingerprint: str | None = Field(None, max_length=200)
|
| 691 |
+
accepted_ai_instructions: list[str] = Field(default_factory=list, max_length=10)
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
class ReviewApproveIn(BaseModel):
|
| 695 |
+
scenes: list[ReviewApproveScene] = Field(max_length=200)
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
@router.get("/{artifact_id}/script")
|
| 699 |
+
def get_script(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 700 |
+
_, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 701 |
+
with upstream():
|
| 702 |
+
project = b2v.get_project(vid)
|
| 703 |
+
scenes = sorted(project.get("scenes") or [], key=lambda s: s.get("order", 0))
|
| 704 |
+
return {"status": project.get("status"), "scenes": [
|
| 705 |
+
{k: s.get(k) for k in ("id", "order", "title", "display_text", "narration_text", "preferred_layout")}
|
| 706 |
+
for s in scenes]}
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
@router.post("/{artifact_id}/script/scenes/{scene_id}/narration-preview")
|
| 710 |
+
def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreviewIn, ctx: Ctx = Depends(get_ctx),
|
| 711 |
+
_: None = Depends(b2v_ready)):
|
| 712 |
+
"""Rewrite one scene's narration to match its edited title and text."""
|
| 713 |
+
_, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 714 |
+
with upstream():
|
| 715 |
+
return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/narration-preview",
|
| 716 |
+
json=body.model_dump(), timeout=60)
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
@router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview")
|
| 720 |
+
def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx),
|
| 721 |
+
_: None = Depends(b2v_ready)):
|
| 722 |
+
"""★ Rewrite one scene from an instruction. Uses one AI edit."""
|
| 723 |
+
_, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 724 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites")
|
| 725 |
+
video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
|
| 726 |
+
try:
|
| 727 |
+
with upstream():
|
| 728 |
+
return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview",
|
| 729 |
+
json=body.model_dump(), timeout=60)
|
| 730 |
+
except HTTPException:
|
| 731 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits")
|
| 732 |
+
raise
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
@router.post("/{artifact_id}/script/approve")
|
| 736 |
+
def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
|
| 737 |
+
_: None = Depends(b2v_ready)):
|
| 738 |
+
a, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 739 |
+
with upstream():
|
| 740 |
+
result = b2v.project_call("POST", vid, "script-review/approve", json=body.model_dump(), timeout=60)
|
| 741 |
+
store(ctx, a, status="generating")
|
| 742 |
+
return result
|
backend/app/services/artifacts.py
CHANGED
|
@@ -62,8 +62,11 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
|
|
| 62 |
"document_id": str(a.document_id) if a.document_id else None,
|
| 63 |
"content": content,
|
| 64 |
"storage_key": a.storage_key,
|
| 65 |
-
|
| 66 |
-
"
|
|
|
|
|
|
|
|
|
|
| 67 |
"slide_urls": [storage.presign_get(k) for k in slides],
|
| 68 |
"created_at": a.created_at.isoformat() if a.created_at else None,
|
| 69 |
"job": serialize_job(job) if job else None,
|
|
|
|
| 62 |
"document_id": str(a.document_id) if a.document_id else None,
|
| 63 |
"content": content,
|
| 64 |
"storage_key": a.storage_key,
|
| 65 |
+
# blog2video videos live on blog2video's storage (video_url once rendered)
|
| 66 |
+
"url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
|
| 67 |
+
"download_url": (storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key
|
| 68 |
+
else content.get("video_url")),
|
| 69 |
+
"provider": content.get("provider"),
|
| 70 |
"slide_urls": [storage.presign_get(k) for k in slides],
|
| 71 |
"created_at": a.created_at.isoformat() if a.created_at else None,
|
| 72 |
"job": serialize_job(job) if job else None,
|
backend/app/services/auth_identity.py
CHANGED
|
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class IdentityError(Exception):
|
|
@@ -65,7 +66,9 @@ def _bootstrap_workspace(db: Session, user: User) -> None:
|
|
| 65 |
db.add(workspace)
|
| 66 |
db.flush()
|
| 67 |
db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def resolve_or_create_google_user(
|
|
|
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
|
| 12 |
+
from app.services.plans import PLANS
|
| 13 |
|
| 14 |
|
| 15 |
class IdentityError(Exception):
|
|
|
|
| 66 |
db.add(workspace)
|
| 67 |
db.flush()
|
| 68 |
db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
|
| 69 |
+
free = PLANS["free"]
|
| 70 |
+
db.add(Subscription(workspace_id=workspace.id, plan="free", video_plan=free.id, video_limit=free.videos,
|
| 71 |
+
videos_period_start=datetime.now(UTC)))
|
| 72 |
|
| 73 |
|
| 74 |
def resolve_or_create_google_user(
|
backend/app/services/b2v_access.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Who owns what on our one blog2video account.
|
| 2 |
+
|
| 3 |
+
blog2video's ownership checks stop at the account: any video, custom template, custom voice or style made with our
|
| 4 |
+
key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
|
| 5 |
+
belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
|
| 6 |
+
edit body has its references checked before it is forwarded (check_refs).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
|
| 11 |
+
from fastapi import HTTPException
|
| 12 |
+
from sqlalchemy import select
|
| 13 |
+
|
| 14 |
+
from app.auth import Ctx
|
| 15 |
+
from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
|
| 16 |
+
from app.services import blog2video as b2v
|
| 17 |
+
from app.services.plans import Plan, effective_plan
|
| 18 |
+
from app.services.video_limits import require_premium
|
| 19 |
+
|
| 20 |
+
BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
|
| 21 |
+
PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def not_found(what: str = "Not found") -> HTTPException:
|
| 25 |
+
return HTTPException(404, what)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Videos
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def owned_video(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo | None]:
|
| 32 |
+
"""The artifact, blog2video's id, and our ledger row (None for videos made before the API key).
|
| 33 |
+
|
| 34 |
+
The row is the reference: its b2v_video_id (and created_via) is what every blog2video call uses. The artifact's
|
| 35 |
+
content_json only keeps a copy for the Library, and is read only for legacy videos, which have no row."""
|
| 36 |
+
a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id,
|
| 37 |
+
Artifact.type == "video"))
|
| 38 |
+
content = (a.content_json or {}) if a else {}
|
| 39 |
+
if not a or content.get("provider") != "blog2video":
|
| 40 |
+
raise not_found("Video not found")
|
| 41 |
+
row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id,
|
| 42 |
+
B2VVideo.workspace_id == ctx.workspace.id))
|
| 43 |
+
if row is not None and row.b2v_video_id is not None:
|
| 44 |
+
return a, row.b2v_video_id, row
|
| 45 |
+
if row is None and content.get("b2v_video_id"):
|
| 46 |
+
return a, int(content["b2v_video_id"]), None # legacy
|
| 47 |
+
raise not_found("Video not found") # the create never got an id from blog2video
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def video_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo]:
|
| 51 |
+
"""A video this workspace owns and blog2video can still reach with our key."""
|
| 52 |
+
a, vid, row = owned_video(ctx, artifact_id)
|
| 53 |
+
if row is None:
|
| 54 |
+
raise HTTPException(410, {"code": "video_legacy",
|
| 55 |
+
"message": "This video was made before the video service changed and can not be "
|
| 56 |
+
"edited any more. Its links still work."})
|
| 57 |
+
return a, vid, row
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Templates, voices, styles
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate:
|
| 64 |
+
row = ctx.db.scalar(select(B2VTemplate).where(B2VTemplate.b2v_template_id == template_id,
|
| 65 |
+
B2VTemplate.workspace_id == ctx.workspace.id))
|
| 66 |
+
if not row:
|
| 67 |
+
raise not_found("Template not found")
|
| 68 |
+
return row
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice:
|
| 72 |
+
row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id,
|
| 73 |
+
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
| 74 |
+
if not row:
|
| 75 |
+
raise not_found("Voice not found")
|
| 76 |
+
return row
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
|
| 80 |
+
row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
|
| 81 |
+
B2VStyle.workspace_id == ctx.workspace.id))
|
| 82 |
+
if not row:
|
| 83 |
+
raise not_found("Style not found")
|
| 84 |
+
return row
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _custom_id(ref: str, prefix: str) -> int | None:
|
| 88 |
+
raw = ref.removeprefix(prefix)
|
| 89 |
+
return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def crafted_ids() -> set[str]:
|
| 93 |
+
out = set()
|
| 94 |
+
for t in b2v.crafted_templates():
|
| 95 |
+
tid = t.get("id") if isinstance(t, dict) else t
|
| 96 |
+
if isinstance(tid, str) and tid.startswith("crafted_"):
|
| 97 |
+
out.add(tid)
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
|
| 102 |
+
if template == "default":
|
| 103 |
+
return
|
| 104 |
+
if (cid := _custom_id(template, "custom_")) is not None:
|
| 105 |
+
row = owned_template(ctx, cid)
|
| 106 |
+
if not row.ready:
|
| 107 |
+
raise HTTPException(400, "That template is still being made.")
|
| 108 |
+
return
|
| 109 |
+
if template.startswith("crafted_"):
|
| 110 |
+
# blog2video silently falls back to "default" for an unknown id, so check it exists.
|
| 111 |
+
if template not in crafted_ids():
|
| 112 |
+
raise not_found("Template not found")
|
| 113 |
+
require_premium(ctx.db, ctx.workspace, "Designer templates", plan)
|
| 114 |
+
return
|
| 115 |
+
if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
|
| 116 |
+
raise not_found("Template not found")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def check_style(ctx: Ctx, style: str) -> None:
|
| 120 |
+
if style in BUILTIN_STYLES:
|
| 121 |
+
return
|
| 122 |
+
if (sid := _custom_id(style, "custom:")) is not None:
|
| 123 |
+
owned_style(ctx, sid)
|
| 124 |
+
return
|
| 125 |
+
raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
|
| 129 |
+
"""A built-in voice (premium if blog2video marks it paid) or one of this workspace's custom voices."""
|
| 130 |
+
prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
|
| 131 |
+
if voice_id in prebuilt:
|
| 132 |
+
if prebuilt[voice_id].get("plan") == "paid":
|
| 133 |
+
require_premium(ctx.db, ctx.workspace, "This voice", plan)
|
| 134 |
+
return
|
| 135 |
+
if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id,
|
| 136 |
+
B2VCustomVoice.workspace_id == ctx.workspace.id)):
|
| 137 |
+
require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
|
| 138 |
+
return
|
| 139 |
+
raise not_found("Voice not found")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
|
| 143 |
+
"""Every id in a create or edit body must be this workspace's, and premium options need a paid plan."""
|
| 144 |
+
plan = plan or effective_plan(ctx.db, ctx.workspace)
|
| 145 |
+
if body.get("template"):
|
| 146 |
+
check_template(ctx, str(body["template"]), plan)
|
| 147 |
+
if body.get("video_style"):
|
| 148 |
+
check_style(ctx, str(body["video_style"]))
|
| 149 |
+
if body.get("custom_voice_id") and body.get("voice_gender") != "none":
|
| 150 |
+
check_voice(ctx, str(body["custom_voice_id"]), plan)
|
| 151 |
+
if str(body.get("video_length") or "") in PREMIUM_LENGTHS:
|
| 152 |
+
require_premium(ctx.db, ctx.workspace, "Longer videos", plan)
|
| 153 |
+
if body.get("voice_emotion"):
|
| 154 |
+
require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan)
|
| 155 |
+
return plan
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# Catalog, filtered to what this workspace may see
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def visible_styles(ctx: Ctx) -> list[dict]:
|
| 162 |
+
"""Built-in styles (read-only) and this workspace's custom styles. Never "Your Style", never others'."""
|
| 163 |
+
mine = set(ctx.db.scalars(select(B2VStyle.b2v_style_id).where(B2VStyle.workspace_id == ctx.workspace.id)))
|
| 164 |
+
out = []
|
| 165 |
+
for s in (b2v.video_styles() or {}).get("styles") or []:
|
| 166 |
+
if s.get("kind") == "builtin" and s.get("id") in BUILTIN_STYLES:
|
| 167 |
+
out.append({"id": s["id"], "name": s.get("name"), "description": s.get("description"), "kind": "builtin"})
|
| 168 |
+
elif s.get("kind") == "custom" and s.get("custom_id") in mine:
|
| 169 |
+
out.append({"id": s["id"], "custom_id": s["custom_id"], "name": s.get("name"),
|
| 170 |
+
"description": s.get("description"), "guidance": s.get("guidance"),
|
| 171 |
+
"version": s.get("version"), "kind": "custom"})
|
| 172 |
+
return out
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def my_templates(ctx: Ctx, ready_only: bool = False) -> list[B2VTemplate]:
|
| 176 |
+
q = select(B2VTemplate).where(B2VTemplate.workspace_id == ctx.workspace.id)
|
| 177 |
+
if ready_only:
|
| 178 |
+
q = q.where(B2VTemplate.ready.is_(True))
|
| 179 |
+
return list(ctx.db.scalars(q.order_by(B2VTemplate.created_at.desc())))
|
backend/app/services/billing.py
CHANGED
|
@@ -13,13 +13,14 @@ from app.config import settings
|
|
| 13 |
from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
|
| 14 |
from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
|
| 15 |
from app.services.usage import month_usage
|
|
|
|
| 16 |
|
| 17 |
CYCLES = {"monthly", "annual"}
|
| 18 |
LOW_FUEL = 0.8 # meters at or above this share of the allowance start nudging
|
| 19 |
|
| 20 |
METERS = {
|
| 21 |
"audio_minutes": ("Audio overviews", "min"),
|
| 22 |
-
"
|
| 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
|
| 94 |
"/app/launch-kit", priority=70))
|
| 95 |
elif kits["limit"] > 0 and kits["used"] < kits["limit"]:
|
| 96 |
left = kits["limit"] - kits["used"]
|
|
@@ -119,6 +120,8 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
|
|
| 119 |
def billing_status(db: Session, workspace: Workspace) -> dict:
|
| 120 |
plan = effective_plan(db, workspace)
|
| 121 |
used = month_usage(db, workspace.id)
|
|
|
|
|
|
|
| 122 |
sources = _count(db, Source, workspace.id)
|
| 123 |
meters = _meters(plan, used, sources)
|
| 124 |
upgrade = next_plan(plan)
|
|
@@ -134,6 +137,7 @@ def billing_status(db: Session, workspace: Workspace) -> dict:
|
|
| 134 |
"period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
|
| 135 |
"meters": meters,
|
| 136 |
"since": used["since"],
|
|
|
|
| 137 |
"nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
|
| 138 |
}
|
| 139 |
|
|
@@ -156,6 +160,8 @@ def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, pro
|
|
| 156 |
sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
|
| 157 |
sub.current_period_end = current_period_end
|
| 158 |
db.commit()
|
|
|
|
|
|
|
| 159 |
if workspace and effective_plan(db, workspace).indexed_posts != before:
|
| 160 |
resync_sources(db, workspace_id)
|
| 161 |
return sub
|
|
|
|
| 13 |
from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
|
| 14 |
from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
|
| 15 |
from app.services.usage import month_usage
|
| 16 |
+
from app.services.video_quota import sync_video_quota, video_usage
|
| 17 |
|
| 18 |
CYCLES = {"monthly", "annual"}
|
| 19 |
LOW_FUEL = 0.8 # meters at or above this share of the allowance start nudging
|
| 20 |
|
| 21 |
METERS = {
|
| 22 |
"audio_minutes": ("Audio overviews", "min"),
|
| 23 |
+
"videos": ("Videos", ""),
|
| 24 |
"launch_kits": ("Launch Kits", ""),
|
| 25 |
}
|
| 26 |
|
|
|
|
| 91 |
left = "Unlimited" if kits["limit"] < 0 else f"{kits['limit']}"
|
| 92 |
out.append(_nudge(f"act-kit-{month}", "action", "Launch your latest post",
|
| 93 |
f"{left} Launch Kits this month, none used yet. One click turns a post into threads, "
|
| 94 |
+
"LinkedIn posts and quote cards.", "Build a Launch Kit",
|
| 95 |
"/app/launch-kit", priority=70))
|
| 96 |
elif kits["limit"] > 0 and kits["used"] < kits["limit"]:
|
| 97 |
left = kits["limit"] - kits["used"]
|
|
|
|
| 120 |
def billing_status(db: Session, workspace: Workspace) -> dict:
|
| 121 |
plan = effective_plan(db, workspace)
|
| 122 |
used = month_usage(db, workspace.id)
|
| 123 |
+
videos = video_usage(db, workspace)
|
| 124 |
+
used["videos"] = videos["used"]
|
| 125 |
sources = _count(db, Source, workspace.id)
|
| 126 |
meters = _meters(plan, used, sources)
|
| 127 |
upgrade = next_plan(plan)
|
|
|
|
| 137 |
"period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
|
| 138 |
"meters": meters,
|
| 139 |
"since": used["since"],
|
| 140 |
+
"videos_resets_at": videos["resets_at"],
|
| 141 |
"nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
|
| 142 |
}
|
| 143 |
|
|
|
|
| 160 |
sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
|
| 161 |
sub.current_period_end = current_period_end
|
| 162 |
db.commit()
|
| 163 |
+
if workspace:
|
| 164 |
+
sync_video_quota(db, workspace)
|
| 165 |
if workspace and effective_plan(db, workspace).indexed_posts != before:
|
| 166 |
resync_sources(db, workspace_id)
|
| 167 |
return sub
|
backend/app/services/blog2video.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""blog2video: turns a post into an editable, narrated video. Every call goes through here.
|
| 2 |
+
|
| 3 |
+
NoteStack is an ordinary blog2video API customer: each request carries our account's key
|
| 4 |
+
(Authorization: Bearer b2v_live_...), and everything made with it (videos, custom templates, custom voices, video
|
| 5 |
+
styles) belongs to that one account. blog2video does not know our workspaces and its ownership checks stop at the
|
| 6 |
+
account, so callers must check ownership first (app/services/b2v_access.py) and charge the workspace's own
|
| 7 |
+
allowances (app/services/video_quota.py, app/services/video_limits.py).
|
| 8 |
+
|
| 9 |
+
Some endpoints change state shared by every workspace, or hand one workspace another's data. They are refused here,
|
| 10 |
+
before anything is sent (DENIED). The key never reaches the browser or the logs.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import re
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from collections.abc import Callable
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
|
| 22 |
+
from app.config import settings
|
| 23 |
+
|
| 24 |
+
log = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
STATUS_TIMEOUT = 30 # every call without its own timeout (status polls, edits): blog2video can take well over 10s
|
| 27 |
+
UPLOAD_TIMEOUT = 120
|
| 28 |
+
CREATE_ATTEMPTS = 3 # a create is retried with the same Idempotency-Key, which never makes a duplicate
|
| 29 |
+
CATALOG_TTL = 3600
|
| 30 |
+
|
| 31 |
+
# Account-wide endpoints NoteStack must never call (the integration guide, section 11).
|
| 32 |
+
DENIED: list[tuple[str, re.Pattern]] = [
|
| 33 |
+
(m, re.compile(p)) for m, p in [
|
| 34 |
+
("POST", r"^/api/projects/?$"), # same URL within 10 minutes returns another workspace's project
|
| 35 |
+
("POST", r"^/api/projects/bulk/?$"),
|
| 36 |
+
("GET", r"^/api/projects/?$"), # every project on the account
|
| 37 |
+
("GET", r"^/api/projects/template-availability/?$"),
|
| 38 |
+
("*", r"^/api/voices/saved(/.*)?$"), # one saved-voice list shared by everyone
|
| 39 |
+
("*", r"^/api/video-styles/builtin/.*$"), # changes a built-in style for everyone
|
| 40 |
+
("*", r"^/api/video-styles/pin/?$"),
|
| 41 |
+
("*", r"^/api/video-styles/selection/?$"),
|
| 42 |
+
("*", r"^/api/video-styles/your-style/?$"),
|
| 43 |
+
("*", r"^/api/auth/me/script-preferences/?$"),
|
| 44 |
+
]
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class B2VError(RuntimeError):
|
| 49 |
+
def __init__(self, message: str, status: int = 502, detail: Any = None):
|
| 50 |
+
super().__init__(message)
|
| 51 |
+
self.status = status
|
| 52 |
+
self.detail = detail
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class AccountOutOfVideos(B2VError):
|
| 56 |
+
"""Our blog2video account has no videos (or template slots) left. Not the user's fault; ops must buy more."""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class NotFound(B2VError):
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Busy(B2VError):
|
| 64 |
+
"""Another change is still running (409), or an account rate limit was hit (429)."""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TemplateInUse(B2VError):
|
| 68 |
+
"""409 template_in_use: a video on the account still uses this custom template."""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class BadRequest(B2VError):
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class Upstream(B2VError):
|
| 76 |
+
"""blog2video is down or slow, or rejected our key or plan (a configuration problem, not the user's)."""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class Unreachable(Upstream):
|
| 80 |
+
"""No answer at all (network error or timeout): a create may or may not have happened."""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class Denied(B2VError):
|
| 84 |
+
"""We refused to send this: the endpoint is account-wide (see DENIED)."""
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
|
| 88 |
+
NOT_RESPONDING = "The video service is not responding. Try again in a minute."
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def configured() -> bool:
|
| 92 |
+
return bool(settings.b2v_api_base_url and settings.b2v_api_key)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _base() -> str:
|
| 96 |
+
return settings.b2v_api_base_url.rstrip("/")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def denied(method: str, path: str) -> bool:
|
| 100 |
+
path = path.split("?", 1)[0]
|
| 101 |
+
return any(m in ("*", method.upper()) and p.match(path) for m, p in DENIED)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _body(resp: httpx.Response) -> Any:
|
| 105 |
+
try:
|
| 106 |
+
return resp.json()
|
| 107 |
+
except ValueError:
|
| 108 |
+
return resp.text[:500]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _error_code(body: Any) -> str | None:
|
| 112 |
+
"""blog2video errors look like {"detail": {"error": "..."}} or {"detail": "..."}."""
|
| 113 |
+
if not isinstance(body, dict):
|
| 114 |
+
return None
|
| 115 |
+
detail = body.get("detail", body)
|
| 116 |
+
if isinstance(detail, dict):
|
| 117 |
+
return detail.get("error")
|
| 118 |
+
return detail if isinstance(detail, str) else None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _message(detail: Any, fallback: str) -> str:
|
| 122 |
+
if isinstance(detail, str) and detail.strip():
|
| 123 |
+
return detail.strip()[:300]
|
| 124 |
+
if isinstance(detail, dict) and isinstance(detail.get("message"), str):
|
| 125 |
+
return detail["message"][:300]
|
| 126 |
+
if isinstance(detail, list): # 422s list the fields: [{"loc": [..., "url"], "msg": "..."}]
|
| 127 |
+
parts = [f"{(d.get('loc') or ['?'])[-1]}: {d.get('msg')}" for d in detail if isinstance(d, dict)]
|
| 128 |
+
if parts:
|
| 129 |
+
return "; ".join(parts)[:300]
|
| 130 |
+
return fallback
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _raise_for(resp: httpx.Response, path: str) -> None:
|
| 134 |
+
if resp.status_code < 400:
|
| 135 |
+
return
|
| 136 |
+
body = _body(resp)
|
| 137 |
+
detail = body.get("detail", body) if isinstance(body, dict) else body
|
| 138 |
+
code = _error_code(body) or ""
|
| 139 |
+
status = resp.status_code
|
| 140 |
+
text = str(detail).lower()
|
| 141 |
+
if status == 402 or (status == 403 and "video limit reached" in text):
|
| 142 |
+
log.error("ALERT blog2video: our account is out of videos (%s): buy credits", path)
|
| 143 |
+
raise AccountOutOfVideos(UNAVAILABLE, 503, detail)
|
| 144 |
+
if status == 403 and code == "custom_template_limit":
|
| 145 |
+
log.error("ALERT blog2video: our account is out of custom template slots (%s): buy slots", path)
|
| 146 |
+
raise AccountOutOfVideos("Creating templates is temporarily unavailable. Try again later.", 503, detail)
|
| 147 |
+
if status == 401:
|
| 148 |
+
log.error("ALERT blog2video rejected our API key on %s (missing, revoked or rotated): update B2V_API_KEY",
|
| 149 |
+
path)
|
| 150 |
+
raise Upstream(UNAVAILABLE, 503, detail)
|
| 151 |
+
if status == 403:
|
| 152 |
+
log.error("ALERT blog2video refused %s with 403 (%s): is the account still on a paid plan?", path,
|
| 153 |
+
code or text[:120])
|
| 154 |
+
raise Upstream(UNAVAILABLE, 503, detail)
|
| 155 |
+
if 400 <= status < 500 and status not in (401, 403):
|
| 156 |
+
# What blog2video said, so a refused edit can be traced (never includes our key).
|
| 157 |
+
log.warning("blog2video %s -> %s: %s", path, status, str(detail)[:300])
|
| 158 |
+
if status == 404:
|
| 159 |
+
raise NotFound(_message(detail, "Not found"), 404, detail)
|
| 160 |
+
if status == 409 and code == "template_in_use":
|
| 161 |
+
raise TemplateInUse("A video still uses this template.", 409, detail)
|
| 162 |
+
if status == 409 or (status == 400 and "running" in text):
|
| 163 |
+
raise Busy(_message(detail, "Another change is still running, try again in a moment."), 409, detail)
|
| 164 |
+
if status == 429:
|
| 165 |
+
raise Busy(_message(detail, "The daily limit was reached. Try again later."), 429, detail)
|
| 166 |
+
if status in (400, 413, 415, 422):
|
| 167 |
+
raise BadRequest(_message(detail, str(code or "blog2video rejected the request")), 400, detail)
|
| 168 |
+
raise Upstream(NOT_RESPONDING, 502, detail)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _retry_after(resp: httpx.Response) -> float:
|
| 172 |
+
try:
|
| 173 |
+
return min(float(resp.headers.get("Retry-After", 1)), 5)
|
| 174 |
+
except ValueError:
|
| 175 |
+
return 1
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def request(method: str, path: str, *, json: Any = None, params: dict | None = None, data: dict | None = None,
|
| 179 |
+
files: Any = None, headers: dict | None = None, timeout: float | None = None,
|
| 180 |
+
raw: bool = False) -> Any:
|
| 181 |
+
"""Call blog2video with our key. JSON by default; `data` + `files` send multipart; `raw` returns
|
| 182 |
+
(bytes, content_type) for audio and downloads. A POST with an Idempotency-Key (a create) is retried on no
|
| 183 |
+
answer, 5xx and 429, always with the same key: blog2video returns the original video, charged once."""
|
| 184 |
+
if denied(method, path):
|
| 185 |
+
log.error("refused to call account-wide blog2video endpoint %s %s", method, path)
|
| 186 |
+
raise Denied("Not available.", 404)
|
| 187 |
+
if not configured():
|
| 188 |
+
raise Upstream("Video is not set up yet.", 503)
|
| 189 |
+
url = f"{_base()}{path}"
|
| 190 |
+
timeout = timeout or STATUS_TIMEOUT
|
| 191 |
+
retryable = method == "POST" and bool(headers and headers.get("Idempotency-Key"))
|
| 192 |
+
attempts = CREATE_ATTEMPTS if retryable else 1
|
| 193 |
+
for attempt in range(1, attempts + 1):
|
| 194 |
+
last = attempt == attempts
|
| 195 |
+
started = time.monotonic()
|
| 196 |
+
try:
|
| 197 |
+
resp = httpx.request(method, url, json=json, params=params, data=data, files=files, timeout=timeout,
|
| 198 |
+
headers={**(headers or {}), "Authorization": f"Bearer {settings.b2v_api_key}"})
|
| 199 |
+
except httpx.HTTPError as e:
|
| 200 |
+
log.warning("blog2video %s %s failed (attempt %d): %s", method, path, attempt, type(e).__name__)
|
| 201 |
+
if last:
|
| 202 |
+
raise Unreachable(NOT_RESPONDING) from None
|
| 203 |
+
time.sleep(attempt)
|
| 204 |
+
continue
|
| 205 |
+
log.info("blog2video %s %s -> %s in %.0fms", method, path, resp.status_code,
|
| 206 |
+
(time.monotonic() - started) * 1000)
|
| 207 |
+
if not last and (resp.status_code >= 500 or resp.status_code == 429):
|
| 208 |
+
time.sleep(_retry_after(resp) if resp.status_code == 429 else attempt)
|
| 209 |
+
continue
|
| 210 |
+
_raise_for(resp, path)
|
| 211 |
+
if raw:
|
| 212 |
+
return resp.content, resp.headers.get("content-type", "application/octet-stream")
|
| 213 |
+
return None if resp.status_code == 204 or not resp.content else _body(resp)
|
| 214 |
+
raise AssertionError("unreachable")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# Shared catalog: the same for every workspace, cached for an hour.
|
| 218 |
+
|
| 219 |
+
_cache: dict[str, tuple[float, Any]] = {}
|
| 220 |
+
_cache_lock = threading.Lock()
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def cached(key: str, fetch: Callable[[], Any], ttl: float = CATALOG_TTL) -> Any:
|
| 224 |
+
with _cache_lock:
|
| 225 |
+
hit = _cache.get(key)
|
| 226 |
+
if hit and time.monotonic() - hit[0] < ttl:
|
| 227 |
+
return hit[1]
|
| 228 |
+
try:
|
| 229 |
+
value = fetch()
|
| 230 |
+
except B2VError:
|
| 231 |
+
if hit is None:
|
| 232 |
+
raise
|
| 233 |
+
# blog2video is slow or down: the last copy is better than failing the request.
|
| 234 |
+
log.warning("blog2video catalog %r could not be refreshed; using the copy from %.0fs ago",
|
| 235 |
+
key, time.monotonic() - hit[0])
|
| 236 |
+
return hit[1]
|
| 237 |
+
with _cache_lock:
|
| 238 |
+
_cache[key] = (time.monotonic(), value)
|
| 239 |
+
return value
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def clear_cache() -> None:
|
| 243 |
+
with _cache_lock:
|
| 244 |
+
_cache.clear()
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _catalog_timeout() -> float:
|
| 248 |
+
"""Catalog lists are fetched about once an hour, so wait as long as a create would rather than fail."""
|
| 249 |
+
return settings.b2v_timeout_seconds
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def builtin_templates() -> list[dict]:
|
| 253 |
+
return cached("templates", lambda: request("GET", "/api/templates", timeout=_catalog_timeout()) or [])
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def crafted_templates() -> list[dict]:
|
| 257 |
+
return cached("crafted", lambda: request("GET", "/api/crafted-templates", timeout=_catalog_timeout()) or [])
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def prebuilt_voices() -> list[dict]:
|
| 261 |
+
"""voice_id, name, preview_url, labels, description, plan ('free' | 'paid')."""
|
| 262 |
+
return cached("prebuilt", lambda: (request("GET", "/api/voices/prebuilt", timeout=_catalog_timeout()) or {})
|
| 263 |
+
.get("voices") or [])
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def music_tracks() -> list[dict]:
|
| 267 |
+
"""track_id, display_name, mood, r2_url."""
|
| 268 |
+
return cached("music", lambda: request("GET", "/api/background-music/tracks", timeout=_catalog_timeout()) or [])
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def video_styles() -> dict:
|
| 272 |
+
"""Account-wide picker payload. Filter before use (b2v_access.visible_styles)."""
|
| 273 |
+
return request("GET", "/api/video-styles") or {}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def account() -> Any:
|
| 277 |
+
"""Our blog2video account: plan, videos, AI-edit allowance and credits, template slots."""
|
| 278 |
+
return request("GET", "/api/auth/me")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# Videos
|
| 282 |
+
|
| 283 |
+
def create_video(body: dict, idempotency_key: str) -> Any:
|
| 284 |
+
return request("POST", "/api/v1/videos", json=body, headers={"Idempotency-Key": idempotency_key},
|
| 285 |
+
timeout=settings.b2v_timeout_seconds)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def upload_project(form: dict, files: list[tuple[str, bytes, str]]) -> Any:
|
| 289 |
+
"""Create a project from documents. Generation starts with generate()."""
|
| 290 |
+
return request("POST", "/api/projects/upload", data=form, timeout=UPLOAD_TIMEOUT,
|
| 291 |
+
files=[("files", (name, content, ctype)) for name, content, ctype in files])
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def generate(project_id: int) -> Any:
|
| 295 |
+
return request("POST", f"/api/projects/{project_id}/generate", timeout=settings.b2v_timeout_seconds)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def status(project_id: int, created_via: str = "v1") -> Any:
|
| 299 |
+
if created_via == "upload":
|
| 300 |
+
return request("GET", f"/api/projects/{project_id}/status")
|
| 301 |
+
return request("GET", f"/api/v1/videos/{project_id}/status")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def get_project(project_id: int) -> Any:
|
| 305 |
+
"""The whole project (scenes, assets). Much heavier than a status poll, so it gets the long timeout."""
|
| 306 |
+
return request("GET", f"/api/projects/{project_id}", timeout=settings.b2v_timeout_seconds)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def delete_project(project_id: int) -> Any:
|
| 310 |
+
# blog2video removes the project's files before answering, which can take well over the 10s status timeout:
|
| 311 |
+
# timing out early reported a failure for a delete that went on to succeed.
|
| 312 |
+
return request("DELETE", f"/api/projects/{project_id}", timeout=settings.b2v_timeout_seconds)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def upload_logo(project_id: int, filename: str, content_type: str, content: bytes) -> Any:
|
| 316 |
+
return request("POST", f"/api/projects/{project_id}/logo", files={"file": (filename, content, content_type)},
|
| 317 |
+
timeout=UPLOAD_TIMEOUT)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def preview_token(project_id: int) -> Any:
|
| 321 |
+
return request("POST", f"/api/embed/token/{project_id}")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def project_call(method: str, project_id: int, sub: str, **kwargs) -> Any:
|
| 325 |
+
"""Any /api/projects/{id}/... editor endpoint. Callers allowlist `sub` (app/routers/video_edit.py)."""
|
| 326 |
+
return request(method, f"/api/projects/{project_id}/{sub.lstrip('/')}", **kwargs)
|
backend/app/services/plans.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 24 |
launch_kits: int # -1 = unlimited
|
| 25 |
voice_cloning: bool
|
| 26 |
brand_kit: bool
|
| 27 |
features: tuple[str, ...]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
"
|
| 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 |
-
|
| 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 |
-
"
|
| 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 |
-
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
def next_plan(plan: Plan) -> Plan | None:
|
| 137 |
-
|
| 138 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 144 |
candidate = PLANS[pid]
|
| 145 |
if kind is None:
|
| 146 |
return pid
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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, "
|
| 58 |
-
"launch_kits": plan.launch_kits}[kind]
|
| 59 |
if limit >= 0 and used[kind] + amount > limit:
|
| 60 |
label = kind.replace("_", " ")
|
| 61 |
raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
|
|
|
|
| 7 |
|
| 8 |
from app.models import Artifact, UsageEvent, Workspace
|
| 9 |
from app.services.plans import Plan, effective_plan, plan_limit_error
|
| 10 |
+
from app.services.video_quota import video_usage
|
| 11 |
|
| 12 |
|
| 13 |
def month_start(now: datetime | None = None) -> datetime:
|
|
|
|
| 33 |
) or 0
|
| 34 |
return {
|
| 35 |
"audio_minutes": round(total("tts", "seconds") / 60, 1),
|
|
|
|
| 36 |
"launch_kits": kits,
|
| 37 |
"llm_tokens": int(total("llm", "tokens")),
|
| 38 |
"since": since.isoformat(),
|
|
|
|
| 42 |
def usage_report(db: Session, workspace: Workspace) -> dict:
|
| 43 |
plan = effective_plan(db, workspace)
|
| 44 |
used = month_usage(db, workspace.id)
|
| 45 |
+
videos = video_usage(db, workspace)
|
| 46 |
+
used["videos"] = videos["used"]
|
| 47 |
return {
|
| 48 |
"plan": plan.id,
|
| 49 |
"used": used,
|
| 50 |
+
"videos_resets_at": videos["resets_at"],
|
| 51 |
+
"limits": {"audio_minutes": plan.audio_minutes, "videos": videos["limit"],
|
| 52 |
"launch_kits": plan.launch_kits, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
|
| 53 |
}
|
| 54 |
|
|
|
|
| 57 |
"""Raise 402 plan_limit when this request would go over the monthly allowance."""
|
| 58 |
plan = effective_plan(db, workspace)
|
| 59 |
used = month_usage(db, workspace.id)
|
| 60 |
+
limit = {"audio_minutes": plan.audio_minutes, "launch_kits": plan.launch_kits}[kind]
|
|
|
|
| 61 |
if limit >= 0 and used[kind] + amount > limit:
|
| 62 |
label = kind.replace("_", " ")
|
| 63 |
raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
|
backend/app/services/video_limits.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-workspace shares of what blog2video only limits account-wide: AI edits, custom templates, template AI
|
| 2 |
+
operations, voice designs, voice samples, custom voices (plans.VIDEO_LIMITS), plus premium (★) video options.
|
| 3 |
+
|
| 4 |
+
blog2video checks every limit against our own (Pro) account, so every workspace passes its checks. These counters
|
| 5 |
+
stop a workspace before it uses up everyone's share. Videos themselves are counted in video_quota.py.
|
| 6 |
+
|
| 7 |
+
A take is one conditional UPDATE (used + n <= limit), so two requests can not both take the last unit.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import UTC, datetime
|
| 12 |
+
|
| 13 |
+
from sqlalchemy import case, select, update
|
| 14 |
+
from sqlalchemy.exc import IntegrityError
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
|
| 17 |
+
from app.models import UsageCounter, Workspace
|
| 18 |
+
from app.services.plans import VIDEO_LIMITS, Plan, effective_plan, plan_limit_error
|
| 19 |
+
|
| 20 |
+
LABELS = {
|
| 21 |
+
"ai_edits": ("AI edits", "this month"),
|
| 22 |
+
"templates": ("custom templates", "in total"),
|
| 23 |
+
"template_ai_daily": ("AI template steps", "today"),
|
| 24 |
+
"voice_designs_daily": ("voice designs", "today"),
|
| 25 |
+
"voice_samples_daily": ("voice samples", "today"),
|
| 26 |
+
"custom_voices": ("custom voices", "at a time"),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def period_key(metric: str, now: datetime | None = None) -> str:
|
| 31 |
+
now = now or datetime.now(UTC)
|
| 32 |
+
kind = VIDEO_LIMITS[metric]
|
| 33 |
+
return now.strftime("%Y-%m-%d") if kind == "day" else now.strftime("%Y-%m") if kind == "month" else "all"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _ensure_row(db: Session, workspace_id: uuid.UUID, period: str, metric: str) -> None:
|
| 37 |
+
key = {"workspace_id": workspace_id, "period": period, "metric": metric}
|
| 38 |
+
if db.get(UsageCounter, key) is not None:
|
| 39 |
+
return
|
| 40 |
+
db.add(UsageCounter(**key, used=0))
|
| 41 |
+
try:
|
| 42 |
+
db.commit()
|
| 43 |
+
except IntegrityError: # a concurrent request made it first
|
| 44 |
+
db.rollback()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def used(db: Session, workspace_id: uuid.UUID, metric: str) -> int:
|
| 48 |
+
row = db.get(UsageCounter, {"workspace_id": workspace_id, "period": period_key(metric), "metric": metric})
|
| 49 |
+
return row.used if row else 0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def take(db: Session, workspace: Workspace, metric: str, n: int = 1, plan: Plan | None = None) -> None:
|
| 53 |
+
"""Use n units of metric, or raise the 402 that opens the upgrade popup."""
|
| 54 |
+
plan = plan or effective_plan(db, workspace)
|
| 55 |
+
limit = int(plan.video_limits.get(metric, 0))
|
| 56 |
+
period = period_key(metric)
|
| 57 |
+
_ensure_row(db, workspace.id, period, metric)
|
| 58 |
+
where = [UsageCounter.workspace_id == workspace.id, UsageCounter.period == period, UsageCounter.metric == metric]
|
| 59 |
+
if limit >= 0: # -1 = unlimited, still counted
|
| 60 |
+
where.append(UsageCounter.used + n <= limit)
|
| 61 |
+
taken = db.execute(update(UsageCounter).where(*where).values(used=UsageCounter.used + n)).rowcount
|
| 62 |
+
db.commit()
|
| 63 |
+
if taken != 1:
|
| 64 |
+
label, when = LABELS[metric]
|
| 65 |
+
message = (f"Your plan does not include {label}." if limit == 0
|
| 66 |
+
else f"You have used all {limit} {label} {when}.")
|
| 67 |
+
raise plan_limit_error(plan, f"video_limits.{metric}", message)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> None:
|
| 71 |
+
"""Undo a take whose blog2video call failed (or a custom voice that was deleted)."""
|
| 72 |
+
db.execute(update(UsageCounter)
|
| 73 |
+
.where(UsageCounter.workspace_id == workspace_id, UsageCounter.period == period_key(metric),
|
| 74 |
+
UsageCounter.metric == metric)
|
| 75 |
+
.values(used=case((UsageCounter.used > n, UsageCounter.used - n), else_=0)))
|
| 76 |
+
db.commit()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def require_premium(db: Session, workspace: Workspace, what: str, plan: Plan | None = None) -> Plan:
|
| 80 |
+
plan = plan or effective_plan(db, workspace)
|
| 81 |
+
if not plan.video_premium:
|
| 82 |
+
raise plan_limit_error(plan, "video_premium", f"{what} comes with a paid plan.")
|
| 83 |
+
return plan
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def report(db: Session, workspace: Workspace) -> dict:
|
| 87 |
+
"""What the wizard shows: premium or not, and each counter's used / limit."""
|
| 88 |
+
plan = effective_plan(db, workspace)
|
| 89 |
+
rows = {(r.period, r.metric): r.used for r in db.scalars(
|
| 90 |
+
select(UsageCounter).where(UsageCounter.workspace_id == workspace.id))}
|
| 91 |
+
return {"premium": plan.video_premium,
|
| 92 |
+
"limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))}
|
| 93 |
+
for m in VIDEO_LIMITS}}
|
backend/app/services/video_quota.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Video allowance, kept on the workspace's subscriptions row and enforced only by us.
|
| 2 |
+
|
| 3 |
+
blog2video charges every video to our own account; it does not know our workspaces. So we:
|
| 4 |
+
- reserve one video (videos_used + 1, only while below video_limit) before asking blog2video for it;
|
| 5 |
+
- refund it exactly once if the create is refused or generation fails (the b2v_videos row records which);
|
| 6 |
+
- keep video_plan and video_limit equal to the effective plan, and set videos_used back to 0 each month on plans
|
| 7 |
+
with a monthly allowance: on Stripe renewal (invoice.paid, see stripe_billing.handle_event), and monthly for
|
| 8 |
+
workspaces with no renewal to wait for (billing disabled, annual plans between renewals).
|
| 9 |
+
- Free's allowance is a lifetime total (plan.videos_monthly False): never reset. When a workspace drops to such a
|
| 10 |
+
plan, videos_used becomes its lifetime count of videos that were not refunded.
|
| 11 |
+
|
| 12 |
+
videos_used is only ever written with a single SQL UPDATE here, never through the ORM, so a sync can not
|
| 13 |
+
overwrite a concurrent reserve or refund.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import calendar
|
| 17 |
+
import logging
|
| 18 |
+
import uuid
|
| 19 |
+
from collections.abc import Callable
|
| 20 |
+
from datetime import UTC, datetime, timedelta
|
| 21 |
+
|
| 22 |
+
from sqlalchemy import case, func, select, update
|
| 23 |
+
from sqlalchemy.orm import Session
|
| 24 |
+
|
| 25 |
+
from app.config import settings
|
| 26 |
+
from app.db import SessionLocal
|
| 27 |
+
from app.models import Artifact, B2VVideo, Subscription, Workspace
|
| 28 |
+
from app.services import blog2video as b2v
|
| 29 |
+
from app.services.plans import PLANS, effective_plan
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
LIVE = {"active", "trialing", "past_due"}
|
| 34 |
+
RENEWAL_WINDOW = timedelta(days=45) # a Stripe renewal this close to the period start does the reset instead
|
| 35 |
+
FAILED = {"failed", "error"}
|
| 36 |
+
FINISHED = {"generated", "done"}
|
| 37 |
+
SWEEP_AFTER = timedelta(minutes=30) # the sweep only looks at videos older than this (users who closed the tab)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def add_month(when: datetime) -> datetime:
|
| 41 |
+
year, month = (when.year + 1, 1) if when.month == 12 else (when.year, when.month + 1)
|
| 42 |
+
return when.replace(year=year, month=month, day=min(when.day, calendar.monthrange(year, month)[1]))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _aware(when: datetime | None) -> datetime | None:
|
| 46 |
+
return when.replace(tzinfo=UTC) if when and when.tzinfo is None else when
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _row(db: Session, workspace_id: uuid.UUID) -> Subscription:
|
| 50 |
+
sub = db.scalar(select(Subscription).where(Subscription.workspace_id == workspace_id))
|
| 51 |
+
if not sub:
|
| 52 |
+
sub = Subscription(workspace_id=workspace_id, plan="free", status="active")
|
| 53 |
+
db.add(sub)
|
| 54 |
+
db.flush()
|
| 55 |
+
return sub
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def sync_video_quota(db: Session, workspace: Workspace) -> Subscription:
|
| 59 |
+
"""Make video_plan and video_limit match the workspace's effective plan. Cheap when nothing changed."""
|
| 60 |
+
plan = effective_plan(db, workspace)
|
| 61 |
+
sub = _row(db, workspace.id)
|
| 62 |
+
changed = False
|
| 63 |
+
if sub.video_plan != plan.id and not plan.videos_monthly:
|
| 64 |
+
# Onto a lifetime allowance: everything made so far counts (a Writer who made 10 can't make more on Free).
|
| 65 |
+
made = db.scalar(select(func.count(B2VVideo.id)).where(B2VVideo.workspace_id == workspace.id,
|
| 66 |
+
B2VVideo.quota_state != "refunded")) or 0
|
| 67 |
+
db.execute(update(Subscription).where(Subscription.id == sub.id).values(videos_used=made))
|
| 68 |
+
changed = True
|
| 69 |
+
if sub.video_plan != plan.id or sub.video_limit != plan.videos:
|
| 70 |
+
sub.video_plan, sub.video_limit = plan.id, plan.videos
|
| 71 |
+
changed = True
|
| 72 |
+
if sub.videos_period_start is None:
|
| 73 |
+
sub.videos_period_start = datetime.now(UTC)
|
| 74 |
+
changed = True
|
| 75 |
+
if changed:
|
| 76 |
+
db.commit()
|
| 77 |
+
return sub
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _renews_by_stripe(sub: Subscription) -> bool:
|
| 81 |
+
start, end = _aware(sub.videos_period_start), _aware(sub.current_period_end)
|
| 82 |
+
return bool(sub.provider_subscription_id and sub.status in LIVE and start and end
|
| 83 |
+
and end <= start + RENEWAL_WINDOW)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def monthly(sub: Subscription) -> bool:
|
| 87 |
+
plan = PLANS.get(sub.video_plan)
|
| 88 |
+
return plan.videos_monthly if plan else True
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def resets_at(sub: Subscription) -> datetime | None:
|
| 92 |
+
start = _aware(sub.videos_period_start)
|
| 93 |
+
if not start or not monthly(sub):
|
| 94 |
+
return None
|
| 95 |
+
return _aware(sub.current_period_end) if _renews_by_stripe(sub) else add_month(start)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def video_usage(db: Session, workspace: Workspace) -> dict:
|
| 99 |
+
sub = sync_video_quota(db, workspace)
|
| 100 |
+
db.refresh(sub) # videos_used changes under us (reserves and refunds from other requests)
|
| 101 |
+
reset = resets_at(sub)
|
| 102 |
+
start = _aware(sub.videos_period_start)
|
| 103 |
+
return {"used": sub.videos_used, "limit": sub.video_limit, "plan": sub.video_plan,
|
| 104 |
+
"period_start": start.isoformat() if start else None, "resets_at": reset.isoformat() if reset else None}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def reset_videos(db: Session, workspace_id: uuid.UUID, at: datetime | None = None) -> None:
|
| 108 |
+
db.execute(update(Subscription).where(Subscription.workspace_id == workspace_id)
|
| 109 |
+
.values(videos_used=0, videos_period_start=at or datetime.now(UTC)))
|
| 110 |
+
db.commit()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def reset_due_video_periods(session_factory: Callable[[], Session] = SessionLocal) -> int:
|
| 114 |
+
"""Monthly reset for workspaces a Stripe renewal will not reset in time. Runs from the worker's PERIODIC list."""
|
| 115 |
+
now = datetime.now(UTC)
|
| 116 |
+
count = 0
|
| 117 |
+
with session_factory() as db:
|
| 118 |
+
for sub in db.scalars(select(Subscription)):
|
| 119 |
+
start = _aware(sub.videos_period_start)
|
| 120 |
+
if start is None or _renews_by_stripe(sub) or not monthly(sub):
|
| 121 |
+
continue
|
| 122 |
+
if add_month(start) <= now:
|
| 123 |
+
reset_videos(db, sub.workspace_id, now)
|
| 124 |
+
count += 1
|
| 125 |
+
return count
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def reserve(db: Session, workspace_id: uuid.UUID) -> bool:
|
| 129 |
+
"""Take one video from the allowance, atomically: two requests can not both take the last one.
|
| 130 |
+
False = none left (do not call blog2video)."""
|
| 131 |
+
taken = db.execute(update(Subscription)
|
| 132 |
+
.where(Subscription.workspace_id == workspace_id,
|
| 133 |
+
Subscription.videos_used < Subscription.video_limit)
|
| 134 |
+
.values(videos_used=Subscription.videos_used + 1)).rowcount
|
| 135 |
+
db.commit()
|
| 136 |
+
return taken == 1
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def unreserve(db: Session, workspace_id: uuid.UUID) -> None:
|
| 140 |
+
"""Give back a reservation that never got a b2v_videos row."""
|
| 141 |
+
db.execute(update(Subscription).where(Subscription.workspace_id == workspace_id)
|
| 142 |
+
.values(videos_used=case((Subscription.videos_used > 0, Subscription.videos_used - 1), else_=0)))
|
| 143 |
+
db.commit()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def refund(db: Session, row: B2VVideo, status: str | None = None) -> bool:
|
| 147 |
+
"""Give the workspace its video back, exactly once per row, however many callers race here, and never once
|
| 148 |
+
it finished generating ('kept'). True if this call did the refund."""
|
| 149 |
+
values = {"quota_state": "refunded", "updated_at": datetime.now(UTC)}
|
| 150 |
+
if status:
|
| 151 |
+
values["status"] = status
|
| 152 |
+
won = db.execute(update(B2VVideo).where(B2VVideo.id == row.id, B2VVideo.quota_state == "charged")
|
| 153 |
+
.values(**values)).rowcount == 1
|
| 154 |
+
if won:
|
| 155 |
+
db.execute(update(Subscription).where(Subscription.workspace_id == row.workspace_id)
|
| 156 |
+
.values(videos_used=case((Subscription.videos_used > 0, Subscription.videos_used - 1),
|
| 157 |
+
else_=0)))
|
| 158 |
+
db.commit()
|
| 159 |
+
db.refresh(row)
|
| 160 |
+
return won
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def record_status(db: Session, row: B2VVideo, status: str) -> None:
|
| 164 |
+
"""Remember blog2video's latest status, refunding when it says the video failed. Once generation finished the
|
| 165 |
+
charge is kept (quota_state 'kept'): a later failed edit does not give the video back."""
|
| 166 |
+
if status in FAILED:
|
| 167 |
+
refund(db, row, "failed")
|
| 168 |
+
elif status and row.status != status:
|
| 169 |
+
values = {"status": status, "updated_at": datetime.now(UTC)}
|
| 170 |
+
if status in FINISHED:
|
| 171 |
+
values["quota_state"] = case((B2VVideo.quota_state == "charged", "kept"), else_=B2VVideo.quota_state)
|
| 172 |
+
db.execute(update(B2VVideo).where(B2VVideo.id == row.id).values(**values))
|
| 173 |
+
db.commit()
|
| 174 |
+
db.refresh(row)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def sweep_failed_videos(session_factory: Callable[[], Session] = SessionLocal) -> int:
|
| 178 |
+
"""Refund videos that failed while nobody was polling (the user closed the tab). Runs from PERIODIC."""
|
| 179 |
+
if not b2v.configured():
|
| 180 |
+
return 0
|
| 181 |
+
cutoff = datetime.now(UTC) - SWEEP_AFTER
|
| 182 |
+
refunded = 0
|
| 183 |
+
with session_factory() as db:
|
| 184 |
+
rows = db.scalars(select(B2VVideo).where(B2VVideo.quota_state == "charged",
|
| 185 |
+
B2VVideo.status.not_in(FINISHED),
|
| 186 |
+
B2VVideo.created_at < cutoff)).all()
|
| 187 |
+
for row in rows:
|
| 188 |
+
if row.b2v_video_id is None:
|
| 189 |
+
# The create never got an answer and the request died before refunding.
|
| 190 |
+
refunded += refund(db, row, "failed")
|
| 191 |
+
continue
|
| 192 |
+
try:
|
| 193 |
+
s = b2v.status(row.b2v_video_id, row.created_via)
|
| 194 |
+
except b2v.NotFound:
|
| 195 |
+
s = {"status": "failed", "error": "Video not found"}
|
| 196 |
+
except b2v.B2VError as e:
|
| 197 |
+
log.warning("video sweep: status of %s failed: %s", row.b2v_video_id, e)
|
| 198 |
+
continue
|
| 199 |
+
raw = str(s.get("status") or "")
|
| 200 |
+
if s.get("error") and raw not in FAILED:
|
| 201 |
+
raw = "failed"
|
| 202 |
+
if raw in FAILED:
|
| 203 |
+
refunded += refund(db, row, "failed")
|
| 204 |
+
if row.artifact_id and (a := db.get(Artifact, row.artifact_id)):
|
| 205 |
+
a.status = "failed"
|
| 206 |
+
a.content_json = {**(a.content_json or {}), "b2v_status": raw, "error": s.get("error")}
|
| 207 |
+
db.commit()
|
| 208 |
+
elif raw:
|
| 209 |
+
record_status(db, row, raw)
|
| 210 |
+
return refunded
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
AI_EDITS_ALERT_BELOW = 200
|
| 214 |
+
TEMPLATE_SLOTS_ALERT_BELOW = 3
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def check_capacity() -> dict | None:
|
| 218 |
+
"""Alert when our blog2video account runs low: our workspaces' limits added up can exceed it. Hourly."""
|
| 219 |
+
if not b2v.configured():
|
| 220 |
+
return None
|
| 221 |
+
try:
|
| 222 |
+
account = b2v.account() or {}
|
| 223 |
+
except b2v.B2VError as e:
|
| 224 |
+
log.error("ALERT blog2video capacity check failed: %s", e)
|
| 225 |
+
return None
|
| 226 |
+
low = []
|
| 227 |
+
limit, used = account.get("video_limit"), account.get("videos_used_this_period")
|
| 228 |
+
if not account.get("can_create_video") or (
|
| 229 |
+
limit is not None and used is not None and limit - used < settings.b2v_capacity_alert_below):
|
| 230 |
+
low.append(f"videos {used}/{limit} used")
|
| 231 |
+
ai = (account.get("ai_edit_allowance_remaining") or 0) + (account.get("ai_edit_credits") or 0)
|
| 232 |
+
if ai < AI_EDITS_ALERT_BELOW:
|
| 233 |
+
low.append(f"{ai} AI edits left")
|
| 234 |
+
slots, made = account.get("custom_template_limit"), account.get("custom_templates_created")
|
| 235 |
+
if slots is not None and made is not None and slots - made < TEMPLATE_SLOTS_ALERT_BELOW:
|
| 236 |
+
low.append(f"template slots {made}/{slots} used")
|
| 237 |
+
if low:
|
| 238 |
+
log.error("ALERT blog2video account (plan %s) is running low: %s", account.get("plan"), "; ".join(low))
|
| 239 |
+
return account
|
backend/app/worker.py
CHANGED
|
@@ -44,7 +44,7 @@ from app.models import (
|
|
| 44 |
from app.pipeline import generate, launchkit, media
|
| 45 |
from app.pipeline.generate import NothingToDo
|
| 46 |
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 47 |
-
from app.services import launchpad, tts
|
| 48 |
from app.services.email import email_service
|
| 49 |
from app.services.email_verification import purge_old_codes
|
| 50 |
from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
|
|
@@ -226,7 +226,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
|
|
| 226 |
"resurface_scan": handle_resurface,
|
| 227 |
"summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
|
| 228 |
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 229 |
-
"video": _artifact_handler(media.make_video, "
|
| 230 |
"quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
|
| 231 |
"carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
|
| 232 |
"launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
|
|
@@ -364,6 +364,9 @@ PERIODIC = [
|
|
| 364 |
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 365 |
Periodic("update_email_batch", 300, update_email_batch),
|
| 366 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
|
|
|
|
|
|
|
|
|
| 367 |
]
|
| 368 |
|
| 369 |
|
|
|
|
| 44 |
from app.pipeline import generate, launchkit, media
|
| 45 |
from app.pipeline.generate import NothingToDo
|
| 46 |
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 47 |
+
from app.services import launchpad, tts, video_quota
|
| 48 |
from app.services.email import email_service
|
| 49 |
from app.services.email_verification import purge_old_codes
|
| 50 |
from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
|
|
|
|
| 226 |
"resurface_scan": handle_resurface,
|
| 227 |
"summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
|
| 228 |
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 229 |
+
"video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
|
| 230 |
"quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
|
| 231 |
"carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
|
| 232 |
"launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
|
|
|
|
| 364 |
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 365 |
Periodic("update_email_batch", 300, update_email_batch),
|
| 366 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
| 367 |
+
Periodic("video_period_reset", 3600, video_quota.reset_due_video_periods),
|
| 368 |
+
Periodic("video_refund_sweep", 600, video_quota.sweep_failed_videos),
|
| 369 |
+
Periodic("video_capacity_check", 3600, video_quota.check_capacity),
|
| 370 |
]
|
| 371 |
|
| 372 |
|
backend/requirements.txt
CHANGED
|
@@ -13,6 +13,7 @@ requests==2.*
|
|
| 13 |
boto3==1.35.*
|
| 14 |
resend==2.*
|
| 15 |
httpx==0.28.*
|
|
|
|
| 16 |
dspy==2.6.*
|
| 17 |
feedparser==6.*
|
| 18 |
beautifulsoup4==4.*
|
|
|
|
| 13 |
boto3==1.35.*
|
| 14 |
resend==2.*
|
| 15 |
httpx==0.28.*
|
| 16 |
+
python-multipart==0.0.*
|
| 17 |
dspy==2.6.*
|
| 18 |
feedparser==6.*
|
| 19 |
beautifulsoup4==4.*
|
backend/tests/conftest.py
CHANGED
|
@@ -11,6 +11,8 @@ os.environ["ELEVENLABS_API_KEY"] = ""
|
|
| 11 |
os.environ["FIRECRAWL_API_KEY"] = ""
|
| 12 |
os.environ["R2_PREFIX"] = ""
|
| 13 |
os.environ["TYPESAFE_API_KEY"] = ""
|
|
|
|
|
|
|
| 14 |
os.environ.setdefault("EMAIL_PROVIDER", "console")
|
| 15 |
os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
|
| 16 |
os.environ.setdefault("BILLING_ENABLED", "false")
|
|
|
|
| 11 |
os.environ["FIRECRAWL_API_KEY"] = ""
|
| 12 |
os.environ["R2_PREFIX"] = ""
|
| 13 |
os.environ["TYPESAFE_API_KEY"] = ""
|
| 14 |
+
os.environ["B2V_API_BASE_URL"] = ""
|
| 15 |
+
os.environ["B2V_API_KEY"] = ""
|
| 16 |
os.environ.setdefault("EMAIL_PROVIDER", "console")
|
| 17 |
os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
|
| 18 |
os.environ.setdefault("BILLING_ENABLED", "false")
|
backend/tests/test_billing.py
CHANGED
|
@@ -23,7 +23,7 @@ def billing_on(monkeypatch):
|
|
| 23 |
def test_status_without_billing_has_no_upgrade_nudges(client, auth):
|
| 24 |
body = client.get("/api/billing/status", headers=auth).json()
|
| 25 |
assert body["plan"]["id"] == "studio" and body["can_upgrade"] is False
|
| 26 |
-
assert {m["key"] for m in body["meters"]} == {"audio_minutes", "
|
| 27 |
assert all(n["tone"] == "action" for n in body["nudges"])
|
| 28 |
assert body["nudges"][0]["id"] == "act-source"
|
| 29 |
|
|
@@ -76,3 +76,69 @@ def test_plan_limit_error_names_the_upgrade():
|
|
| 76 |
assert upgrade_for(PLANS["studio"], "audio_minutes") is None
|
| 77 |
detail = plan_limit_error(PLANS["free"], "audio_minutes", "x").detail
|
| 78 |
assert detail["code"] == "plan_limit" and detail["upgrade_to"] == "writer" and detail["kind"] == "audio_minutes"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def test_status_without_billing_has_no_upgrade_nudges(client, auth):
|
| 24 |
body = client.get("/api/billing/status", headers=auth).json()
|
| 25 |
assert body["plan"]["id"] == "studio" and body["can_upgrade"] is False
|
| 26 |
+
assert {m["key"] for m in body["meters"]} == {"audio_minutes", "videos", "launch_kits", "sources"}
|
| 27 |
assert all(n["tone"] == "action" for n in body["nudges"])
|
| 28 |
assert body["nudges"][0]["id"] == "act-source"
|
| 29 |
|
|
|
|
| 76 |
assert upgrade_for(PLANS["studio"], "audio_minutes") is None
|
| 77 |
detail = plan_limit_error(PLANS["free"], "audio_minutes", "x").detail
|
| 78 |
assert detail["code"] == "plan_limit" and detail["upgrade_to"] == "writer" and detail["kind"] == "audio_minutes"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.fixture()
|
| 82 |
+
def plans_table(monkeypatch):
|
| 83 |
+
"""Point the plan catalog at its own database, seeded like migration 0006. (Not the client's: that one
|
| 84 |
+
shares a single connection, and the catalog's own session would roll back the request's work.)"""
|
| 85 |
+
from sqlalchemy import create_engine
|
| 86 |
+
from sqlalchemy.orm import sessionmaker
|
| 87 |
+
from sqlalchemy.pool import StaticPool
|
| 88 |
+
|
| 89 |
+
import app.db
|
| 90 |
+
from app.db import Base
|
| 91 |
+
from app.models import PlanRecord
|
| 92 |
+
from app.services.plans import DEFAULT_PLANS, PLANS
|
| 93 |
+
|
| 94 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 95 |
+
Base.metadata.create_all(engine)
|
| 96 |
+
session_factory = sessionmaker(bind=engine, expire_on_commit=False)
|
| 97 |
+
monkeypatch.setattr(app.db, "SessionLocal", session_factory)
|
| 98 |
+
with session_factory() as db:
|
| 99 |
+
for i, p in enumerate(DEFAULT_PLANS.values()):
|
| 100 |
+
db.add(PlanRecord(id=p.id, sort_order=i, name=p.name, tagline=p.tagline,
|
| 101 |
+
price_monthly_usd=p.price_monthly_usd, sources=p.sources, indexed_posts=p.indexed_posts,
|
| 102 |
+
audio_minutes=p.audio_minutes, videos=p.videos, launch_kits=p.launch_kits,
|
| 103 |
+
voice_cloning=p.voice_cloning, brand_kit=p.brand_kit, features=list(p.features)))
|
| 104 |
+
db.commit()
|
| 105 |
+
PLANS.reload()
|
| 106 |
+
yield session_factory
|
| 107 |
+
PLANS.reload()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_plans_come_from_the_table(plans_table):
|
| 111 |
+
from app.models import PlanRecord
|
| 112 |
+
from app.services.plans import PLANS, next_plan
|
| 113 |
+
|
| 114 |
+
with plans_table() as db:
|
| 115 |
+
db.get(PlanRecord, "studio").videos = 50
|
| 116 |
+
db.get(PlanRecord, "writer").features = ["Edited copy"]
|
| 117 |
+
db.commit()
|
| 118 |
+
PLANS.reload()
|
| 119 |
+
assert PLANS["studio"].videos == 50 and PLANS["writer"].features == ("Edited copy",)
|
| 120 |
+
assert list(PLANS) == ["free", "writer", "studio"] and next_plan(PLANS["free"]).id == "writer"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_edited_limit_reaches_video_quota(plans_table, client, auth, db_session):
|
| 124 |
+
from app.models import PlanRecord, Workspace
|
| 125 |
+
from app.services.plans import PLANS
|
| 126 |
+
from app.services.video_quota import sync_video_quota
|
| 127 |
+
|
| 128 |
+
with plans_table() as db:
|
| 129 |
+
db.get(PlanRecord, "studio").videos = 42 # billing disabled: everyone is on Studio
|
| 130 |
+
db.commit()
|
| 131 |
+
PLANS.reload()
|
| 132 |
+
ws = db_session.query(Workspace).one()
|
| 133 |
+
assert sync_video_quota(db_session, ws).video_limit == 42
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_empty_or_broken_table_falls_back_to_defaults(plans_table):
|
| 137 |
+
from app.models import PlanRecord
|
| 138 |
+
from app.services.plans import DEFAULT_PLANS, PLANS
|
| 139 |
+
|
| 140 |
+
with plans_table() as db:
|
| 141 |
+
db.query(PlanRecord).filter(PlanRecord.id == "free").delete() # no free plan: unusable
|
| 142 |
+
db.commit()
|
| 143 |
+
PLANS.reload()
|
| 144 |
+
assert PLANS["free"] == DEFAULT_PLANS["free"] and PLANS["studio"].videos == DEFAULT_PLANS["studio"].videos
|
backend/tests/test_buildout.py
CHANGED
|
@@ -404,26 +404,32 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
|
|
| 404 |
assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit"
|
| 405 |
|
| 406 |
|
| 407 |
-
def
|
| 408 |
-
from app.pipeline import media
|
| 409 |
-
|
| 410 |
connect(client, auth, run_jobs)
|
| 411 |
doc = docs(client, auth)[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
sent = {}
|
| 413 |
monkeypatch.setattr(media, "request_render", lambda job, artifact, comp, props=None, stills=None:
|
| 414 |
sent.update(comp=comp, props=props, job=str(job.id)))
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
{"type": "section", "on_screen_text": "Readers valued it", "narration": "", "duration_hint_s": 3,
|
| 418 |
-
"visual": ""},
|
| 419 |
-
]}
|
| 420 |
-
art = client.post("/api/artifacts/generate", json={"type": "video", "document_id": doc["id"], "style": "short"},
|
| 421 |
-
headers=auth).json()
|
| 422 |
job = run_jobs()[0]
|
| 423 |
assert job.status == "running" # handed off to the renderer
|
| 424 |
-
assert sent["comp"] == "
|
| 425 |
-
from app.config import settings
|
| 426 |
-
|
| 427 |
r = client.post(f"/api/internal/jobs/{sent['job']}/progress", headers={"x-internal-token": settings.internal_token},
|
| 428 |
json={"status": "done", "progress": 1, "storage_key": "ws/x/video.mp4", "render_seconds": 12})
|
| 429 |
assert r.status_code == 200
|
|
|
|
| 404 |
assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit"
|
| 405 |
|
| 406 |
|
| 407 |
+
def test_native_video_moved_to_blog2video(client, auth, run_jobs, feed):
|
|
|
|
|
|
|
| 408 |
connect(client, auth, run_jobs)
|
| 409 |
doc = docs(client, auth)[0]
|
| 410 |
+
r = client.post("/api/artifacts/generate", json={"type": "video", "document_id": doc["id"], "style": "short"},
|
| 411 |
+
headers=auth)
|
| 412 |
+
assert r.status_code == 410
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def test_audiogram_hands_off_and_renderer_callback(client, auth, monkeypatch, db_session, run_jobs):
|
| 416 |
+
from app.config import settings
|
| 417 |
+
from app.models import Artifact, Workspace
|
| 418 |
+
from app.pipeline import media
|
| 419 |
+
|
| 420 |
+
ws = db_session.query(Workspace).one()
|
| 421 |
+
audio = Artifact(workspace_id=ws.id, type="audio_overview", status="ready", storage_key="ws/x/audio.mp3",
|
| 422 |
+
content_json={"title": "Pricing", "duration_s": 42, "segments": []})
|
| 423 |
+
db_session.add(audio)
|
| 424 |
+
db_session.commit()
|
| 425 |
sent = {}
|
| 426 |
monkeypatch.setattr(media, "request_render", lambda job, artifact, comp, props=None, stills=None:
|
| 427 |
sent.update(comp=comp, props=props, job=str(job.id)))
|
| 428 |
+
art = client.post("/api/artifacts/generate", json={"type": "video", "style": "audiogram",
|
| 429 |
+
"audio_artifact_id": str(audio.id)}, headers=auth).json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
job = run_jobs()[0]
|
| 431 |
assert job.status == "running" # handed off to the renderer
|
| 432 |
+
assert sent["comp"] == "AudiogramSquare"
|
|
|
|
|
|
|
| 433 |
r = client.post(f"/api/internal/jobs/{sent['job']}/progress", headers={"x-internal-token": settings.internal_token},
|
| 434 |
json={"status": "done", "progress": 1, "storage_key": "ws/x/video.mp4", "render_seconds": 12})
|
| 435 |
assert r.status_code == 200
|
backend/tests/test_simplified_flows.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
"""One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
|
| 2 |
|
|
|
|
| 3 |
from sqlalchemy import select
|
|
|
|
| 4 |
|
| 5 |
from app.config import settings
|
| 6 |
-
from app.models import Job
|
| 7 |
from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
|
| 8 |
|
| 9 |
|
|
@@ -23,6 +25,17 @@ def test_archive_notebook_is_created_once_and_listed_first(client, auth, run_job
|
|
| 23 |
assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed): # noqa: F811
|
| 27 |
connect(client, auth, run_jobs)
|
| 28 |
art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
|
|
|
|
| 1 |
"""One-click flows: the "All posts" notebook, whole-archive generation, and jobs that run after a sync."""
|
| 2 |
|
| 3 |
+
import pytest
|
| 4 |
from sqlalchemy import select
|
| 5 |
+
from sqlalchemy.exc import IntegrityError
|
| 6 |
|
| 7 |
from app.config import settings
|
| 8 |
+
from app.models import Job, Notebook, Workspace
|
| 9 |
from tests.test_buildout import auth, connect, docs, feed, llm # noqa: F401 (fixtures)
|
| 10 |
|
| 11 |
|
|
|
|
| 25 |
assert nb["is_archive"] and len(nb["documents"]) == len(indexed)
|
| 26 |
|
| 27 |
|
| 28 |
+
def test_a_workspace_cannot_have_two_archive_notebooks(client, auth, db_session): # noqa: F811
|
| 29 |
+
client.post("/api/notebooks/archive", headers=auth)
|
| 30 |
+
ws = db_session.scalar(select(Workspace))
|
| 31 |
+
db_session.add(Notebook(workspace_id=ws.id, title="All posts", is_archive=True))
|
| 32 |
+
with pytest.raises(IntegrityError):
|
| 33 |
+
db_session.commit()
|
| 34 |
+
db_session.rollback()
|
| 35 |
+
db_session.add(Notebook(workspace_id=ws.id, title="Mine")) # ordinary notebooks are not limited
|
| 36 |
+
db_session.commit()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
def test_generate_from_whole_archive_uses_the_archive_notebook(client, auth, run_jobs, feed): # noqa: F811
|
| 40 |
connect(client, auth, run_jobs)
|
| 41 |
art = client.post("/api/artifacts/generate", json={"type": "summary", "archive": True}, headers=auth).json()
|
backend/tests/test_stripe_billing.py
CHANGED
|
@@ -199,3 +199,37 @@ def test_upgrade_needing_3ds_sends_user_to_invoice(client, stripe_on, owner, db_
|
|
| 199 |
r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
|
| 200 |
assert r.json()["url"] == "https://invoice.stripe.test/i_1"
|
| 201 |
assert plan_of(db_session, ws) == "writer" # switches only once the payment succeeds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
|
| 200 |
assert r.json()["url"] == "https://invoice.stripe.test/i_1"
|
| 201 |
assert plan_of(db_session, ws) == "writer" # switches only once the payment succeeds
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def videos_used(db, ws) -> int:
|
| 205 |
+
db.expire_all()
|
| 206 |
+
return db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).videos_used
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def set_videos_used(db, ws, n: int) -> None:
|
| 210 |
+
db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).videos_used = n
|
| 211 |
+
db.commit()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@pytest.mark.parametrize("reason,resets", [("subscription_cycle", True), ("subscription_create", True),
|
| 215 |
+
("subscription_update", False)])
|
| 216 |
+
def test_invoice_paid_resets_videos(client, stripe_on, owner, db_session, reason, resets):
|
| 217 |
+
_, ws = owner
|
| 218 |
+
stripe_on.add_sub("sub_v", ws.id, plan="writer")
|
| 219 |
+
set_videos_used(db_session, ws, 7)
|
| 220 |
+
paid = event("invoice.paid", {"subscription": "sub_v", "customer": "cus_1", "billing_reason": reason})
|
| 221 |
+
send(client, paid)
|
| 222 |
+
assert videos_used(db_session, ws) == (0 if resets else 7)
|
| 223 |
+
# A retried delivery of the same event must not wipe videos made since.
|
| 224 |
+
set_videos_used(db_session, ws, 3)
|
| 225 |
+
send(client, paid)
|
| 226 |
+
assert videos_used(db_session, ws) == 3
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_subscription_sync_sets_video_limit(client, stripe_on, owner, db_session):
|
| 230 |
+
_, ws = owner
|
| 231 |
+
stripe_on.add_sub("sub_l", ws.id, plan="studio")
|
| 232 |
+
send(client, event("customer.subscription.created", stripe_on.subs["sub_l"]))
|
| 233 |
+
db_session.expire_all()
|
| 234 |
+
sub = db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
|
| 235 |
+
assert (sub.video_plan, sub.video_limit) == ("studio", 20)
|
backend/tests/test_videos.py
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Videos through blog2video, with a fake blog2video: API key, denylist, the 3-step create (link, post, upload,
|
| 2 |
+
multi-link), our own quotas and counters, premium gating, and workspace isolation for videos, templates, voices
|
| 3 |
+
and styles (the integration guide's acceptance checklist)."""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import json as jsonlib
|
| 7 |
+
import logging
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import UTC, datetime, timedelta
|
| 10 |
+
|
| 11 |
+
import httpx
|
| 12 |
+
import pytest
|
| 13 |
+
from sqlalchemy import select
|
| 14 |
+
|
| 15 |
+
from app.config import settings
|
| 16 |
+
from app.models import (
|
| 17 |
+
Artifact,
|
| 18 |
+
B2VCustomVoice,
|
| 19 |
+
B2VStyle,
|
| 20 |
+
B2VTemplate,
|
| 21 |
+
B2VVideo,
|
| 22 |
+
Document,
|
| 23 |
+
Notebook,
|
| 24 |
+
Subscription,
|
| 25 |
+
UsageCounter,
|
| 26 |
+
UserSavedVoice,
|
| 27 |
+
Workspace,
|
| 28 |
+
)
|
| 29 |
+
from app.services import blog2video, video_limits, video_quota
|
| 30 |
+
from tests.conftest import last_code
|
| 31 |
+
|
| 32 |
+
KEY = "b2v_live_test_key"
|
| 33 |
+
API = "https://b2v.test"
|
| 34 |
+
SARAH, BELLA = "EXAVITQu4vr4xnSDxMaL", "hpp4J3VqNfWAUOO0d1Us" # a free and a paid built-in voice
|
| 35 |
+
|
| 36 |
+
DEFAULTS: dict[str, object] = {
|
| 37 |
+
"GET /api/templates": [{"id": "default", "name": "Default"}, {"id": "geometric", "name": "Geometric",
|
| 38 |
+
"preview_colors": {"accent": "#FF0000"}}],
|
| 39 |
+
"GET /api/crafted-templates": [{"id": "crafted_neon", "name": "Neon"}],
|
| 40 |
+
"GET /api/voices/prebuilt": {"voices": [
|
| 41 |
+
{"voice_id": SARAH, "name": "Sarah - Mature", "preview_url": "https://cdn.test/sarah.mp3",
|
| 42 |
+
"labels": {"gender": "female", "accent": "american"}, "description": "Warm", "plan": "free"},
|
| 43 |
+
{"voice_id": BELLA, "name": "Bella - Bright", "preview_url": "https://cdn.test/bella.mp3",
|
| 44 |
+
"labels": {"gender": "female", "accent": "british"}, "description": "Bright", "plan": "paid"}]},
|
| 45 |
+
"GET /api/background-music/tracks": [{"track_id": "corporate_upbeat", "display_name": "Corporate Upbeat"}],
|
| 46 |
+
"GET /api/video-styles": {"styles": [
|
| 47 |
+
{"id": "explainer", "name": "Explainer", "kind": "builtin"},
|
| 48 |
+
{"id": "your_style", "name": "Your Style", "kind": "learned"},
|
| 49 |
+
{"id": "custom:7", "custom_id": 7, "name": "Mine", "kind": "custom", "guidance": "Short."},
|
| 50 |
+
{"id": "custom:9", "custom_id": 9, "name": "Theirs", "kind": "custom", "guidance": "Long."}]},
|
| 51 |
+
"POST /api/voices/custom": {"id": 31, "voice_id": "vc_designed", "name": "Narrator", "preview_url": None},
|
| 52 |
+
"POST /api/voices/clone": {"id": 32, "voice_id": "vc_clone", "name": "Me", "preview_url": None},
|
| 53 |
+
"POST /api/voices/design-from-prompt": {"previews": [{"generated_voice_id": "g1", "audio_base_64": "AAA",
|
| 54 |
+
"media_type": "audio/mpeg", "duration_secs": 3}]},
|
| 55 |
+
"POST /api/video-styles/custom": {"id": "custom:41", "custom_id": 41, "name": "Punchy", "guidance": "Punchy."},
|
| 56 |
+
"POST /api/custom-templates": {"id": 58, "name": "Brand"},
|
| 57 |
+
"POST /api/custom-templates/extract-theme-from-prompt": {"extractable": True, "theme": {"colors": {}}},
|
| 58 |
+
"GET /api/auth/me": {"plan": "pro", "video_limit": 150, "videos_used_this_period": 145, "can_create_video": True,
|
| 59 |
+
"ai_edit_allowance_remaining": 4000, "ai_edit_credits": 0, "custom_template_limit": 20,
|
| 60 |
+
"custom_templates_created": 3},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class FakeB2V:
|
| 65 |
+
"""Records every call; answers from a queue of (status, body) per "METHOD path", else a sensible default."""
|
| 66 |
+
|
| 67 |
+
def __init__(self):
|
| 68 |
+
self.calls: list[dict] = []
|
| 69 |
+
self.queued: dict[str, list[tuple[int, object]]] = {}
|
| 70 |
+
self.next_id = 812
|
| 71 |
+
|
| 72 |
+
def queue(self, method: str, path: str, *answers: tuple[int, object]):
|
| 73 |
+
self.queued.setdefault(f"{method} {path}", []).extend(answers)
|
| 74 |
+
|
| 75 |
+
def paths(self, method: str | None = None) -> list[str]:
|
| 76 |
+
return [c["path"] for c in self.calls if method is None or c["method"] == method]
|
| 77 |
+
|
| 78 |
+
def __call__(self, method, url, json=None, params=None, data=None, files=None, timeout=None, headers=None):
|
| 79 |
+
if json is not None:
|
| 80 |
+
jsonlib.dumps(json) # what httpx does: anything that is not plain JSON fails here, as it would for real
|
| 81 |
+
path = url.removeprefix(API)
|
| 82 |
+
key = f"{method} {path}"
|
| 83 |
+
self.calls.append({"method": method, "path": path, "json": json, "data": data, "files": files,
|
| 84 |
+
"headers": headers or {}, "params": params})
|
| 85 |
+
answers = self.queued.get(key)
|
| 86 |
+
ctype = "application/json"
|
| 87 |
+
if answers:
|
| 88 |
+
status, body = answers.pop(0)
|
| 89 |
+
if isinstance(body, Exception):
|
| 90 |
+
raise body
|
| 91 |
+
elif key in DEFAULTS:
|
| 92 |
+
status, body = 200, DEFAULTS[key]
|
| 93 |
+
elif key in ("POST /api/v1/videos", "POST /api/projects/upload"):
|
| 94 |
+
vid, self.next_id = self.next_id, self.next_id + 1
|
| 95 |
+
status, body = (202, {"video_id": vid, "state": "queued"}) if "v1" in path else (200, {"id": vid})
|
| 96 |
+
elif path.startswith("/api/embed/token/"):
|
| 97 |
+
status, body = 200, {"embed_token": "t", "preview_url": f"{API}/embed/t"}
|
| 98 |
+
elif path.endswith("/status"):
|
| 99 |
+
status, body = 200, {"status": "scripted", "step": 2, "running": True, "ready": False, "error": None,
|
| 100 |
+
"video_url": None}
|
| 101 |
+
elif method == "GET" and path.startswith("/api/projects/") and path.count("/") == 3:
|
| 102 |
+
status, body = 200, {"id": int(path.rsplit("/", 1)[1]), "status": "generated", "r2_video_url": None,
|
| 103 |
+
"scenes": [{"id": 2, "order": 2, "title": "B"}, {"id": 1, "order": 1, "title": "A"}]}
|
| 104 |
+
elif path in ("/api/voice/preview",) or path.endswith("/download"):
|
| 105 |
+
return httpx.Response(200, content=b"ID3audio", headers={"content-type": "audio/mpeg"},
|
| 106 |
+
request=httpx.Request(method, url))
|
| 107 |
+
else:
|
| 108 |
+
status, body = 200, {"ok": True}
|
| 109 |
+
return httpx.Response(status, content=_json(body), headers={"content-type": ctype},
|
| 110 |
+
request=httpx.Request(method, url))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _json(body) -> bytes:
|
| 114 |
+
return json.dumps(body).encode()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@pytest.fixture()
|
| 118 |
+
def b2v(monkeypatch):
|
| 119 |
+
fake = FakeB2V()
|
| 120 |
+
monkeypatch.setattr(settings, "b2v_api_base_url", API)
|
| 121 |
+
monkeypatch.setattr(settings, "b2v_api_key", KEY)
|
| 122 |
+
monkeypatch.setattr(blog2video.httpx, "request", fake)
|
| 123 |
+
monkeypatch.setattr(blog2video.time, "sleep", lambda s: None)
|
| 124 |
+
blog2video.clear_cache()
|
| 125 |
+
return fake
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def register(client, email: str) -> dict:
|
| 129 |
+
client.post("/api/auth/email/register/start", json={"email": email, "password": "stardust-42", "name": "Ada"})
|
| 130 |
+
r = client.post("/api/auth/email/register/verify", json={"email": email, "code": last_code()})
|
| 131 |
+
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@pytest.fixture()
|
| 135 |
+
def owner(client, db_session):
|
| 136 |
+
headers = register(client, "ada@example.com")
|
| 137 |
+
return headers, db_session.scalar(select(Workspace))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@pytest.fixture()
|
| 141 |
+
def other(client, db_session, owner):
|
| 142 |
+
headers = register(client, "grace@example.com")
|
| 143 |
+
ws = db_session.scalar(select(Workspace).where(Workspace.id != owner[1].id))
|
| 144 |
+
return headers, ws
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@pytest.fixture()
|
| 148 |
+
def free_plan(monkeypatch):
|
| 149 |
+
monkeypatch.setattr(settings, "billing_enabled", True)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def add_doc(db, ws, url: str, text: str = "") -> Document:
|
| 153 |
+
doc = Document(workspace_id=ws.id, title="On Pricing", url=url, clean_text=text, path="sources/x/pricing.md")
|
| 154 |
+
db.add(doc)
|
| 155 |
+
db.commit()
|
| 156 |
+
return doc
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def creates(fake: FakeB2V) -> list[dict]:
|
| 160 |
+
return [c for c in fake.calls if c["method"] == "POST" and c["path"] == "/api/v1/videos"]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def used(db, ws) -> int:
|
| 164 |
+
db.expire_all()
|
| 165 |
+
return db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).videos_used
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def make_video(client, headers, **body) -> dict:
|
| 169 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", **body}, headers=headers)
|
| 170 |
+
assert r.status_code == 201, r.text
|
| 171 |
+
return r.json()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def age(db, row: B2VVideo) -> None:
|
| 175 |
+
row.created_at = datetime.now(UTC) - timedelta(hours=1)
|
| 176 |
+
db.commit()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# Client
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_sends_our_api_key(b2v):
|
| 183 |
+
blog2video.account()
|
| 184 |
+
assert b2v.calls[0]["headers"]["Authorization"] == f"Bearer {KEY}"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_create_retries_with_the_same_idempotency_key(b2v):
|
| 188 |
+
b2v.queue("POST", "/api/v1/videos", (0, httpx.ConnectTimeout("slow")), (503, {"detail": "busy"}))
|
| 189 |
+
assert blog2video.create_video({"url": "https://a.test"}, "ws:1")["video_id"] == 812
|
| 190 |
+
assert [c["headers"]["Idempotency-Key"] for c in b2v.calls] == ["ws:1"] * 3
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_other_calls_are_not_retried(b2v):
|
| 194 |
+
b2v.queue("GET", "/api/auth/me", (500, {"detail": "boom"}))
|
| 195 |
+
with pytest.raises(blog2video.Upstream):
|
| 196 |
+
blog2video.account()
|
| 197 |
+
assert len(b2v.calls) == 1
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@pytest.mark.parametrize("status", [401, 402, 403])
|
| 201 |
+
def test_account_problems_alert_ops(b2v, caplog, status):
|
| 202 |
+
b2v.queue("GET", "/api/auth/me", (status, {"detail": {"error": "x"}}))
|
| 203 |
+
with caplog.at_level(logging.ERROR), pytest.raises(blog2video.Upstream if status != 402
|
| 204 |
+
else blog2video.AccountOutOfVideos):
|
| 205 |
+
blog2video.account()
|
| 206 |
+
assert "ALERT" in caplog.text and KEY not in caplog.text
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@pytest.mark.parametrize("method,path", [
|
| 210 |
+
("POST", "/api/projects"), ("POST", "/api/projects/bulk"), ("GET", "/api/projects"),
|
| 211 |
+
("GET", "/api/projects/template-availability"), ("GET", "/api/voices/saved"), ("POST", "/api/voices/saved"),
|
| 212 |
+
("DELETE", "/api/voices/saved/abc"), ("PATCH", "/api/video-styles/builtin/explainer"),
|
| 213 |
+
("PUT", "/api/video-styles/pin"), ("PUT", "/api/video-styles/selection"), ("PATCH", "/api/video-styles/your-style"),
|
| 214 |
+
("DELETE", "/api/auth/me/script-preferences"),
|
| 215 |
+
])
|
| 216 |
+
def test_account_wide_endpoints_are_never_called(b2v, method, path):
|
| 217 |
+
with pytest.raises(blog2video.Denied):
|
| 218 |
+
blog2video.request(method, path)
|
| 219 |
+
assert b2v.calls == []
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# Create: the 3 steps
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_link_create_forwards_every_wizard_field(client, b2v, owner, db_session):
|
| 226 |
+
headers, ws = owner
|
| 227 |
+
body = {"stock_footage_enabled": False, "aspect_ratio": "portrait",
|
| 228 |
+
"video_length": "medium", "logo_position": "top_left", "logo_opacity": 0.5, "template": "geometric",
|
| 229 |
+
"video_style": "storytelling", "accent_color": "#FF5A1F", "bg_color": "#0B0B0F", "text_color": "#FFFFFF",
|
| 230 |
+
"content_language": "es", "voice_gender": "female", "voice_accent": "british",
|
| 231 |
+
"custom_voice_id": SARAH, "voice_emotion": '["0.5","1.0","calm","0","1"]',
|
| 232 |
+
"bgm_track_id": "corporate_upbeat", "bgm_volume": 0.2}
|
| 233 |
+
art = make_video(client, headers, **body)
|
| 234 |
+
call = creates(b2v)[0]
|
| 235 |
+
for k, v in body.items():
|
| 236 |
+
assert call["json"][k] == v, k
|
| 237 |
+
assert call["json"]["script_review_enabled"] is False
|
| 238 |
+
assert call["headers"]["Idempotency-Key"].startswith(f"{ws.id}:")
|
| 239 |
+
assert call["json"]["external_user_id"] == str(ws.id)
|
| 240 |
+
row = db_session.scalar(select(B2VVideo))
|
| 241 |
+
assert (row.b2v_video_id, row.quota_state, row.created_via, row.template_ref) == (812, "charged", "v1", "geometric")
|
| 242 |
+
assert art["content"]["b2v_video_id"] == 812 and used(db_session, ws) == 1
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_script_review_is_always_off(client, b2v, owner):
|
| 246 |
+
make_video(client, owner[0], script_review_enabled=True) # an old client asking for it is ignored
|
| 247 |
+
assert creates(b2v)[0]["json"]["script_review_enabled"] is False
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_create_from_feed_post_and_uploaded_post(client, b2v, owner, db_session):
|
| 251 |
+
headers, ws = owner
|
| 252 |
+
doc = add_doc(db_session, ws, "https://ada.example.com/p/pricing")
|
| 253 |
+
client.post("/api/videos", json={"document_id": str(doc.id)}, headers=headers)
|
| 254 |
+
assert creates(b2v)[-1]["json"]["url"] == "https://ada.example.com/p/pricing"
|
| 255 |
+
doc = add_doc(db_session, ws, "upload://notes.md", "Charging more made my readers take the work seriously. " * 3)
|
| 256 |
+
client.post("/api/videos", json={"document_id": str(doc.id)}, headers=headers)
|
| 257 |
+
sent = creates(b2v)[-1]["json"]
|
| 258 |
+
assert "url" not in sent and sent["content"].startswith("Charging more")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def test_notebook_posts_make_one_combined_video(client, b2v, owner, db_session):
|
| 262 |
+
headers, ws = owner
|
| 263 |
+
new = add_doc(db_session, ws, "https://ada.example.com/p/new", "Raise prices once a year, and say why. " * 3)
|
| 264 |
+
old = add_doc(db_session, ws, "https://ada.example.com/p/old", "Start cheaper than you think you should. " * 3)
|
| 265 |
+
new.title, new.published_at = "Annual raises", datetime(2026, 5, 1, tzinfo=UTC)
|
| 266 |
+
old.title, old.published_at = "Starting out", datetime(2025, 1, 1, tzinfo=UTC)
|
| 267 |
+
nb = Notebook(workspace_id=ws.id, title="Pricing series")
|
| 268 |
+
db_session.add(nb)
|
| 269 |
+
db_session.commit()
|
| 270 |
+
r = client.post("/api/videos", json={"document_ids": [str(new.id), str(old.id)], "notebook_id": str(nb.id)},
|
| 271 |
+
headers=headers)
|
| 272 |
+
assert r.status_code == 201 and r.json()["content"]["title"] == "Pricing series"
|
| 273 |
+
sent = creates(b2v)[-1]["json"]
|
| 274 |
+
assert len(creates(b2v)) == 1 and "url" not in sent and sent["title"] == "Pricing series"
|
| 275 |
+
assert not {"document_ids", "notebook_id", "document_id"} & set(sent) # the source is not a wizard option
|
| 276 |
+
assert sent["content"].index("# Starting out") < sent["content"].index("# Annual raises")
|
| 277 |
+
assert used(db_session, ws) == 1
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def test_one_ticked_post_goes_by_its_link(client, b2v, owner, db_session):
|
| 281 |
+
headers, ws = owner
|
| 282 |
+
doc = add_doc(db_session, ws, "https://ada.example.com/p/pricing")
|
| 283 |
+
client.post("/api/videos", json={"document_ids": [str(doc.id)]}, headers=headers)
|
| 284 |
+
assert creates(b2v)[-1]["json"]["url"] == "https://ada.example.com/p/pricing"
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def test_locked_posts_are_refused_before_charging(client, b2v, owner, db_session):
|
| 288 |
+
headers, ws = owner
|
| 289 |
+
ok = add_doc(db_session, ws, "https://ada.example.com/p/a", "Enough text to make a video from, really. " * 3)
|
| 290 |
+
locked = add_doc(db_session, ws, "https://ada.example.com/p/b")
|
| 291 |
+
locked.path, locked.metadata_json = None, {"locked": True}
|
| 292 |
+
db_session.commit()
|
| 293 |
+
for body in ({"document_id": str(locked.id)}, {"document_ids": [str(ok.id), str(locked.id)]}):
|
| 294 |
+
r = client.post("/api/videos", json=body, headers=headers)
|
| 295 |
+
assert r.status_code == 400 and "not indexed" in r.json()["detail"]
|
| 296 |
+
assert not creates(b2v) and used(db_session, ws) == 0
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def test_another_workspaces_post_is_404(client, b2v, owner, other, db_session):
|
| 300 |
+
headers, ws = owner
|
| 301 |
+
mine = add_doc(db_session, ws, "https://ada.example.com/p/a", "Enough text to make a video from, really. " * 3)
|
| 302 |
+
theirs = add_doc(db_session, other[1], "https://bo.example.com/p/b")
|
| 303 |
+
r = client.post("/api/videos", json={"document_ids": [str(mine.id), str(theirs.id)]}, headers=headers)
|
| 304 |
+
assert r.status_code == 404 and not creates(b2v)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_upload_goes_to_blog2video_and_generates(client, b2v, owner, db_session):
|
| 308 |
+
headers, ws = owner
|
| 309 |
+
files = [("files", ("notes.md", b"# Notes\n" * 20, "text/markdown")), ("files", ("deck.pptx", b"x", "app/x"))]
|
| 310 |
+
r = client.post("/api/videos/upload", files=files, data={"options": json.dumps({"video_length": "medium"})},
|
| 311 |
+
headers=headers)
|
| 312 |
+
assert r.status_code == 201, r.text
|
| 313 |
+
upload = next(c for c in b2v.calls if c["path"] == "/api/projects/upload")
|
| 314 |
+
assert [f[1][0] for f in upload["files"]] == ["notes.md", "deck.pptx"]
|
| 315 |
+
assert upload["data"]["video_length"] == "medium" and upload["data"]["script_review_enabled"] == "false"
|
| 316 |
+
assert "/api/projects/812/generate" in b2v.paths("POST")
|
| 317 |
+
row = db_session.scalar(select(B2VVideo))
|
| 318 |
+
assert row.created_via == "upload" and row.idempotency_key is None
|
| 319 |
+
client.get(f"/api/videos/{r.json()['id']}/status", headers=headers)
|
| 320 |
+
assert "/api/projects/812/status" in b2v.paths("GET") # uploads are not under /api/v1
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_upload_rejects_wrong_files_before_charging(client, b2v, owner, db_session):
|
| 324 |
+
headers, ws = owner
|
| 325 |
+
r = client.post("/api/videos/upload", files=[("files", ("a.exe", b"x", "app/x"))], headers=headers)
|
| 326 |
+
assert r.status_code == 400 and used(db_session, ws) == 0 and b2v.calls == []
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def test_multi_link_makes_one_video_per_link(client, b2v, owner, db_session):
|
| 330 |
+
headers, ws = owner
|
| 331 |
+
r = client.post("/api/videos/batch", json={"urls": ["https://a.test/1", "https://a.test/2", "nope"],
|
| 332 |
+
"bgm_track_id": "corporate_upbeat"}, headers=headers)
|
| 333 |
+
assert r.status_code == 201 and len(r.json()) == 2
|
| 334 |
+
calls = creates(b2v)
|
| 335 |
+
assert len({c["headers"]["Idempotency-Key"] for c in calls}) == 2
|
| 336 |
+
assert all("bgm_track_id" not in c["json"] for c in calls) # music is single-video only
|
| 337 |
+
assert used(db_session, ws) == 2
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def test_our_account_out_of_videos_refunds_the_user(client, b2v, owner, db_session, caplog):
|
| 341 |
+
headers, ws = owner
|
| 342 |
+
b2v.queue("POST", "/api/v1/videos", (402, {"detail": {"error": "quota_exceeded"}}))
|
| 343 |
+
with caplog.at_level(logging.ERROR):
|
| 344 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
|
| 345 |
+
assert r.status_code == 503 and "temporarily unavailable" in r.json()["detail"] and "ALERT" in caplog.text
|
| 346 |
+
assert db_session.scalar(select(Artifact).where(Artifact.type == "video")) is None
|
| 347 |
+
assert db_session.scalar(select(B2VVideo)).quota_state == "refunded" and used(db_session, ws) == 0
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def test_validation_error_refunds_and_says_what_was_wrong(client, b2v, owner, db_session):
|
| 351 |
+
headers, ws = owner
|
| 352 |
+
b2v.queue("POST", "/api/v1/videos", (422, {"detail": [{"loc": ["body", "url"], "msg": "could not fetch"}]}))
|
| 353 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
|
| 354 |
+
assert r.status_code == 400 and "url: could not fetch" in r.json()["detail"] and used(db_session, ws) == 0
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def test_slow_catalog_is_a_clean_error_not_a_500(client, b2v, owner, db_session):
|
| 358 |
+
headers, ws = owner
|
| 359 |
+
b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
|
| 360 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
|
| 361 |
+
headers=headers)
|
| 362 |
+
assert r.status_code == 502 and "not responding" in r.json()["detail"]
|
| 363 |
+
assert not creates(b2v) and used(db_session, ws) == 0
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def test_expired_catalog_is_used_when_blog2video_is_slow(client, b2v, owner):
|
| 367 |
+
make_video(client, owner[0], template="geometric") # fills the catalog cache
|
| 368 |
+
for key, (at, value) in list(blog2video._cache.items()):
|
| 369 |
+
blog2video._cache[key] = (at - 2 * blog2video.CATALOG_TTL, value) # an hour and more ago
|
| 370 |
+
b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
|
| 371 |
+
make_video(client, owner[0], template="geometric")
|
| 372 |
+
assert len(creates(b2v)) == 2
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def test_no_answer_after_retries_refunds(client, b2v, owner, db_session):
|
| 376 |
+
headers, ws = owner
|
| 377 |
+
b2v.queue("POST", "/api/v1/videos", *[(0, httpx.ConnectError("down"))] * 3)
|
| 378 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
|
| 379 |
+
assert r.status_code == 502 and len(creates(b2v)) == 3 and used(db_session, ws) == 0
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def test_local_limit_blocks_without_calling_out(client, b2v, owner, db_session):
|
| 383 |
+
headers, ws = owner
|
| 384 |
+
client.get("/api/videos/quota", headers=headers) # syncs the row
|
| 385 |
+
sub = db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
|
| 386 |
+
sub.videos_used = sub.video_limit
|
| 387 |
+
db_session.commit()
|
| 388 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=headers)
|
| 389 |
+
assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit" and not creates(b2v)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def test_two_workspaces_same_url_get_separate_videos(client, b2v, owner, other):
|
| 393 |
+
make_video(client, owner[0])
|
| 394 |
+
make_video(client, other[0])
|
| 395 |
+
calls = creates(b2v)
|
| 396 |
+
assert calls[0]["headers"]["Idempotency-Key"].split(":")[0] != calls[1]["headers"]["Idempotency-Key"].split(":")[0]
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def test_not_configured(client, owner):
|
| 400 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a"}, headers=owner[0])
|
| 401 |
+
assert r.status_code == 503
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# Isolation: another workspace's ids are refused before blog2video is called
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@pytest.fixture()
|
| 408 |
+
def theirs(db_session, other):
|
| 409 |
+
_, ws = other
|
| 410 |
+
db_session.add_all([B2VTemplate(b2v_template_id=77, workspace_id=ws.id, name="Theirs", ready=True),
|
| 411 |
+
B2VStyle(b2v_style_id=9, workspace_id=ws.id, name="Theirs"),
|
| 412 |
+
B2VCustomVoice(b2v_custom_voice_id=90, workspace_id=ws.id, voice_id="vc_theirs", name="T",
|
| 413 |
+
source="clone")])
|
| 414 |
+
db_session.commit()
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
@pytest.mark.parametrize("field,value", [("template", "custom_77"), ("video_style", "custom:9"),
|
| 418 |
+
("custom_voice_id", "vc_theirs"), ("video_style", "your_style"),
|
| 419 |
+
("template", "custom_12345"), ("template", "not-a-template"),
|
| 420 |
+
("template", "crafted_other")])
|
| 421 |
+
def test_foreign_references_are_refused_before_calling_out(client, b2v, owner, theirs, db_session, field, value):
|
| 422 |
+
headers, ws = owner
|
| 423 |
+
r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", field: value}, headers=headers)
|
| 424 |
+
assert r.status_code == 404 and not creates(b2v) and used(db_session, ws) == 0
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def test_own_references_pass(client, b2v, owner, db_session):
|
| 428 |
+
headers, ws = owner
|
| 429 |
+
db_session.add_all([B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True),
|
| 430 |
+
B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"),
|
| 431 |
+
B2VCustomVoice(b2v_custom_voice_id=31, workspace_id=ws.id, voice_id="vc_mine", name="M",
|
| 432 |
+
source="prompt")])
|
| 433 |
+
db_session.commit()
|
| 434 |
+
make_video(client, headers, template="custom_58", video_style="custom:7", custom_voice_id="vc_mine")
|
| 435 |
+
make_video(client, headers, template="crafted_neon")
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def test_template_still_generating_is_refused(client, b2v, owner, db_session):
|
| 439 |
+
headers, ws = owner
|
| 440 |
+
db_session.add(B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=False))
|
| 441 |
+
db_session.commit()
|
| 442 |
+
r = client.post("/api/videos", json={"url": "https://a.test/x", "template": "custom_58"}, headers=headers)
|
| 443 |
+
assert r.status_code == 400 and not creates(b2v)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def test_other_workspace_video_is_404_everywhere(client, b2v, owner, other):
|
| 447 |
+
art = make_video(client, owner[0])
|
| 448 |
+
b2v.calls.clear()
|
| 449 |
+
for method, path in [("GET", ""), ("GET", "/status"), ("GET", "/script"), ("DELETE", ""),
|
| 450 |
+
("PUT", "/p/scenes/1"), ("POST", "/p/render")]:
|
| 451 |
+
r = client.request(method, f"/api/videos/{art['id']}{path}", json={}, headers=other[0])
|
| 452 |
+
assert r.status_code == 404, (method, path)
|
| 453 |
+
assert b2v.calls == []
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def test_catalog_shows_only_this_workspace(client, b2v, owner, theirs, db_session):
|
| 457 |
+
headers, ws = owner
|
| 458 |
+
db_session.add_all([B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"),
|
| 459 |
+
B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True),
|
| 460 |
+
B2VTemplate(b2v_template_id=59, workspace_id=ws.id, name="Not yet", ready=False)])
|
| 461 |
+
db_session.commit()
|
| 462 |
+
cat = client.get("/api/videos/catalog", headers=headers).json()
|
| 463 |
+
assert [s["id"] for s in cat["video_styles"]] == ["explainer", "custom:7"]
|
| 464 |
+
assert [t["id"] for t in cat["my_templates"]] == ["custom_58"]
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# Premium (★) and per-workspace counters
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
@pytest.mark.parametrize("body", [{"video_length": "detailed"}, {"video_length": "more_detailed"},
|
| 471 |
+
{"custom_voice_id": BELLA}, {"voice_emotion": '["0.5","1.0","calm","0","1"]'},
|
| 472 |
+
{"template": "crafted_neon"}])
|
| 473 |
+
def test_premium_options_refused_on_free(client, b2v, owner, free_plan, db_session, body):
|
| 474 |
+
headers, ws = owner
|
| 475 |
+
r = client.post("/api/videos", json={"url": "https://a.test/x", **body}, headers=headers)
|
| 476 |
+
assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_premium" and not creates(b2v)
|
| 477 |
+
assert r.json()["detail"]["upgrade_to"] == "writer"
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def test_free_plan_can_make_a_plain_video(client, b2v, owner, free_plan):
|
| 481 |
+
make_video(client, owner[0], custom_voice_id=SARAH)
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def test_ai_edits_counted_and_given_back_on_failure(client, b2v, owner, db_session):
|
| 485 |
+
headers, ws = owner
|
| 486 |
+
art = make_video(client, headers)
|
| 487 |
+
r = client.post(f"/api/videos/{art['id']}/p/scenes/1/regenerate", json={"description": "x"}, headers=headers)
|
| 488 |
+
assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 1
|
| 489 |
+
b2v.queue("POST", "/api/projects/812/scenes/1/regenerate", (409, {"detail": "busy"}))
|
| 490 |
+
r = client.post(f"/api/videos/{art['id']}/p/scenes/1/regenerate", json={}, headers=headers)
|
| 491 |
+
assert r.status_code == 409 and video_limits.used(db_session, ws.id, "ai_edits") == 1
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def test_ai_edits_stop_at_the_workspace_limit(client, b2v, owner, db_session):
|
| 495 |
+
headers, ws = owner
|
| 496 |
+
art = make_video(client, headers)
|
| 497 |
+
db_session.add(UsageCounter(workspace_id=ws.id, period=video_limits.period_key("ai_edits"), metric="ai_edits",
|
| 498 |
+
used=1000))
|
| 499 |
+
db_session.commit()
|
| 500 |
+
b2v.calls.clear()
|
| 501 |
+
r = client.post(f"/api/videos/{art['id']}/p/chat", json={"message": "shorter"}, headers=headers)
|
| 502 |
+
assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_limits.ai_edits" and b2v.calls == []
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def test_avatar_batch_charges_ten_per_scene(client, b2v, owner, db_session):
|
| 506 |
+
headers, ws = owner
|
| 507 |
+
art = make_video(client, headers)
|
| 508 |
+
r = client.post(f"/api/videos/{art['id']}/p/avatar-batch/authorize", json={"scene_ids": [1, 2, 3]},
|
| 509 |
+
headers=headers)
|
| 510 |
+
assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 30
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def test_template_switch_uses_a_video_and_checks_the_template(client, b2v, owner, theirs, db_session):
|
| 514 |
+
headers, ws = owner
|
| 515 |
+
art = make_video(client, headers)
|
| 516 |
+
r = client.post(f"/api/videos/{art['id']}/p/change-template-regenerate-layouts", json={"template": "custom_77"},
|
| 517 |
+
headers=headers)
|
| 518 |
+
assert r.status_code == 404 and used(db_session, ws) == 1
|
| 519 |
+
r = client.post(f"/api/videos/{art['id']}/p/change-template-regenerate-layouts", json={"template": "geometric"},
|
| 520 |
+
headers=headers)
|
| 521 |
+
assert r.status_code == 200 and used(db_session, ws) == 2
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
# Edit jobs keep blog2video's own status on the artifact (not "generating"), so the list and editor can tell them apart
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def artifact_status(db, art) -> str:
|
| 528 |
+
db.expire_all()
|
| 529 |
+
return db.get(Artifact, uuid.UUID(art["id"])).status
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
@pytest.mark.parametrize("raw", ["regenerating", "voice_regenerating", "language_regenerating", "script_regenerating"])
|
| 533 |
+
def test_an_edit_job_keeps_blog2videos_status(client, b2v, owner, db_session, raw):
|
| 534 |
+
headers, _ = owner
|
| 535 |
+
art = make_video(client, headers)
|
| 536 |
+
b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": raw, "ready": False}))
|
| 537 |
+
assert client.get(f"/api/videos/{art['id']}/status", headers=headers).json()["artifact_status"] == raw
|
| 538 |
+
assert artifact_status(db_session, art) == raw
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def test_starting_a_template_switch_marks_the_video(client, b2v, owner, db_session):
|
| 542 |
+
headers, _ = owner
|
| 543 |
+
art = make_video(client, headers)
|
| 544 |
+
r = client.post(f"/api/videos/{art['id']}/p/change-template-regenerate-layouts", json={"template": "geometric"},
|
| 545 |
+
headers=headers)
|
| 546 |
+
assert r.status_code == 200 and artifact_status(db_session, art) == "regenerating"
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def test_the_list_rechecks_a_video_whose_job_ended_unwatched(client, b2v, owner, db_session):
|
| 550 |
+
headers, _ = owner
|
| 551 |
+
art = make_video(client, headers)
|
| 552 |
+
b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": "regenerating", "ready": False}),
|
| 553 |
+
(200, {"status": "generated", "ready": True}))
|
| 554 |
+
client.get(f"/api/videos/{art['id']}/status", headers=headers)
|
| 555 |
+
assert artifact_status(db_session, art) == "regenerating"
|
| 556 |
+
listed = client.get("/api/videos", headers=headers).json()
|
| 557 |
+
assert listed[0]["status"] == "ready" and listed[0]["b2v_status"] == "generated"
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def test_editor_proxy_only_allows_listed_paths(client, b2v, owner):
|
| 561 |
+
headers, _ = owner
|
| 562 |
+
art = make_video(client, headers)
|
| 563 |
+
b2v.calls.clear()
|
| 564 |
+
for method, path in [("GET", "members"), ("POST", "review"), ("POST", "launch-studio"), ("GET", "../../auth/me")]:
|
| 565 |
+
assert client.request(method, f"/api/videos/{art['id']}/p/{path}", headers=headers).status_code == 404
|
| 566 |
+
assert b2v.calls == []
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def test_editor_passes_downloads_through(client, b2v, owner):
|
| 570 |
+
headers, _ = owner
|
| 571 |
+
art = make_video(client, headers)
|
| 572 |
+
r = client.get(f"/api/videos/{art['id']}/p/download", headers=headers)
|
| 573 |
+
assert r.status_code == 200 and r.content == b"ID3audio" and r.headers["content-type"] == "audio/mpeg"
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def test_editor_premium_features_refused_on_free(client, b2v, owner, free_plan):
|
| 577 |
+
headers, _ = owner
|
| 578 |
+
art = make_video(client, headers)
|
| 579 |
+
for method, path in [("POST", "chat"), ("GET", "download-studio"), ("POST", "avatar-portrait")]:
|
| 580 |
+
r = client.request(method, f"/api/videos/{art['id']}/p/{path}", json={}, headers=headers)
|
| 581 |
+
assert r.status_code == 402, path
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
# Script review
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def test_script_review_flow(client, b2v, owner, db_session):
|
| 588 |
+
headers, ws = owner
|
| 589 |
+
art = make_video(client, headers)
|
| 590 |
+
scenes = client.get(f"/api/videos/{art['id']}/script", headers=headers).json()["scenes"]
|
| 591 |
+
assert [s["id"] for s in scenes] == [1, 2]
|
| 592 |
+
draft = {"title": "A", "display_text": "Text", "narration_text": "", "draft_scenes": [], "revision": 1}
|
| 593 |
+
client.post(f"/api/videos/{art['id']}/script/scenes/1/ai-preview", json={**draft, "instruction": "punchier"},
|
| 594 |
+
headers=headers)
|
| 595 |
+
assert video_limits.used(db_session, ws.id, "ai_edits") == 1
|
| 596 |
+
r = client.post(f"/api/videos/{art['id']}/script/approve",
|
| 597 |
+
json={"scenes": [{"id": 1, "title": "A", "narration_text": "Hi"}]}, headers=headers)
|
| 598 |
+
assert r.status_code == 200 and "/api/projects/812/script-review/approve" in b2v.paths("POST")
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
# Status, refunds, sweep
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def test_failure_refunds_once_from_poller_and_sweep(client, b2v, owner, db_session, session_factory):
|
| 605 |
+
headers, ws = owner
|
| 606 |
+
art = make_video(client, headers)
|
| 607 |
+
failed = (200, {"status": "failed", "step": 1, "running": False, "error": "unreachable url"})
|
| 608 |
+
b2v.queue("GET", "/api/v1/videos/812/status", failed, failed)
|
| 609 |
+
assert client.get(f"/api/videos/{art['id']}/status", headers=headers).json()["artifact_status"] == "failed"
|
| 610 |
+
assert used(db_session, ws) == 0
|
| 611 |
+
client.get(f"/api/videos/{art['id']}/status", headers=headers)
|
| 612 |
+
age(db_session, db_session.scalar(select(B2VVideo)))
|
| 613 |
+
video_quota.sweep_failed_videos(session_factory)
|
| 614 |
+
assert used(db_session, ws) == 0
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def test_sweep_refunds_videos_nobody_polled(client, b2v, owner, db_session, session_factory):
|
| 618 |
+
headers, ws = owner
|
| 619 |
+
art = make_video(client, headers)
|
| 620 |
+
age(db_session, db_session.scalar(select(B2VVideo)))
|
| 621 |
+
b2v.queue("GET", "/api/v1/videos/812/status", (404, {"detail": "Not found"}))
|
| 622 |
+
assert video_quota.sweep_failed_videos(session_factory) == 1
|
| 623 |
+
db_session.expire_all()
|
| 624 |
+
assert used(db_session, ws) == 0 and db_session.get(Artifact, uuid.UUID(art["id"])).status == "failed"
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def test_a_finished_video_is_not_refunded_by_a_later_error(client, b2v, owner, db_session):
|
| 628 |
+
headers, ws = owner
|
| 629 |
+
art = make_video(client, headers)
|
| 630 |
+
b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": "generated", "ready": True}),
|
| 631 |
+
(200, {"status": "error", "error": "a later edit failed"}))
|
| 632 |
+
client.get(f"/api/videos/{art['id']}/status", headers=headers)
|
| 633 |
+
client.get(f"/api/videos/{art['id']}/status", headers=headers)
|
| 634 |
+
assert used(db_session, ws) == 1 and db_session.scalar(select(B2VVideo)).quota_state == "kept"
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def test_legacy_video_shows_stored_links_and_refuses_edits(client, b2v, owner, db_session):
|
| 638 |
+
headers, ws = owner
|
| 639 |
+
a = Artifact(workspace_id=ws.id, type="video", status="ready",
|
| 640 |
+
content_json={"provider": "blog2video", "b2v_video_id": 99, "video_url": "https://cdn.test/old.mp4"})
|
| 641 |
+
db_session.add(a)
|
| 642 |
+
db_session.commit()
|
| 643 |
+
got = client.get(f"/api/videos/{a.id}", headers=headers).json()
|
| 644 |
+
assert got["legacy"] is True and got["video_url"] == "https://cdn.test/old.mp4"
|
| 645 |
+
assert client.patch(f"/api/videos/{a.id}/p/update-project", json={}, headers=headers).status_code == 410
|
| 646 |
+
assert b2v.calls == []
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def test_get_video_mints_the_preview_link_once(client, b2v, owner):
|
| 650 |
+
headers, _ = owner
|
| 651 |
+
art = make_video(client, headers)
|
| 652 |
+
first = client.get(f"/api/videos/{art['id']}", headers=headers).json()
|
| 653 |
+
client.get(f"/api/videos/{art['id']}", headers=headers)
|
| 654 |
+
assert first["preview_url"] == f"{API}/embed/t" and first["project"]["id"] == 812
|
| 655 |
+
assert len([p for p in b2v.paths("POST") if p.startswith("/api/embed/token/")]) == 1
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def test_scene_stock_clips_are_read_from_assigned_video(client, b2v, owner):
|
| 659 |
+
headers, _ = owner
|
| 660 |
+
art = make_video(client, headers)
|
| 661 |
+
clip = {"id": 7, "asset_type": "video", "filename": "scene_1_99.mp4", "r2_url": "https://r2.example/clip.mp4",
|
| 662 |
+
"excluded": False}
|
| 663 |
+
still = {"id": 8, "asset_type": "image", "filename": "img_a.webp", "r2_url": "https://r2.example/a.webp",
|
| 664 |
+
"excluded": False}
|
| 665 |
+
code = '{"layout": "news_headline", "layoutProps": {"assignedVideo": "scene_1_99.mp4"}}'
|
| 666 |
+
b2v.queue("GET", "/api/projects/812", (200, {"id": 812, "status": "generated", "assets": [clip, still],
|
| 667 |
+
"scenes": [{"id": 1, "order": 1, "title": "A", "remotion_code": code}]}))
|
| 668 |
+
project = client.get(f"/api/videos/{art['id']}", headers=headers).json()["project"]
|
| 669 |
+
assert project["scenes"][0]["images"] == [{"filename": "scene_1_99.mp4", "asset_id": 7, "kind": "video",
|
| 670 |
+
"url": "https://r2.example/clip.mp4"}]
|
| 671 |
+
assert project["summary"]["images"] == 1 and project["summary"]["clips"] == 1
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def test_delete_removes_it_from_blog2video(client, b2v, owner):
|
| 675 |
+
headers, _ = owner
|
| 676 |
+
art = make_video(client, headers)
|
| 677 |
+
assert client.delete(f"/api/videos/{art['id']}", headers=headers).status_code == 200
|
| 678 |
+
assert "/api/projects/812" in b2v.paths("DELETE")
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def test_capacity_alert(b2v, caplog):
|
| 682 |
+
with caplog.at_level(logging.ERROR):
|
| 683 |
+
video_quota.check_capacity()
|
| 684 |
+
assert "ALERT" in caplog.text and "videos 145/150" in caplog.text
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
# Voices
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def test_new_workspace_gets_starter_voices_and_library(client, b2v, owner):
|
| 691 |
+
got = client.get("/api/video-voices", headers=owner[0]).json()
|
| 692 |
+
assert [v["voice_id"] for v in got["saved"]] == [SARAH] # free voices only
|
| 693 |
+
assert {v["voice_id"]: v["premium"] for v in got["library"]} == {SARAH: False, BELLA: True}
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
def test_saving_a_paid_voice_needs_premium(client, b2v, owner, free_plan):
|
| 697 |
+
r = client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=owner[0])
|
| 698 |
+
assert r.status_code == 402
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def test_saved_voices_never_touch_blog2video_saved_list(client, b2v, owner, db_session):
|
| 702 |
+
headers, _ = owner
|
| 703 |
+
client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers)
|
| 704 |
+
client.delete(f"/api/video-voices/saved/{BELLA}", headers=headers)
|
| 705 |
+
assert not [p for p in b2v.paths() if "/voices/saved" in p]
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def test_design_keep_and_delete_a_custom_voice(client, b2v, owner, other, db_session):
|
| 709 |
+
headers, ws = owner
|
| 710 |
+
previews = client.post("/api/video-voices/design/prompt", json={"prompt": "A calm, warm narrator voice please"},
|
| 711 |
+
headers=headers).json()["previews"]
|
| 712 |
+
kept = client.post("/api/video-voices/custom", json={"generated_voice_id": previews[0]["generated_voice_id"],
|
| 713 |
+
"source": "prompt", "name": "Narrator"}, headers=headers)
|
| 714 |
+
assert kept.status_code == 201
|
| 715 |
+
assert db_session.get(UserSavedVoice, {"workspace_id": ws.id, "voice_id": "vc_designed"}).is_custom
|
| 716 |
+
assert video_limits.used(db_session, ws.id, "voice_designs_daily") == 1
|
| 717 |
+
assert video_limits.used(db_session, ws.id, "custom_voices") == 1
|
| 718 |
+
# Another workspace can not play or delete it, nor use it on a video.
|
| 719 |
+
assert client.get("/api/video-voices/custom/31/preview", headers=other[0]).status_code == 404
|
| 720 |
+
assert client.delete("/api/video-voices/custom/31", headers=other[0]).status_code == 404
|
| 721 |
+
r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "vc_designed"},
|
| 722 |
+
headers=other[0])
|
| 723 |
+
assert r.status_code == 404
|
| 724 |
+
assert client.delete("/api/video-voices/custom/31", headers=headers).status_code == 200
|
| 725 |
+
assert video_limits.used(db_session, ws.id, "custom_voices") == 0
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def test_clone_voice(client, b2v, owner):
|
| 729 |
+
r = client.post("/api/video-voices/clone", data={"name": "Me"}, files={"file": ("me.mp3", b"ID3", "audio/mpeg")},
|
| 730 |
+
headers=owner[0])
|
| 731 |
+
assert r.status_code == 201 and r.json()["voice_id"] == "vc_clone"
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def test_voice_sample_returns_audio_and_counts(client, b2v, owner, db_session):
|
| 735 |
+
headers, ws = owner
|
| 736 |
+
r = client.post("/api/video-voices/sample", json={"custom_voice_id": SARAH}, headers=headers)
|
| 737 |
+
assert r.status_code == 200 and r.content == b"ID3audio"
|
| 738 |
+
assert video_limits.used(db_session, ws.id, "voice_samples_daily") == 1
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
# Styles
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def test_styles_are_per_workspace(client, b2v, owner, other, db_session):
|
| 745 |
+
headers, ws = owner
|
| 746 |
+
made = client.post("/api/video-styles", json={"name": "Punchy", "guidance": "Punchy."}, headers=headers).json()
|
| 747 |
+
assert made["id"] == "custom:41" and db_session.get(B2VStyle, 41).workspace_id == ws.id
|
| 748 |
+
assert client.patch("/api/video-styles/41", json={"name": "x", "guidance": "y", "version": 0},
|
| 749 |
+
headers=other[0]).status_code == 404
|
| 750 |
+
assert client.delete("/api/video-styles/41", headers=other[0]).status_code == 404
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
# Templates
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
def test_custom_templates_are_switched_off(client, b2v, owner):
|
| 757 |
+
headers, _ = owner
|
| 758 |
+
assert client.post("/api/video-templates/extract/prompt", json={"prompt": "x"}, headers=headers).status_code == 404
|
| 759 |
+
assert client.post("/api/video-templates", json={"name": "x", "theme": {}}, headers=headers).status_code == 404
|
| 760 |
+
assert client.get("/api/video-templates", headers=headers).status_code == 404
|
| 761 |
+
assert b2v.calls == []
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
@pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
|
| 765 |
+
def test_template_create_generate_and_ready(client, b2v, owner, db_session):
|
| 766 |
+
headers, ws = owner
|
| 767 |
+
theme = client.post("/api/video-templates/extract/prompt", json={"prompt": "A bold dark brand with neon"},
|
| 768 |
+
headers=headers).json()
|
| 769 |
+
made = client.post("/api/video-templates", json={"name": "Brand", "theme": theme["theme"]}, headers=headers)
|
| 770 |
+
assert made.status_code == 201 and made.json()["ref"] == "custom_58"
|
| 771 |
+
client.post("/api/video-templates/58/generate", headers=headers)
|
| 772 |
+
b2v.queue("GET", "/api/custom-templates/58/generation-status", (200, {"status": "complete"}))
|
| 773 |
+
assert client.get("/api/video-templates/58/generation-status", headers=headers).json()["ready"] is True
|
| 774 |
+
assert video_limits.used(db_session, ws.id, "templates") == 1
|
| 775 |
+
assert video_limits.used(db_session, ws.id, "template_ai_daily") == 2
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
@pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
|
| 779 |
+
def test_free_plan_can_not_start_a_template(client, b2v, owner, free_plan):
|
| 780 |
+
r = client.post("/api/video-templates/extract/url", json={"url": "https://brand.test"}, headers=owner[0])
|
| 781 |
+
assert r.status_code == 402 and b2v.calls == []
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
@pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
|
| 785 |
+
def test_template_delete_forces_only_over_own_videos(client, b2v, owner, other, db_session):
|
| 786 |
+
headers, ws = owner
|
| 787 |
+
db_session.add(B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True))
|
| 788 |
+
db_session.add(B2VVideo(workspace_id=other[1].id, b2v_video_id=5, template_ref="custom_58"))
|
| 789 |
+
db_session.commit()
|
| 790 |
+
b2v.queue("DELETE", "/api/custom-templates/58", (409, {"detail": {"error": "template_in_use"}}))
|
| 791 |
+
assert client.delete("/api/video-templates/58", headers=headers).status_code == 409
|
| 792 |
+
db_session.query(B2VVideo).delete()
|
| 793 |
+
db_session.commit()
|
| 794 |
+
b2v.queue("DELETE", "/api/custom-templates/58", (409, {"detail": {"error": "template_in_use"}}))
|
| 795 |
+
assert client.delete("/api/video-templates/58", headers=headers).status_code == 200
|
| 796 |
+
assert b2v.calls[-1]["params"] == {"force": "true"}
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
@pytest.mark.skip(reason="custom templates are switched off (video_templates is not mounted)")
|
| 800 |
+
def test_template_editor_is_per_workspace_and_allowlisted(client, b2v, owner, other, db_session):
|
| 801 |
+
headers, ws = owner
|
| 802 |
+
db_session.add(B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True))
|
| 803 |
+
db_session.commit()
|
| 804 |
+
assert client.get("/api/video-templates/58/p/versions", headers=headers).status_code == 200
|
| 805 |
+
assert client.get("/api/video-templates/58/p/versions", headers=other[0]).status_code == 404
|
| 806 |
+
assert client.get("/api/video-templates/58/p/internal/ids", headers=headers).status_code == 404
|
| 807 |
+
r = client.post("/api/video-templates/58/p/scenes/intro/ai-edit", json={"prompt": "bigger"}, headers=headers)
|
| 808 |
+
assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 1
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
# Quota bookkeeping
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
def test_sync_follows_effective_plan(client, owner, db_session, monkeypatch):
|
| 815 |
+
_, ws = owner
|
| 816 |
+
sub = video_quota.sync_video_quota(db_session, ws)
|
| 817 |
+
assert (sub.video_plan, sub.video_limit) == ("studio", 20) # billing disabled
|
| 818 |
+
monkeypatch.setattr(settings, "billing_enabled", True)
|
| 819 |
+
sub.plan, sub.status = "writer", "active"
|
| 820 |
+
db_session.commit()
|
| 821 |
+
assert video_quota.sync_video_quota(db_session, ws).video_limit == 10
|
| 822 |
+
sub.status = "canceled"
|
| 823 |
+
db_session.commit()
|
| 824 |
+
sub = video_quota.sync_video_quota(db_session, ws)
|
| 825 |
+
assert (sub.video_plan, sub.video_limit) == ("free", 1)
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
def test_monthly_fallback_reset(client, owner, db_session, session_factory):
|
| 829 |
+
_, ws = owner
|
| 830 |
+
sub = video_quota.sync_video_quota(db_session, ws) # studio (billing disabled): monthly
|
| 831 |
+
now = datetime.now(UTC)
|
| 832 |
+
# No Stripe renewal, period started 40 days ago: resets.
|
| 833 |
+
sub.videos_used, sub.videos_period_start = 1, now - timedelta(days=40)
|
| 834 |
+
db_session.commit()
|
| 835 |
+
assert video_quota.reset_due_video_periods(session_factory) == 1
|
| 836 |
+
db_session.expire_all()
|
| 837 |
+
assert sub.videos_used == 0
|
| 838 |
+
|
| 839 |
+
# Monthly Stripe subscription renewing soon: left to invoice.paid.
|
| 840 |
+
sub.videos_used, sub.videos_period_start = 3, now - timedelta(days=40)
|
| 841 |
+
sub.provider_subscription_id, sub.status = "sub_1", "active"
|
| 842 |
+
sub.current_period_end = now - timedelta(days=10)
|
| 843 |
+
db_session.commit()
|
| 844 |
+
assert video_quota.reset_due_video_periods(session_factory) == 0
|
| 845 |
+
|
| 846 |
+
# Annual subscription (renews in 11 months): still resets monthly.
|
| 847 |
+
sub.current_period_end = now + timedelta(days=330)
|
| 848 |
+
db_session.commit()
|
| 849 |
+
assert video_quota.reset_due_video_periods(session_factory) == 1
|
| 850 |
+
|
| 851 |
+
|
| 852 |
+
# The video's reference lives on its row
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
def test_row_holds_the_full_reference(client, b2v, owner, db_session):
|
| 856 |
+
headers, ws = owner
|
| 857 |
+
art = make_video(client, headers)
|
| 858 |
+
row = db_session.scalar(select(B2VVideo))
|
| 859 |
+
assert row.user_id == ws.owner_id and row.artifact_id == uuid.UUID(art["id"])
|
| 860 |
+
assert (row.title, row.source_url, row.aspect_ratio) == ("https://ada.example.com/p/a", "https://ada.example.com/p/a",
|
| 861 |
+
"landscape")
|
| 862 |
+
sent = creates(b2v)[0]["json"]["metadata"]
|
| 863 |
+
assert sent["user_id"] == str(ws.owner_id) and sent["workspace_id"] == str(ws.id)
|
| 864 |
+
assert sent["video_ref"] == str(row.id)
|
| 865 |
+
b2v.queue("GET", "/api/v1/videos/812/status", (200, {"status": "done", "ready": True,
|
| 866 |
+
"video_url": "https://cdn.test/v.mp4"}))
|
| 867 |
+
client.get(f"/api/videos/{art['id']}/status", headers=headers)
|
| 868 |
+
client.get(f"/api/videos/{art['id']}", headers=headers)
|
| 869 |
+
db_session.expire_all()
|
| 870 |
+
assert row.video_url == "https://cdn.test/v.mp4" and row.preview_url == f"{API}/embed/t"
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
def test_calls_use_the_row_not_the_artifact_copy(client, b2v, owner, db_session):
|
| 874 |
+
headers, _ = owner
|
| 875 |
+
art = make_video(client, headers)
|
| 876 |
+
a = db_session.get(Artifact, uuid.UUID(art["id"]))
|
| 877 |
+
a.content_json = {**a.content_json, "b2v_video_id": 999} # a stale or tampered copy
|
| 878 |
+
db_session.commit()
|
| 879 |
+
client.patch(f"/api/videos/{art['id']}/p/update-project", json={"accent_color": "#000000"}, headers=headers)
|
| 880 |
+
assert "/api/projects/812/update-project" in b2v.paths("PATCH")
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
def test_library_delete_removes_it_from_blog2video_and_keeps_the_record(client, b2v, owner, db_session):
|
| 884 |
+
headers, _ = owner
|
| 885 |
+
art = make_video(client, headers)
|
| 886 |
+
assert client.delete(f"/api/artifacts/{art['id']}", headers=headers).status_code == 200
|
| 887 |
+
assert "/api/projects/812" in b2v.paths("DELETE")
|
| 888 |
+
db_session.expire_all()
|
| 889 |
+
row = db_session.scalar(select(B2VVideo))
|
| 890 |
+
assert row.status == "deleted" and row.artifact_id is None
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
# Video limits: Free 1 in total, Writer 10 a month, Studio 20 a month
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
def _on_plan(db, ws, plan: str) -> None:
|
| 897 |
+
sub = db.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
|
| 898 |
+
sub.plan, sub.status = plan, "active"
|
| 899 |
+
db.commit()
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
def test_plan_video_limits(client, owner, db_session, monkeypatch):
|
| 903 |
+
_, ws = owner
|
| 904 |
+
monkeypatch.setattr(settings, "billing_enabled", True)
|
| 905 |
+
video_quota.sync_video_quota(db_session, ws)
|
| 906 |
+
for plan, limit in (("free", 1), ("writer", 10), ("studio", 20)):
|
| 907 |
+
_on_plan(db_session, ws, plan)
|
| 908 |
+
assert video_quota.sync_video_quota(db_session, ws).video_limit == limit
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
def test_free_is_one_video_in_total(client, b2v, owner, free_plan, db_session, session_factory):
|
| 912 |
+
headers, ws = owner
|
| 913 |
+
make_video(client, headers)
|
| 914 |
+
r = client.post("/api/videos", json={"url": "https://a.test/2"}, headers=headers)
|
| 915 |
+
assert r.status_code == 402
|
| 916 |
+
usage = client.get("/api/videos/quota", headers=headers).json()
|
| 917 |
+
assert usage["resets_at"] is None
|
| 918 |
+
sub = db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id))
|
| 919 |
+
sub.videos_period_start = datetime.now(UTC) - timedelta(days=90)
|
| 920 |
+
db_session.commit()
|
| 921 |
+
assert video_quota.reset_due_video_periods(session_factory) == 0 # never refills
|
| 922 |
+
# Deleting the video does not give it back.
|
| 923 |
+
art_id = db_session.scalar(select(B2VVideo)).artifact_id
|
| 924 |
+
client.delete(f"/api/videos/{art_id}", headers=headers)
|
| 925 |
+
assert client.post("/api/videos", json={"url": "https://a.test/3"}, headers=headers).status_code == 402
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
def test_free_video_that_failed_is_given_back(client, b2v, owner, free_plan, db_session):
|
| 929 |
+
headers, ws = owner
|
| 930 |
+
b2v.queue("POST", "/api/v1/videos", (422, {"detail": "bad url"}))
|
| 931 |
+
assert client.post("/api/videos", json={"url": "https://a.test/1"}, headers=headers).status_code == 400
|
| 932 |
+
make_video(client, headers)
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
def test_dropping_to_free_counts_what_was_made(client, b2v, owner, db_session, monkeypatch):
|
| 936 |
+
headers, ws = owner
|
| 937 |
+
monkeypatch.setattr(settings, "billing_enabled", True)
|
| 938 |
+
video_quota.sync_video_quota(db_session, ws)
|
| 939 |
+
_on_plan(db_session, ws, "writer")
|
| 940 |
+
for i in range(3):
|
| 941 |
+
make_video(client, headers, url=f"https://a.test/{i}")
|
| 942 |
+
_on_plan(db_session, ws, "free")
|
| 943 |
+
usage = client.get("/api/videos/quota", headers=headers).json()
|
| 944 |
+
assert (usage["used"], usage["limit"]) == (3, 1)
|
| 945 |
+
assert client.post("/api/videos", json={"url": "https://a.test/x"}, headers=headers).status_code == 402
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def test_list_restores_a_video_whose_library_item_went_missing(client, b2v, owner, db_session):
|
| 949 |
+
headers, _ = owner
|
| 950 |
+
art = make_video(client, headers)
|
| 951 |
+
row = db_session.scalar(select(B2VVideo))
|
| 952 |
+
row.artifact_id, row.status = None, "generated"
|
| 953 |
+
db_session.delete(db_session.get(Artifact, uuid.UUID(art["id"])))
|
| 954 |
+
db_session.commit()
|
| 955 |
+
listed = client.get("/api/videos", headers=headers).json()
|
| 956 |
+
assert len(listed) == 1 and listed[0]["status"] == "ready"
|
| 957 |
+
b2v.calls.clear()
|
| 958 |
+
client.get(f"/api/videos/{listed[0]['id']}/status", headers=headers)
|
| 959 |
+
assert "/api/v1/videos/812/status" in b2v.paths("GET") # the same blog2video video
|
| 960 |
+
assert len(client.get("/api/videos", headers=headers).json()) == 1 # restored once
|
docs/BLOG2VIDEO.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# blog2video integration
|
| 2 |
+
|
| 3 |
+
Videos are made and edited by blog2video. Notestack is an ordinary blog2video API customer: we own one paid
|
| 4 |
+
blog2video account (Pro, topped up with per-video credits) and the backend calls its public `/api/v1` API with that
|
| 5 |
+
account's key. Every video is charged to our account; we charge our workspaces from our own allowance.
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
Browser ──► /api/videos/* (backend/app/routers/videos.py) ──(Bearer b2v_live_…)──► blog2video /api/v1
|
| 9 |
+
├─ checks the session and workspace
|
| 10 |
+
├─ reserves / refunds the workspace's allowance (subscriptions)
|
| 11 |
+
└─ records which video belongs to which workspace (b2v_videos)
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
The browser never talks to blog2video, and the key never reaches the browser or the logs.
|
| 15 |
+
|
| 16 |
+
## Settings
|
| 17 |
+
|
| 18 |
+
Backend `.env` only (never a `VITE_` variable):
|
| 19 |
+
|
| 20 |
+
| Env var | Value |
|
| 21 |
+
|---|---|
|
| 22 |
+
| `B2V_API_BASE_URL` | blog2video API host, no trailing slash (`B2V_API_URL` is still read as a fallback) |
|
| 23 |
+
| `B2V_API_KEY` | `b2v_live_…`, from blog2video → Account → API keys on our Pro account |
|
| 24 |
+
| `B2V_TIMEOUT_SECONDS` | create timeout (default 30); every other call uses 10s |
|
| 25 |
+
| `B2V_CAPACITY_ALERT_BELOW` | log an `ALERT` when our account has fewer videos left (default 20) |
|
| 26 |
+
|
| 27 |
+
A revoked or rotated key answers 401; a blog2video account that dropped to Free answers 403 to everything. Both
|
| 28 |
+
log an `ALERT` line and users see "temporarily unavailable".
|
| 29 |
+
|
| 30 |
+
## Ownership
|
| 31 |
+
|
| 32 |
+
Everything made with the key (videos, custom templates, custom voices, styles) belongs to one blog2video account,
|
| 33 |
+
and blog2video's ownership checks stop at the account. Notestack is the only place each workspace's things are kept
|
| 34 |
+
apart (`backend/app/services/b2v_access.py`):
|
| 35 |
+
|
| 36 |
+
| Table | What | Used as |
|
| 37 |
+
|---|---|---|
|
| 38 |
+
| `b2v_videos` | videos (also the allowance ledger) | project id; `created_via` v1 or upload |
|
| 39 |
+
| `b2v_templates` | custom templates (`ready` once generated) | `template: "custom_<id>"` |
|
| 40 |
+
| `b2v_custom_voices` | designed and cloned voices | `custom_voice_id: "<voice_id>"` |
|
| 41 |
+
| `user_saved_voices` | "My voices" (kept only here) | the wizard's voice list |
|
| 42 |
+
| `b2v_styles` | custom video styles | `video_style: "custom:<id>"` |
|
| 43 |
+
| `usage_counters` | per-workspace counters (below) | |
|
| 44 |
+
|
| 45 |
+
Every route that takes one of these ids checks it is the workspace's (404 otherwise), and every create or edit body
|
| 46 |
+
has its `template`, `video_style` and `custom_voice_id` checked before it is forwarded. Lists come from these tables.
|
| 47 |
+
Account-wide endpoints (`POST /api/projects`, `/api/projects/bulk`, `GET /api/projects`, `/api/voices/saved*`,
|
| 48 |
+
built-in style overrides, style pin/selection, "Your Style", script preferences) are refused in
|
| 49 |
+
`blog2video.DENIED` before anything is sent.
|
| 50 |
+
|
| 51 |
+
## Routes
|
| 52 |
+
|
| 53 |
+
| Notestack | blog2video |
|
| 54 |
+
|---|---|
|
| 55 |
+
| `POST /api/videos` (link, text, a post) | `POST /api/v1/videos` with `Idempotency-Key: <workspace>:<uuid>` |
|
| 56 |
+
| `POST /api/videos/upload` (≤5 files) | `POST /api/projects/upload` then `/generate` |
|
| 57 |
+
| `POST /api/videos/batch` (≤10 links) | one `POST /api/v1/videos` per link |
|
| 58 |
+
| `GET /api/videos/{id}/status` | `/api/v1/videos/{id}/status`, or `/api/projects/{id}/status` for uploads |
|
| 59 |
+
| `/api/videos/{id}/script*` | `/api/projects/{id}/script-review/*` |
|
| 60 |
+
| `/api/videos/{id}/p/<path>` | `/api/projects/{id}/<path>`, allowlisted in `routers/video_edit.py` |
|
| 61 |
+
| `/api/video-voices/*` | `/api/voices/design-from-*`, `/api/voices/custom*`, `/api/voices/clone`, `/api/voice/preview` |
|
| 62 |
+
| `/api/video-templates/*` | `/api/custom-templates/*` (editor paths allowlisted) |
|
| 63 |
+
| `/api/video-styles/*` | `/api/video-styles/ai-draft`, `/api/video-styles/custom*` |
|
| 64 |
+
|
| 65 |
+
The live preview link comes from `POST /api/embed/token/{id}` and is safe for the browser.
|
| 66 |
+
|
| 67 |
+
## Premium and per-workspace limits
|
| 68 |
+
|
| 69 |
+
blog2video checks every limit against our Pro account, so every workspace passes its checks. Notestack divides them:
|
| 70 |
+
each plan row has `video_premium` (★: detailed lengths, paid and custom voices, advanced voice tuning, voice samples,
|
| 71 |
+
AI script rewrites, AI chat, avatars, designer templates, the source download) and `video_limits`:
|
| 72 |
+
|
| 73 |
+
| Metric | Period | Free / Writer / Studio | Counts |
|
| 74 |
+
|---|---|---|---|
|
| 75 |
+
| `ai_edits` | month | 20 / 300 / 1000 | scene regenerate/add, AI image, stock clip, AI chat, AI rewrites, template AI edits, avatars (10 per scene) |
|
| 76 |
+
| `templates` | lifetime | 0 / 2 / 5 | custom templates made (blog2video slots are never given back) |
|
| 77 |
+
| `template_ai_daily` | day | 0 / 3 / 5 | theme from a document or prompt, code generation |
|
| 78 |
+
| `voice_designs_daily` | day | 0 / 5 / 10 | voice designs |
|
| 79 |
+
| `voice_samples_daily` | day | 0 / 20 / 40 | voice samples |
|
| 80 |
+
| `custom_voices` | total | 0 / 3 / 10 | kept or cloned voices (a delete frees one) |
|
| 81 |
+
|
| 82 |
+
Edit a plan row to change them. A counter is given back when blog2video refuses the call. A template switch also
|
| 83 |
+
uses a video.
|
| 84 |
+
|
| 85 |
+
## Quota
|
| 86 |
+
|
| 87 |
+
One row per workspace in `subscriptions`, enforced only by us (`backend/app/services/video_quota.py`):
|
| 88 |
+
|
| 89 |
+
| Column | Meaning |
|
| 90 |
+
|---|---|
|
| 91 |
+
| `video_plan` | effective plan id (`free`, `writer`, `studio`) |
|
| 92 |
+
| `video_limit` | videos per period, from the plan |
|
| 93 |
+
| `videos_used` | videos made this period |
|
| 94 |
+
| `videos_period_start` | start of the current period |
|
| 95 |
+
|
| 96 |
+
- **Reserve** before calling blog2video: `videos_used + 1` only while below `video_limit`, in one `UPDATE`. At the
|
| 97 |
+
limit we answer 402 `plan_limit` and blog2video is never called.
|
| 98 |
+
- **Refund exactly once** (conditional `UPDATE b2v_videos SET quota_state = 'refunded' WHERE quota_state = 'charged'`)
|
| 99 |
+
when the create is refused (402, 422, key/plan problems), gets no answer after 3 tries with the same
|
| 100 |
+
`Idempotency-Key`, or generation fails (status `failed`/`error`, or 404).
|
| 101 |
+
- The worker's `video_refund_sweep` (every 10 min) checks charged, unfinished videos older than 30 minutes, for users
|
| 102 |
+
who closed the tab. `video_capacity_check` (hourly) reads `GET /api/v1/me` and alerts when we run low.
|
| 103 |
+
- Resets: Stripe `invoice.paid` with `billing_reason` `subscription_cycle` or `subscription_create`; otherwise the
|
| 104 |
+
hourly `video_period_reset` resets any period older than a month (free, billing disabled, annual plans).
|
| 105 |
+
|
| 106 |
+
A 402 `quota_exceeded` from blog2video means **our** account is out of videos, not the user: they are refunded and
|
| 107 |
+
see "temporarily unavailable"; buy credits. Our users' limits added up can exceed the account, so watch the alert.
|
| 108 |
+
|
| 109 |
+
## Videos made before the switch
|
| 110 |
+
|
| 111 |
+
Videos made through the old partner integration belonged to a blog2video service account that is now deactivated;
|
| 112 |
+
the key gets 404 for them. They have no `b2v_videos` row, so the API treats them as legacy: `GET` returns the stored
|
| 113 |
+
`video_url`/`preview_url` with `legacy: true`, and edits answer 410. blog2video can move them to our account on
|
| 114 |
+
request if we ever need them editable.
|
| 115 |
+
|
| 116 |
+
## Clean-up after the switch
|
| 117 |
+
|
| 118 |
+
The partner-token code and settings (`B2V_PARTNER_SLUG`, `B2V_PARTNER_SECRET`) are gone. blog2video used to connect
|
| 119 |
+
to our database, so drop that access:
|
| 120 |
+
|
| 121 |
+
```sql
|
| 122 |
+
REVOKE ALL ON public.subscriptions FROM b2v_partner;
|
| 123 |
+
DROP VIEW IF EXISTS b2v_quota;
|
| 124 |
+
DROP ROLE IF EXISTS b2v_partner;
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
and remove blog2video's server IP from the database firewall.
|
| 128 |
+
|
| 129 |
+
## Local development
|
| 130 |
+
|
| 131 |
+
Set `B2V_API_BASE_URL` and a test key. Unit tests (`backend/tests/test_videos.py`) use a fake blog2video.
|
frontend/seo/prerender.ts
CHANGED
|
@@ -240,6 +240,16 @@ function head(r: Rendered, origin: string) {
|
|
| 240 |
return tags.join("\n ");
|
| 241 |
}
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
export function prerenderPlugin(siteUrl: string): Plugin {
|
| 244 |
const origin = siteUrl.replace(/\/+$/, "");
|
| 245 |
let config: ResolvedConfig;
|
|
@@ -255,14 +265,14 @@ export function prerenderPlugin(siteUrl: string): Plugin {
|
|
| 255 |
const all = routes();
|
| 256 |
for (const r of all) {
|
| 257 |
const html = template
|
| 258 |
-
.replace("</head>", ` ${head(r, origin)}\n </head>`)
|
| 259 |
-
.replace('<div id="root"></div>', `<div id="root">${r.body}</div>`);
|
| 260 |
const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
|
| 261 |
await mkdir(path.dirname(file), { recursive: true });
|
| 262 |
await writeFile(file, html, "utf8");
|
| 263 |
}
|
| 264 |
// Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
|
| 265 |
-
await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n </head>`), "utf8");
|
| 266 |
config.logger.info(`prerendered ${all.length} public routes`);
|
| 267 |
},
|
| 268 |
};
|
|
|
|
| 240 |
return tags.join("\n ");
|
| 241 |
}
|
| 242 |
|
| 243 |
+
/**
|
| 244 |
+
* The static copy is for crawlers and screen readers, not for people: until the app's JS and CSS load it would flash
|
| 245 |
+
* as an unstyled page of links. Keep it in the document but visually hidden (not display:none, which some crawlers
|
| 246 |
+
* skip), and paint the app's black background from the first frame. React's createRoot replaces it on load.
|
| 247 |
+
*/
|
| 248 |
+
const STATIC_STYLE =
|
| 249 |
+
"<style>html,body{background:#000;color:#fff;margin:0}" +
|
| 250 |
+
".seo-static{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;" +
|
| 251 |
+
"clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}</style>";
|
| 252 |
+
|
| 253 |
export function prerenderPlugin(siteUrl: string): Plugin {
|
| 254 |
const origin = siteUrl.replace(/\/+$/, "");
|
| 255 |
let config: ResolvedConfig;
|
|
|
|
| 265 |
const all = routes();
|
| 266 |
for (const r of all) {
|
| 267 |
const html = template
|
| 268 |
+
.replace("</head>", ` ${head(r, origin)}\n ${STATIC_STYLE}\n </head>`)
|
| 269 |
+
.replace('<div id="root"></div>', `<div id="root"><div class="seo-static">${r.body}</div></div>`);
|
| 270 |
const file = r.path === "/" ? path.join(outDir, "index.html") : path.join(outDir, `${r.path.slice(1)}.html`);
|
| 271 |
await mkdir(path.dirname(file), { recursive: true });
|
| 272 |
await writeFile(file, html, "utf8");
|
| 273 |
}
|
| 274 |
// Signed in and unknown routes fall back to an app shell with no page content (see public/_redirects).
|
| 275 |
+
await writeFile(path.join(outDir, "app.html"), template.replace("</head>", ` <meta name="robots" content="noindex" />\n <title>Notestack</title>\n ${STATIC_STYLE}\n </head>`), "utf8");
|
| 276 |
config.logger.info(`prerendered ${all.length} public routes`);
|
| 277 |
},
|
| 278 |
};
|
frontend/src/App.tsx
CHANGED
|
@@ -18,6 +18,10 @@ import Resurface from "./pages/Resurface";
|
|
| 18 |
import Settings from "./pages/Settings";
|
| 19 |
import Sources from "./pages/Sources";
|
| 20 |
import Studio from "./pages/Studio";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
import TopicMap from "./pages/TopicMap";
|
| 22 |
import { ToolPage, ToolsHub } from "./pages/Tools";
|
| 23 |
import VoiceProfile from "./pages/VoiceProfile";
|
|
@@ -55,6 +59,10 @@ export default function App() {
|
|
| 55 |
<Route index element={<MissionControl />} />
|
| 56 |
<Route path="notebooks" element={<Notebooks />} />
|
| 57 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
<Route path="sources" element={<Sources />} />
|
| 59 |
<Route path="map" element={<TopicMap />} />
|
| 60 |
<Route path="voice" element={<VoiceProfile />} />
|
|
|
|
| 18 |
import Settings from "./pages/Settings";
|
| 19 |
import Sources from "./pages/Sources";
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
+
import VideoCreate from "./pages/VideoCreate";
|
| 22 |
+
import VideoEditor from "./pages/VideoEditor";
|
| 23 |
+
import VideoVoices from "./pages/VideoVoices";
|
| 24 |
+
import Videos from "./pages/Videos";
|
| 25 |
import TopicMap from "./pages/TopicMap";
|
| 26 |
import { ToolPage, ToolsHub } from "./pages/Tools";
|
| 27 |
import VoiceProfile from "./pages/VoiceProfile";
|
|
|
|
| 59 |
<Route index element={<MissionControl />} />
|
| 60 |
<Route path="notebooks" element={<Notebooks />} />
|
| 61 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 62 |
+
<Route path="videos" element={<Videos />} />
|
| 63 |
+
<Route path="videos/new" element={<VideoCreate />} />
|
| 64 |
+
<Route path="videos/voices" element={<VideoVoices />} />
|
| 65 |
+
<Route path="videos/:id" element={<VideoEditor />} />
|
| 66 |
<Route path="sources" element={<Sources />} />
|
| 67 |
<Route path="map" element={<TopicMap />} />
|
| 68 |
<Route path="voice" element={<VoiceProfile />} />
|
frontend/src/api/client.ts
CHANGED
|
@@ -87,16 +87,31 @@ async function tryRefresh(): Promise<boolean> {
|
|
| 87 |
return true;
|
| 88 |
}
|
| 89 |
|
| 90 |
-
|
| 91 |
const headers = new Headers(init.headers);
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
| 93 |
if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`);
|
| 94 |
const res = await fetch(`${BASE}${path}`, { ...init, headers });
|
| 95 |
-
if (res.status === 401 && retry && (await tryRefresh())) return
|
| 96 |
if (!res.ok) throw await parseError(res);
|
| 97 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
| 100 |
export const post = <T>(path: string, body?: unknown) =>
|
| 101 |
api<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
|
| 102 |
|
|
|
|
| 87 |
return true;
|
| 88 |
}
|
| 89 |
|
| 90 |
+
async function request(path: string, init: RequestInit, retry: boolean): Promise<Response> {
|
| 91 |
const headers = new Headers(init.headers);
|
| 92 |
+
// FormData sets its own multipart Content-Type (with the boundary); everything else is JSON.
|
| 93 |
+
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
|
| 94 |
+
headers.set("Content-Type", "application/json");
|
| 95 |
+
}
|
| 96 |
if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`);
|
| 97 |
const res = await fetch(`${BASE}${path}`, { ...init, headers });
|
| 98 |
+
if (res.status === 401 && retry && (await tryRefresh())) return request(path, init, false);
|
| 99 |
if (!res.ok) throw await parseError(res);
|
| 100 |
+
return res;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export async function api<T>(path: string, init: RequestInit = {}, retry = true): Promise<T> {
|
| 104 |
+
return (await (await request(path, init, retry)).json()) as T;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/** A binary answer (audio, a download) as a Blob. */
|
| 108 |
+
export async function apiBlob(path: string, init: RequestInit = {}): Promise<Blob> {
|
| 109 |
+
return (await request(path, init, true)).blob();
|
| 110 |
}
|
| 111 |
|
| 112 |
+
/** Multipart upload to our API (files plus form fields). */
|
| 113 |
+
export const postForm = <T>(path: string, form: FormData) => api<T>(path, { method: "POST", body: form });
|
| 114 |
+
|
| 115 |
export const post = <T>(path: string, body?: unknown) =>
|
| 116 |
api<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
|
| 117 |
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { api, del, patch, post, put, qs, uploadFile } from "./client";
|
| 2 |
import type {
|
| 3 |
Artifact,
|
| 4 |
ArtifactType,
|
|
@@ -28,6 +28,29 @@ import type {
|
|
| 28 |
VoiceDesignPreview,
|
| 29 |
VoiceProfileData,
|
| 30 |
VoiceState,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
} from "./types";
|
| 32 |
|
| 33 |
type SourceJob = { source: Source; job: Job };
|
|
@@ -78,7 +101,7 @@ export type GenerateBody = {
|
|
| 78 |
archive?: boolean;
|
| 79 |
format?: "deep_dive" | "brief" | "debate";
|
| 80 |
minutes?: number;
|
| 81 |
-
style?: "
|
| 82 |
audio_artifact_id?: string;
|
| 83 |
slides?: { heading: string; body: string }[];
|
| 84 |
parent_id?: string;
|
|
@@ -198,3 +221,215 @@ export const billingApi = {
|
|
| 198 |
portal: () => post<{ url: string }>("/api/billing/portal"),
|
| 199 |
confirm: (sessionId: string) => post<BillingStatus>("/api/billing/confirm", { session_id: sessionId }),
|
| 200 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
|
| 2 |
import type {
|
| 3 |
Artifact,
|
| 4 |
ArtifactType,
|
|
|
|
| 28 |
VoiceDesignPreview,
|
| 29 |
VoiceProfileData,
|
| 30 |
VoiceState,
|
| 31 |
+
ExtractedTheme,
|
| 32 |
+
LegacyVideo,
|
| 33 |
+
MyTemplate,
|
| 34 |
+
ScriptPreview,
|
| 35 |
+
ScriptScene,
|
| 36 |
+
TemplateGeneration,
|
| 37 |
+
TemplatesResponse,
|
| 38 |
+
VideoCatalog,
|
| 39 |
+
VideoConfig,
|
| 40 |
+
VideoCreateBody,
|
| 41 |
+
VideoCustomVoice,
|
| 42 |
+
VideoDesignedVoice,
|
| 43 |
+
VideoJobState,
|
| 44 |
+
VideoListItem,
|
| 45 |
+
VideoOptions,
|
| 46 |
+
VideoProject,
|
| 47 |
+
VideoProjectData,
|
| 48 |
+
VideoQuota,
|
| 49 |
+
VideoSavedVoice,
|
| 50 |
+
VideoScene,
|
| 51 |
+
VideoStatus,
|
| 52 |
+
VideoStyleItem,
|
| 53 |
+
VideoVoicesResponse,
|
| 54 |
} from "./types";
|
| 55 |
|
| 56 |
type SourceJob = { source: Source; job: Job };
|
|
|
|
| 101 |
archive?: boolean;
|
| 102 |
format?: "deep_dive" | "brief" | "debate";
|
| 103 |
minutes?: number;
|
| 104 |
+
style?: "audiogram";
|
| 105 |
audio_artifact_id?: string;
|
| 106 |
slides?: { heading: string; body: string }[];
|
| 107 |
parent_id?: string;
|
|
|
|
| 221 |
portal: () => post<{ url: string }>("/api/billing/portal"),
|
| 222 |
confirm: (sessionId: string) => post<BillingStatus>("/api/billing/confirm", { session_id: sessionId }),
|
| 223 |
};
|
| 224 |
+
|
| 225 |
+
function form(fields: Record<string, string | number | boolean | Blob | null | undefined>): FormData {
|
| 226 |
+
const f = new FormData();
|
| 227 |
+
for (const [k, v] of Object.entries(fields)) {
|
| 228 |
+
if (v === undefined || v === null) continue;
|
| 229 |
+
f.append(k, v instanceof Blob ? v : String(v));
|
| 230 |
+
}
|
| 231 |
+
return f;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
export type ScriptDraft = { title: string; display_text: string; narration_text: string; revision: number;
|
| 235 |
+
draft_scenes: { id: number; title: string; display_text: string | null; narration_text: string }[] };
|
| 236 |
+
|
| 237 |
+
/** Videos are made by blog2video; the backend proxies every call. Ids here are our artifact ids. */
|
| 238 |
+
export const videosApi = {
|
| 239 |
+
config: () => api<VideoConfig>("/api/videos/config"),
|
| 240 |
+
quota: () => api<VideoQuota>("/api/videos/quota"),
|
| 241 |
+
catalog: () => api<VideoCatalog>("/api/videos/catalog"),
|
| 242 |
+
list: () => api<VideoListItem[]>("/api/videos"),
|
| 243 |
+
create: (body: VideoCreateBody) => post<Artifact>("/api/videos", body),
|
| 244 |
+
/** Multi-link: one video per link. Items with `error` say where it stopped (usually the allowance ran out). */
|
| 245 |
+
createBatch: (urls: string[], options: VideoOptions) =>
|
| 246 |
+
post<(Artifact | { error: string; url: string })[]>("/api/videos/batch", { ...options, urls }),
|
| 247 |
+
/** Up to 5 documents; blog2video reads them itself. */
|
| 248 |
+
createFromFiles: (files: File[], options: VideoOptions) => {
|
| 249 |
+
const f = new FormData();
|
| 250 |
+
files.forEach((file) => f.append("files", file));
|
| 251 |
+
f.append("options", JSON.stringify(options));
|
| 252 |
+
return postForm<Artifact>("/api/videos/upload", f);
|
| 253 |
+
},
|
| 254 |
+
get: (id: string) => api<VideoProject | LegacyVideo>(`/api/videos/${id}`),
|
| 255 |
+
status: (id: string) => api<VideoStatus>(`/api/videos/${id}/status`),
|
| 256 |
+
remove: (id: string) => del(`/api/videos/${id}`),
|
| 257 |
+
// Script review
|
| 258 |
+
script: (id: string) => api<{ status: string; scenes: ScriptScene[] }>(`/api/videos/${id}/script`),
|
| 259 |
+
narrationPreview: (id: string, sceneId: number, body: ScriptDraft) =>
|
| 260 |
+
post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/narration-preview`, body),
|
| 261 |
+
aiPreview: (id: string, sceneId: number, body: ScriptDraft & { instruction: string }) =>
|
| 262 |
+
post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/ai-preview`, body),
|
| 263 |
+
approveScript: (id: string, scenes: (ScriptScene & { source_fingerprint?: string; accepted_ai_instructions?: string[] })[]) =>
|
| 264 |
+
post<unknown>(`/api/videos/${id}/script/approve`, { scenes }),
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
const P = (id: string, path: string) => `/api/videos/${id}/p/${path}`;
|
| 268 |
+
export type LayoutSchema = Record<string, { defaults?: Record<string, unknown>; fields?: Record<string, unknown>[] }>;
|
| 269 |
+
|
| 270 |
+
/** The template's layouts, from blog2video's GET .../layouts. Variants ("news_headline__v2") are visual styles of a base
|
| 271 |
+
* layout: layout_variants maps a base to its variant ids, layout_variant_labels names each ("Broadsheet"). */
|
| 272 |
+
export type LayoutInfo = {
|
| 273 |
+
layouts: string[];
|
| 274 |
+
layout_names?: Record<string, string>;
|
| 275 |
+
layout_prop_schema?: LayoutSchema;
|
| 276 |
+
layout_variants?: Record<string, string[]>;
|
| 277 |
+
layout_variant_labels?: Record<string, string>;
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
export type StockClip = { provider: string; id: string; preview_url: string; thumbnail_url: string; download_url: string;
|
| 281 |
+
width: number; height: number; duration: number; author: string; page_url: string };
|
| 282 |
+
|
| 283 |
+
/** The editor: blog2video's project endpoints, through our allowlisted proxy. */
|
| 284 |
+
export const videoEditApi = {
|
| 285 |
+
project: (id: string) => api<VideoProjectData>(P(id, "")),
|
| 286 |
+
updateProject: (id: string, body: Partial<VideoProjectData>) => patch<VideoProjectData>(P(id, "update-project"), body),
|
| 287 |
+
// Scenes
|
| 288 |
+
updateScene: (id: string, sceneId: number, body: Partial<Pick<VideoScene, "title" | "display_text" | "narration_text" |
|
| 289 |
+
"visual_description" | "duration_seconds" | "extra_hold_seconds" | "remotion_code">>) =>
|
| 290 |
+
put<VideoScene>(P(id, `scenes/${sceneId}`), body),
|
| 291 |
+
deleteScene: (id: string, sceneId: number) => del<unknown>(P(id, `scenes/${sceneId}`)),
|
| 292 |
+
reorderScenes: (id: string, scene_orders: { scene_id: number; order: number }[]) =>
|
| 293 |
+
post<VideoScene[]>(P(id, "scenes/reorder"), { scene_orders }),
|
| 294 |
+
regenerateScene: (id: string, sceneId: number, body: { description?: string; narration_text?: string;
|
| 295 |
+
regenerate_voiceover?: boolean; voiceover_verbatim?: boolean; layout?: string; image?: File }) =>
|
| 296 |
+
postForm<VideoScene>(P(id, `scenes/${sceneId}/regenerate`), form(body)),
|
| 297 |
+
addScene: (id: string, prompt: string, position?: number) => post<VideoJobState>(P(id, "scenes/add"), { prompt, position }),
|
| 298 |
+
addSceneStatus: (id: string) => api<VideoJobState | null>(P(id, "scenes/add-status")),
|
| 299 |
+
// Images
|
| 300 |
+
uploadImage: (id: string, sceneId: number, image: File) => postForm<VideoScene>(P(id, `scenes/${sceneId}/image`), form({ image })),
|
| 301 |
+
generateImage: (id: string, sceneId: number, image_description: string) =>
|
| 302 |
+
// Not saved on the scene: the image comes back for the user to keep (uploadImage) or discard.
|
| 303 |
+
post<{ image_base64: string; refined_prompt: string }>(P(id, `scenes/${sceneId}/generate-image`), { image_description }),
|
| 304 |
+
imageFocus: (id: string, sceneId: number, body: { image_focus_x: number; image_focus_y: number; image_zoom?: number }) =>
|
| 305 |
+
patch<VideoScene>(P(id, `scenes/${sceneId}/image-focus`), body),
|
| 306 |
+
moveImage: (id: string, from_scene_id: number, to_scene_id: number) =>
|
| 307 |
+
post<unknown>(P(id, "images/move"), { from_scene_id, to_scene_id }),
|
| 308 |
+
swapImages: (id: string, first_scene_id: number, second_scene_id: number) =>
|
| 309 |
+
post<unknown>(P(id, "images/swap"), { first_scene_id, second_scene_id }),
|
| 310 |
+
duplicateImage: (id: string, source_scene_id: number, target_scene_id: number) =>
|
| 311 |
+
post<unknown>(P(id, "images/duplicate"), { source_scene_id, target_scene_id }),
|
| 312 |
+
/** Deletes the image or clip from the video for good (files too) and takes it off any scene. */
|
| 313 |
+
/** The template's layouts; layout_prop_schema[layout].defaults holds its default font sizes (meta.json). */
|
| 314 |
+
layouts: (id: string) => api<LayoutInfo>(P(id, "layouts")),
|
| 315 |
+
deleteAsset: (id: string, assetId: number) => del<unknown>(P(id, `assets/${assetId}`)),
|
| 316 |
+
assignImage: (id: string, scene_id: number, asset_id: number) =>
|
| 317 |
+
post<unknown>(P(id, "images/assign-existing"), { scene_id, asset_id }),
|
| 318 |
+
// Stock footage
|
| 319 |
+
searchStock: (id: string, q: string) => api<{ clips: StockClip[] }>(P(id, `stock-footage/search${qs({ q })}`)),
|
| 320 |
+
/** Downloads the clip into the project (a new asset). It is not on the scene until linkStock. */
|
| 321 |
+
useStock: (id: string, sceneId: number, c: StockClip) =>
|
| 322 |
+
post<{ asset_id: number; filename: string }>(P(id, `scenes/${sceneId}/stock-footage`), { provider: c.provider, clip_id: c.id,
|
| 323 |
+
download_url: c.download_url, width: c.width, height: c.height, duration: c.duration, author: c.author,
|
| 324 |
+
page_url: c.page_url }),
|
| 325 |
+
/** Put a clip that is already in the project on a scene (assignedVideo). */
|
| 326 |
+
linkStock: (id: string, scene_id: number, filename: string) =>
|
| 327 |
+
post<unknown>(P(id, "stock-footage/link"), { scene_id, filename }),
|
| 328 |
+
pendingStock: (id: string) => api<{ scenes?: unknown[]; [k: string]: unknown }>(P(id, "stock-footage/pending")),
|
| 329 |
+
approveStock: (id: string) => post<unknown>(P(id, "stock-footage/approve")),
|
| 330 |
+
rejectStock: (id: string) => post<unknown>(P(id, "stock-footage/reject")),
|
| 331 |
+
// Voice, voiceovers, language, template
|
| 332 |
+
uploadVoiceover: (id: string, sceneId: number, audio: File) =>
|
| 333 |
+
postForm<VideoScene>(P(id, `scenes/${sceneId}/voiceover`), form({ audio })),
|
| 334 |
+
deleteVoiceover: (id: string) => post<unknown>(P(id, "delete-voiceover")),
|
| 335 |
+
changeVoice: (id: string, body: { voice_gender?: string; voice_accent?: string; custom_voice_id?: string | null;
|
| 336 |
+
voice_emotion?: string | null }) => post<VideoJobState>(P(id, "change-voice"), body),
|
| 337 |
+
voiceStatus: (id: string) => api<VideoJobState>(P(id, "voice-change-status")),
|
| 338 |
+
changeLanguage: (id: string, content_language: string) => post<VideoJobState>(P(id, "change-language"), { content_language }),
|
| 339 |
+
languageStatus: (id: string) => api<VideoJobState>(P(id, "language-change-status")),
|
| 340 |
+
changeTemplate: (id: string, template: string) =>
|
| 341 |
+
post<VideoJobState>(P(id, "change-template-regenerate-layouts"), { template }),
|
| 342 |
+
templateStatus: (id: string) => api<VideoJobState>(P(id, "template-change-status")),
|
| 343 |
+
// Refresh the script (keeps narration and voiceovers, rewrites titles and layouts)
|
| 344 |
+
refreshScript: (id: string, user_instruction?: string) => post<VideoJobState>(P(id, "regenerate-script"), { user_instruction }),
|
| 345 |
+
refreshStatus: (id: string) => api<VideoJobState>(P(id, "regenerate-script-status")),
|
| 346 |
+
refreshPreview: (id: string) => api<Record<string, unknown>>(P(id, "regenerate-script/preview")),
|
| 347 |
+
refreshVerify: (id: string) => post<unknown>(P(id, "regenerate-script/verify")),
|
| 348 |
+
refreshRetry: (id: string, user_instruction?: string) =>
|
| 349 |
+
post<VideoJobState>(P(id, "regenerate-script/regenerate"), { user_instruction }),
|
| 350 |
+
// AI chat editing ★
|
| 351 |
+
chat: (id: string, message: string, conversation_id?: number) =>
|
| 352 |
+
post<{ reply?: string; message?: string; conversation_id?: number; [k: string]: unknown }>(P(id, "chat"), { message, conversation_id }),
|
| 353 |
+
chatHistory: (id: string) => api<unknown>(P(id, "chat/history")),
|
| 354 |
+
// Avatars ★
|
| 355 |
+
authorizeAvatars: (id: string, scene_ids: number[], avatar_preset?: string, avatar_motion_style?: string) =>
|
| 356 |
+
post<unknown>(P(id, "avatar-batch/authorize"), { scene_ids, avatar_preset, avatar_motion_style }),
|
| 357 |
+
makeAvatar: (id: string, sceneId: number, avatar_preset?: string) =>
|
| 358 |
+
postForm<unknown>(P(id, `scenes/${sceneId}/avatar`), form({ avatar_preset })),
|
| 359 |
+
avatarProgress: (id: string) => api<Record<string, unknown>>(P(id, "avatar-progress")),
|
| 360 |
+
deleteAvatar: (id: string, sceneId: number) => del<unknown>(P(id, `scenes/${sceneId}/avatar`)),
|
| 361 |
+
uploadPortrait: (id: string, file: File) => postForm<unknown>(P(id, "avatar-portrait"), form({ file })),
|
| 362 |
+
deletePortrait: (id: string) => del<unknown>(P(id, "avatar-portrait")),
|
| 363 |
+
avatarAppearance: (id: string, sceneId: number, body: { avatar_shape?: string; avatar_size?: number;
|
| 364 |
+
avatar_position?: string; avatar_opacity?: number }) => patch<unknown>(P(id, `scenes/${sceneId}/avatar-appearance`), body),
|
| 365 |
+
// Render, download, frames
|
| 366 |
+
/** force renders again even when an MP4 is already on R2 (blog2video otherwise just returns the old one). */
|
| 367 |
+
render: (id: string, { force = false, resolution = "1080p" }: { force?: boolean; resolution?: string } = {}) =>
|
| 368 |
+
post<VideoJobState>(P(id, `render${qs({ resolution, force_render: force || undefined })}`)),
|
| 369 |
+
renderStatus: (id: string) => api<VideoJobState>(P(id, "render-status")),
|
| 370 |
+
cancelRender: (id: string) => post<unknown>(P(id, "cancel-render")),
|
| 371 |
+
downloadUrl: (id: string) => api<{ url: string }>(P(id, "download-url")),
|
| 372 |
+
downloadStudio: (id: string) => apiBlob(P(id, "download-studio")),
|
| 373 |
+
still: (id: string, frame: number) => apiBlob(P(id, `render-still${qs({ frame })}`)),
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
export const videoVoicesApi = {
|
| 377 |
+
list: () => api<VideoVoicesResponse>("/api/video-voices"),
|
| 378 |
+
save: (voice_id: string) => post<VideoSavedVoice>("/api/video-voices/saved", { voice_id }),
|
| 379 |
+
unsave: (voice_id: string) => del(`/api/video-voices/saved/${encodeURIComponent(voice_id)}`),
|
| 380 |
+
designFromPrompt: (prompt: string) => post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/prompt", { prompt }),
|
| 381 |
+
designFromPreset: (body: { gender: string; age: string; persona: string; speed: string; accent: string }) =>
|
| 382 |
+
post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/preset", body),
|
| 383 |
+
keep: (body: { generated_voice_id: string; source: "prompt" | "preset"; name: string; prompt_text?: string }) =>
|
| 384 |
+
post<VideoCustomVoice>("/api/video-voices/custom", body),
|
| 385 |
+
clone: (name: string, file: File, remove_background_noise = true) =>
|
| 386 |
+
postForm<VideoCustomVoice>("/api/video-voices/clone", form({ name, file, remove_background_noise })),
|
| 387 |
+
customPreview: (customId: number) =>
|
| 388 |
+
api<{ preview_url: string | null; ready: boolean }>(`/api/video-voices/custom/${customId}/preview`),
|
| 389 |
+
removeCustom: (customId: number) => del(`/api/video-voices/custom/${customId}`),
|
| 390 |
+
/** ★ A few seconds of the voice with the chosen tuning (audio/mpeg). */
|
| 391 |
+
sample: (body: { voice_gender: string; voice_accent: string; custom_voice_id?: string; voice_emotion?: string;
|
| 392 |
+
video_style?: string }) => apiBlob("/api/video-voices/sample", { method: "POST", body: JSON.stringify(body) }),
|
| 393 |
+
};
|
| 394 |
+
|
| 395 |
+
export const videoStylesApi = {
|
| 396 |
+
list: () => api<VideoStyleItem[]>("/api/video-styles"),
|
| 397 |
+
aiDraft: (prompt: string) => post<{ name: string; guidance: string }>("/api/video-styles/ai-draft", { prompt }),
|
| 398 |
+
create: (body: { name: string; guidance: string; creation_method: "manual" | "ai"; source_prompt?: string }) =>
|
| 399 |
+
post<VideoStyleItem>("/api/video-styles", body),
|
| 400 |
+
update: (styleId: number, body: { name: string; guidance: string; version: number }) =>
|
| 401 |
+
patch<VideoStyleItem>(`/api/video-styles/${styleId}`, body),
|
| 402 |
+
remove: (styleId: number) => del(`/api/video-styles/${styleId}`),
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
const T = (id: number, path: string) => `/api/video-templates/${id}/p/${path}`;
|
| 406 |
+
|
| 407 |
+
export const videoTemplatesApi = {
|
| 408 |
+
list: () => api<TemplatesResponse>("/api/video-templates"),
|
| 409 |
+
extractFromUrl: (url: string) => post<ExtractedTheme>("/api/video-templates/extract/url", { url }),
|
| 410 |
+
extractFromPrompt: (prompt: string, name?: string) =>
|
| 411 |
+
post<ExtractedTheme>("/api/video-templates/extract/prompt", { prompt, name }),
|
| 412 |
+
extractFromDoc: (file: File, name: string) => postForm<ExtractedTheme>("/api/video-templates/extract/doc", form({ file, name })),
|
| 413 |
+
create: (body: { name: string; theme: Record<string, unknown>; source_url?: string; logo_urls?: string[];
|
| 414 |
+
og_image?: string; screenshot_url?: string }) => post<MyTemplate>("/api/video-templates", body),
|
| 415 |
+
generate: (id: number) => post<unknown>(`/api/video-templates/${id}/generate`),
|
| 416 |
+
generationStatus: (id: number) => api<TemplateGeneration>(`/api/video-templates/${id}/generation-status`),
|
| 417 |
+
remove: (id: number) => del(`/api/video-templates/${id}`),
|
| 418 |
+
// Editing
|
| 419 |
+
get: (id: number) => api<Record<string, unknown>>(T(id, "")),
|
| 420 |
+
update: (id: number, body: { name?: string; theme?: Record<string, unknown> }) => put<Record<string, unknown>>(T(id, ""), body),
|
| 421 |
+
uploadLogo: (id: number, file: File) => postForm<unknown>(T(id, "upload-logo"), form({ file })),
|
| 422 |
+
aiEdit: (id: number, sceneKey: string, prompt: string) => post<unknown>(T(id, `scenes/${sceneKey}/ai-edit`), { prompt }),
|
| 423 |
+
aiEditStatus: (id: number, sceneKey: string) => api<VideoJobState>(T(id, `scenes/${sceneKey}/ai-edit/status`)),
|
| 424 |
+
drafts: (id: number) => api<unknown>(T(id, "scene-drafts")),
|
| 425 |
+
applyDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/apply`)),
|
| 426 |
+
discardDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/discard`)),
|
| 427 |
+
fontDefaults: (id: number, body: Record<string, unknown>, sceneKey?: string) =>
|
| 428 |
+
patch<unknown>(T(id, sceneKey ? `scenes/${sceneKey}/font-defaults` : "scenes/font-defaults"), body),
|
| 429 |
+
chart: (id: number, sceneKey: string, body: Record<string, unknown>) => patch<unknown>(T(id, `scenes/${sceneKey}/chart`), body),
|
| 430 |
+
versions: (id: number) => api<unknown>(T(id, "versions")),
|
| 431 |
+
rollback: (id: number, versionId: number) => post<unknown>(T(id, `versions/${versionId}/rollback`)),
|
| 432 |
+
regenerate: (id: number) => post<unknown>(T(id, "regenerate-code")),
|
| 433 |
+
resume: (id: number) => post<unknown>(T(id, "resume-generation")),
|
| 434 |
+
rate: (id: number, body: Record<string, unknown>) => post<unknown>(T(id, "rating"), body),
|
| 435 |
+
};
|
frontend/src/api/types.ts
CHANGED
|
@@ -114,7 +114,7 @@ export type Artifact = {
|
|
| 114 |
type: ArtifactType;
|
| 115 |
type_label: string;
|
| 116 |
title: string;
|
| 117 |
-
status: "pending" | "generating" | "rendering" | "ready" | "failed" | "draft";
|
| 118 |
notebook_id: string | null;
|
| 119 |
document_id: string | null;
|
| 120 |
content: Record<string, any>;
|
|
@@ -124,6 +124,8 @@ export type Artifact = {
|
|
| 124 |
slide_urls: string[];
|
| 125 |
created_at: string | null;
|
| 126 |
job: Job | null;
|
|
|
|
|
|
|
| 127 |
};
|
| 128 |
|
| 129 |
export type TopicStatus = "rising" | "steady" | "dormant";
|
|
@@ -301,7 +303,8 @@ export type Plan = {
|
|
| 301 |
sources: number;
|
| 302 |
indexed_posts: number;
|
| 303 |
audio_minutes: number;
|
| 304 |
-
|
|
|
|
| 305 |
launch_kits: number;
|
| 306 |
voice_cloning: boolean;
|
| 307 |
brand_kit: boolean;
|
|
@@ -309,8 +312,9 @@ export type Plan = {
|
|
| 309 |
|
| 310 |
export type Usage = {
|
| 311 |
plan: string;
|
| 312 |
-
used: { audio_minutes: number;
|
| 313 |
-
limits: { audio_minutes: number;
|
|
|
|
| 314 |
};
|
| 315 |
|
| 316 |
export type Settings = {
|
|
@@ -355,5 +359,295 @@ export type BillingStatus = {
|
|
| 355 |
period_end: string | null;
|
| 356 |
meters: Meter[];
|
| 357 |
since: string;
|
|
|
|
|
|
|
| 358 |
nudges: Nudge[];
|
| 359 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
type: ArtifactType;
|
| 115 |
type_label: string;
|
| 116 |
title: string;
|
| 117 |
+
status: "pending" | "generating" | "rendering" | "review" | "ready" | "failed" | "draft";
|
| 118 |
notebook_id: string | null;
|
| 119 |
document_id: string | null;
|
| 120 |
content: Record<string, any>;
|
|
|
|
| 124 |
slide_urls: string[];
|
| 125 |
created_at: string | null;
|
| 126 |
job: Job | null;
|
| 127 |
+
/** "blog2video" for videos made in the Videos editor. */
|
| 128 |
+
provider?: string | null;
|
| 129 |
};
|
| 130 |
|
| 131 |
export type TopicStatus = "rising" | "steady" | "dormant";
|
|
|
|
| 303 |
sources: number;
|
| 304 |
indexed_posts: number;
|
| 305 |
audio_minutes: number;
|
| 306 |
+
/** blog2video videos per period */
|
| 307 |
+
videos: number;
|
| 308 |
launch_kits: number;
|
| 309 |
voice_cloning: boolean;
|
| 310 |
brand_kit: boolean;
|
|
|
|
| 312 |
|
| 313 |
export type Usage = {
|
| 314 |
plan: string;
|
| 315 |
+
used: { audio_minutes: number; videos: number; launch_kits: number; llm_tokens: number; since: string };
|
| 316 |
+
limits: { audio_minutes: number; videos: number; launch_kits: number; sources: number; indexed_posts: number };
|
| 317 |
+
videos_resets_at: string | null;
|
| 318 |
};
|
| 319 |
|
| 320 |
export type Settings = {
|
|
|
|
| 359 |
period_end: string | null;
|
| 360 |
meters: Meter[];
|
| 361 |
since: string;
|
| 362 |
+
/** Videos reset on renewal (or monthly without one), not on the 1st like the other meters. */
|
| 363 |
+
videos_resets_at: string | null;
|
| 364 |
nudges: Nudge[];
|
| 365 |
};
|
| 366 |
+
|
| 367 |
+
// Videos (blog2video). Everything goes through our API; ids are our artifact ids unless named b2v/custom.
|
| 368 |
+
|
| 369 |
+
export type VideoLimitMetric =
|
| 370 |
+
| "ai_edits" | "templates" | "template_ai_daily" | "voice_designs_daily" | "voice_samples_daily" | "custom_voices";
|
| 371 |
+
|
| 372 |
+
export type VideoConfig = {
|
| 373 |
+
configured: boolean;
|
| 374 |
+
/** Premium (★) video options are included in this workspace's plan. */
|
| 375 |
+
premium: boolean;
|
| 376 |
+
limits: Record<VideoLimitMetric, { used: number; limit: number }>;
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
+
/** A row on the Videos page. */
|
| 380 |
+
export type VideoListItem = Artifact & {
|
| 381 |
+
source_url: string | null;
|
| 382 |
+
scenes: number | null;
|
| 383 |
+
/** blog2video's own status: generated, done, awaiting_script_review, ... */
|
| 384 |
+
b2v_status: string | null;
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
export type VideoQuota = { used: number; limit: number; plan: string; period_start: string | null; resets_at: string | null };
|
| 388 |
+
|
| 389 |
+
export type VideoTemplate = {
|
| 390 |
+
id: string;
|
| 391 |
+
name: string;
|
| 392 |
+
description?: string;
|
| 393 |
+
preview_url?: string | null;
|
| 394 |
+
/** The 9:16 poster, for portrait videos. */
|
| 395 |
+
preview_portrait_url?: string | null;
|
| 396 |
+
genres?: string[];
|
| 397 |
+
badge?: string | null;
|
| 398 |
+
popular_template?: boolean;
|
| 399 |
+
new_template?: boolean;
|
| 400 |
+
preview_colors?: { accent?: string; bg?: string; text?: string };
|
| 401 |
+
/** One of this workspace's own custom templates. */
|
| 402 |
+
custom?: boolean;
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
export type VideoStyleItem = {
|
| 406 |
+
/** "auto" | "explainer" | "storytelling" | "promotional" | "custom:<id>" */
|
| 407 |
+
id: string;
|
| 408 |
+
name: string;
|
| 409 |
+
description?: string | null;
|
| 410 |
+
kind: "builtin" | "custom";
|
| 411 |
+
custom_id?: number;
|
| 412 |
+
guidance?: string | null;
|
| 413 |
+
version?: number;
|
| 414 |
+
};
|
| 415 |
+
|
| 416 |
+
export type MusicTrack = { track_id: string; display_name: string; mood?: string; r2_url?: string | null };
|
| 417 |
+
|
| 418 |
+
export type VideoCatalog = {
|
| 419 |
+
templates: VideoTemplate[];
|
| 420 |
+
crafted_templates: VideoTemplate[];
|
| 421 |
+
my_templates: VideoTemplate[];
|
| 422 |
+
video_styles: VideoStyleItem[];
|
| 423 |
+
music: MusicTrack[];
|
| 424 |
+
languages: { code: string; name: string }[];
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
+
/** A voice in the workspace's "My voices" list (kept only in Notestack). */
|
| 428 |
+
export type VideoSavedVoice = {
|
| 429 |
+
voice_id: string;
|
| 430 |
+
name: string;
|
| 431 |
+
preview_url: string | null;
|
| 432 |
+
gender: string | null;
|
| 433 |
+
accent: string | null;
|
| 434 |
+
premium: boolean;
|
| 435 |
+
is_custom: boolean;
|
| 436 |
+
};
|
| 437 |
+
|
| 438 |
+
export type VideoLibraryVoice = {
|
| 439 |
+
voice_id: string;
|
| 440 |
+
name: string;
|
| 441 |
+
description: string;
|
| 442 |
+
preview_url: string | null;
|
| 443 |
+
gender: string | null;
|
| 444 |
+
accent: string | null;
|
| 445 |
+
age?: string | null;
|
| 446 |
+
premium: boolean;
|
| 447 |
+
saved: boolean;
|
| 448 |
+
};
|
| 449 |
+
|
| 450 |
+
export type VideoCustomVoice = {
|
| 451 |
+
id: number;
|
| 452 |
+
voice_id: string;
|
| 453 |
+
name: string;
|
| 454 |
+
source: "prompt" | "preset" | "clone";
|
| 455 |
+
preview_url: string | null;
|
| 456 |
+
saved: boolean;
|
| 457 |
+
};
|
| 458 |
+
|
| 459 |
+
export type VideoVoicesResponse = { saved: VideoSavedVoice[]; library: VideoLibraryVoice[]; custom: VideoCustomVoice[] };
|
| 460 |
+
|
| 461 |
+
export type VideoDesignedVoice = {
|
| 462 |
+
generated_voice_id: string;
|
| 463 |
+
audio_base_64: string;
|
| 464 |
+
media_type: string;
|
| 465 |
+
duration_secs: number;
|
| 466 |
+
};
|
| 467 |
+
|
| 468 |
+
export type VideoLength = "short" | "medium" | "detailed" | "more_detailed";
|
| 469 |
+
export type LogoPosition = "bottom_right" | "bottom_left" | "top_left" | "top_right";
|
| 470 |
+
|
| 471 |
+
/** Every field of the 3-step wizard, named as blog2video names them. */
|
| 472 |
+
export type VideoOptions = {
|
| 473 |
+
// Step 1: Project
|
| 474 |
+
stock_footage_enabled: boolean;
|
| 475 |
+
aspect_ratio: "landscape" | "portrait";
|
| 476 |
+
video_length: VideoLength;
|
| 477 |
+
logo_upload_id?: string;
|
| 478 |
+
logo_position: LogoPosition;
|
| 479 |
+
logo_opacity: number;
|
| 480 |
+
// Step 2: Template
|
| 481 |
+
template: string;
|
| 482 |
+
video_style: string;
|
| 483 |
+
accent_color?: string;
|
| 484 |
+
bg_color?: string;
|
| 485 |
+
text_color?: string;
|
| 486 |
+
// Step 3: Voice
|
| 487 |
+
content_language?: string | null;
|
| 488 |
+
voice_gender: "female" | "male" | "none";
|
| 489 |
+
voice_accent: string;
|
| 490 |
+
custom_voice_id?: string;
|
| 491 |
+
voice_emotion?: string;
|
| 492 |
+
bgm_track_id?: string;
|
| 493 |
+
bgm_volume: number;
|
| 494 |
+
};
|
| 495 |
+
|
| 496 |
+
export type VideoCreateBody = VideoOptions & {
|
| 497 |
+
document_id?: string;
|
| 498 |
+
/** A notebook's ticked posts, combined into one video. */
|
| 499 |
+
document_ids?: string[];
|
| 500 |
+
/** Names a combined video after its notebook. */
|
| 501 |
+
notebook_id?: string;
|
| 502 |
+
url?: string;
|
| 503 |
+
content?: string;
|
| 504 |
+
title?: string;
|
| 505 |
+
};
|
| 506 |
+
|
| 507 |
+
export type VideoStatus = {
|
| 508 |
+
video_id: number;
|
| 509 |
+
status: string;
|
| 510 |
+
step: number | null;
|
| 511 |
+
running: boolean;
|
| 512 |
+
ready: boolean;
|
| 513 |
+
error: string | null;
|
| 514 |
+
video_url: string | null;
|
| 515 |
+
artifact_status: Artifact["status"];
|
| 516 |
+
/** Made through the old partner integration: blog2video can no longer reach it, so only stored links remain. */
|
| 517 |
+
legacy?: true;
|
| 518 |
+
};
|
| 519 |
+
|
| 520 |
+
export type VideoScene = {
|
| 521 |
+
id: number;
|
| 522 |
+
order: number;
|
| 523 |
+
title: string;
|
| 524 |
+
display_text: string | null;
|
| 525 |
+
narration_text: string;
|
| 526 |
+
visual_description?: string;
|
| 527 |
+
preferred_layout?: string | null;
|
| 528 |
+
duration_seconds: number;
|
| 529 |
+
voiceover_path?: string | null;
|
| 530 |
+
avatar_video_path?: string | null;
|
| 531 |
+
bgm_volume?: number | null;
|
| 532 |
+
extra_hold_seconds?: number | null;
|
| 533 |
+
/** JSON: {"layout": ..., "layoutProps": {...}} (layoutConfig on custom templates). */
|
| 534 |
+
remotion_code?: string | null;
|
| 535 |
+
// Worked out by our backend (routers/videos.py _enrich)
|
| 536 |
+
audio_url?: string | null;
|
| 537 |
+
images?: { filename: string; asset_id: number | null; kind: string; url: string }[];
|
| 538 |
+
layout?: string | null;
|
| 539 |
+
title_font_size?: number | null;
|
| 540 |
+
desc_font_size?: number | null;
|
| 541 |
+
[key: string]: unknown;
|
| 542 |
+
};
|
| 543 |
+
|
| 544 |
+
export type VideoAsset = {
|
| 545 |
+
id: number;
|
| 546 |
+
asset_type: string;
|
| 547 |
+
r2_url: string | null;
|
| 548 |
+
filename: string;
|
| 549 |
+
excluded: boolean;
|
| 550 |
+
duration_seconds?: number | null;
|
| 551 |
+
source_provider?: string | null;
|
| 552 |
+
};
|
| 553 |
+
|
| 554 |
+
/** blog2video's project, as its editor sees it. */
|
| 555 |
+
export type VideoProjectData = {
|
| 556 |
+
id: number;
|
| 557 |
+
name: string;
|
| 558 |
+
status: string;
|
| 559 |
+
template: string;
|
| 560 |
+
aspect_ratio: "landscape" | "portrait";
|
| 561 |
+
accent_color: string;
|
| 562 |
+
bg_color: string;
|
| 563 |
+
text_color: string;
|
| 564 |
+
font_family: string | null;
|
| 565 |
+
voice_gender: string;
|
| 566 |
+
voice_accent: string;
|
| 567 |
+
custom_voice_id: string | null;
|
| 568 |
+
content_language: string | null;
|
| 569 |
+
logo_r2_url: string | null;
|
| 570 |
+
logo_position: string;
|
| 571 |
+
logo_opacity: number;
|
| 572 |
+
logo_size: number;
|
| 573 |
+
bgm_track_id: string | null;
|
| 574 |
+
bgm_volume: number;
|
| 575 |
+
playback_speed: number;
|
| 576 |
+
captions_enabled: boolean;
|
| 577 |
+
caption_position: string;
|
| 578 |
+
caption_font_family?: string | null;
|
| 579 |
+
caption_font_size?: number | string | null;
|
| 580 |
+
/** -100 (down) to 100 (up); 0 is the template's default place. */
|
| 581 |
+
caption_offset?: number | null;
|
| 582 |
+
r2_video_url: string | null;
|
| 583 |
+
scenes: VideoScene[];
|
| 584 |
+
assets: VideoAsset[];
|
| 585 |
+
summary?: { scenes: number; images: number; clips?: number; duration_seconds: number };
|
| 586 |
+
[key: string]: unknown;
|
| 587 |
+
};
|
| 588 |
+
|
| 589 |
+
/** A video made before the switch to our API key: stored links only, no edits. */
|
| 590 |
+
export type LegacyVideo = {
|
| 591 |
+
legacy: true;
|
| 592 |
+
status: string | null;
|
| 593 |
+
ready: boolean;
|
| 594 |
+
preview_url: string | null;
|
| 595 |
+
video_url: string | null;
|
| 596 |
+
artifact: Artifact;
|
| 597 |
+
};
|
| 598 |
+
|
| 599 |
+
export type VideoProject = {
|
| 600 |
+
legacy?: undefined;
|
| 601 |
+
video_id: number;
|
| 602 |
+
status: string;
|
| 603 |
+
preview_url: string | null;
|
| 604 |
+
video_url: string | null;
|
| 605 |
+
project: VideoProjectData;
|
| 606 |
+
artifact: Artifact;
|
| 607 |
+
};
|
| 608 |
+
|
| 609 |
+
/** Background jobs (template, voice, language, add scene, render, script refresh) report progress in roughly this
|
| 610 |
+
* shape; each names things slightly differently. */
|
| 611 |
+
export type VideoJobState = { status?: string; running?: boolean; done?: boolean; error?: string | null;
|
| 612 |
+
progress?: number; video_url?: string | null; [key: string]: unknown };
|
| 613 |
+
|
| 614 |
+
export type ScriptScene = {
|
| 615 |
+
id: number;
|
| 616 |
+
order: number;
|
| 617 |
+
title: string;
|
| 618 |
+
display_text: string | null;
|
| 619 |
+
narration_text: string;
|
| 620 |
+
preferred_layout?: string | null;
|
| 621 |
+
};
|
| 622 |
+
|
| 623 |
+
export type ScriptPreview = { revision: number; title: string; display_text: string; narration_text: string;
|
| 624 |
+
source_fingerprint: string };
|
| 625 |
+
|
| 626 |
+
// Custom templates
|
| 627 |
+
|
| 628 |
+
export type MyTemplate = { id: number; ref: string; name: string; ready: boolean; created_at: string | null };
|
| 629 |
+
|
| 630 |
+
export type TemplatesResponse = {
|
| 631 |
+
templates: MyTemplate[];
|
| 632 |
+
limits: Record<"templates" | "template_ai_daily", { used: number; limit: number }>;
|
| 633 |
+
};
|
| 634 |
+
|
| 635 |
+
export type ExtractedTheme = {
|
| 636 |
+
extractable: boolean;
|
| 637 |
+
reason: string;
|
| 638 |
+
theme: Record<string, unknown> | null;
|
| 639 |
+
template_name: string;
|
| 640 |
+
logo_urls: string[];
|
| 641 |
+
og_image: string;
|
| 642 |
+
screenshot_url: string;
|
| 643 |
+
};
|
| 644 |
+
|
| 645 |
+
export type TemplateGeneration = {
|
| 646 |
+
status: string;
|
| 647 |
+
step?: string | null;
|
| 648 |
+
running: boolean;
|
| 649 |
+
error: string | null;
|
| 650 |
+
scenes_done?: number;
|
| 651 |
+
scenes_total?: number;
|
| 652 |
+
ready: boolean;
|
| 653 |
+
};
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import SkyCanvas from "./SkyCanvas";
|
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
AsteroidIcon,
|
|
|
|
| 11 |
HelmetIcon,
|
| 12 |
LaunchpadIcon,
|
| 13 |
LaunchWindowIcon,
|
|
@@ -18,12 +19,13 @@ import {
|
|
| 18 |
} from "./icons/Icons";
|
| 19 |
|
| 20 |
// Theme in the icon, clarity in the label: the plain label always shows.
|
| 21 |
-
//
|
| 22 |
const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [
|
| 23 |
{ to: "/app", label: "Home", icon: RadarIcon, end: true },
|
| 24 |
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 25 |
{ to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
|
| 26 |
{ to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] },
|
|
|
|
| 27 |
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
|
| 28 |
{ to: "/app/archive", label: "Library", icon: AsteroidIcon },
|
| 29 |
{ to: "/app/voice", label: "Voice", icon: HelmetIcon },
|
|
|
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
AsteroidIcon,
|
| 11 |
+
CometIcon,
|
| 12 |
HelmetIcon,
|
| 13 |
LaunchpadIcon,
|
| 14 |
LaunchWindowIcon,
|
|
|
|
| 19 |
} from "./icons/Icons";
|
| 20 |
|
| 21 |
// Theme in the icon, clarity in the label: the plain label always shows.
|
| 22 |
+
// Eight stops: related pages share one entry (`also` keeps it lit on them).
|
| 23 |
const NAV: { to: string; label: string; icon: typeof RadarIcon; end?: boolean; also?: string[] }[] = [
|
| 24 |
{ to: "/app", label: "Home", icon: RadarIcon, end: true },
|
| 25 |
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 26 |
{ to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
|
| 27 |
{ to: "/app/studio", label: "Create", icon: LaunchWindowIcon, also: ["/app/launch-kit"] },
|
| 28 |
+
{ to: "/app/videos", label: "Videos", icon: CometIcon },
|
| 29 |
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
|
| 30 |
{ to: "/app/archive", label: "Library", icon: AsteroidIcon },
|
| 31 |
{ to: "/app/voice", label: "Voice", icon: HelmetIcon },
|