Spaces:
Runtime error
Runtime error
Merge pull request #2 from FireBird-Technologies/fix/b2v-integration
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/alembic/versions/{0005_workspace_memory.py → 0013_workspace_memory.py} +4 -4
- backend/alembic/versions/{0006_chat_citations.py → 0014_chat_citations.py} +4 -4
- backend/alembic/versions/{0007_message_feedback.py → 0015_message_feedback.py} +4 -4
- backend/alembic/versions/{0008_support_chat.py → 0016_support_chat.py} +4 -4
- backend/app/config.py +9 -0
- backend/app/llm/provider.py +12 -0
- backend/app/llm/run.py +47 -6
- backend/app/llm/signatures.py +27 -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/generate.py +55 -12
- 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 +29 -5
- backend/app/routers/topics.py +2 -2
- 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 +297 -0
- backend/app/routers/videos.py +949 -0
- backend/app/routers/voice.py +11 -3
- backend/app/services/artifacts.py +5 -2
- backend/app/services/auth_identity.py +4 -1
- backend/app/services/b2v_access.py +219 -0
- backend/app/services/billing.py +8 -2
- backend/app/services/blog2video.py +326 -0
- backend/app/services/jobs.py +6 -0
- backend/app/services/notestack_voices.py +37 -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_focus.py +156 -0
- backend/app/services/video_limits.py +93 -0
- backend/app/services/video_quota.py +239 -0
- backend/app/support/corpus.py +7 -4
- backend/app/worker.py +31 -10
- backend/requirements.txt +1 -0
.env.example
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
# ---- App
|
| 2 |
-
ENV=development
|
| 3 |
-
APP_NAME=Notestack
|
| 4 |
-
FRONTEND_URL=http://localhost:5173
|
| 5 |
-
API_URL=http://localhost:8000
|
| 6 |
-
CORS_ORIGINS=http://localhost:5173
|
| 7 |
-
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
-
BILLING_ENABLED=false
|
| 9 |
-
# Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
|
| 10 |
-
# (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
|
| 11 |
-
# Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
|
| 12 |
-
# checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
|
| 13 |
-
# customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
|
| 14 |
-
# customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
|
| 15 |
-
# invoice.payment_action_required, charge.dispute.created.
|
| 16 |
-
STRIPE_SECRET_KEY=
|
| 17 |
-
STRIPE_WEBHOOK_SECRET=
|
| 18 |
-
STRIPE_PRICE_WRITER_MONTHLY=
|
| 19 |
-
STRIPE_PRICE_WRITER_ANNUAL=
|
| 20 |
-
STRIPE_PRICE_STUDIO_MONTHLY=
|
| 21 |
-
STRIPE_PRICE_STUDIO_ANNUAL=
|
| 22 |
-
|
| 23 |
-
# ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
|
| 24 |
-
JWT_SECRET=replace-with-64-random-chars
|
| 25 |
-
JWT_ALGORITHM=HS256
|
| 26 |
-
JWT_EXPIRATION_HOURS=72
|
| 27 |
-
JWT_REFRESH_EXPIRATION_DAYS=30
|
| 28 |
-
|
| 29 |
-
# ---- Database (also backs the job queue)
|
| 30 |
-
# Local: a SQLite file, created by `alembic upgrade head` run from backend/.
|
| 31 |
-
# Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
|
| 32 |
-
# DATABASE_URL=postgresql+psycopg://user:pass@host:5432/dbname?sslmode=require
|
| 33 |
-
# docker-compose ignores this and points at its own Postgres container.
|
| 34 |
-
DATABASE_URL=sqlite:///./notestack.db
|
| 35 |
-
|
| 36 |
-
# ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
|
| 37 |
-
# Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
|
| 38 |
-
# Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
|
| 39 |
-
# (default {API_URL}/api/auth/google/callback, e.g. http://localhost:8000/api/auth/google/callback)
|
| 40 |
-
# to "Authorized redirect URIs" on the OAuth client. Never prefix the secret with VITE_.
|
| 41 |
-
GOOGLE_CLIENT_ID=
|
| 42 |
-
GOOGLE_CLIENT_SECRET=
|
| 43 |
-
GOOGLE_REDIRECT_URI=
|
| 44 |
-
|
| 45 |
-
# ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
|
| 46 |
-
EMAIL_PROVIDER=console
|
| 47 |
-
RESEND_API_KEY=
|
| 48 |
-
FROM_EMAIL=Notestack <hello@notestack.ai>
|
| 49 |
-
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 50 |
-
UNSUBSCRIBE_SECRET=replace-me
|
| 51 |
-
# Hour (UTC, 0 to 23) at which scheduled update email campaigns send their daily batch.
|
| 52 |
-
UPDATE_EMAIL_SEND_HOUR=9
|
| 53 |
-
|
| 54 |
-
# ---- Local dev conveniences
|
| 55 |
-
# The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
|
| 56 |
-
RUN_WORKER_IN_API=
|
| 57 |
-
# auto = local disk (LOCAL_STORAGE_DIR) when no R2 account or endpoint is set, else R2
|
| 58 |
-
STORAGE_BACKEND=auto
|
| 59 |
-
LOCAL_STORAGE_DIR=.storage
|
| 60 |
-
|
| 61 |
-
# ---- Storage: Cloudflare R2
|
| 62 |
-
# Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
|
| 63 |
-
# Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
|
| 64 |
-
R2_ACCOUNT_ID=
|
| 65 |
-
R2_ACCESS_KEY_ID=minioadmin
|
| 66 |
-
R2_SECRET_ACCESS_KEY=minioadmin
|
| 67 |
-
R2_BUCKET=notestack-assets
|
| 68 |
-
R2_ENDPOINT_URL=
|
| 69 |
-
R2_PUBLIC_BASE_URL=
|
| 70 |
-
# Folder inside the bucket when it is shared with other apps, e.g. notestack. Empty = bucket root.
|
| 71 |
-
R2_PREFIX=
|
| 72 |
-
# How long presigned download links stay valid, in seconds.
|
| 73 |
-
R2_PRESIGN_TTL_SECONDS=3600
|
| 74 |
-
# Largest file a browser may upload (bytes). 209715200 = 200 MB.
|
| 75 |
-
MAX_UPLOAD_BYTES=209715200
|
| 76 |
-
|
| 77 |
-
# ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
|
| 78 |
-
# GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
|
| 79 |
-
# Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
|
| 80 |
-
LLM_MODEL=openai/glm-5.3
|
| 81 |
-
LLM_FAST_MODEL=openai/glm-5.3-flash
|
| 82 |
-
LLM_API_BASE=https://api.z.ai/api/paas/v4
|
| 83 |
-
LLM_API_KEY=
|
| 84 |
-
LLM_TEMPERATURE=1.0
|
| 85 |
-
LLM_MAX_TOKENS=10000
|
| 86 |
-
LLM_REASONING_EFFORT=low
|
| 87 |
-
# Fallback chat triage model when Jev is not configured. Empty = LLM_FAST_MODEL.
|
| 88 |
-
LLM_TRIAGE_MODEL=
|
| 89 |
-
|
| 90 |
-
# ---- Chat triage: TypeSafe Jev (https://typesafe.ai)
|
| 91 |
-
# A fast classifier (typed choices, no text generation) that decides what each chat message needs
|
| 92 |
-
# before any research runs. With a key set, greetings, off topic and app questions never reach the
|
| 93 |
-
# LLM, and archive questions get a quick or deep research budget. Empty = LLM triage above.
|
| 94 |
-
TYPESAFE_API_KEY=
|
| 95 |
-
TYPESAFE_MODEL=jev-latest
|
| 96 |
-
TYPESAFE_URL=https://api.typesafe.ai
|
| 97 |
-
|
| 98 |
-
# Firecrawl (firecrawl.dev): lets Connect read sites with no RSS feed, including JavaScript sites
|
| 99 |
-
# (Next.js, Framer, Webflow), and makes single URL imports reliable. Empty = plain HTTP fetching only.
|
| 100 |
-
FIRECRAWL_API_KEY=
|
| 101 |
-
|
| 102 |
-
# ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
|
| 103 |
-
CORPUS_CACHE_DIR=.corpus-cache
|
| 104 |
-
RESEARCH_MAX_STEPS=10
|
| 105 |
-
|
| 106 |
-
# ---- Worker (jobs queue lives in the database; no Redis)
|
| 107 |
-
WORKER_CONCURRENCY=4
|
| 108 |
-
WORKER_POLL_SECONDS=1
|
| 109 |
-
JOB_STALE_SECONDS=900
|
| 110 |
-
|
| 111 |
-
# ---- ElevenLabs (audio overviews, video narration, voice cloning)
|
| 112 |
-
ELEVENLABS_API_KEY=
|
| 113 |
-
ELEVENLABS_MODEL=eleven_multilingual_v2
|
| 114 |
-
ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
|
| 115 |
-
ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
|
| 116 |
-
|
| 117 |
-
# ---- Renderer (Remotion service for videos, quote cards and carousels: cd renderer && npm start)
|
| 118 |
-
RENDERER_URL=http://localhost:3100
|
| 119 |
-
|
| 120 |
-
# ---- Launchpad social publishing
|
| 121 |
-
# Bluesky needs nothing here (users connect with an app password).
|
| 122 |
-
# X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
|
| 123 |
-
# Callback URI: {API_URL}/api/social/x/callback e.g. http://localhost:8000/api/social/x/callback
|
| 124 |
-
# LinkedIn: app with the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products.
|
| 125 |
-
# Redirect URL: {API_URL}/api/social/linkedin/callback
|
| 126 |
-
X_CLIENT_ID=
|
| 127 |
-
X_CLIENT_SECRET=
|
| 128 |
-
LINKEDIN_CLIENT_ID=
|
| 129 |
-
LINKEDIN_CLIENT_SECRET=
|
| 130 |
-
# Fernet key for stored social tokens; derived from JWT_SECRET when empty.
|
| 131 |
-
SOCIAL_TOKEN_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/alembic/versions/0005_video_quota.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""subscriptions.video_plan / videos_used / video_limit / videos_period_start: the video allowance
|
| 2 |
+
|
| 3 |
+
Revision ID: 0005_video_quota
|
| 4 |
+
Revises: 0004_archive_notebook
|
| 5 |
+
Create Date: 2026-09-29
|
| 6 |
+
"""
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from alembic import op
|
| 9 |
+
|
| 10 |
+
revision = "0005_video_quota"
|
| 11 |
+
down_revision = "0004_archive_notebook"
|
| 12 |
+
branch_labels = None
|
| 13 |
+
depends_on = None
|
| 14 |
+
|
| 15 |
+
COLUMNS = ("video_plan", "videos_used", "video_limit", "videos_period_start")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
# 0001 builds from the live models, so a fresh database may already have the columns.
|
| 20 |
+
existing = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("subscriptions")}
|
| 21 |
+
new = {
|
| 22 |
+
"video_plan": sa.Column("video_plan", sa.String(20), nullable=False, server_default="free"),
|
| 23 |
+
"videos_used": sa.Column("videos_used", sa.Integer(), nullable=False, server_default="0"),
|
| 24 |
+
"video_limit": sa.Column("video_limit", sa.Integer(), nullable=False, server_default="0"),
|
| 25 |
+
"videos_period_start": sa.Column("videos_period_start", sa.DateTime(timezone=True), nullable=True),
|
| 26 |
+
}
|
| 27 |
+
missing = [new[name] for name in COLUMNS if name not in existing]
|
| 28 |
+
if missing:
|
| 29 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 30 |
+
for column in missing:
|
| 31 |
+
batch.add_column(column)
|
| 32 |
+
# Same limits as app/services/plans.py; the app re-syncs on first use (billing disabled -> studio).
|
| 33 |
+
op.execute("""
|
| 34 |
+
UPDATE subscriptions SET
|
| 35 |
+
video_plan = CASE WHEN status IN ('active', 'trialing', 'past_due') AND plan IN ('writer', 'studio')
|
| 36 |
+
THEN plan ELSE 'free' END
|
| 37 |
+
""")
|
| 38 |
+
op.execute("""
|
| 39 |
+
UPDATE subscriptions SET
|
| 40 |
+
video_limit = CASE video_plan WHEN 'studio' THEN 30 WHEN 'writer' THEN 10 ELSE 1 END,
|
| 41 |
+
videos_period_start = COALESCE(videos_period_start, CURRENT_TIMESTAMP)
|
| 42 |
+
""")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def downgrade() -> None:
|
| 46 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 47 |
+
for name in reversed(COLUMNS):
|
| 48 |
+
batch.drop_column(name)
|
backend/alembic/versions/0006_plans.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""plans: one row per plan (limits, price, pricing-page copy), editable without a deploy
|
| 2 |
+
|
| 3 |
+
Seeded with the plans that were hard-coded in app/services/plans.py. Rows that already exist are left alone.
|
| 4 |
+
|
| 5 |
+
Revision ID: 0006_plans
|
| 6 |
+
Revises: 0005_video_quota
|
| 7 |
+
Create Date: 2026-09-29
|
| 8 |
+
"""
|
| 9 |
+
from datetime import UTC, datetime
|
| 10 |
+
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from alembic import op
|
| 13 |
+
|
| 14 |
+
revision = "0006_plans"
|
| 15 |
+
down_revision = "0005_video_quota"
|
| 16 |
+
branch_labels = None
|
| 17 |
+
depends_on = None
|
| 18 |
+
|
| 19 |
+
SEED = [
|
| 20 |
+
{"id": "free", "sort_order": 0, "name": "Free", "tagline": "For trying Notestack on your archive",
|
| 21 |
+
"price_monthly_usd": 0, "sources": 1, "indexed_posts": 5, "audio_minutes": 3, "videos": 1, "launch_kits": 2,
|
| 22 |
+
"voice_cloning": False, "brand_kit": False,
|
| 23 |
+
"features": ["1 source, your latest 5 posts indexed", "Grounded research chat with citations",
|
| 24 |
+
"3 min of audio overviews a month", "1 video a month", "2 Launch Kits a month"]},
|
| 25 |
+
{"id": "writer", "sort_order": 1, "name": "Writer", "tagline": "For writers publishing every week",
|
| 26 |
+
"price_monthly_usd": 24.99, "sources": 3, "indexed_posts": 500, "audio_minutes": 60, "videos": 10,
|
| 27 |
+
"launch_kits": 50, "voice_cloning": True, "brand_kit": True,
|
| 28 |
+
"features": ["3 sources, 500 indexed posts", "60 min of audio overviews a month", "10 videos a month",
|
| 29 |
+
"50 Launch Kits a month", "Voice cloning with consent", "Your brand colors and logo"]},
|
| 30 |
+
{"id": "studio", "sort_order": 2, "name": "Studio", "tagline": "For publications and power users",
|
| 31 |
+
"price_monthly_usd": 48.99, "sources": 10, "indexed_posts": 5000, "audio_minutes": 240, "videos": 30,
|
| 32 |
+
"launch_kits": -1, "voice_cloning": True, "brand_kit": True,
|
| 33 |
+
"features": ["10 sources, 5,000 indexed posts", "240 min of audio overviews a month", "30 videos a month",
|
| 34 |
+
"Unlimited Launch Kits", "Launchpad calendar and resurfacing", "Priority rendering"]},
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def upgrade() -> None:
|
| 39 |
+
bind = op.get_bind()
|
| 40 |
+
# 0001 builds from the live models, so a fresh database may already have the table (empty).
|
| 41 |
+
if "plans" not in sa.inspect(bind).get_table_names():
|
| 42 |
+
op.create_table(
|
| 43 |
+
"plans",
|
| 44 |
+
sa.Column("id", sa.String(20), primary_key=True),
|
| 45 |
+
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
| 46 |
+
sa.Column("name", sa.String(50), nullable=False),
|
| 47 |
+
sa.Column("tagline", sa.String(200), nullable=False, server_default=""),
|
| 48 |
+
sa.Column("price_monthly_usd", sa.Float(), nullable=False, server_default="0"),
|
| 49 |
+
sa.Column("sources", sa.Integer(), nullable=False, server_default="1"),
|
| 50 |
+
sa.Column("indexed_posts", sa.Integer(), nullable=False, server_default="5"),
|
| 51 |
+
sa.Column("audio_minutes", sa.Integer(), nullable=False, server_default="0"),
|
| 52 |
+
sa.Column("videos", sa.Integer(), nullable=False, server_default="0"),
|
| 53 |
+
sa.Column("launch_kits", sa.Integer(), nullable=False, server_default="0"),
|
| 54 |
+
sa.Column("voice_cloning", sa.Boolean(), nullable=False, server_default=sa.false()),
|
| 55 |
+
sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false()),
|
| 56 |
+
sa.Column("features", sa.JSON(), nullable=False),
|
| 57 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 58 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 59 |
+
)
|
| 60 |
+
types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
|
| 61 |
+
"created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
|
| 62 |
+
plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*SEED[0], "created_at", "updated_at"]))
|
| 63 |
+
existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
|
| 64 |
+
now = datetime.now(UTC)
|
| 65 |
+
missing = [{**p, "created_at": now, "updated_at": now} for p in SEED if p["id"] not in existing]
|
| 66 |
+
if missing:
|
| 67 |
+
op.bulk_insert(plans, missing)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def downgrade() -> None:
|
| 71 |
+
op.drop_table("plans")
|
backend/alembic/versions/0007_subscription_plan_fk.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""subscriptions.plan -> plans.id: the plan is picked from the plans table, not typed as free text
|
| 2 |
+
|
| 3 |
+
Unknown plan values (typos, removed plans) become 'free' first so the constraint can be added.
|
| 4 |
+
|
| 5 |
+
Revision ID: 0007_subscription_plan_fk
|
| 6 |
+
Revises: 0006_plans
|
| 7 |
+
Create Date: 2026-09-29
|
| 8 |
+
"""
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from alembic import op
|
| 11 |
+
|
| 12 |
+
revision = "0007_subscription_plan_fk"
|
| 13 |
+
down_revision = "0006_plans"
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
NAME = "fk_subscriptions_plan_plans"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def upgrade() -> None:
|
| 21 |
+
bind = op.get_bind()
|
| 22 |
+
# 0001 builds from the live models, so a fresh database may already have the constraint.
|
| 23 |
+
if any(fk["referred_table"] == "plans" for fk in sa.inspect(bind).get_foreign_keys("subscriptions")):
|
| 24 |
+
return
|
| 25 |
+
op.execute("UPDATE subscriptions SET plan = 'free' WHERE plan IS NULL OR plan NOT IN (SELECT id FROM plans)")
|
| 26 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 27 |
+
batch.create_foreign_key(NAME, "plans", ["plan"], ["id"], onupdate="CASCADE")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
fks = sa.inspect(op.get_bind()).get_foreign_keys("subscriptions")
|
| 32 |
+
name = next((fk["name"] for fk in fks if fk["referred_table"] == "plans"), None)
|
| 33 |
+
if not name:
|
| 34 |
+
return # unnamed (built by 0001 on SQLite): nothing we can drop by name
|
| 35 |
+
with op.batch_alter_table("subscriptions") as batch:
|
| 36 |
+
batch.drop_constraint(name, type_="foreignkey")
|
backend/alembic/versions/0008_b2v_videos.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""b2v_videos: which workspace owns each blog2video video, and whether its allowance is still charged
|
| 2 |
+
|
| 3 |
+
blog2video now only takes our account's API key, so this table is our access control and refund ledger.
|
| 4 |
+
blog2video no longer connects to this database: drop the role it used (see docs/BLOG2VIDEO.md).
|
| 5 |
+
|
| 6 |
+
Revision ID: 0008_b2v_videos
|
| 7 |
+
Revises: 0007_subscription_plan_fk
|
| 8 |
+
Create Date: 2026-09-30
|
| 9 |
+
"""
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from alembic import op
|
| 12 |
+
|
| 13 |
+
revision = "0008_b2v_videos"
|
| 14 |
+
down_revision = "0007_subscription_plan_fk"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
# 0001 builds from the live models, so a fresh database may already have the table.
|
| 21 |
+
if sa.inspect(op.get_bind()).has_table("b2v_videos"):
|
| 22 |
+
return
|
| 23 |
+
op.create_table(
|
| 24 |
+
"b2v_videos",
|
| 25 |
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
| 26 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
| 27 |
+
sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL"), nullable=True),
|
| 28 |
+
sa.Column("b2v_video_id", sa.Integer(), nullable=True, unique=True),
|
| 29 |
+
sa.Column("idempotency_key", sa.String(100), nullable=False, unique=True),
|
| 30 |
+
sa.Column("quota_state", sa.String(20), nullable=False, server_default="charged"),
|
| 31 |
+
sa.Column("status", sa.String(40), nullable=False, server_default="queued"),
|
| 32 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
| 33 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
| 34 |
+
)
|
| 35 |
+
op.create_index("ix_b2v_videos_ws_created", "b2v_videos", ["workspace_id", "created_at"])
|
| 36 |
+
op.create_index("ix_b2v_videos_artifact_id", "b2v_videos", ["artifact_id"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def downgrade() -> None:
|
| 40 |
+
op.drop_table("b2v_videos")
|
backend/alembic/versions/0009_b2v_resources.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""blog2video resources per workspace: templates, custom voices, saved voices, styles, usage counters;
|
| 2 |
+
b2v_videos.created_via / template_ref; plans.video_premium / video_limits
|
| 3 |
+
|
| 4 |
+
Everything made with our blog2video key belongs to one account, so these tables are what keeps each workspace's
|
| 5 |
+
templates, voices and styles its own (app/services/b2v_access.py).
|
| 6 |
+
|
| 7 |
+
Revision ID: 0009_b2v_resources
|
| 8 |
+
Revises: 0008_b2v_videos
|
| 9 |
+
Create Date: 2026-09-30
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
+
from alembic import op
|
| 15 |
+
|
| 16 |
+
revision = "0009_b2v_resources"
|
| 17 |
+
down_revision = "0008_b2v_videos"
|
| 18 |
+
branch_labels = None
|
| 19 |
+
depends_on = None
|
| 20 |
+
|
| 21 |
+
# Same as app/services/plans.py
|
| 22 |
+
VIDEO_LIMITS = {
|
| 23 |
+
"free": {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
|
| 24 |
+
"voice_samples_daily": 0, "custom_voices": 0},
|
| 25 |
+
"writer": {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
|
| 26 |
+
"voice_samples_daily": 20, "custom_voices": 3},
|
| 27 |
+
"studio": {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
|
| 28 |
+
"voice_samples_daily": 40, "custom_voices": 10},
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _ws() -> sa.Column:
|
| 33 |
+
return sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False,
|
| 34 |
+
index=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _times() -> list[sa.Column]:
|
| 38 |
+
return [sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
| 39 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def upgrade() -> None:
|
| 43 |
+
bind = op.get_bind()
|
| 44 |
+
inspector = sa.inspect(bind)
|
| 45 |
+
|
| 46 |
+
# 0001 builds from the live models, so a fresh database may already have all of this.
|
| 47 |
+
video_cols = {c["name"] for c in inspector.get_columns("b2v_videos")}
|
| 48 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 49 |
+
if "created_via" not in video_cols:
|
| 50 |
+
batch.add_column(sa.Column("created_via", sa.String(10), nullable=False, server_default="v1"))
|
| 51 |
+
if "template_ref" not in video_cols:
|
| 52 |
+
batch.add_column(sa.Column("template_ref", sa.String(100), nullable=True))
|
| 53 |
+
batch.alter_column("idempotency_key", existing_type=sa.String(100), nullable=True)
|
| 54 |
+
|
| 55 |
+
if not inspector.has_table("b2v_templates"):
|
| 56 |
+
op.create_table("b2v_templates",
|
| 57 |
+
sa.Column("b2v_template_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
|
| 58 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 59 |
+
sa.Column("ready", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
|
| 60 |
+
if not inspector.has_table("b2v_custom_voices"):
|
| 61 |
+
op.create_table("b2v_custom_voices",
|
| 62 |
+
sa.Column("b2v_custom_voice_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
|
| 63 |
+
sa.Column("voice_id", sa.String(100), nullable=False, unique=True),
|
| 64 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 65 |
+
sa.Column("source", sa.String(20), nullable=False),
|
| 66 |
+
sa.Column("preview_url", sa.String(1000), nullable=True), *_times())
|
| 67 |
+
if not inspector.has_table("user_saved_voices"):
|
| 68 |
+
op.create_table("user_saved_voices",
|
| 69 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 70 |
+
primary_key=True),
|
| 71 |
+
sa.Column("voice_id", sa.String(100), primary_key=True),
|
| 72 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 73 |
+
sa.Column("preview_url", sa.String(1000), nullable=True),
|
| 74 |
+
sa.Column("gender", sa.String(20), nullable=True),
|
| 75 |
+
sa.Column("accent", sa.String(50), nullable=True),
|
| 76 |
+
sa.Column("premium", sa.Boolean(), nullable=False, server_default=sa.false()),
|
| 77 |
+
sa.Column("is_custom", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
|
| 78 |
+
if not inspector.has_table("b2v_styles"):
|
| 79 |
+
op.create_table("b2v_styles",
|
| 80 |
+
sa.Column("b2v_style_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
|
| 81 |
+
sa.Column("name", sa.String(80), nullable=False), *_times())
|
| 82 |
+
if not inspector.has_table("usage_counters"):
|
| 83 |
+
op.create_table("usage_counters",
|
| 84 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 85 |
+
primary_key=True),
|
| 86 |
+
sa.Column("period", sa.String(10), primary_key=True),
|
| 87 |
+
sa.Column("metric", sa.String(30), primary_key=True),
|
| 88 |
+
sa.Column("used", sa.Integer(), nullable=False, server_default="0"))
|
| 89 |
+
|
| 90 |
+
plan_cols = {c["name"] for c in inspector.get_columns("plans")}
|
| 91 |
+
with op.batch_alter_table("plans") as batch:
|
| 92 |
+
if "video_premium" not in plan_cols:
|
| 93 |
+
batch.add_column(sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false()))
|
| 94 |
+
if "video_limits" not in plan_cols:
|
| 95 |
+
batch.add_column(sa.Column("video_limits", sa.JSON(), nullable=True))
|
| 96 |
+
plans = sa.table("plans", sa.column("id", sa.String), sa.column("video_premium", sa.Boolean),
|
| 97 |
+
sa.column("video_limits", sa.JSON))
|
| 98 |
+
for plan_id, limits in VIDEO_LIMITS.items():
|
| 99 |
+
op.execute(plans.update().where(plans.c.id == plan_id)
|
| 100 |
+
.values(video_premium=plan_id != "free", video_limits=json.loads(json.dumps(limits))))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def downgrade() -> None:
|
| 104 |
+
with op.batch_alter_table("plans") as batch:
|
| 105 |
+
batch.drop_column("video_limits")
|
| 106 |
+
batch.drop_column("video_premium")
|
| 107 |
+
for table in ("usage_counters", "b2v_styles", "user_saved_voices", "b2v_custom_voices", "b2v_templates"):
|
| 108 |
+
op.drop_table(table)
|
| 109 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 110 |
+
batch.drop_column("template_ref")
|
| 111 |
+
batch.drop_column("created_via")
|
backend/alembic/versions/0010_b2v_video_refs.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""b2v_videos holds each video's full reference (who made it, title, source, links); video limits 1 total / 10 / 20
|
| 2 |
+
|
| 3 |
+
- b2v_videos: user_id, title, source_url, aspect_ratio, preview_url, video_url, backfilled from the workspace owner
|
| 4 |
+
and the linked artifact.
|
| 5 |
+
- plans: videos_monthly (False on Free: 1 video in total, never refilled); Studio 20 videos a month.
|
| 6 |
+
|
| 7 |
+
Revision ID: 0010_b2v_video_refs
|
| 8 |
+
Revises: 0009_b2v_resources
|
| 9 |
+
Create Date: 2026-09-30
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
+
from alembic import op
|
| 15 |
+
|
| 16 |
+
revision = "0010_b2v_video_refs"
|
| 17 |
+
down_revision = "0009_b2v_resources"
|
| 18 |
+
branch_labels = None
|
| 19 |
+
depends_on = None
|
| 20 |
+
|
| 21 |
+
VIDEO_COLUMNS = {
|
| 22 |
+
"user_id": lambda: sa.Column("user_id", sa.Uuid(), nullable=True),
|
| 23 |
+
"title": lambda: sa.Column("title", sa.String(300), nullable=True),
|
| 24 |
+
"source_url": lambda: sa.Column("source_url", sa.String(2000), nullable=True),
|
| 25 |
+
"aspect_ratio": lambda: sa.Column("aspect_ratio", sa.String(20), nullable=True),
|
| 26 |
+
"preview_url": lambda: sa.Column("preview_url", sa.String(1000), nullable=True),
|
| 27 |
+
"video_url": lambda: sa.Column("video_url", sa.String(1000), nullable=True),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Same as app/services/plans.py
|
| 31 |
+
PLAN_VIDEOS = {"free": (1, False, "1 video a month", "1 video to try it"),
|
| 32 |
+
"writer": (10, True, None, None),
|
| 33 |
+
"studio": (20, True, "30 videos a month", "20 videos a month")}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def upgrade() -> None:
|
| 37 |
+
bind = op.get_bind()
|
| 38 |
+
inspector = sa.inspect(bind)
|
| 39 |
+
|
| 40 |
+
# 0001 builds from the live models, so a fresh database may already have these columns.
|
| 41 |
+
existing = {c["name"] for c in inspector.get_columns("b2v_videos")}
|
| 42 |
+
missing = [make() for name, make in VIDEO_COLUMNS.items() if name not in existing]
|
| 43 |
+
if missing:
|
| 44 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 45 |
+
for column in missing:
|
| 46 |
+
batch.add_column(column)
|
| 47 |
+
if "user_id" not in existing:
|
| 48 |
+
batch.create_foreign_key("fk_b2v_videos_user_id_users", "users", ["user_id"], ["id"],
|
| 49 |
+
ondelete="SET NULL")
|
| 50 |
+
batch.create_index("ix_b2v_videos_user_id", ["user_id"])
|
| 51 |
+
|
| 52 |
+
# Backfill: the workspace owner made it; title and links come from the artifact.
|
| 53 |
+
rows = bind.execute(sa.text("""
|
| 54 |
+
SELECT v.id, w.owner_id, a.content_json FROM b2v_videos v
|
| 55 |
+
JOIN workspaces w ON w.id = v.workspace_id
|
| 56 |
+
LEFT JOIN artifacts a ON a.id = v.artifact_id
|
| 57 |
+
WHERE v.user_id IS NULL
|
| 58 |
+
""")).fetchall()
|
| 59 |
+
for vid, owner_id, content in rows:
|
| 60 |
+
c = content if isinstance(content, dict) else json.loads(content or "{}")
|
| 61 |
+
bind.execute(sa.text("""
|
| 62 |
+
UPDATE b2v_videos SET user_id = :u, title = COALESCE(title, :t), aspect_ratio = COALESCE(aspect_ratio, :ar),
|
| 63 |
+
preview_url = COALESCE(preview_url, :p), video_url = COALESCE(video_url, :v) WHERE id = :id
|
| 64 |
+
"""), {"u": owner_id, "t": (c.get("title") or None), "ar": c.get("aspect_ratio"), "p": c.get("preview_url"),
|
| 65 |
+
"v": c.get("video_url"), "id": vid})
|
| 66 |
+
|
| 67 |
+
plan_cols = {c["name"] for c in inspector.get_columns("plans")}
|
| 68 |
+
if "videos_monthly" not in plan_cols:
|
| 69 |
+
with op.batch_alter_table("plans") as batch:
|
| 70 |
+
batch.add_column(sa.Column("videos_monthly", sa.Boolean(), nullable=False, server_default=sa.true()))
|
| 71 |
+
for plan_id, (videos, monthly, old, new) in PLAN_VIDEOS.items():
|
| 72 |
+
row = bind.execute(sa.text("SELECT features FROM plans WHERE id = :id"), {"id": plan_id}).fetchone()
|
| 73 |
+
if row is None:
|
| 74 |
+
continue
|
| 75 |
+
features = row[0] if isinstance(row[0], list) else json.loads(row[0] or "[]")
|
| 76 |
+
if old:
|
| 77 |
+
features = [new if f == old else f for f in features]
|
| 78 |
+
bind.execute(sa.text("UPDATE plans SET videos = :v, videos_monthly = :m, features = :f WHERE id = :id"),
|
| 79 |
+
{"v": videos, "m": monthly, "f": json.dumps(features), "id": plan_id})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def downgrade() -> None:
|
| 83 |
+
with op.batch_alter_table("plans") as batch:
|
| 84 |
+
batch.drop_column("videos_monthly")
|
| 85 |
+
# A database built from the models (0001) names these differently, so look them up.
|
| 86 |
+
inspector = sa.inspect(op.get_bind())
|
| 87 |
+
fks = [fk["name"] for fk in inspector.get_foreign_keys("b2v_videos")
|
| 88 |
+
if fk["constrained_columns"] == ["user_id"] and fk.get("name")]
|
| 89 |
+
indexes = [ix["name"] for ix in inspector.get_indexes("b2v_videos") if ix["column_names"] == ["user_id"]]
|
| 90 |
+
with op.batch_alter_table("b2v_videos") as batch:
|
| 91 |
+
for name in indexes:
|
| 92 |
+
batch.drop_index(name)
|
| 93 |
+
for name in fks:
|
| 94 |
+
batch.drop_constraint(name, type_="foreignkey")
|
| 95 |
+
for name in reversed(list(VIDEO_COLUMNS)):
|
| 96 |
+
batch.drop_column(name)
|
backend/alembic/versions/0011_one_archive_notebook.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One "All posts" notebook per workspace
|
| 2 |
+
|
| 3 |
+
Two requests opening the Notebooks page at once could each create the archive notebook. This merges the copies into
|
| 4 |
+
the oldest one (its posts, chats and artifacts move over) and adds a unique index so it cannot happen again.
|
| 5 |
+
|
| 6 |
+
Revision ID: 0011_one_archive_notebook
|
| 7 |
+
Revises: 0010_b2v_video_refs
|
| 8 |
+
Create Date: 2026-10-01
|
| 9 |
+
"""
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from alembic import op
|
| 12 |
+
|
| 13 |
+
revision = "0011_one_archive_notebook"
|
| 14 |
+
down_revision = "0010_b2v_video_refs"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
INDEX = "uq_notebooks_one_archive"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
conn = op.get_bind()
|
| 23 |
+
rows = conn.execute(sa.text(
|
| 24 |
+
"SELECT workspace_id, id FROM notebooks WHERE is_archive ORDER BY workspace_id, created_at, id"
|
| 25 |
+
)).all()
|
| 26 |
+
keep: dict = {}
|
| 27 |
+
for ws, nb in rows:
|
| 28 |
+
if ws not in keep:
|
| 29 |
+
keep[ws] = nb
|
| 30 |
+
continue
|
| 31 |
+
ids = {"keep": keep[ws], "dup": nb}
|
| 32 |
+
conn.execute(sa.text(
|
| 33 |
+
"INSERT INTO notebook_documents (notebook_id, document_id) "
|
| 34 |
+
"SELECT :keep, document_id FROM notebook_documents WHERE notebook_id = :dup AND document_id NOT IN "
|
| 35 |
+
"(SELECT document_id FROM notebook_documents WHERE notebook_id = :keep)"
|
| 36 |
+
), ids)
|
| 37 |
+
conn.execute(sa.text("UPDATE chats SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
|
| 38 |
+
conn.execute(sa.text("UPDATE artifacts SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
|
| 39 |
+
conn.execute(sa.text("DELETE FROM notebooks WHERE id = :dup"), ids)
|
| 40 |
+
|
| 41 |
+
# 0001 builds from the live models, so a fresh database may already have the index.
|
| 42 |
+
if INDEX not in {i["name"] for i in sa.inspect(conn).get_indexes("notebooks")}:
|
| 43 |
+
op.create_index(INDEX, "notebooks", ["workspace_id"], unique=True,
|
| 44 |
+
postgresql_where=sa.text("is_archive"), sqlite_where=sa.text("is_archive"))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def downgrade() -> None:
|
| 48 |
+
op.drop_index(INDEX, table_name="notebooks")
|
backend/alembic/versions/0012_artifact_status_len.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Longer artifact status
|
| 2 |
+
|
| 3 |
+
A video's artifact now takes blog2video's job statuses as they are (e.g. "language_regenerating", 21 characters),
|
| 4 |
+
which do not fit the old 20.
|
| 5 |
+
|
| 6 |
+
Revision ID: 0012_artifact_status_len
|
| 7 |
+
Revises: 0011_one_archive_notebook
|
| 8 |
+
Create Date: 2026-10-02
|
| 9 |
+
"""
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from alembic import op
|
| 12 |
+
|
| 13 |
+
revision = "0012_artifact_status_len"
|
| 14 |
+
down_revision = "0011_one_archive_notebook"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
with op.batch_alter_table("artifacts") as batch:
|
| 21 |
+
batch.alter_column("status", existing_type=sa.String(20), type_=sa.String(40), existing_nullable=False)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def downgrade() -> None:
|
| 25 |
+
with op.batch_alter_table("artifacts") as batch:
|
| 26 |
+
batch.alter_column("status", existing_type=sa.String(40), type_=sa.String(20), existing_nullable=False)
|
backend/alembic/versions/{0005_workspace_memory.py → 0013_workspace_memory.py}
RENAMED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
"""workspace_memory: one row of standing notes per workspace, stored as JSON
|
| 2 |
|
| 3 |
-
Revision ID:
|
| 4 |
-
Revises:
|
| 5 |
Create Date: 2026-09-30
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
-
revision = "
|
| 11 |
-
down_revision = "
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
|
|
|
| 1 |
"""workspace_memory: one row of standing notes per workspace, stored as JSON
|
| 2 |
|
| 3 |
+
Revision ID: 0013_workspace_memory
|
| 4 |
+
Revises: 0012_artifact_status_len
|
| 5 |
Create Date: 2026-09-30
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
+
revision = "0013_workspace_memory"
|
| 11 |
+
down_revision = "0012_artifact_status_len"
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
backend/alembic/versions/{0006_chat_citations.py → 0014_chat_citations.py}
RENAMED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
"""citations.kind: a citation can point at an earlier chat (no document) as well as a post
|
| 2 |
|
| 3 |
-
Revision ID:
|
| 4 |
-
Revises:
|
| 5 |
Create Date: 2026-10-01
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
-
revision = "
|
| 11 |
-
down_revision = "
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
|
|
|
| 1 |
"""citations.kind: a citation can point at an earlier chat (no document) as well as a post
|
| 2 |
|
| 3 |
+
Revision ID: 0014_chat_citations
|
| 4 |
+
Revises: 0013_workspace_memory
|
| 5 |
Create Date: 2026-10-01
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
+
revision = "0014_chat_citations"
|
| 11 |
+
down_revision = "0013_workspace_memory"
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
backend/alembic/versions/{0007_message_feedback.py → 0015_message_feedback.py}
RENAMED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
"""message_feedback: thumbs up/down on chat answers, plus messages.recall_json (how the answer was produced)
|
| 2 |
|
| 3 |
-
Revision ID:
|
| 4 |
-
Revises:
|
| 5 |
Create Date: 2026-10-02
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
-
revision = "
|
| 11 |
-
down_revision = "
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
|
|
|
| 1 |
"""message_feedback: thumbs up/down on chat answers, plus messages.recall_json (how the answer was produced)
|
| 2 |
|
| 3 |
+
Revision ID: 0015_message_feedback
|
| 4 |
+
Revises: 0014_chat_citations
|
| 5 |
Create Date: 2026-10-02
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
+
revision = "0015_message_feedback"
|
| 11 |
+
down_revision = "0014_chat_citations"
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
backend/alembic/versions/{0008_support_chat.py → 0016_support_chat.py}
RENAMED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
"""support_chat: conversations and messages for the in app help bot
|
| 2 |
|
| 3 |
-
Revision ID:
|
| 4 |
-
Revises:
|
| 5 |
Create Date: 2026-10-06
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
-
revision = "
|
| 11 |
-
down_revision = "
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
|
|
|
| 1 |
"""support_chat: conversations and messages for the in app help bot
|
| 2 |
|
| 3 |
+
Revision ID: 0016_support_chat
|
| 4 |
+
Revises: 0015_message_feedback
|
| 5 |
Create Date: 2026-10-06
|
| 6 |
"""
|
| 7 |
import sqlalchemy as sa
|
| 8 |
from alembic import op
|
| 9 |
|
| 10 |
+
revision = "0016_support_chat"
|
| 11 |
+
down_revision = "0015_message_feedback"
|
| 12 |
branch_labels = None
|
| 13 |
depends_on = None
|
| 14 |
|
backend/app/config.py
CHANGED
|
@@ -121,6 +121,15 @@ class Settings(BaseSettings):
|
|
| 121 |
linkedin_client_id: str = ""
|
| 122 |
linkedin_client_secret: str = ""
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
# Renderer
|
| 125 |
renderer_url: str = "http://localhost:3100"
|
| 126 |
internal_token: str = "internal-change-me"
|
|
|
|
| 121 |
linkedin_client_id: str = ""
|
| 122 |
linkedin_client_secret: str = ""
|
| 123 |
|
| 124 |
+
# blog2video: makes the videos. We call its public API with our own account's key (b2v_live_...), so every
|
| 125 |
+
# video is charged to that account. Backend only: the key must never reach the browser or the logs.
|
| 126 |
+
b2v_api_base_url: str = Field("", validation_alias=AliasChoices("B2V_API_BASE_URL", "B2V_API_URL"))
|
| 127 |
+
b2v_api_key: str = ""
|
| 128 |
+
# blog2video's web app: serves the template poster images (<app>/template-posters/<id>.webp).
|
| 129 |
+
b2v_app_url: str = "https://blog2video.app"
|
| 130 |
+
b2v_timeout_seconds: float = 30 # creates, uploads and full-project reads; other calls also wait 30s (STATUS_TIMEOUT)
|
| 131 |
+
b2v_capacity_alert_below: int = 20 # log an error when our blog2video account has fewer videos left
|
| 132 |
+
|
| 133 |
# Renderer
|
| 134 |
renderer_url: str = "http://localhost:3100"
|
| 135 |
internal_token: str = "internal-change-me"
|
backend/app/llm/provider.py
CHANGED
|
@@ -60,6 +60,18 @@ def triage_lm() -> dspy.LM:
|
|
| 60 |
return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def configure_default() -> None:
|
| 64 |
dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
|
| 65 |
|
|
|
|
| 60 |
return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
|
| 61 |
|
| 62 |
|
| 63 |
+
FOCUS_MAX_TOKENS = 2000 # three titles and two-line descriptions plus GLM's low-effort reasoning
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@lru_cache
|
| 67 |
+
def focus_lm(retry: bool = False) -> dspy.LM:
|
| 68 |
+
"""The video wizard's focus suggestions: a little material in, three short topics out, so a small cap keeps it
|
| 69 |
+
quick. Only for a source with no topics to show. retry=True is the second try after an unusable answer: a
|
| 70 |
+
different temperature, so it is a fresh call and not that answer again from the cache."""
|
| 71 |
+
return _build(settings.llm_fast_model, effort="low", temperature=0.8 if retry else 0.4,
|
| 72 |
+
max_tokens=FOCUS_MAX_TOKENS)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
def configure_default() -> None:
|
| 76 |
dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
|
| 77 |
|
backend/app/llm/run.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
|
| 2 |
em dashes from the outputs. Tests replace `predict` to return canned outputs."""
|
| 3 |
|
|
|
|
| 4 |
import uuid
|
|
|
|
| 5 |
|
| 6 |
import dspy
|
| 7 |
from sqlalchemy.orm import Session
|
|
@@ -12,6 +14,8 @@ from app.llm.provider import main_lm, provider_name, track_usage
|
|
| 12 |
from app.models import DspyTrace, Job, Workspace
|
| 13 |
from app.services.jobs import record_usage
|
| 14 |
|
|
|
|
|
|
|
| 15 |
|
| 16 |
def _dump(value):
|
| 17 |
if hasattr(value, "model_dump"):
|
|
@@ -35,14 +39,51 @@ def predict(
|
|
| 35 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 36 |
pred = dspy.Predict(signature)(**inputs)
|
| 37 |
outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
if usage.total:
|
| 39 |
record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
|
| 40 |
quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
|
| 41 |
workspace = db.get(Workspace, workspace_id)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
db.commit()
|
| 48 |
-
return outputs
|
|
|
|
| 1 |
"""One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
|
| 2 |
em dashes from the outputs. Tests replace `predict` to return canned outputs."""
|
| 3 |
|
| 4 |
+
import logging
|
| 5 |
import uuid
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
|
| 8 |
import dspy
|
| 9 |
from sqlalchemy.orm import Session
|
|
|
|
| 14 |
from app.models import DspyTrace, Job, Workspace
|
| 15 |
from app.services.jobs import record_usage
|
| 16 |
|
| 17 |
+
log = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
|
| 20 |
def _dump(value):
|
| 21 |
if hasattr(value, "model_dump"):
|
|
|
|
| 39 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 40 |
pred = dspy.Predict(signature)(**inputs)
|
| 41 |
outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
| 42 |
+
_record(db, workspace_id, job, signature, lm, usage, [(inputs, outputs)])
|
| 43 |
+
return outputs
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def predict_many(
|
| 47 |
+
signature: type[dspy.Signature],
|
| 48 |
+
inputs_list: list[dict],
|
| 49 |
+
*,
|
| 50 |
+
db: Session,
|
| 51 |
+
workspace_id: uuid.UUID,
|
| 52 |
+
lm: dspy.LM | None = None,
|
| 53 |
+
workers: int = 5,
|
| 54 |
+
) -> list[dict | None]:
|
| 55 |
+
"""`predict` once per inputs, in parallel. Only the model calls run in threads: usage, traces and the commit
|
| 56 |
+
happen here, on the caller's session. A call that fails is None in its place."""
|
| 57 |
+
if not inputs_list:
|
| 58 |
+
return []
|
| 59 |
+
lm = lm or main_lm()
|
| 60 |
+
|
| 61 |
+
def call(inputs: dict) -> dict | None:
|
| 62 |
+
try:
|
| 63 |
+
with dspy.context(lm=lm):
|
| 64 |
+
pred = dspy.Predict(signature)(**inputs)
|
| 65 |
+
return {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
| 66 |
+
except Exception:
|
| 67 |
+
log.warning("%s failed", signature.__name__, exc_info=True)
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
with track_usage(lm) as usage, ThreadPoolExecutor(max_workers=min(workers, len(inputs_list))) as pool:
|
| 71 |
+
results = list(pool.map(call, inputs_list))
|
| 72 |
+
_record(db, workspace_id, None, signature, lm, usage,
|
| 73 |
+
[(i, o) for i, o in zip(inputs_list, results, strict=True) if o is not None])
|
| 74 |
+
return results
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
|
| 78 |
+
usage, calls: list[tuple[dict, dict]]) -> None:
|
| 79 |
if usage.total:
|
| 80 |
record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
|
| 81 |
quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
|
| 82 |
workspace = db.get(Workspace, workspace_id)
|
| 83 |
+
for inputs, outputs in calls:
|
| 84 |
+
db.add(DspyTrace(
|
| 85 |
+
workspace_id=workspace_id, module=signature.__name__, model=lm.model,
|
| 86 |
+
inputs={k: (v if len(str(v)) < 4000 else str(v)[:4000]) for k, v in inputs.items()},
|
| 87 |
+
outputs=outputs, training_allowed=bool(workspace and workspace.training_opt_in),
|
| 88 |
+
))
|
| 89 |
db.commit()
|
|
|
backend/app/llm/signatures.py
CHANGED
|
@@ -47,14 +47,6 @@ class ScriptLine(BaseModel):
|
|
| 47 |
sources: list[SourceRef] = Field(default_factory=list)
|
| 48 |
|
| 49 |
|
| 50 |
-
class Scene(BaseModel):
|
| 51 |
-
type: Literal["title", "section", "pull_quote", "number", "outro"]
|
| 52 |
-
on_screen_text: str
|
| 53 |
-
narration: str
|
| 54 |
-
duration_hint_s: float
|
| 55 |
-
visual: str = ""
|
| 56 |
-
|
| 57 |
-
|
| 58 |
class Hook(BaseModel):
|
| 59 |
text: str
|
| 60 |
strength: float = Field(ge=0, le=1)
|
|
@@ -278,14 +270,6 @@ class PodcastScript(dspy.Signature):
|
|
| 278 |
lines: list[ScriptLine] = dspy.OutputField()
|
| 279 |
|
| 280 |
|
| 281 |
-
class VideoStoryboard(dspy.Signature):
|
| 282 |
-
"""Turn the post into a scene list for a Remotion composition."""
|
| 283 |
-
|
| 284 |
-
passages: list[str] = dspy.InputField()
|
| 285 |
-
aspect: Literal["16:9", "9:16", "1:1"] = dspy.InputField()
|
| 286 |
-
scenes: list[Scene] = dspy.OutputField()
|
| 287 |
-
|
| 288 |
-
|
| 289 |
class HookGenerator(dspy.Signature):
|
| 290 |
"""Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
|
| 291 |
|
|
@@ -458,3 +442,30 @@ class TriageWithMemory(TriageMessage):
|
|
| 458 |
"`slug | label | last active | gist`")
|
| 459 |
topic: str = dspy.OutputField(desc="The slug of the topic the message belongs to, or none")
|
| 460 |
memory: Literal["none", "lookup", "replay", "compose"] = dspy.OutputField()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
sources: list[SourceRef] = Field(default_factory=list)
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
class Hook(BaseModel):
|
| 51 |
text: str
|
| 52 |
strength: float = Field(ge=0, le=1)
|
|
|
|
| 270 |
lines: list[ScriptLine] = dspy.OutputField()
|
| 271 |
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
class HookGenerator(dspy.Signature):
|
| 274 |
"""Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
|
| 275 |
|
|
|
|
| 442 |
"`slug | label | last active | gist`")
|
| 443 |
topic: str = dspy.OutputField(desc="The slug of the topic the message belongs to, or none")
|
| 444 |
memory: Literal["none", "lookup", "replay", "compose"] = dspy.OutputField()
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
class FocusTopic(BaseModel):
|
| 448 |
+
# Not "title"/"description": with those names GLM sometimes wrote the word "Description" as every title.
|
| 449 |
+
topic: str = Field(description="The angle itself in 3 to 7 words, e.g. \"Why tokens break software margins\". "
|
| 450 |
+
"Never a label such as Title, Topic or Description. No trailing period")
|
| 451 |
+
summary: str = Field(description="Two short sentences (at most 40 words): what the video would cover and the "
|
| 452 |
+
"angle it takes, using only this material")
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
class FocusAngle(FocusTopic):
|
| 456 |
+
tag: str = Field(description="The candidate tag this angle is built on, copied exactly (its name only), or an "
|
| 457 |
+
"empty string when it comes from the material alone")
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
class VideoFocusAngles(dspy.Signature):
|
| 461 |
+
"""Suggest three distinct angles a short explainer video could focus on, drawn only from this material. Build
|
| 462 |
+
each angle on a different candidate tag when tags are given (a tag shared by several items makes a good angle that
|
| 463 |
+
connects them). Each has a short, specific topic (different for each of the three) and a two sentence summary of
|
| 464 |
+
what the video covers about that angle. Never reuse or closely reword an avoided title: find a new angle instead.
|
| 465 |
+
Never use em dashes."""
|
| 466 |
+
|
| 467 |
+
items: list[str] = dspy.InputField(desc="Each item: a title (when there is one), then the start of its text")
|
| 468 |
+
candidate_tags: list[str] = dspy.InputField(
|
| 469 |
+
desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
|
| 470 |
+
avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
|
| 471 |
+
topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
|
backend/app/main.py
CHANGED
|
@@ -23,6 +23,10 @@ from app.routers import (
|
|
| 23 |
support,
|
| 24 |
topics,
|
| 25 |
unsubscribe,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
voice,
|
| 27 |
)
|
| 28 |
from app.routers import settings as settings_router
|
|
@@ -88,8 +92,9 @@ def health():
|
|
| 88 |
return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
|
| 89 |
|
| 90 |
|
|
|
|
| 91 |
for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
|
| 92 |
-
launchpad, settings_router, memory, support):
|
| 93 |
app.include_router(r.router)
|
| 94 |
app.include_router(sources.documents_router)
|
| 95 |
app.include_router(launchpad.links_router)
|
|
|
|
| 23 |
support,
|
| 24 |
topics,
|
| 25 |
unsubscribe,
|
| 26 |
+
video_edit,
|
| 27 |
+
video_styles,
|
| 28 |
+
video_voices,
|
| 29 |
+
videos,
|
| 30 |
voice,
|
| 31 |
)
|
| 32 |
from app.routers import settings as settings_router
|
|
|
|
| 92 |
return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
|
| 93 |
|
| 94 |
|
| 95 |
+
# video_templates (custom templates) is switched off: users pick only from the built-in templates.
|
| 96 |
for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
|
| 97 |
+
launchpad, videos, video_edit, video_voices, video_styles, settings_router, memory, support):
|
| 98 |
app.include_router(r.router)
|
| 99 |
app.include_router(sources.documents_router)
|
| 100 |
app.include_router(launchpad.links_router)
|
backend/app/models/__init__.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
from app.models.content import (
|
| 2 |
Artifact,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
CalendarItem,
|
| 4 |
Chat,
|
| 5 |
Citation,
|
|
@@ -17,20 +21,26 @@ from app.models.content import (
|
|
| 17 |
Topic,
|
| 18 |
TrackedLink,
|
| 19 |
Upload,
|
|
|
|
| 20 |
UsageEvent,
|
|
|
|
| 21 |
VoiceConsent,
|
| 22 |
VoiceProfile,
|
| 23 |
WorkspaceMemory,
|
| 24 |
)
|
| 25 |
from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
|
| 26 |
from app.models.support import SupportConversation, SupportMessage
|
| 27 |
-
from app.models.user import AuthProvider, BillingEvent, Subscription, User, Workspace, WorkspaceMember
|
| 28 |
|
| 29 |
__all__ = [
|
| 30 |
"SupportConversation",
|
| 31 |
"SupportMessage",
|
| 32 |
"Artifact",
|
| 33 |
"AuthProvider",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"BillingEvent",
|
| 35 |
"CalendarItem",
|
| 36 |
"Chat",
|
|
@@ -44,6 +54,7 @@ __all__ = [
|
|
| 44 |
"Message",
|
| 45 |
"MessageFeedback",
|
| 46 |
"Notebook",
|
|
|
|
| 47 |
"NotebookDocument",
|
| 48 |
"SocialAccount",
|
| 49 |
"Source",
|
|
@@ -53,7 +64,9 @@ __all__ = [
|
|
| 53 |
"UpdateEmail",
|
| 54 |
"UpdateEmailSend",
|
| 55 |
"Upload",
|
|
|
|
| 56 |
"UsageEvent",
|
|
|
|
| 57 |
"User",
|
| 58 |
"VerificationPurpose",
|
| 59 |
"VoiceConsent",
|
|
|
|
| 1 |
from app.models.content import (
|
| 2 |
Artifact,
|
| 3 |
+
B2VCustomVoice,
|
| 4 |
+
B2VStyle,
|
| 5 |
+
B2VTemplate,
|
| 6 |
+
B2VVideo,
|
| 7 |
CalendarItem,
|
| 8 |
Chat,
|
| 9 |
Citation,
|
|
|
|
| 21 |
Topic,
|
| 22 |
TrackedLink,
|
| 23 |
Upload,
|
| 24 |
+
UsageCounter,
|
| 25 |
UsageEvent,
|
| 26 |
+
UserSavedVoice,
|
| 27 |
VoiceConsent,
|
| 28 |
VoiceProfile,
|
| 29 |
WorkspaceMemory,
|
| 30 |
)
|
| 31 |
from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
|
| 32 |
from app.models.support import SupportConversation, SupportMessage
|
| 33 |
+
from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
|
| 34 |
|
| 35 |
__all__ = [
|
| 36 |
"SupportConversation",
|
| 37 |
"SupportMessage",
|
| 38 |
"Artifact",
|
| 39 |
"AuthProvider",
|
| 40 |
+
"B2VCustomVoice",
|
| 41 |
+
"B2VStyle",
|
| 42 |
+
"B2VTemplate",
|
| 43 |
+
"B2VVideo",
|
| 44 |
"BillingEvent",
|
| 45 |
"CalendarItem",
|
| 46 |
"Chat",
|
|
|
|
| 54 |
"Message",
|
| 55 |
"MessageFeedback",
|
| 56 |
"Notebook",
|
| 57 |
+
"PlanRecord",
|
| 58 |
"NotebookDocument",
|
| 59 |
"SocialAccount",
|
| 60 |
"Source",
|
|
|
|
| 64 |
"UpdateEmail",
|
| 65 |
"UpdateEmailSend",
|
| 66 |
"Upload",
|
| 67 |
+
"UsageCounter",
|
| 68 |
"UsageEvent",
|
| 69 |
+
"UserSavedVoice",
|
| 70 |
"User",
|
| 71 |
"VerificationPurpose",
|
| 72 |
"VoiceConsent",
|
backend/app/models/content.py
CHANGED
|
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
|
| 14 |
Text,
|
| 15 |
UniqueConstraint,
|
| 16 |
Uuid,
|
|
|
|
| 17 |
)
|
| 18 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 19 |
|
|
@@ -60,6 +61,9 @@ class Document(IdMixin, TimestampMixin, Base):
|
|
| 60 |
|
| 61 |
class Notebook(IdMixin, TimestampMixin, Base):
|
| 62 |
__tablename__ = "notebooks"
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 65 |
title: Mapped[str] = mapped_column(String(300))
|
|
@@ -184,11 +188,106 @@ class Artifact(IdMixin, TimestampMixin, Base):
|
|
| 184 |
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
|
| 185 |
# summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
|
| 186 |
type: Mapped[str] = mapped_column(String(30))
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
content_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 189 |
storage_key: Mapped[str | None] = mapped_column(String(500))
|
| 190 |
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
class Job(IdMixin, TimestampMixin, Base):
|
| 193 |
"""Covers ingest, tts and render jobs. kind decides which fields matter.
|
| 194 |
|
|
|
|
| 14 |
Text,
|
| 15 |
UniqueConstraint,
|
| 16 |
Uuid,
|
| 17 |
+
text,
|
| 18 |
)
|
| 19 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 20 |
|
|
|
|
| 61 |
|
| 62 |
class Notebook(IdMixin, TimestampMixin, Base):
|
| 63 |
__tablename__ = "notebooks"
|
| 64 |
+
# One "All posts" notebook per workspace: two requests opening it at once cannot both create one.
|
| 65 |
+
__table_args__ = (Index("uq_notebooks_one_archive", "workspace_id", unique=True,
|
| 66 |
+
postgresql_where=text("is_archive"), sqlite_where=text("is_archive")),)
|
| 67 |
|
| 68 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 69 |
title: Mapped[str] = mapped_column(String(300))
|
|
|
|
| 188 |
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
|
| 189 |
# summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
|
| 190 |
type: Mapped[str] = mapped_column(String(30))
|
| 191 |
+
# pending | generating | ready | failed | rendering | review; a video also takes blog2video's job statuses as they
|
| 192 |
+
# are: regenerating (template switch) | voice_regenerating | language_regenerating | script_regenerating
|
| 193 |
+
status: Mapped[str] = mapped_column(String(40), default="pending")
|
| 194 |
content_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 195 |
storage_key: Mapped[str | None] = mapped_column(String(500))
|
| 196 |
|
| 197 |
|
| 198 |
+
class B2VVideo(IdMixin, TimestampMixin, Base):
|
| 199 |
+
"""One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
|
| 200 |
+
blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
|
| 201 |
+
and whether the workspace's video allowance is still charged for it.
|
| 202 |
+
|
| 203 |
+
Our API key reaches every video it created, for every workspace, so this table (with the artifact) is what
|
| 204 |
+
ties a blog2video id to one workspace. quota_state moves charged -> refunded (or -> kept once generated), with a
|
| 205 |
+
conditional UPDATE (see video_quota.refund), so the status poller and the sweep can not refund twice."""
|
| 206 |
+
|
| 207 |
+
__tablename__ = "b2v_videos"
|
| 208 |
+
__table_args__ = (Index("ix_b2v_videos_ws_created", "workspace_id", "created_at"),)
|
| 209 |
+
|
| 210 |
+
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
|
| 211 |
+
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"),
|
| 212 |
+
index=True) # who made it
|
| 213 |
+
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"),
|
| 214 |
+
index=True)
|
| 215 |
+
# blog2video's video id, which is also its project id. Null until blog2video accepts the create.
|
| 216 |
+
b2v_video_id: Mapped[int | None] = mapped_column(Integer, unique=True)
|
| 217 |
+
idempotency_key: Mapped[str | None] = mapped_column(String(100), unique=True) # "<workspace_id>:<uuid>"; v1 only
|
| 218 |
+
created_via: Mapped[str] = mapped_column(String(10), default="v1", server_default="v1") # v1 | upload
|
| 219 |
+
template_ref: Mapped[str | None] = mapped_column(String(100)) # e.g. custom_58: blocks deleting that template
|
| 220 |
+
title: Mapped[str | None] = mapped_column(String(300))
|
| 221 |
+
source_url: Mapped[str | None] = mapped_column(String(2000)) # the link it was made from (null for files, text)
|
| 222 |
+
aspect_ratio: Mapped[str | None] = mapped_column(String(20))
|
| 223 |
+
preview_url: Mapped[str | None] = mapped_column(String(1000)) # public live preview (embed token)
|
| 224 |
+
video_url: Mapped[str | None] = mapped_column(String(1000)) # the rendered MP4
|
| 225 |
+
quota_state: Mapped[str] = mapped_column(String(20), default="charged") # charged | refunded | kept
|
| 226 |
+
status: Mapped[str] = mapped_column(String(40), default="queued") # last known blog2video status
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class B2VTemplate(TimestampMixin, Base):
|
| 230 |
+
"""A custom template this workspace made. Used on videos as custom_<b2v_template_id>; only when ready."""
|
| 231 |
+
|
| 232 |
+
__tablename__ = "b2v_templates"
|
| 233 |
+
|
| 234 |
+
b2v_template_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
| 235 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 236 |
+
name: Mapped[str] = mapped_column(String(255))
|
| 237 |
+
ready: Mapped[bool] = mapped_column(Boolean, default=False) # code generation finished ('complete')
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class B2VCustomVoice(TimestampMixin, Base):
|
| 241 |
+
"""A designed or cloned voice this workspace made. Videos reference it by voice_id (custom_voice_id)."""
|
| 242 |
+
|
| 243 |
+
__tablename__ = "b2v_custom_voices"
|
| 244 |
+
|
| 245 |
+
b2v_custom_voice_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) # preview/delete
|
| 246 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 247 |
+
voice_id: Mapped[str] = mapped_column(String(100), unique=True) # the ElevenLabs id sent as custom_voice_id
|
| 248 |
+
name: Mapped[str] = mapped_column(String(255))
|
| 249 |
+
source: Mapped[str] = mapped_column(String(20)) # prompt | preset | clone
|
| 250 |
+
preview_url: Mapped[str | None] = mapped_column(String(1000))
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class UserSavedVoice(TimestampMixin, Base):
|
| 254 |
+
"""The workspace's "My voices" list. Kept only here: blog2video's saved-voice list is shared by everyone."""
|
| 255 |
+
|
| 256 |
+
__tablename__ = "user_saved_voices"
|
| 257 |
+
|
| 258 |
+
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 259 |
+
primary_key=True)
|
| 260 |
+
voice_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
| 261 |
+
name: Mapped[str] = mapped_column(String(255))
|
| 262 |
+
preview_url: Mapped[str | None] = mapped_column(String(1000))
|
| 263 |
+
gender: Mapped[str | None] = mapped_column(String(20))
|
| 264 |
+
accent: Mapped[str | None] = mapped_column(String(50))
|
| 265 |
+
premium: Mapped[bool] = mapped_column(Boolean, default=False) # a paid built-in voice, or any custom voice
|
| 266 |
+
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class B2VStyle(TimestampMixin, Base):
|
| 270 |
+
"""A custom video style this workspace made. Used on videos as custom:<b2v_style_id>."""
|
| 271 |
+
|
| 272 |
+
__tablename__ = "b2v_styles"
|
| 273 |
+
|
| 274 |
+
b2v_style_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
| 275 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 276 |
+
name: Mapped[str] = mapped_column(String(80))
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class UsageCounter(Base):
|
| 280 |
+
"""Per-workspace counters for the video features blog2video only limits account-wide (video_limits.py)."""
|
| 281 |
+
|
| 282 |
+
__tablename__ = "usage_counters"
|
| 283 |
+
|
| 284 |
+
workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"),
|
| 285 |
+
primary_key=True)
|
| 286 |
+
period: Mapped[str] = mapped_column(String(10), primary_key=True) # 2026-10 | 2026-10-02 | all
|
| 287 |
+
metric: Mapped[str] = mapped_column(String(30), primary_key=True)
|
| 288 |
+
used: Mapped[int] = mapped_column(Integer, default=0)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
class Job(IdMixin, TimestampMixin, Base):
|
| 292 |
"""Covers ingest, tts and render jobs. kind decides which fields matter.
|
| 293 |
|
backend/app/models/user.py
CHANGED
|
@@ -2,7 +2,20 @@ import enum
|
|
| 2 |
import uuid
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
-
from sqlalchemy import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/generate.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
"""Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import uuid
|
| 4 |
from collections import Counter, defaultdict
|
| 5 |
from datetime import UTC, datetime
|
|
@@ -19,11 +21,13 @@ from app.llm.signatures import (
|
|
| 19 |
ExtractTopics,
|
| 20 |
SummarizeNotebook,
|
| 21 |
)
|
| 22 |
-
from app.models import Artifact, DocumentTopic, Job, Notebook, Topic, VoiceProfile
|
| 23 |
from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
|
| 24 |
from app.pipeline.research import verify_citations
|
| 25 |
from app.services.jobs import update_job
|
| 26 |
|
|
|
|
|
|
|
| 27 |
|
| 28 |
class NothingToDo(ValueError):
|
| 29 |
pass
|
|
@@ -244,26 +248,65 @@ def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 244 |
# Topic map
|
| 245 |
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
|
| 248 |
"""Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
|
| 249 |
docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
|
| 250 |
-
todo = [d for d in docs if doc_ids or
|
| 251 |
known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
|
| 252 |
-
|
| 253 |
for i, d in enumerate(todo):
|
| 254 |
update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
except Exception:
|
| 259 |
-
continue
|
| 260 |
-
tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
|
| 261 |
-
d.metadata_json = {**(d.metadata_json or {}), "topics": tags}
|
| 262 |
-
known = sorted(set(known) | {t["name"] for t in tags})[:200]
|
| 263 |
db.commit()
|
| 264 |
update_job(db, job, progress=0.85, message="Drawing constellations")
|
| 265 |
count = rebuild_topics(db, job, workspace_id)
|
| 266 |
-
return {"tagged": len(todo), "topics": count}
|
| 267 |
|
| 268 |
|
| 269 |
def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
|
|
|
|
| 1 |
"""Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
import uuid
|
| 6 |
from collections import Counter, defaultdict
|
| 7 |
from datetime import UTC, datetime
|
|
|
|
| 21 |
ExtractTopics,
|
| 22 |
SummarizeNotebook,
|
| 23 |
)
|
| 24 |
+
from app.models import Artifact, Document, DocumentTopic, Job, Notebook, Topic, VoiceProfile
|
| 25 |
from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
|
| 26 |
from app.pipeline.research import verify_citations
|
| 27 |
from app.services.jobs import update_job
|
| 28 |
|
| 29 |
+
log = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
|
| 32 |
class NothingToDo(ValueError):
|
| 33 |
pass
|
|
|
|
| 248 |
# Topic map
|
| 249 |
|
| 250 |
|
| 251 |
+
TAG_TRIES = 3 # per post per job: the model times out or rate limits now and then
|
| 252 |
+
TAG_BACKOFF = (2.0, 6.0) # seconds before the second and third try
|
| 253 |
+
MAX_TAG_FAILURES = 5 # jobs in a row that could not tag a post: the sweep stops asking for it (and logs it)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def needs_topics(meta: dict | None) -> bool:
|
| 257 |
+
"""A post without topics yet that is still worth another try (see worker.topics_backfill)."""
|
| 258 |
+
meta = meta or {}
|
| 259 |
+
return not meta.get("topics") and int(meta.get("topics_failures") or 0) < MAX_TAG_FAILURES
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def save_topic_tags(doc: Document, out: dict) -> list[dict]:
|
| 263 |
+
"""ExtractTopics' answer for a post (at most 8 named topics), kept in metadata_json["topics"]. An answer with no
|
| 264 |
+
topics is not kept, so the post still counts as untagged. Also used by the video wizard, which tags a post on
|
| 265 |
+
demand when it has none yet."""
|
| 266 |
+
tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
|
| 267 |
+
if tags:
|
| 268 |
+
meta = {k: v for k, v in (doc.metadata_json or {}).items() if k != "topics_failures"}
|
| 269 |
+
doc.metadata_json = {**meta, "topics": tags}
|
| 270 |
+
return tags
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def tag_post(db: Session, job: Job | None, workspace_id: uuid.UUID, doc: Document, known: list[str]) -> list[dict]:
|
| 274 |
+
"""One post's topics, tried a few times. Out of tries, the post is counted as failed once more (the sweep asks
|
| 275 |
+
again later) and [] returned."""
|
| 276 |
+
error: object = None
|
| 277 |
+
for attempt in range(TAG_TRIES):
|
| 278 |
+
if attempt:
|
| 279 |
+
time.sleep(TAG_BACKOFF[attempt - 1])
|
| 280 |
+
try:
|
| 281 |
+
out = run.predict(ExtractTopics, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(),
|
| 282 |
+
title=doc.title, text=(doc.clean_text or "")[:12000], known_topics=known)
|
| 283 |
+
except Exception as exc:
|
| 284 |
+
error = exc
|
| 285 |
+
continue
|
| 286 |
+
if tags := save_topic_tags(doc, out):
|
| 287 |
+
return tags
|
| 288 |
+
error = "no topics in the answer"
|
| 289 |
+
meta = doc.metadata_json or {}
|
| 290 |
+
doc.metadata_json = {**meta, "topics_failures": int(meta.get("topics_failures") or 0) + 1}
|
| 291 |
+
log.warning("could not tag post %s (%s): %s", doc.id, doc.title[:60], error)
|
| 292 |
+
return []
|
| 293 |
+
|
| 294 |
+
|
| 295 |
def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
|
| 296 |
"""Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
|
| 297 |
docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
|
| 298 |
+
todo = [d for d in docs if doc_ids or needs_topics(d.metadata_json)]
|
| 299 |
known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
|
| 300 |
+
tagged = 0
|
| 301 |
for i, d in enumerate(todo):
|
| 302 |
update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
|
| 303 |
+
if tags := tag_post(db, job, workspace_id, d, known):
|
| 304 |
+
tagged += 1
|
| 305 |
+
known = sorted(set(known) | {t["name"] for t in tags})[:200]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
db.commit()
|
| 307 |
update_job(db, job, progress=0.85, message="Drawing constellations")
|
| 308 |
count = rebuild_topics(db, job, workspace_id)
|
| 309 |
+
return {"tagged": tagged, "failed": len(todo) - tagged, "topics": count}
|
| 310 |
|
| 311 |
|
| 312 |
def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
|
backend/app/pipeline/media.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Audio overviews (two hosts, ElevenLabs),
|
| 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
|
|
@@ -84,12 +84,12 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 84 |
body.notebook_id = ensure_archive_notebook(ctx).id
|
| 85 |
params: dict = {}
|
| 86 |
content: dict = {}
|
| 87 |
-
if body.type == "video" and body.style =
|
|
|
|
|
|
|
| 88 |
if not body.audio_artifact_id:
|
| 89 |
raise HTTPException(400, "Pick an audio overview for the audiogram")
|
| 90 |
source = get_artifact_or_404(ctx, body.audio_artifact_id)
|
| 91 |
-
seconds = float((source.content_json or {}).get("duration_s", 60))
|
| 92 |
-
check_limit(ctx.db, ctx.workspace, "video_minutes", seconds / 60)
|
| 93 |
title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
|
| 94 |
params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
|
| 95 |
artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
|
|
@@ -104,10 +104,6 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 104 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 105 |
params = {"format": body.format, "minutes": body.minutes}
|
| 106 |
title = f"Audio overview: {target}"
|
| 107 |
-
elif body.type == "video":
|
| 108 |
-
check_limit(ctx.db, ctx.workspace, "video_minutes", 1 if body.style == "short" else 3)
|
| 109 |
-
params = {"style": body.style}
|
| 110 |
-
title = f"{'Short' if body.style == 'short' else 'Explainer'} video: {target}"
|
| 111 |
elif body.type == "mind_map":
|
| 112 |
if not body.notebook_id:
|
| 113 |
raise HTTPException(400, "A Mind Constellation is made from a notebook")
|
|
@@ -182,6 +178,11 @@ def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get
|
|
| 182 |
@router.delete("/{artifact_id}")
|
| 183 |
def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 184 |
a = get_artifact_or_404(ctx, artifact_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
|
| 186 |
ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
|
| 187 |
ctx.db.delete(a)
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class RenderIn(BaseModel):
|
| 24 |
+
composition: Literal["AudiogramSquare", "QuoteCard", "CarouselSlide"]
|
| 25 |
props: dict
|
| 26 |
|
| 27 |
|
|
|
|
| 33 |
# audio_overview
|
| 34 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 35 |
minutes: int = Field(6, ge=1, le=30)
|
| 36 |
+
# video: only audiograms here; other videos are made by blog2video (/api/videos)
|
| 37 |
+
style: str = "audiogram"
|
| 38 |
audio_artifact_id: uuid.UUID | None = None
|
| 39 |
# carousel
|
| 40 |
slides: list[dict] | None = None
|
|
|
|
| 84 |
body.notebook_id = ensure_archive_notebook(ctx).id
|
| 85 |
params: dict = {}
|
| 86 |
content: dict = {}
|
| 87 |
+
if body.type == "video" and body.style != "audiogram":
|
| 88 |
+
raise HTTPException(410, "Videos moved to /app/videos")
|
| 89 |
+
if body.type == "video":
|
| 90 |
if not body.audio_artifact_id:
|
| 91 |
raise HTTPException(400, "Pick an audio overview for the audiogram")
|
| 92 |
source = get_artifact_or_404(ctx, body.audio_artifact_id)
|
|
|
|
|
|
|
| 93 |
title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
|
| 94 |
params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
|
| 95 |
artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
|
|
|
|
| 104 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 105 |
params = {"format": body.format, "minutes": body.minutes}
|
| 106 |
title = f"Audio overview: {target}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
elif body.type == "mind_map":
|
| 108 |
if not body.notebook_id:
|
| 109 |
raise HTTPException(400, "A Mind Constellation is made from a notebook")
|
|
|
|
| 178 |
@router.delete("/{artifact_id}")
|
| 179 |
def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 180 |
a = get_artifact_or_404(ctx, artifact_id)
|
| 181 |
+
if a.type == "video" and (a.content_json or {}).get("provider") == "blog2video":
|
| 182 |
+
from app.routers.videos import remove_video # also removes it from blog2video
|
| 183 |
+
|
| 184 |
+
remove_video(ctx, a)
|
| 185 |
+
return {"ok": True}
|
| 186 |
storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
|
| 187 |
ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
|
| 188 |
ctx.db.delete(a)
|
backend/app/routers/billing.py
CHANGED
|
@@ -8,7 +8,7 @@ from app.auth import Ctx, get_ctx
|
|
| 8 |
from app.config import settings
|
| 9 |
from app.db import get_db
|
| 10 |
from app.services.billing import billing_status, create_checkout_url, create_portal_url
|
| 11 |
-
from app.services.plans import
|
| 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
|
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|
| 10 |
from fastapi.responses import StreamingResponse
|
| 11 |
from pydantic import BaseModel, Field
|
| 12 |
from sqlalchemy import delete, func, select
|
|
|
|
| 13 |
|
| 14 |
from app.auth import Ctx, get_ctx
|
| 15 |
from app.chat_memory.context import load_chat_memory
|
|
@@ -103,12 +104,20 @@ ARCHIVE_TITLE = "All posts"
|
|
| 103 |
|
| 104 |
def ensure_archive_notebook(ctx: Ctx) -> Notebook:
|
| 105 |
"""The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
if not nb:
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
|
| 113 |
_add_docs(ctx, nb, ids)
|
| 114 |
ctx.db.commit()
|
|
@@ -161,6 +170,21 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
|
| 161 |
]
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
@router.get("/{notebook_id}")
|
| 165 |
def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 166 |
nb = _get(ctx, notebook_id)
|
|
|
|
| 10 |
from fastapi.responses import StreamingResponse
|
| 11 |
from pydantic import BaseModel, Field
|
| 12 |
from sqlalchemy import delete, func, select
|
| 13 |
+
from sqlalchemy.exc import IntegrityError
|
| 14 |
|
| 15 |
from app.auth import Ctx, get_ctx
|
| 16 |
from app.chat_memory.context import load_chat_memory
|
|
|
|
| 104 |
|
| 105 |
def ensure_archive_notebook(ctx: Ctx) -> Notebook:
|
| 106 |
"""The workspace's "All posts" notebook, topped up with every indexed post (locked posts are skipped)."""
|
| 107 |
+
def find() -> Notebook | None:
|
| 108 |
+
return ctx.db.scalar(select(Notebook).where(Notebook.workspace_id == ctx.workspace.id,
|
| 109 |
+
Notebook.is_archive.is_(True)))
|
| 110 |
+
|
| 111 |
+
nb = find()
|
| 112 |
if not nb:
|
| 113 |
+
try:
|
| 114 |
+
nb = Notebook(workspace_id=ctx.workspace.id, title=ARCHIVE_TITLE, is_archive=True,
|
| 115 |
+
description="Every indexed post in your archive, kept up to date.")
|
| 116 |
+
ctx.db.add(nb)
|
| 117 |
+
ctx.db.flush()
|
| 118 |
+
except IntegrityError: # a concurrent request made it first (uq_notebooks_one_archive)
|
| 119 |
+
ctx.db.rollback()
|
| 120 |
+
nb = find()
|
| 121 |
ids = list(ctx.db.scalars(select(Document.id).where(Document.workspace_id == ctx.workspace.id)))
|
| 122 |
_add_docs(ctx, nb, ids)
|
| 123 |
ctx.db.commit()
|
|
|
|
| 170 |
]
|
| 171 |
|
| 172 |
|
| 173 |
+
# Above /{notebook_id}, so "chats" is not read as a notebook id.
|
| 174 |
+
@router.get("/chats")
|
| 175 |
+
def list_all_chats(ctx: Ctx = Depends(get_ctx)):
|
| 176 |
+
"""Every chat in the workspace that has an answer, newest first (the video wizard's "Made from" menu)."""
|
| 177 |
+
answered = select(Message.chat_id).where(Message.role == "assistant")
|
| 178 |
+
rows = ctx.db.execute(
|
| 179 |
+
select(Chat, Notebook.title).join(Notebook, Notebook.id == Chat.notebook_id)
|
| 180 |
+
.where(Chat.workspace_id == ctx.workspace.id, Chat.id.in_(answered))
|
| 181 |
+
.order_by(Chat.updated_at.desc())
|
| 182 |
+
).all()
|
| 183 |
+
return [{"id": str(c.id), "title": c.title, "notebook_id": str(c.notebook_id), "notebook_title": nb_title,
|
| 184 |
+
"updated_at": c.updated_at.isoformat() if c.updated_at else None}
|
| 185 |
+
for c, nb_title in rows]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
@router.get("/{notebook_id}")
|
| 189 |
def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 190 |
nb = _get(ctx, notebook_id)
|
backend/app/routers/topics.py
CHANGED
|
@@ -234,8 +234,8 @@ def rebuild(ctx: Ctx = Depends(get_ctx), full: bool = False):
|
|
| 234 |
if full:
|
| 235 |
# Clear per post tags so every post is mapped again.
|
| 236 |
for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
|
| 237 |
-
if d.metadata_json and "topics"
|
| 238 |
-
d.metadata_json = {k: v for k, v in d.metadata_json.items() if k
|
| 239 |
ctx.db.commit()
|
| 240 |
job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
|
| 241 |
return serialize_job(job)
|
|
|
|
| 234 |
if full:
|
| 235 |
# Clear per post tags so every post is mapped again.
|
| 236 |
for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
|
| 237 |
+
if d.metadata_json and ({"topics", "topics_failures"} & d.metadata_json.keys()):
|
| 238 |
+
d.metadata_json = {k: v for k, v in d.metadata_json.items() if k not in ("topics", "topics_failures")}
|
| 239 |
ctx.db.commit()
|
| 240 |
job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
|
| 241 |
return serialize_job(job)
|
backend/app/routers/video_edit.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The video editor: one allowlisted proxy onto blog2video's /api/projects/{id}/... endpoints.
|
| 2 |
+
|
| 3 |
+
<METHOD> /api/videos/{artifact_id}/p/<path> -> <METHOD> /api/projects/{blog2video id}/<path>
|
| 4 |
+
|
| 5 |
+
Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
|
| 6 |
+
(b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
|
| 7 |
+
premium (★) features need a paid plan, and AI edits come out of the workspace's own counter (given back if
|
| 8 |
+
blog2video refuses). A template switch also uses one video, as it does on blog2video.
|
| 9 |
+
|
| 10 |
+
JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
import uuid
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
| 19 |
+
from starlette.concurrency import run_in_threadpool
|
| 20 |
+
from starlette.datastructures import UploadFile
|
| 21 |
+
|
| 22 |
+
from app.auth import Ctx, get_ctx
|
| 23 |
+
from app.routers.videos import b2v_ready, store, upstream
|
| 24 |
+
from app.services import b2v_access, video_limits, video_quota
|
| 25 |
+
from app.services import blog2video as b2v
|
| 26 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/api/videos", tags=["videos"])
|
| 29 |
+
|
| 30 |
+
MAX_JSON_BYTES = 512 * 1024
|
| 31 |
+
MAX_FILE_BYTES = 50 * 1024 * 1024
|
| 32 |
+
S = r"\d+" # a scene id
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class Rule:
|
| 37 |
+
ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
|
| 38 |
+
per_scene: bool = False # ai_edits is per scene_ids entry (avatar batches)
|
| 39 |
+
premium: str | None = None # ★: what to call it in the upgrade message
|
| 40 |
+
uses_video: bool = False # a template switch: one video from the allowance
|
| 41 |
+
raw: bool = False # binary answer (download, still)
|
| 42 |
+
timeout: float = 30
|
| 43 |
+
status: str | None = None # artifact status to show once it started
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
|
| 47 |
+
# Project settings, logo, assets
|
| 48 |
+
("GET", "", Rule(timeout=60)), # the whole project: slow on a far-away database
|
| 49 |
+
("PATCH", "update-project", Rule()),
|
| 50 |
+
("PATCH", "", Rule()),
|
| 51 |
+
("POST", "logo", Rule()),
|
| 52 |
+
("DELETE", "logo", Rule()),
|
| 53 |
+
("PATCH", rf"assets/{S}/exclude", Rule()),
|
| 54 |
+
("DELETE", rf"assets/{S}", Rule()),
|
| 55 |
+
("GET", "layouts", Rule()),
|
| 56 |
+
("GET", "status", Rule()),
|
| 57 |
+
# Scenes
|
| 58 |
+
("PUT", rf"scenes/{S}", Rule()),
|
| 59 |
+
("DELETE", rf"scenes/{S}", Rule()),
|
| 60 |
+
("PUT", "bulk-update-scenes", Rule()),
|
| 61 |
+
("POST", "scenes/reorder", Rule()),
|
| 62 |
+
("POST", rf"scenes/{S}/regenerate", Rule(ai_edits=1, timeout=120)),
|
| 63 |
+
("POST", "scenes/add", Rule(ai_edits=1)),
|
| 64 |
+
("GET", "scenes/add-status", Rule()),
|
| 65 |
+
# Images
|
| 66 |
+
("POST", rf"scenes/{S}/image", Rule(timeout=60)),
|
| 67 |
+
("POST", rf"scenes/{S}/generate-image", Rule(ai_edits=1, timeout=120)),
|
| 68 |
+
("PATCH", rf"scenes/{S}/image-focus", Rule()),
|
| 69 |
+
("POST", "images/move", Rule()),
|
| 70 |
+
("POST", "images/swap", Rule()),
|
| 71 |
+
("POST", "images/duplicate", Rule()),
|
| 72 |
+
("POST", "images/assign-existing", Rule()),
|
| 73 |
+
# Stock footage
|
| 74 |
+
("GET", "stock-footage/search", Rule()),
|
| 75 |
+
("POST", rf"scenes/{S}/stock-footage", Rule(ai_edits=1, timeout=60)),
|
| 76 |
+
("GET", "stock-footage/pending", Rule()),
|
| 77 |
+
("POST", "stock-footage/link", Rule()),
|
| 78 |
+
("POST", "stock-footage/approve", Rule(status="generating")),
|
| 79 |
+
("POST", "stock-footage/reject", Rule(status="generating")),
|
| 80 |
+
# Voice, voiceovers, language, template
|
| 81 |
+
("POST", rf"scenes/{S}/voiceover", Rule(timeout=60)),
|
| 82 |
+
# blog2video keeps the project's status during a voiceover delete; marking ours lets a reload resume its progress
|
| 83 |
+
# (voice-change-status reports it, kind "delete"), and the next status check sets it back.
|
| 84 |
+
("POST", "delete-voiceover", Rule(status="voice_regenerating")),
|
| 85 |
+
("POST", "change-voice", Rule(status="voice_regenerating")),
|
| 86 |
+
("GET", "voice-change-status", Rule()),
|
| 87 |
+
("POST", "change-language", Rule(status="language_regenerating")),
|
| 88 |
+
("GET", "language-change-status", Rule()),
|
| 89 |
+
("POST", "change-template-regenerate-layouts", Rule(uses_video=True, status="regenerating")),
|
| 90 |
+
("GET", "template-change-status", Rule()),
|
| 91 |
+
# Refresh the script
|
| 92 |
+
("POST", "regenerate-script", Rule(status="script_regenerating")),
|
| 93 |
+
("GET", "regenerate-script-status", Rule()),
|
| 94 |
+
("GET", "regenerate-script/preview", Rule()),
|
| 95 |
+
("POST", "regenerate-script/verify", Rule()),
|
| 96 |
+
("POST", "regenerate-script/regenerate", Rule()),
|
| 97 |
+
# AI chat editing ★
|
| 98 |
+
("POST", "chat", Rule(ai_edits=1, premium="AI chat editing", timeout=120)),
|
| 99 |
+
("GET", "chat/history", Rule(premium="AI chat editing")),
|
| 100 |
+
# Avatars ★ (blog2video charges the whole batch at authorize: 10 AI edits per scene)
|
| 101 |
+
("POST", "avatar-batch/authorize", Rule(ai_edits=10, per_scene=True, premium="Avatars")),
|
| 102 |
+
("POST", rf"scenes/{S}/avatar", Rule(premium="Avatars")),
|
| 103 |
+
("GET", rf"scenes/{S}/avatar-status", Rule()),
|
| 104 |
+
("DELETE", rf"scenes/{S}/avatar", Rule()),
|
| 105 |
+
("POST", rf"scenes/{S}/avatar-matte", Rule(premium="Avatars")),
|
| 106 |
+
("POST", "avatar-matte-all", Rule(premium="Avatars")),
|
| 107 |
+
("POST", "avatar-retry-failed", Rule(premium="Avatars")),
|
| 108 |
+
("GET", "avatar-progress", Rule()),
|
| 109 |
+
("POST", "avatar-portrait", Rule(premium="Avatars", timeout=60)),
|
| 110 |
+
("DELETE", "avatar-portrait", Rule()),
|
| 111 |
+
("PATCH", rf"scenes/{S}/avatar-focus", Rule()),
|
| 112 |
+
("PATCH", rf"scenes/{S}/avatar-appearance", Rule()),
|
| 113 |
+
# Render, download, frames
|
| 114 |
+
("POST", "render", Rule(status="rendering")),
|
| 115 |
+
("GET", "render-status", Rule()),
|
| 116 |
+
("POST", "cancel-render", Rule()),
|
| 117 |
+
("GET", "download-url", Rule()),
|
| 118 |
+
("GET", "download", Rule(raw=True, timeout=120)),
|
| 119 |
+
("GET", "download-studio", Rule(raw=True, premium="The Remotion source download", timeout=120)),
|
| 120 |
+
("GET", "render-still", Rule(raw=True, timeout=60)),
|
| 121 |
+
("POST", "render-stills", Rule(timeout=120)),
|
| 122 |
+
]]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def rule_for(method: str, path: str) -> Rule | None:
|
| 126 |
+
for m, pattern, rule in RULES:
|
| 127 |
+
if m == method and pattern.match(path):
|
| 128 |
+
return rule
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def _read_body(request: Request) -> tuple[dict | None, dict | None, list | None]:
|
| 133 |
+
"""(json, form fields, files) from the browser's request."""
|
| 134 |
+
ctype = request.headers.get("content-type", "")
|
| 135 |
+
if ctype.startswith("multipart/form-data"):
|
| 136 |
+
form = await request.form()
|
| 137 |
+
data, files = {}, []
|
| 138 |
+
for key, value in form.multi_items():
|
| 139 |
+
if isinstance(value, UploadFile):
|
| 140 |
+
content = await value.read(MAX_FILE_BYTES + 1)
|
| 141 |
+
if len(content) > MAX_FILE_BYTES:
|
| 142 |
+
raise HTTPException(413, "That file is too large.")
|
| 143 |
+
files.append((key, (value.filename or "file", content, value.content_type or
|
| 144 |
+
"application/octet-stream")))
|
| 145 |
+
else:
|
| 146 |
+
data[key] = value
|
| 147 |
+
return None, data, files
|
| 148 |
+
raw = await request.body()
|
| 149 |
+
if not raw:
|
| 150 |
+
return None, None, None
|
| 151 |
+
if len(raw) > MAX_JSON_BYTES:
|
| 152 |
+
raise HTTPException(413, "Request too large")
|
| 153 |
+
try:
|
| 154 |
+
return json.loads(raw), None, None
|
| 155 |
+
except ValueError:
|
| 156 |
+
raise HTTPException(400, "Send JSON") from None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
|
| 160 |
+
"""Premium, references and counters, all before blog2video is called. Returns AI edits taken."""
|
| 161 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 162 |
+
if rule.premium:
|
| 163 |
+
video_limits.require_premium(ctx.db, ctx.workspace, rule.premium, plan)
|
| 164 |
+
refs = body if isinstance(body, dict) else (form or {})
|
| 165 |
+
with upstream(): # checking a template or voice may need blog2video's catalog
|
| 166 |
+
b2v_access.check_refs(ctx, refs, plan)
|
| 167 |
+
edits = rule.ai_edits
|
| 168 |
+
if rule.per_scene:
|
| 169 |
+
scenes = (body or {}).get("scene_ids") if isinstance(body, dict) else None
|
| 170 |
+
if not isinstance(scenes, list) or not scenes:
|
| 171 |
+
raise HTTPException(400, "Pick at least one scene")
|
| 172 |
+
edits *= len(scenes)
|
| 173 |
+
if edits:
|
| 174 |
+
video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
|
| 175 |
+
if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
|
| 176 |
+
if edits:
|
| 177 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
|
| 178 |
+
raise plan_limit_error(plan, "videos", "Switching the template uses a video, and you have none left.")
|
| 179 |
+
return edits
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@router.api_route("/{artifact_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
| 183 |
+
async def project_proxy(artifact_id: uuid.UUID, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
|
| 184 |
+
_: None = Depends(b2v_ready)):
|
| 185 |
+
path = path.strip("/")
|
| 186 |
+
rule = rule_for(request.method, path)
|
| 187 |
+
if rule is None:
|
| 188 |
+
raise HTTPException(404, "Not found")
|
| 189 |
+
a, vid, _row = b2v_access.video_or_404(ctx, artifact_id)
|
| 190 |
+
body, form, files = await _read_body(request)
|
| 191 |
+
edits = await run_in_threadpool(_charge, ctx, rule, body, form)
|
| 192 |
+
params = dict(request.query_params)
|
| 193 |
+
|
| 194 |
+
def call():
|
| 195 |
+
return b2v.project_call(request.method, vid, path, json=body, data=form, files=files or None,
|
| 196 |
+
params=params or None, timeout=rule.timeout, raw=rule.raw)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
with upstream():
|
| 200 |
+
result = await run_in_threadpool(call)
|
| 201 |
+
except HTTPException:
|
| 202 |
+
if edits:
|
| 203 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits", edits)
|
| 204 |
+
if rule.uses_video:
|
| 205 |
+
video_quota.unreserve(ctx.db, ctx.workspace.id)
|
| 206 |
+
raise
|
| 207 |
+
if rule.status:
|
| 208 |
+
store(ctx, a, status=rule.status)
|
| 209 |
+
if rule.raw:
|
| 210 |
+
content, ctype = result
|
| 211 |
+
return Response(content, media_type=ctype, headers={"Cache-Control": "no-store"})
|
| 212 |
+
return result
|
backend/app/routers/video_styles.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom video styles: writing guidance for the script, used on videos as custom:<id>.
|
| 2 |
+
|
| 3 |
+
Styles are made on our one blog2video account, so b2v_styles records which workspace made each; only that
|
| 4 |
+
workspace sees, uses, edits or deletes it. Built-in styles are read-only here, and "Your Style" (which learns from
|
| 5 |
+
every workspace's edits) is never offered. The account-wide style selection, pin and built-in overrides are never
|
| 6 |
+
touched (blog2video.DENIED).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from app.auth import Ctx, get_ctx
|
| 15 |
+
from app.models import B2VStyle
|
| 16 |
+
from app.routers.videos import b2v_ready, upstream
|
| 17 |
+
from app.services import b2v_access
|
| 18 |
+
from app.services import blog2video as b2v
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/video-styles", tags=["videos"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class DraftIn(BaseModel):
|
| 24 |
+
prompt: str = Field(min_length=20, max_length=1000)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class StyleIn(BaseModel):
|
| 28 |
+
name: str = Field(min_length=1, max_length=80)
|
| 29 |
+
guidance: str = Field(min_length=1, max_length=2000)
|
| 30 |
+
creation_method: Literal["manual", "ai"] = "manual"
|
| 31 |
+
source_prompt: str | None = Field(None, max_length=1000)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class StylePatchIn(BaseModel):
|
| 35 |
+
name: str = Field(min_length=1, max_length=80)
|
| 36 |
+
guidance: str = Field(min_length=1, max_length=2000)
|
| 37 |
+
version: int = Field(ge=0)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("")
|
| 41 |
+
def list_styles(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 42 |
+
with upstream():
|
| 43 |
+
return b2v_access.visible_styles(ctx)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/ai-draft")
|
| 47 |
+
def ai_draft(body: DraftIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 48 |
+
"""A name and guidance written from a description; nothing is saved."""
|
| 49 |
+
with upstream():
|
| 50 |
+
return b2v.request("POST", "/api/video-styles/ai-draft", json=body.model_dump(), timeout=90)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _out(s: dict) -> dict:
|
| 54 |
+
return {"id": s.get("id"), "custom_id": s.get("custom_id"), "name": s.get("name"), "guidance": s.get("guidance"),
|
| 55 |
+
"description": s.get("description"), "version": s.get("version"), "kind": "custom"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("", status_code=201)
|
| 59 |
+
def create_style(body: StyleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 60 |
+
with upstream():
|
| 61 |
+
made = b2v.request("POST", "/api/video-styles/custom", json=body.model_dump())
|
| 62 |
+
ctx.db.add(B2VStyle(b2v_style_id=int(made["custom_id"]), workspace_id=ctx.workspace.id, name=body.name))
|
| 63 |
+
ctx.db.commit()
|
| 64 |
+
return _out(made)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.patch("/{style_id}")
|
| 68 |
+
def update_style(style_id: int, body: StylePatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 69 |
+
row = b2v_access.owned_style(ctx, style_id)
|
| 70 |
+
with upstream():
|
| 71 |
+
made = b2v.request("PATCH", f"/api/video-styles/custom/{style_id}", json=body.model_dump())
|
| 72 |
+
row.name = body.name
|
| 73 |
+
ctx.db.commit()
|
| 74 |
+
return _out(made)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.delete("/{style_id}")
|
| 78 |
+
def delete_style(style_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 79 |
+
row = b2v_access.owned_style(ctx, style_id)
|
| 80 |
+
try:
|
| 81 |
+
with upstream():
|
| 82 |
+
b2v.request("DELETE", f"/api/video-styles/custom/{style_id}")
|
| 83 |
+
except HTTPException as e:
|
| 84 |
+
if e.status_code != 404:
|
| 85 |
+
raise
|
| 86 |
+
ctx.db.delete(row)
|
| 87 |
+
ctx.db.commit()
|
| 88 |
+
return {"ok": True}
|
backend/app/routers/video_templates.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom video templates: made from a website, a document or a description, then generated and edited.
|
| 2 |
+
|
| 3 |
+
Templates are made on our one blog2video account, which has a small lifetime number of template slots (a delete
|
| 4 |
+
does not give one back). b2v_templates records which workspace made each template; only that workspace sees, uses,
|
| 5 |
+
edits or deletes it, and each workspace has its own share of slots and daily AI steps (video_limits).
|
| 6 |
+
|
| 7 |
+
Create: extract a theme (/extract/url | /extract/doc | /extract/prompt) -> POST "" (uses a slot) -> /{id}/generate
|
| 8 |
+
-> poll /{id}/generation-status until complete (the template becomes usable in the wizard) or error.
|
| 9 |
+
Edit: /{id}/p/<path> -> /api/custom-templates/{id}/<path>, allowlisted in RULES.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
|
| 17 |
+
from pydantic import BaseModel, Field
|
| 18 |
+
from sqlalchemy import select
|
| 19 |
+
from starlette.concurrency import run_in_threadpool
|
| 20 |
+
from starlette.datastructures import UploadFile as StarletteUpload
|
| 21 |
+
|
| 22 |
+
from app.auth import Ctx, get_ctx
|
| 23 |
+
from app.models import B2VTemplate, B2VVideo
|
| 24 |
+
from app.routers.videos import b2v_ready, upstream
|
| 25 |
+
from app.services import b2v_access, video_limits
|
| 26 |
+
from app.services import blog2video as b2v
|
| 27 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/api/video-templates", tags=["videos"])
|
| 30 |
+
|
| 31 |
+
DOC_MAX_BYTES = 10 * 1024 * 1024
|
| 32 |
+
LOGO_MAX_BYTES = 2 * 1024 * 1024
|
| 33 |
+
AI_TIMEOUT = 120
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _out(t: B2VTemplate) -> dict:
|
| 37 |
+
return {"id": t.b2v_template_id, "ref": f"custom_{t.b2v_template_id}", "name": t.name, "ready": t.ready,
|
| 38 |
+
"created_at": t.created_at.isoformat() if t.created_at else None}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _can_make(ctx: Ctx) -> None:
|
| 42 |
+
"""Refuse early (before any AI work) when the workspace has no template slots left."""
|
| 43 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 44 |
+
limit = int(plan.video_limits.get("templates", 0))
|
| 45 |
+
if limit >= 0 and video_limits.used(ctx.db, ctx.workspace.id, "templates") >= limit:
|
| 46 |
+
raise plan_limit_error(plan, "video_limits.templates",
|
| 47 |
+
"Your plan does not include custom templates." if limit == 0
|
| 48 |
+
else f"You have made all {limit} custom templates your plan includes.")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _counted(ctx: Ctx, metric: str, call):
|
| 52 |
+
video_limits.take(ctx.db, ctx.workspace, metric)
|
| 53 |
+
try:
|
| 54 |
+
with upstream():
|
| 55 |
+
return call()
|
| 56 |
+
except HTTPException:
|
| 57 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, metric)
|
| 58 |
+
raise
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("")
|
| 62 |
+
def list_templates(ctx: Ctx = Depends(get_ctx)):
|
| 63 |
+
limits = video_limits.report(ctx.db, ctx.workspace)["limits"]
|
| 64 |
+
return {"templates": [_out(t) for t in b2v_access.my_templates(ctx)],
|
| 65 |
+
"limits": {k: limits[k] for k in ("templates", "template_ai_daily")}}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# 1. Extract a theme
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ExtractUrlIn(BaseModel):
|
| 72 |
+
url: str = Field(min_length=1, max_length=2048)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ExtractPromptIn(BaseModel):
|
| 76 |
+
prompt: str = Field(min_length=15, max_length=5000)
|
| 77 |
+
name: str | None = Field(None, max_length=255)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/extract/url")
|
| 81 |
+
def extract_from_url(body: ExtractUrlIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 82 |
+
if not re.match(r"https?://", body.url):
|
| 83 |
+
raise HTTPException(400, "Enter a link that starts with http:// or https://")
|
| 84 |
+
_can_make(ctx)
|
| 85 |
+
with upstream():
|
| 86 |
+
return b2v.request("POST", "/api/custom-templates/extract-theme", json=body.model_dump(), timeout=AI_TIMEOUT)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.post("/extract/prompt")
|
| 90 |
+
def extract_from_prompt(body: ExtractPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 91 |
+
_can_make(ctx)
|
| 92 |
+
return _counted(ctx, "template_ai_daily", lambda: b2v.request(
|
| 93 |
+
"POST", "/api/custom-templates/extract-theme-from-prompt", json=body.model_dump(), timeout=AI_TIMEOUT))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.post("/extract/doc")
|
| 97 |
+
async def extract_from_doc(file: UploadFile = File(...), name: str = Form(""), ctx: Ctx = Depends(get_ctx),
|
| 98 |
+
_: None = Depends(b2v_ready)):
|
| 99 |
+
data = await file.read(DOC_MAX_BYTES + 1)
|
| 100 |
+
if len(data) > DOC_MAX_BYTES:
|
| 101 |
+
raise HTTPException(400, "Keep the document under 10 MB.")
|
| 102 |
+
_can_make(ctx)
|
| 103 |
+
files = {"file": (file.filename or "brand.pdf", data, file.content_type or "application/octet-stream")}
|
| 104 |
+
return await run_in_threadpool(_counted, ctx, "template_ai_daily", lambda: b2v.request(
|
| 105 |
+
"POST", "/api/custom-templates/extract-theme-from-doc", data={"name": name[:255]}, files=files,
|
| 106 |
+
timeout=AI_TIMEOUT))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# 2. Create (uses a slot) and 3. generate
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class CreateIn(BaseModel):
|
| 113 |
+
name: str = Field(min_length=1, max_length=255)
|
| 114 |
+
theme: dict
|
| 115 |
+
source_url: str | None = Field(None, max_length=2048)
|
| 116 |
+
logo_urls: list[str] | None = Field(None, max_length=20)
|
| 117 |
+
og_image: str | None = Field(None, max_length=2048)
|
| 118 |
+
screenshot_url: str | None = Field(None, max_length=2048)
|
| 119 |
+
reason: str | None = Field(None, max_length=2000)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.post("", status_code=201)
|
| 123 |
+
def create_template(body: CreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 124 |
+
made = _counted(ctx, "templates", lambda: b2v.request("POST", "/api/custom-templates",
|
| 125 |
+
json=body.model_dump(exclude_none=True), timeout=60))
|
| 126 |
+
row = B2VTemplate(b2v_template_id=int(made["id"]), workspace_id=ctx.workspace.id, name=body.name[:255])
|
| 127 |
+
ctx.db.add(row)
|
| 128 |
+
ctx.db.commit()
|
| 129 |
+
return _out(row)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.post("/{template_id}/generate")
|
| 133 |
+
def generate(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 134 |
+
b2v_access.owned_template(ctx, template_id)
|
| 135 |
+
return _counted(ctx, "template_ai_daily", lambda: b2v.request(
|
| 136 |
+
"POST", f"/api/custom-templates/{template_id}/generate-code", timeout=60))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/{template_id}/generation-status")
|
| 140 |
+
def generation_status(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 141 |
+
row = b2v_access.owned_template(ctx, template_id)
|
| 142 |
+
with upstream():
|
| 143 |
+
s = b2v.request("GET", f"/api/custom-templates/{template_id}/generation-status") or {}
|
| 144 |
+
ready = s.get("status") == "complete"
|
| 145 |
+
if ready != row.ready:
|
| 146 |
+
row.ready = ready
|
| 147 |
+
ctx.db.commit()
|
| 148 |
+
return {**s, "ready": ready}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.delete("/{template_id}")
|
| 152 |
+
def delete_template(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 153 |
+
"""blog2video refuses (template_in_use) while any video on the account uses the template. We force it only
|
| 154 |
+
when every such video is this workspace's: another workspace's video must never lose its template."""
|
| 155 |
+
row = b2v_access.owned_template(ctx, template_id)
|
| 156 |
+
ref = f"custom_{template_id}"
|
| 157 |
+
try:
|
| 158 |
+
with upstream():
|
| 159 |
+
b2v.request("DELETE", f"/api/custom-templates/{template_id}")
|
| 160 |
+
except HTTPException as e:
|
| 161 |
+
if e.status_code == 404:
|
| 162 |
+
pass
|
| 163 |
+
elif e.status_code == 409:
|
| 164 |
+
others = ctx.db.scalar(select(B2VVideo.id).where(B2VVideo.template_ref == ref,
|
| 165 |
+
B2VVideo.workspace_id != ctx.workspace.id).limit(1))
|
| 166 |
+
if others is not None:
|
| 167 |
+
raise HTTPException(409, "This template is in use and can not be deleted.") from None
|
| 168 |
+
with upstream():
|
| 169 |
+
b2v.request("DELETE", f"/api/custom-templates/{template_id}", params={"force": "true"})
|
| 170 |
+
else:
|
| 171 |
+
raise
|
| 172 |
+
ctx.db.delete(row)
|
| 173 |
+
ctx.db.commit()
|
| 174 |
+
return {"ok": True} # the slot is not given back: blog2video's slots are lifetime
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# Editing: allowlisted proxy
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@dataclass(frozen=True)
|
| 181 |
+
class Rule:
|
| 182 |
+
ai_edit: bool = False # ★ one AI edit
|
| 183 |
+
metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
|
| 184 |
+
raw: bool = False
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
K = r"(intro|outro|content_\d+)" # scene keys
|
| 188 |
+
RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, p, r in [
|
| 189 |
+
("GET", "", Rule()),
|
| 190 |
+
("PUT", "", Rule()),
|
| 191 |
+
("GET", "code", Rule()),
|
| 192 |
+
("POST", "upload-logo", Rule()),
|
| 193 |
+
("POST", f"scenes/{K}/ai-edit", Rule(ai_edit=True)),
|
| 194 |
+
("GET", f"scenes/{K}/ai-edit/status", Rule()),
|
| 195 |
+
("GET", "scene-drafts", Rule()),
|
| 196 |
+
("GET", f"scenes/{K}/draft", Rule()),
|
| 197 |
+
("POST", f"scenes/{K}/draft/apply", Rule()),
|
| 198 |
+
("POST", f"scenes/{K}/draft/discard", Rule()),
|
| 199 |
+
("PATCH", f"scenes/{K}/chart", Rule()),
|
| 200 |
+
("PATCH", f"scenes/{K}/font-defaults", Rule()),
|
| 201 |
+
("PATCH", "scenes/font-defaults", Rule()),
|
| 202 |
+
("GET", "versions", Rule()),
|
| 203 |
+
("POST", r"versions/\d+/rollback", Rule()),
|
| 204 |
+
("POST", "regenerate-code", Rule(metric="templates")), # uses a slot, like creating one
|
| 205 |
+
("POST", "resume-generation", Rule()),
|
| 206 |
+
("POST", "rating", Rule()),
|
| 207 |
+
]]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _rule(method: str, path: str) -> Rule | None:
|
| 211 |
+
return next((r for m, p, r in RULES if m == method and p.match(path)), None)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _take(ctx: Ctx, rule: Rule) -> list[str]:
|
| 215 |
+
taken: list[str] = []
|
| 216 |
+
try:
|
| 217 |
+
if rule.ai_edit:
|
| 218 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI template editing")
|
| 219 |
+
video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
|
| 220 |
+
taken.append("ai_edits")
|
| 221 |
+
if rule.metric:
|
| 222 |
+
video_limits.take(ctx.db, ctx.workspace, rule.metric)
|
| 223 |
+
taken.append(rule.metric)
|
| 224 |
+
except HTTPException:
|
| 225 |
+
for metric in taken:
|
| 226 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, metric)
|
| 227 |
+
raise
|
| 228 |
+
return taken
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.api_route("/{template_id}/p/{path:path}", methods=["GET", "POST", "PUT", "PATCH"])
|
| 232 |
+
async def template_proxy(template_id: int, path: str, request: Request, ctx: Ctx = Depends(get_ctx),
|
| 233 |
+
_: None = Depends(b2v_ready)):
|
| 234 |
+
path = path.strip("/")
|
| 235 |
+
rule = _rule(request.method, path)
|
| 236 |
+
if rule is None:
|
| 237 |
+
raise HTTPException(404, "Not found")
|
| 238 |
+
row = b2v_access.owned_template(ctx, template_id)
|
| 239 |
+
body = form = files = None
|
| 240 |
+
if request.headers.get("content-type", "").startswith("multipart/form-data"):
|
| 241 |
+
parsed = await request.form()
|
| 242 |
+
form, files = {}, []
|
| 243 |
+
for key, value in parsed.multi_items():
|
| 244 |
+
if isinstance(value, StarletteUpload):
|
| 245 |
+
content = await value.read(LOGO_MAX_BYTES + 1)
|
| 246 |
+
if len(content) > LOGO_MAX_BYTES:
|
| 247 |
+
raise HTTPException(413, "Keep the file under 2 MB.")
|
| 248 |
+
files.append((key, (value.filename or "logo.png", content, value.content_type or "image/png")))
|
| 249 |
+
else:
|
| 250 |
+
form[key] = value
|
| 251 |
+
elif raw := await request.body():
|
| 252 |
+
try:
|
| 253 |
+
body = json.loads(raw)
|
| 254 |
+
except ValueError:
|
| 255 |
+
raise HTTPException(400, "Send JSON") from None
|
| 256 |
+
taken = await run_in_threadpool(_take, ctx, rule)
|
| 257 |
+
|
| 258 |
+
def call():
|
| 259 |
+
return b2v.request(request.method, f"/api/custom-templates/{template_id}/{path}".rstrip("/"), json=body,
|
| 260 |
+
data=form, files=files or None, params=dict(request.query_params) or None,
|
| 261 |
+
timeout=AI_TIMEOUT, raw=rule.raw)
|
| 262 |
+
|
| 263 |
+
try:
|
| 264 |
+
with upstream():
|
| 265 |
+
result = await run_in_threadpool(call)
|
| 266 |
+
except HTTPException:
|
| 267 |
+
for metric in taken:
|
| 268 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, metric)
|
| 269 |
+
raise
|
| 270 |
+
if request.method == "PUT" and path == "" and isinstance(body, dict) and body.get("name"):
|
| 271 |
+
row.name = str(body["name"])[:255]
|
| 272 |
+
ctx.db.commit()
|
| 273 |
+
if rule.raw:
|
| 274 |
+
content, ctype = result
|
| 275 |
+
return Response(content, media_type=ctype)
|
| 276 |
+
return result
|
| 277 |
+
|
backend/app/routers/video_voices.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
|
| 2 |
+
Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
|
| 3 |
+
|
| 4 |
+
"My voices" (user_saved_voices, at most MAX_SAVED_VOICES) lives only here: blog2video's saved-voice list is one list
|
| 5 |
+
for the whole account.
|
| 6 |
+
Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
|
| 7 |
+
only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
|
| 8 |
+
of the workspace's own daily and total counters (video_limits).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Literal
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
from sqlalchemy import select
|
| 17 |
+
|
| 18 |
+
from app.auth import Ctx, get_ctx
|
| 19 |
+
from app.models import B2VCustomVoice, UserSavedVoice
|
| 20 |
+
from app.routers.videos import b2v_ready, upstream
|
| 21 |
+
from app.services import b2v_access, video_limits
|
| 22 |
+
from app.services import blog2video as b2v
|
| 23 |
+
from app.services.notestack_voices import notestack_voices
|
| 24 |
+
|
| 25 |
+
log = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/api/video-voices", tags=["videos"])
|
| 28 |
+
|
| 29 |
+
CLONE_MAX_BYTES = 50 * 1024 * 1024
|
| 30 |
+
STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
|
| 31 |
+
MAX_SAVED_VOICES = 5 # "My voices": what step 3 of a new video offers
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _labels(v: dict) -> dict:
|
| 35 |
+
return v.get("labels") or {}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
|
| 39 |
+
return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
|
| 40 |
+
"accent": v.accent, "premium": v.premium, "is_custom": v.is_custom,
|
| 41 |
+
"source": "notestack" if v.voice_id in ours else "blog2video"}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _room(ctx: Ctx) -> bool:
|
| 45 |
+
return len(_saved(ctx)) < MAX_SAVED_VOICES
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _full() -> HTTPException:
|
| 49 |
+
return HTTPException(409, f"You can keep {MAX_SAVED_VOICES} voices. Remove one first.")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _library_out(v: dict, saved: set[str]) -> dict:
|
| 53 |
+
labels = _labels(v)
|
| 54 |
+
return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
|
| 55 |
+
"description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
|
| 56 |
+
"gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
|
| 57 |
+
"premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
|
| 61 |
+
labels = _labels(v)
|
| 62 |
+
row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
|
| 63 |
+
name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
|
| 64 |
+
preview_url=v.get("preview_url"),
|
| 65 |
+
gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid")
|
| 66 |
+
ctx.db.merge(row)
|
| 67 |
+
return row
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _saved(ctx: Ctx) -> list[UserSavedVoice]:
|
| 71 |
+
return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
|
| 72 |
+
.order_by(UserSavedVoice.created_at)))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.get("")
|
| 76 |
+
def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 77 |
+
"""My voices (for the wizard), the built-in library (to save from) and my custom voices."""
|
| 78 |
+
with upstream():
|
| 79 |
+
library = b2v.prebuilt_voices()
|
| 80 |
+
saved = _saved(ctx)
|
| 81 |
+
if not saved:
|
| 82 |
+
for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]:
|
| 83 |
+
_save_builtin(ctx, v)
|
| 84 |
+
ctx.db.commit()
|
| 85 |
+
saved = _saved(ctx)
|
| 86 |
+
ids = {v.voice_id for v in saved}
|
| 87 |
+
custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
|
| 88 |
+
.order_by(B2VCustomVoice.created_at.desc()))
|
| 89 |
+
mine = notestack_voices(ctx.db, ctx.workspace.id)
|
| 90 |
+
ours = {v["voice_id"] for v in mine}
|
| 91 |
+
return {"saved": [_voice_out(v, ours) for v in saved], "max_saved": MAX_SAVED_VOICES,
|
| 92 |
+
"notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
|
| 93 |
+
"library": [_library_out(v, ids) for v in library if v.get("voice_id")],
|
| 94 |
+
"custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
|
| 95 |
+
"preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class SaveIn(BaseModel):
|
| 99 |
+
voice_id: str = Field(min_length=1, max_length=100)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post("/saved", status_code=201)
|
| 103 |
+
def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 104 |
+
if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
|
| 105 |
+
return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
|
| 106 |
+
if not _room(ctx):
|
| 107 |
+
raise _full()
|
| 108 |
+
custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
|
| 109 |
+
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
| 110 |
+
mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
|
| 111 |
+
if mine:
|
| 112 |
+
# A Notestack voice: premium, like a custom voice. No preview link (the Voice page plays it).
|
| 113 |
+
video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
|
| 114 |
+
row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=mine["voice_id"],
|
| 115 |
+
name=mine["name"][:255], premium=True, is_custom=True))
|
| 116 |
+
ctx.db.commit()
|
| 117 |
+
return _voice_out(row, {row.voice_id})
|
| 118 |
+
if custom:
|
| 119 |
+
row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
|
| 120 |
+
preview_url=custom.preview_url, premium=True, is_custom=True))
|
| 121 |
+
else:
|
| 122 |
+
with upstream():
|
| 123 |
+
v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
|
| 124 |
+
if not v:
|
| 125 |
+
raise HTTPException(404, "Voice not found")
|
| 126 |
+
if v.get("plan") == "paid":
|
| 127 |
+
video_limits.require_premium(ctx.db, ctx.workspace, "This voice")
|
| 128 |
+
row = _save_builtin(ctx, v)
|
| 129 |
+
ctx.db.commit()
|
| 130 |
+
return _voice_out(row)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.delete("/saved/{voice_id}")
|
| 134 |
+
def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
|
| 135 |
+
row = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": voice_id})
|
| 136 |
+
if not row:
|
| 137 |
+
raise HTTPException(404, "Voice not found")
|
| 138 |
+
ctx.db.delete(row)
|
| 139 |
+
ctx.db.commit()
|
| 140 |
+
return {"ok": True}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class DesignPromptIn(BaseModel):
|
| 147 |
+
prompt: str = Field(min_length=20, max_length=1000)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class DesignPresetIn(BaseModel):
|
| 151 |
+
gender: str = Field("", max_length=30)
|
| 152 |
+
age: str = Field("", max_length=30)
|
| 153 |
+
persona: str = Field("", max_length=100)
|
| 154 |
+
speed: str = Field("", max_length=30)
|
| 155 |
+
accent: str = Field("", max_length=60)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _design(ctx: Ctx, path: str, body: dict) -> dict:
|
| 159 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice")
|
| 160 |
+
video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan)
|
| 161 |
+
try:
|
| 162 |
+
with upstream():
|
| 163 |
+
data = b2v.request("POST", path, json=body, timeout=90) or {}
|
| 164 |
+
except HTTPException:
|
| 165 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily")
|
| 166 |
+
raise
|
| 167 |
+
return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")}
|
| 168 |
+
for p in data.get("previews") or []]}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.post("/design/prompt")
|
| 172 |
+
def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 173 |
+
return _design(ctx, "/api/voices/design-from-prompt", body.model_dump())
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.post("/design/preset")
|
| 177 |
+
def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 178 |
+
return _design(ctx, "/api/voices/design-from-preset", body.model_dump())
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class KeepIn(BaseModel):
|
| 182 |
+
generated_voice_id: str = Field(min_length=1, max_length=200)
|
| 183 |
+
source: Literal["prompt", "preset"]
|
| 184 |
+
name: str = Field(min_length=1, max_length=100)
|
| 185 |
+
prompt_text: str | None = Field(None, max_length=1000)
|
| 186 |
+
preview_url: str | None = Field(None, max_length=1000)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
|
| 190 |
+
row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id,
|
| 191 |
+
voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
|
| 192 |
+
preview_url=made.get("preview_url"))
|
| 193 |
+
ctx.db.add(row)
|
| 194 |
+
saved = _room(ctx) # kept in My voices only while there is room; otherwise added from the list later
|
| 195 |
+
if saved:
|
| 196 |
+
ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
|
| 197 |
+
preview_url=row.preview_url, premium=True, is_custom=True))
|
| 198 |
+
ctx.db.commit()
|
| 199 |
+
return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
|
| 200 |
+
"preview_url": row.preview_url, "saved": saved}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _make_custom(ctx: Ctx, source: str, send) -> dict:
|
| 204 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
|
| 205 |
+
video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan)
|
| 206 |
+
try:
|
| 207 |
+
with upstream():
|
| 208 |
+
made = send()
|
| 209 |
+
except HTTPException:
|
| 210 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
|
| 211 |
+
raise
|
| 212 |
+
return _record_custom(ctx, made, source)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@router.post("/custom", status_code=201)
|
| 216 |
+
def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 217 |
+
"""Keep one designed preview as a permanent custom voice."""
|
| 218 |
+
payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form",
|
| 219 |
+
"name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url}
|
| 220 |
+
return _make_custom(ctx, body.source,
|
| 221 |
+
lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60))
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@router.post("/clone", status_code=201)
|
| 225 |
+
async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True),
|
| 226 |
+
file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 227 |
+
"""Clone a voice from one audio or video file (up to 50 MB)."""
|
| 228 |
+
data = await file.read(CLONE_MAX_BYTES + 1)
|
| 229 |
+
if not data:
|
| 230 |
+
raise HTTPException(400, "The file is empty.")
|
| 231 |
+
if len(data) > CLONE_MAX_BYTES:
|
| 232 |
+
raise HTTPException(400, "The file must be under 50 MB.")
|
| 233 |
+
form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"}
|
| 234 |
+
files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")}
|
| 235 |
+
return _make_custom(ctx, "clone",
|
| 236 |
+
lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180))
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@router.get("/custom/{custom_id}/preview")
|
| 240 |
+
def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 241 |
+
row = b2v_access.owned_custom_voice(ctx, custom_id)
|
| 242 |
+
if row.preview_url:
|
| 243 |
+
return {"preview_url": row.preview_url, "ready": True}
|
| 244 |
+
with upstream():
|
| 245 |
+
result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {}
|
| 246 |
+
if result.get("preview_url"):
|
| 247 |
+
row.preview_url = result["preview_url"]
|
| 248 |
+
saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
|
| 249 |
+
if saved:
|
| 250 |
+
saved.preview_url = row.preview_url
|
| 251 |
+
ctx.db.commit()
|
| 252 |
+
return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.delete("/custom/{custom_id}")
|
| 256 |
+
def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 257 |
+
row = b2v_access.owned_custom_voice(ctx, custom_id)
|
| 258 |
+
try:
|
| 259 |
+
with upstream():
|
| 260 |
+
b2v.request("DELETE", f"/api/voices/custom/{custom_id}")
|
| 261 |
+
except HTTPException as e:
|
| 262 |
+
if e.status_code != 404:
|
| 263 |
+
raise
|
| 264 |
+
saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
|
| 265 |
+
if saved:
|
| 266 |
+
ctx.db.delete(saved)
|
| 267 |
+
ctx.db.delete(row)
|
| 268 |
+
ctx.db.commit()
|
| 269 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
|
| 270 |
+
return {"ok": True}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# Voice sample ★: a few seconds of the chosen voice with the chosen tuning
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class SampleIn(BaseModel):
|
| 277 |
+
voice_gender: Literal["female", "male"] = "female"
|
| 278 |
+
voice_accent: str = Field("american", max_length=30)
|
| 279 |
+
custom_voice_id: str | None = Field(None, max_length=100)
|
| 280 |
+
voice_emotion: str | None = Field(None, max_length=200)
|
| 281 |
+
video_style: str | None = Field(None, max_length=40)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@router.post("/sample")
|
| 285 |
+
def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 286 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples")
|
| 287 |
+
payload = body.model_dump(exclude_none=True)
|
| 288 |
+
with upstream(): # checking the voice may need blog2video's catalog
|
| 289 |
+
b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan)
|
| 290 |
+
video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan)
|
| 291 |
+
try:
|
| 292 |
+
with upstream():
|
| 293 |
+
audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True)
|
| 294 |
+
except HTTPException:
|
| 295 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily")
|
| 296 |
+
raise
|
| 297 |
+
return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"})
|
backend/app/routers/videos.py
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Videos, made by blog2video: the 3-step wizard (Project, Template, Voice), script review, status and delete.
|
| 2 |
+
Editing after creation is in video_edit.py; voices, templates and styles have their own routers.
|
| 3 |
+
|
| 4 |
+
The browser only talks to these routes; the backend calls blog2video with our account's API key, which reaches
|
| 5 |
+
every video we ever made. So every route that takes a video first checks it belongs to the current workspace
|
| 6 |
+
(b2v_access.owned_video: 404 otherwise), and every create has its template, style and voice checked (check_refs).
|
| 7 |
+
|
| 8 |
+
A video is an Artifact(type="video") with content_json.provider == "blog2video", so it shows up in the Library and
|
| 9 |
+
Studio like everything else, plus a b2v_videos row: the ownership and allowance ledger. Routes take our artifact id.
|
| 10 |
+
|
| 11 |
+
Videos made through the old partner integration have no b2v_videos row. blog2video answers 404 for them now, so
|
| 12 |
+
they are "legacy": we keep showing the preview/video links we stored and refuse edits.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import re
|
| 18 |
+
import uuid
|
| 19 |
+
from collections.abc import Callable
|
| 20 |
+
from contextlib import contextmanager
|
| 21 |
+
from typing import Literal
|
| 22 |
+
|
| 23 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 24 |
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
| 25 |
+
from sqlalchemy import select
|
| 26 |
+
|
| 27 |
+
from app.auth import Ctx, get_ctx
|
| 28 |
+
from app.config import settings
|
| 29 |
+
from app.llm import run
|
| 30 |
+
from app.llm.provider import focus_lm
|
| 31 |
+
from app.llm.signatures import VideoFocusAngles
|
| 32 |
+
from app.models import Artifact, B2VVideo, Chat, Document, Message, Notebook, Upload
|
| 33 |
+
from app.routers.sources import is_locked
|
| 34 |
+
from app.services import b2v_access, video_focus, video_limits, video_quota
|
| 35 |
+
from app.services import blog2video as b2v
|
| 36 |
+
from app.services.artifacts import serialize_artifact
|
| 37 |
+
from app.services.plans import Plan, plan_limit_error
|
| 38 |
+
from app.services.storage import storage
|
| 39 |
+
from app.services.video_quota import sync_video_quota, video_usage
|
| 40 |
+
|
| 41 |
+
log = logging.getLogger(__name__)
|
| 42 |
+
|
| 43 |
+
router = APIRouter(prefix="/api/videos", tags=["videos"])
|
| 44 |
+
|
| 45 |
+
MIN_CONTENT = 50
|
| 46 |
+
MAX_CONTENT = 100_000
|
| 47 |
+
MAX_LINKS = 10
|
| 48 |
+
MAX_POSTS = 200 # a notebook's posts, combined into one video (the ingest SCAN_CAP)
|
| 49 |
+
MAX_CHATS = 5 # notebook chats, combined into one video (the wizard's cap, as for posts)
|
| 50 |
+
MAX_FILES = 5
|
| 51 |
+
# Sent ahead of chat transcripts: blog2video only takes content, so this tells its script writer what it is reading.
|
| 52 |
+
CHAT_CONTEXT = ("The text below is one or more conversations between a user and an AI assistant. Make the video about "
|
| 53 |
+
"the subject of these conversations: the questions the user asked and the answers and conclusions "
|
| 54 |
+
"reached. Present it "
|
| 55 |
+
"as a clear explainer for a general audience, in your own narration. Do not read it out as a dialogue, "
|
| 56 |
+
"and do not mention the chat, the user or the AI assistant.")
|
| 57 |
+
FILE_MAX_BYTES = 5 * 1024 * 1024
|
| 58 |
+
FILE_TYPES = {".pdf", ".docx", ".pptx", ".md", ".txt", ".vtt"}
|
| 59 |
+
LOGO_MAX_BYTES = 2 * 1024 * 1024
|
| 60 |
+
LOGO_TYPES = {"image/png", "image/jpeg", "image/webp", "image/svg+xml"}
|
| 61 |
+
LANGUAGES = [("en", "English"), ("es", "Spanish"), ("fr", "French"), ("de", "German"), ("it", "Italian"),
|
| 62 |
+
("pt", "Portuguese"), ("nl", "Dutch"), ("pl", "Polish"), ("hi", "Hindi"), ("ar", "Arabic"),
|
| 63 |
+
("ja", "Japanese"), ("ko", "Korean"), ("zh", "Chinese"), ("tr", "Turkish"), ("ru", "Russian"),
|
| 64 |
+
("id", "Indonesian")]
|
| 65 |
+
|
| 66 |
+
# blog2video status -> our artifact status
|
| 67 |
+
STATUS_MAP = {"failed": "failed", "error": "failed", "awaiting_stock_footage_review": "review",
|
| 68 |
+
"awaiting_script_review": "review", "rendering": "rendering"}
|
| 69 |
+
# blog2video's statuses while an edit job runs on a finished video, kept on the artifact as they are so the list and
|
| 70 |
+
# the editor can tell them from first generation: regenerating is a template switch (its relayout).
|
| 71 |
+
JOB_STATUSES = {"regenerating", "voice_regenerating", "language_regenerating", "script_regenerating"}
|
| 72 |
+
# Statuses the video list re-checks with blog2video, in case the work ended while nobody was watching.
|
| 73 |
+
BUSY_STATUSES = JOB_STATUSES | {"generating", "rendering"}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def b2v_ready(ctx: Ctx = Depends(get_ctx)) -> None:
|
| 77 |
+
if not b2v.configured():
|
| 78 |
+
raise HTTPException(503, {"code": "video_not_configured", "message": "Video is not set up yet."})
|
| 79 |
+
sync_video_quota(ctx.db, ctx.workspace)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@contextmanager
|
| 83 |
+
def upstream():
|
| 84 |
+
"""blog2video errors -> our HTTP errors. Problems with our own blog2video account (out of videos, key or plan)
|
| 85 |
+
are not the user's: they get "temporarily unavailable" and ops get an ALERT log line."""
|
| 86 |
+
try:
|
| 87 |
+
yield
|
| 88 |
+
except b2v.Denied:
|
| 89 |
+
raise HTTPException(404, "Not found") from None
|
| 90 |
+
except b2v.NotFound as e:
|
| 91 |
+
raise HTTPException(404, str(e)) from None # e.g. "Scene not found": the editor reloads
|
| 92 |
+
except (b2v.Busy, b2v.TemplateInUse) as e:
|
| 93 |
+
raise HTTPException(e.status, str(e)) from None
|
| 94 |
+
except b2v.BadRequest as e:
|
| 95 |
+
raise HTTPException(400, str(e)) from None
|
| 96 |
+
except b2v.B2VError as e:
|
| 97 |
+
raise HTTPException(e.status if e.status == 503 else 502, str(e)) from None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
ROW_FIELDS = {"video_url", "preview_url", "title"}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def store(ctx: Ctx, a: Artifact, row: B2VVideo | None = None, **fields) -> None:
|
| 104 |
+
"""Merge fields into content_json (and status), and the reference fields into the video's row, committing only
|
| 105 |
+
when something changed."""
|
| 106 |
+
if row is not None:
|
| 107 |
+
for key in ROW_FIELDS & fields.keys():
|
| 108 |
+
if fields[key] is not None and getattr(row, key) != fields[key]:
|
| 109 |
+
setattr(row, key, fields[key])
|
| 110 |
+
ctx.db.commit()
|
| 111 |
+
content = dict(a.content_json or {})
|
| 112 |
+
status = fields.pop("status", None)
|
| 113 |
+
changed = {k: v for k, v in fields.items() if v is not None and content.get(k) != v}
|
| 114 |
+
if changed or (status and status != a.status):
|
| 115 |
+
a.content_json = {**content, **changed}
|
| 116 |
+
if status:
|
| 117 |
+
a.status = status
|
| 118 |
+
ctx.db.commit()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def sync_status(ctx: Ctx, a: Artifact, row: B2VVideo, s: dict) -> None:
|
| 122 |
+
raw = str(s.get("status") or "")
|
| 123 |
+
video_quota.record_status(ctx.db, row, "failed" if s.get("error") and raw not in STATUS_MAP else raw)
|
| 124 |
+
if s.get("error") or raw in STATUS_MAP:
|
| 125 |
+
status = STATUS_MAP.get(raw, "failed")
|
| 126 |
+
elif raw in JOB_STATUSES:
|
| 127 |
+
status = raw
|
| 128 |
+
elif s.get("ready") or raw in {"generated", "done"}:
|
| 129 |
+
status = "ready"
|
| 130 |
+
else:
|
| 131 |
+
status = "generating"
|
| 132 |
+
store(ctx, a, row, status=status, b2v_status=raw or None, step=s.get("step"), video_url=s.get("video_url"),
|
| 133 |
+
error=s.get("error"))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# The wizard's options (every field of blog2video's 3 steps)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class VideoOptions(BaseModel):
|
| 140 |
+
# Step 1: Project (script review is always off: blog2video goes straight to the video)
|
| 141 |
+
stock_footage_enabled: bool = True
|
| 142 |
+
aspect_ratio: Literal["landscape", "portrait"] = "landscape"
|
| 143 |
+
video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short"
|
| 144 |
+
logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
|
| 145 |
+
logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
|
| 146 |
+
logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
|
| 147 |
+
# Step 2: Template
|
| 148 |
+
template: str = Field("default", max_length=100)
|
| 149 |
+
video_style: str = Field("auto", max_length=40)
|
| 150 |
+
accent_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
| 151 |
+
bg_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
| 152 |
+
text_color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
| 153 |
+
# Step 3: Voice
|
| 154 |
+
content_language: str | None = Field(None, max_length=10) # None = detect from the content
|
| 155 |
+
voice_gender: Literal["female", "male", "none"] = "female"
|
| 156 |
+
voice_accent: str = Field("american", max_length=30)
|
| 157 |
+
custom_voice_id: str | None = Field(None, max_length=100)
|
| 158 |
+
voice_emotion: str | None = Field(None, max_length=200) # ["<expressiveness>","<speed>","<emotion>",...]
|
| 159 |
+
bgm_track_id: str | None = Field(None, max_length=100)
|
| 160 |
+
bgm_volume: float = Field(0.10, ge=0, le=1)
|
| 161 |
+
|
| 162 |
+
def payload(self) -> dict:
|
| 163 |
+
"""The fields blog2video takes, as it names them."""
|
| 164 |
+
# Only the wizard's own fields: a subclass (VideoCreateIn) adds the source, which is sent separately.
|
| 165 |
+
data = self.model_dump(include=set(VideoOptions.model_fields) - {"logo_upload_id"}, exclude_none=True,
|
| 166 |
+
mode="json")
|
| 167 |
+
data["script_review_enabled"] = False
|
| 168 |
+
if self.voice_gender == "none":
|
| 169 |
+
data.pop("custom_voice_id", None)
|
| 170 |
+
data.pop("voice_emotion", None)
|
| 171 |
+
if not self.bgm_track_id:
|
| 172 |
+
data.pop("bgm_volume", None)
|
| 173 |
+
return data
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class FocusIn(BaseModel):
|
| 177 |
+
# A source from the workspace: exactly one of these. document_ids are a notebook's ticked posts, combined into one
|
| 178 |
+
# video; chat_ids are notebook chats, sent as their transcripts (combined too).
|
| 179 |
+
document_id: uuid.UUID | None = None
|
| 180 |
+
document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
|
| 181 |
+
chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_CHATS)
|
| 182 |
+
notebook_id: uuid.UUID | None = None # names a combined video after its notebook
|
| 183 |
+
|
| 184 |
+
@model_validator(mode="after")
|
| 185 |
+
def one_source(self):
|
| 186 |
+
if sum(x is not None for x in (self.document_id, self.document_ids, self.chat_ids)) != 1:
|
| 187 |
+
raise ValueError("Pick one source: a notebook, a post or chats")
|
| 188 |
+
return self
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class VideoCreateIn(VideoOptions):
|
| 192 |
+
# Source: exactly one of these (the first three as in FocusIn).
|
| 193 |
+
document_id: uuid.UUID | None = None
|
| 194 |
+
document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
|
| 195 |
+
chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_CHATS)
|
| 196 |
+
url: str | None = Field(None, max_length=2000)
|
| 197 |
+
content: str | None = Field(None, max_length=MAX_CONTENT)
|
| 198 |
+
title: str | None = Field(None, max_length=300)
|
| 199 |
+
notebook_id: uuid.UUID | None = None # names a combined video after its notebook
|
| 200 |
+
# One of the suggested focus topics (/focus-topics): the video is mainly about it. None = the material as a whole.
|
| 201 |
+
focus: str | None = Field(None, max_length=200)
|
| 202 |
+
focus_detail: str | None = Field(None, max_length=400) # the topic's description: what the video should cover
|
| 203 |
+
# Or the user's own topic, in their words: followed as far as the material supports it (instead of `focus`).
|
| 204 |
+
focus_prompt: str | None = Field(None, max_length=500)
|
| 205 |
+
|
| 206 |
+
@model_validator(mode="after")
|
| 207 |
+
def one_source(self):
|
| 208 |
+
if sum(x is not None for x in (self.document_id, self.document_ids, self.url, self.content, self.chat_ids)) != 1:
|
| 209 |
+
raise ValueError("Pick one source: a notebook, a post, chats, a link or pasted text")
|
| 210 |
+
if self.document_ids and len(self.document_ids) == 1:
|
| 211 |
+
self.document_id, self.document_ids = self.document_ids[0], None
|
| 212 |
+
return self
|
| 213 |
+
|
| 214 |
+
@model_validator(mode="after")
|
| 215 |
+
def one_focus(self):
|
| 216 |
+
self.focus_prompt = re.sub(r"\s+", " ", self.focus_prompt or "").strip() or None
|
| 217 |
+
if self.focus_prompt and (self.focus or "").strip():
|
| 218 |
+
raise ValueError("Pick a suggested topic or write your own, not both")
|
| 219 |
+
return self
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class VideoBatchIn(VideoOptions):
|
| 223 |
+
urls: list[str] = Field(min_length=1, max_length=MAX_LINKS)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _not_indexed(count: int) -> HTTPException:
|
| 227 |
+
return HTTPException(400, f"{count} of these posts {'is' if count == 1 else 'are'} not indexed on your plan. "
|
| 228 |
+
"Untick them or upgrade to index your whole archive.")
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _combined(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int) -> tuple[str, str]:
|
| 232 |
+
"""Several posts as one text, oldest first, each given an equal share so none is cut off entirely."""
|
| 233 |
+
ids = set(body.document_ids or [])
|
| 234 |
+
docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
|
| 235 |
+
Document.workspace_id == ctx.workspace.id)))
|
| 236 |
+
if len(docs) != len(ids):
|
| 237 |
+
raise HTTPException(404, "Post not found")
|
| 238 |
+
locked = sum(is_locked(d) for d in docs)
|
| 239 |
+
if locked:
|
| 240 |
+
raise _not_indexed(locked)
|
| 241 |
+
docs.sort(key=lambda d: (d.published_at is None, d.published_at.timestamp() if d.published_at else 0))
|
| 242 |
+
share = limit // len(docs)
|
| 243 |
+
text = "\n\n".join(f"# {d.title}\n\n{(d.clean_text or '').strip()}"[:share] for d in docs)
|
| 244 |
+
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == body.notebook_id,
|
| 245 |
+
Notebook.workspace_id == ctx.workspace.id)) if body.notebook_id else None
|
| 246 |
+
return (nb.title if nb else f"{docs[0].title} + {len(docs) - 1} more"), text
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _cite_free(text: str) -> str:
|
| 250 |
+
"""Citation markers ([1], [2]) point at the notebook's posts, which blog2video never sees."""
|
| 251 |
+
return re.sub(r"\s*\[\d+\]", "", text).strip()
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _load_chats(ctx: Ctx, ids: list[uuid.UUID]) -> list[tuple[Chat, list[Message]]]:
|
| 255 |
+
"""This workspace's chats, oldest first, each with its messages in order. 404 if any is not ours."""
|
| 256 |
+
chats = list(ctx.db.scalars(select(Chat).where(Chat.id.in_(set(ids)), Chat.workspace_id == ctx.workspace.id)
|
| 257 |
+
.order_by(Chat.created_at)))
|
| 258 |
+
if len(chats) != len(set(ids)):
|
| 259 |
+
raise HTTPException(404, "Chat not found")
|
| 260 |
+
msgs = ctx.db.scalars(select(Message).where(Message.chat_id.in_([c.id for c in chats]))
|
| 261 |
+
.order_by(Message.created_at)).all()
|
| 262 |
+
return [(c, [m for m in msgs if m.chat_id == c.id]) for c in chats]
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _chat_title(chat: Chat, msgs: list[Message]) -> str:
|
| 266 |
+
return chat.title or next((m.content for m in msgs if m.role == "user"), "")[:80] or "Chat"
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _chats(ctx: Ctx, ids: list[uuid.UUID], limit: int) -> tuple[str, str]:
|
| 270 |
+
"""Chats' turns as one text, each chat given an equal share. Over its share, a chat's oldest turns go so its
|
| 271 |
+
conclusion stays. Several chats are headed by their titles."""
|
| 272 |
+
loaded = _load_chats(ctx, ids)
|
| 273 |
+
share = limit // len(loaded)
|
| 274 |
+
parts = []
|
| 275 |
+
for chat, msgs in loaded:
|
| 276 |
+
if not any(m.role == "assistant" for m in msgs):
|
| 277 |
+
raise HTTPException(400, f'"{_chat_title(chat, msgs)}" has no answers yet')
|
| 278 |
+
turns = [f"{'User' if m.role == 'user' else 'Assistant'}: {t}" for m in msgs if (t := _cite_free(m.content))]
|
| 279 |
+
head = f"# {_chat_title(chat, msgs)}\n\n" if len(loaded) > 1 else ""
|
| 280 |
+
size = len(head) + sum(len(t) + 2 for t in turns)
|
| 281 |
+
while len(turns) > 1 and size > share:
|
| 282 |
+
size -= len(turns.pop(0)) + 2
|
| 283 |
+
parts.append(head + "\n\n".join(turns))
|
| 284 |
+
first = _chat_title(*loaded[0])
|
| 285 |
+
return (first if len(loaded) == 1 else f"{first} + {len(loaded) - 1} more"), "\n\n".join(parts)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _doc(ctx: Ctx, document_id: uuid.UUID) -> Document:
|
| 289 |
+
doc = ctx.db.scalar(select(Document).where(Document.id == document_id, Document.workspace_id == ctx.workspace.id))
|
| 290 |
+
if not doc:
|
| 291 |
+
raise HTTPException(404, "Post not found")
|
| 292 |
+
if is_locked(doc):
|
| 293 |
+
raise _not_indexed(1)
|
| 294 |
+
return doc
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _source_text(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int = MAX_CONTENT) -> tuple[str, str]:
|
| 298 |
+
"""A workspace source (posts, a chat, one post) or pasted text as a title and one text."""
|
| 299 |
+
if body.document_ids:
|
| 300 |
+
return _combined(ctx, body, limit)
|
| 301 |
+
if body.chat_ids:
|
| 302 |
+
return _chats(ctx, body.chat_ids, limit)
|
| 303 |
+
if body.document_id:
|
| 304 |
+
doc = _doc(ctx, body.document_id)
|
| 305 |
+
return doc.title, (doc.clean_text or "")[:limit]
|
| 306 |
+
return getattr(body, "title", None) or "", getattr(body, "content", None) or ""
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _instructions(body: VideoCreateIn) -> str:
|
| 310 |
+
"""What blog2video's script writer is told ahead of the material: what a chat is, and the focus picked or written
|
| 311 |
+
by the user (worded for the house style's depth). Empty when neither applies."""
|
| 312 |
+
parts = [CHAT_CONTEXT] if body.chat_ids else []
|
| 313 |
+
if prompt := (body.focus_prompt or "").replace('"', "'"):
|
| 314 |
+
depth = {"short": " Make it a brief overview.", "medium": " Make it a deep dive."}
|
| 315 |
+
parts.append(f'The user wants this video to focus on: "{prompt}". Follow it only as far as the material below '
|
| 316 |
+
"supports it: use what the material says about it and leave the rest out. If the material does "
|
| 317 |
+
"not cover it, ignore this request and make a general video about the material."
|
| 318 |
+
+ depth.get(b2v_access.house_length(body.video_style), ""))
|
| 319 |
+
elif focus := (body.focus or "").strip():
|
| 320 |
+
detail = re.sub(r"\s+", " ", body.focus_detail or "").strip().rstrip(".")
|
| 321 |
+
cover = f" What to cover: {detail}." if detail else ""
|
| 322 |
+
length = b2v_access.house_length(body.video_style)
|
| 323 |
+
if length == "short":
|
| 324 |
+
parts.append(f'The main focus of this video is one topic only: "{focus}".{cover} Make it an overview of '
|
| 325 |
+
"that topic: cover just the parts of the material about it, briefly, and leave the rest out.")
|
| 326 |
+
elif length == "medium":
|
| 327 |
+
parts.append(f'The main focus of this video is one topic only: "{focus}".{cover} Make it a deep dive into '
|
| 328 |
+
"that topic: explore it in depth using the parts of the material about it, and leave the rest "
|
| 329 |
+
"out.")
|
| 330 |
+
else:
|
| 331 |
+
parts.append(f'The main focus of this video is: "{focus}".{cover} Leave out parts of the material not '
|
| 332 |
+
"about it.")
|
| 333 |
+
return " ".join(parts)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
|
| 337 |
+
"""The blog2video source fields and a title for our artifact."""
|
| 338 |
+
if body.url:
|
| 339 |
+
if not re.match(r"https?://", body.url):
|
| 340 |
+
raise HTTPException(400, "Enter a link that starts with http:// or https://")
|
| 341 |
+
return {"url": body.url}, body.title or body.url
|
| 342 |
+
if body.document_id and not (body.focus or body.focus_prompt):
|
| 343 |
+
doc = _doc(ctx, body.document_id)
|
| 344 |
+
if re.match(r"https?://", doc.url or ""):
|
| 345 |
+
return {"url": doc.url, "title": doc.title[:255]}, doc.title
|
| 346 |
+
# Uploaded files have no public link (upload://name): their text is sent instead (below). So is any post
|
| 347 |
+
# given a focus, which goes in front of the text.
|
| 348 |
+
with upstream(): # the focus wording needs the house styles
|
| 349 |
+
head = _instructions(body)
|
| 350 |
+
title, text = _source_text(ctx, body, MAX_CONTENT - len(head) - 10)
|
| 351 |
+
if body.content is not None and not title.strip():
|
| 352 |
+
raise HTTPException(400, "Give the pasted text a title")
|
| 353 |
+
text = text.strip()
|
| 354 |
+
if len(text) < MIN_CONTENT:
|
| 355 |
+
raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
|
| 356 |
+
content = f"{head}\n\n---\n\n{text}" if head else text
|
| 357 |
+
return {"content": content[:MAX_CONTENT], "title": title[:255]}, title
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
|
| 361 |
+
"""Sent with every create, and returned by blog2video: ties the video back to us from its side too."""
|
| 362 |
+
return {"workspace_id": str(ctx.workspace.id), "user_id": str(ctx.user.id), "video_ref": str(row.id), **extra}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
|
| 366 |
+
payload = options.payload()
|
| 367 |
+
with upstream(): # checking a template or voice may need blog2video's catalog
|
| 368 |
+
# A house style decides the length, whatever the client sent (before the checks, which gate some lengths).
|
| 369 |
+
if length := b2v_access.house_length(payload.get("video_style")):
|
| 370 |
+
payload["video_length"] = length
|
| 371 |
+
plan = b2v_access.check_refs(ctx, payload)
|
| 372 |
+
accent = (ctx.workspace.brand_json or {}).get("accent")
|
| 373 |
+
if plan.brand_kit and accent and "accent_color" not in payload:
|
| 374 |
+
payload["accent_color"] = accent
|
| 375 |
+
return payload, plan
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _logo(ctx: Ctx, upload_id: uuid.UUID | None) -> tuple[str, str, bytes] | None:
|
| 379 |
+
if not upload_id:
|
| 380 |
+
return None
|
| 381 |
+
upload = ctx.db.scalar(select(Upload).where(Upload.id == upload_id, Upload.workspace_id == ctx.workspace.id))
|
| 382 |
+
if not upload or upload.status != "complete" or upload.content_type not in LOGO_TYPES:
|
| 383 |
+
raise HTTPException(400, "The logo must be a PNG, JPG, WebP or SVG.")
|
| 384 |
+
data = storage.get_bytes(upload.key)
|
| 385 |
+
if len(data) > LOGO_MAX_BYTES:
|
| 386 |
+
raise HTTPException(400, "The logo must be under 2 MB.")
|
| 387 |
+
return upload.filename, upload.content_type, data
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _start(ctx: Ctx, plan: Plan, title: str, aspect_ratio: str, template: str, created_via: str,
|
| 391 |
+
call: Callable[[B2VVideo], int], logo: tuple[str, str, bytes] | None = None,
|
| 392 |
+
document_id: uuid.UUID | None = None, source_url: str | None = None) -> Artifact:
|
| 393 |
+
"""Reserve one video, record it, ask blog2video for it (call returns its id), refund if that fails."""
|
| 394 |
+
if not video_quota.reserve(ctx.db, ctx.workspace.id):
|
| 395 |
+
usage = video_usage(ctx.db, ctx.workspace)
|
| 396 |
+
raise plan_limit_error(plan, "videos",
|
| 397 |
+
f"You have made {usage['used']} of {usage['limit']} videos this period.")
|
| 398 |
+
try:
|
| 399 |
+
artifact = Artifact(workspace_id=ctx.workspace.id, type="video", status="generating", document_id=document_id,
|
| 400 |
+
content_json={"provider": "blog2video", "title": title[:300],
|
| 401 |
+
"aspect_ratio": aspect_ratio})
|
| 402 |
+
ctx.db.add(artifact)
|
| 403 |
+
ctx.db.flush()
|
| 404 |
+
row = B2VVideo(workspace_id=ctx.workspace.id, user_id=ctx.user.id, artifact_id=artifact.id,
|
| 405 |
+
created_via=created_via, template_ref=template, title=title[:300], source_url=source_url,
|
| 406 |
+
aspect_ratio=aspect_ratio,
|
| 407 |
+
# Keys are unique across our whole blog2video account, so they start with the workspace id.
|
| 408 |
+
idempotency_key=f"{ctx.workspace.id}:{uuid.uuid4()}" if created_via == "v1" else None)
|
| 409 |
+
ctx.db.add(row)
|
| 410 |
+
ctx.db.commit()
|
| 411 |
+
except Exception:
|
| 412 |
+
ctx.db.rollback()
|
| 413 |
+
video_quota.unreserve(ctx.db, ctx.workspace.id)
|
| 414 |
+
raise
|
| 415 |
+
try:
|
| 416 |
+
with upstream():
|
| 417 |
+
vid = call(row)
|
| 418 |
+
except HTTPException:
|
| 419 |
+
# Refused, or no answer after every retry: the user gets the video back.
|
| 420 |
+
video_quota.refund(ctx.db, row, "failed")
|
| 421 |
+
ctx.db.delete(artifact)
|
| 422 |
+
ctx.db.commit()
|
| 423 |
+
raise
|
| 424 |
+
row.b2v_video_id, row.status = vid, "queued"
|
| 425 |
+
ctx.db.commit()
|
| 426 |
+
store(ctx, artifact, row, b2v_video_id=vid, b2v_status="queued")
|
| 427 |
+
if logo:
|
| 428 |
+
try:
|
| 429 |
+
b2v.upload_logo(vid, *logo)
|
| 430 |
+
except b2v.B2VError:
|
| 431 |
+
log.warning("logo upload failed for video artifact %s", artifact.id) # the video is made without it
|
| 432 |
+
return artifact
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@router.post("", status_code=201)
|
| 436 |
+
def create_video(body: VideoCreateIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 437 |
+
"""From a link, pasted text or one of the workspace's posts: POST /api/v1/videos, which starts generating."""
|
| 438 |
+
source, title = _source(ctx, body)
|
| 439 |
+
payload, plan = _check(ctx, body)
|
| 440 |
+
logo = _logo(ctx, body.logo_upload_id)
|
| 441 |
+
|
| 442 |
+
def call(row: B2VVideo) -> int:
|
| 443 |
+
# Retried with the same key on no answer: blog2video returns the same video, charged once.
|
| 444 |
+
created = b2v.create_video({**source, **payload, "external_user_id": str(ctx.workspace.id),
|
| 445 |
+
"metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id),
|
| 446 |
+
document_id=str(body.document_id) if body.document_id
|
| 447 |
+
else None,
|
| 448 |
+
document_ids=[str(d) for d in body.document_ids or []]
|
| 449 |
+
or None,
|
| 450 |
+
chat_ids=[str(c) for c in body.chat_ids or []] or None)},
|
| 451 |
+
row.idempotency_key)
|
| 452 |
+
return int(created["video_id"])
|
| 453 |
+
|
| 454 |
+
a = _start(ctx, plan, title, body.aspect_ratio, body.template, "v1", call, logo, body.document_id,
|
| 455 |
+
source_url=source.get("url"))
|
| 456 |
+
return serialize_artifact(a)
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def _excerpt(text: str | None, limit: int) -> str:
|
| 460 |
+
"""The start of a text, cut on a word boundary."""
|
| 461 |
+
text = re.sub(r"\s+", " ", text or "").strip()
|
| 462 |
+
if len(text) <= limit:
|
| 463 |
+
return text
|
| 464 |
+
return text[:limit].rsplit(" ", 1)[0] + "..."
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def _focus_material(ctx: Ctx, body: FocusIn) -> tuple[dict[str, str], list[Document]]:
|
| 468 |
+
"""What the focus suggestions read, per post or chat (by id): each post's title and the start of its text, or a
|
| 469 |
+
chat's title and first exchange. A little text, so a vague title or an untitled chat still says what it is about.
|
| 470 |
+
Also the posts themselves (none for chats). Checked like a create (another workspace's source 404s, locked posts
|
| 471 |
+
400)."""
|
| 472 |
+
if body.chat_ids:
|
| 473 |
+
loaded = _load_chats(ctx, body.chat_ids)
|
| 474 |
+
answer_len = 600 if len(loaded) == 1 else 300
|
| 475 |
+
material = {}
|
| 476 |
+
for chat, msgs in loaded:
|
| 477 |
+
question = next((m.content for m in msgs if m.role == "user"), "")
|
| 478 |
+
answer = next((m.content for m in msgs if m.role == "assistant"), "")
|
| 479 |
+
parts = [chat.title or "", _excerpt(question, 300), _excerpt(_cite_free(answer), answer_len)]
|
| 480 |
+
if item := "\n".join(p for p in parts if p):
|
| 481 |
+
material[str(chat.id)] = item
|
| 482 |
+
return material, []
|
| 483 |
+
ids = set(body.document_ids or [body.document_id])
|
| 484 |
+
docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
|
| 485 |
+
Document.workspace_id == ctx.workspace.id)))
|
| 486 |
+
if len(docs) != len(ids):
|
| 487 |
+
raise HTTPException(404, "Post not found")
|
| 488 |
+
if locked := sum(is_locked(d) for d in docs):
|
| 489 |
+
raise _not_indexed(locked)
|
| 490 |
+
docs.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
|
| 491 |
+
each = 800 if len(docs) == 1 else 400
|
| 492 |
+
items = {str(d.id): "\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs}
|
| 493 |
+
return {k: v for k, v in items.items() if v}, docs
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
@router.post("/focus-topics")
|
| 497 |
+
def focus_topics(body: FocusIn, ctx: Ctx = Depends(get_ctx)):
|
| 498 |
+
"""Up to three topics a video from this source could focus on: the topics the workspace already has for it,
|
| 499 |
+
shown as they are (untagged posts are tagged on demand; a chat's are the cited posts' topics). The AI writes them
|
| 500 |
+
only when there are none. Asked for and shown on the wizard's step 1, once the source is chosen."""
|
| 501 |
+
material, docs = _focus_material(ctx, body)
|
| 502 |
+
if not material:
|
| 503 |
+
return {"topics": []}
|
| 504 |
+
ws = ctx.workspace.id
|
| 505 |
+
if body.chat_ids:
|
| 506 |
+
cited, docs = video_focus.chat_citations(ctx.db, ws, body.chat_ids)
|
| 507 |
+
sources = {chat: cited.get(chat, {}) for chat in material}
|
| 508 |
+
else:
|
| 509 |
+
sources = {str(d.id): {d.id: 1.0} for d in docs}
|
| 510 |
+
video_focus.ensure_tags(ctx.db, ws, docs)
|
| 511 |
+
if cards := video_focus.pick_cards(video_focus.build_pool(ctx.db, ws, sources, docs)):
|
| 512 |
+
return {"topics": [{"title": c.name, "description": c.summary} for c in cards]}
|
| 513 |
+
items = list(material.values())[:FOCUS_ITEMS]
|
| 514 |
+
topics: list[dict] = []
|
| 515 |
+
# One retry when the answer has fewer than 3 usable topics (a placeholder for every title, repeats).
|
| 516 |
+
for retry in (False, True):
|
| 517 |
+
out = run.predict(VideoFocusAngles, db=ctx.db, workspace_id=ws, lm=focus_lm(retry), items=items,
|
| 518 |
+
candidate_tags=[], avoid_titles=[])
|
| 519 |
+
_add_topics(topics, out.get("topics"))
|
| 520 |
+
if len(topics) >= 3:
|
| 521 |
+
break
|
| 522 |
+
return {"topics": topics[:3]}
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
FOCUS_ITEMS = 5 # posts or chats the AI reads when there are no topics to show
|
| 526 |
+
FOCUS_PLACEHOLDERS = {"title", "topic", "description", "summary", "name", "angle", "focus"}
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def _add_topics(topics: list[dict], raw) -> None:
|
| 530 |
+
"""Add the model's usable topics to `topics` (as {title, description}): a real title, not seen before."""
|
| 531 |
+
for t in raw or []:
|
| 532 |
+
if not isinstance(t, dict):
|
| 533 |
+
continue
|
| 534 |
+
title = str(t.get("topic") or t.get("title") or "").strip().strip(".").strip()[:120]
|
| 535 |
+
description = re.sub(r"\s+", " ", str(t.get("summary") or t.get("description") or "")).strip()[:300]
|
| 536 |
+
if not title or title.lower() in FOCUS_PLACEHOLDERS:
|
| 537 |
+
continue
|
| 538 |
+
if title.lower() not in {x["title"].lower() for x in topics}:
|
| 539 |
+
topics.append({"title": title, "description": description})
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
@router.post("/batch", status_code=201)
|
| 543 |
+
def create_videos(body: VideoBatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 544 |
+
"""Multi-link: one video per link, each charged and keyed on its own. Background music is single-video only."""
|
| 545 |
+
urls = [u.strip() for u in body.urls if re.match(r"https?://\S+", u.strip())]
|
| 546 |
+
if not urls:
|
| 547 |
+
raise HTTPException(400, "Enter at least one link that starts with http:// or https://")
|
| 548 |
+
options = body.model_copy(update={"bgm_track_id": None})
|
| 549 |
+
payload, plan = _check(ctx, options)
|
| 550 |
+
logo = _logo(ctx, body.logo_upload_id)
|
| 551 |
+
made: list[dict] = []
|
| 552 |
+
for url in urls:
|
| 553 |
+
def call(row: B2VVideo, url=url) -> int:
|
| 554 |
+
created = b2v.create_video({"url": url, **payload, "external_user_id": str(ctx.workspace.id),
|
| 555 |
+
"metadata": _metadata(ctx, row, artifact_id=str(row.artifact_id))},
|
| 556 |
+
row.idempotency_key)
|
| 557 |
+
return int(created["video_id"])
|
| 558 |
+
|
| 559 |
+
try:
|
| 560 |
+
made.append(serialize_artifact(_start(ctx, plan, url, body.aspect_ratio, body.template, "v1", call,
|
| 561 |
+
logo, source_url=url)))
|
| 562 |
+
except HTTPException as e:
|
| 563 |
+
if not made:
|
| 564 |
+
raise
|
| 565 |
+
# Keep what was made; say where it stopped (usually the allowance ran out).
|
| 566 |
+
detail = e.detail if isinstance(e.detail, str) else (e.detail or {}).get("message")
|
| 567 |
+
made.append({"error": detail, "url": url})
|
| 568 |
+
break
|
| 569 |
+
return made
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
@router.post("/upload", status_code=201)
|
| 573 |
+
async def create_from_files(files: list[UploadFile] = File(...), options: str = Form("{}"),
|
| 574 |
+
ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 575 |
+
"""From up to 5 documents (pdf, docx, pptx, md, txt, vtt; 5 MB each): blog2video parses them itself.
|
| 576 |
+
`options` is the wizard's VideoOptions as JSON."""
|
| 577 |
+
try:
|
| 578 |
+
opts = VideoOptions.model_validate(json.loads(options or "{}"))
|
| 579 |
+
except (ValueError, ValidationError) as e:
|
| 580 |
+
raise HTTPException(422, str(e)[:500]) from None
|
| 581 |
+
if not files or len(files) > MAX_FILES:
|
| 582 |
+
raise HTTPException(400, f"Add 1 to {MAX_FILES} files.")
|
| 583 |
+
parts: list[tuple[str, bytes, str]] = []
|
| 584 |
+
for f in files:
|
| 585 |
+
name = f.filename or "document"
|
| 586 |
+
if not any(name.lower().endswith(ext) for ext in FILE_TYPES):
|
| 587 |
+
raise HTTPException(400, f"{name}: use PDF, DOCX, PPTX, MD, TXT or VTT.")
|
| 588 |
+
data = await f.read(FILE_MAX_BYTES + 1)
|
| 589 |
+
if len(data) > FILE_MAX_BYTES:
|
| 590 |
+
raise HTTPException(400, f"{name} is over 5 MB.")
|
| 591 |
+
parts.append((name, data, f.content_type or "application/octet-stream"))
|
| 592 |
+
payload, plan = _check(ctx, opts)
|
| 593 |
+
logo = _logo(ctx, opts.logo_upload_id)
|
| 594 |
+
title = parts[0][0].rsplit(".", 1)[0] if len(parts) == 1 else f"{len(parts)} documents"
|
| 595 |
+
form = {k: (str(v).lower() if isinstance(v, bool) else str(v)) for k, v in payload.items()}
|
| 596 |
+
|
| 597 |
+
def call(row: B2VVideo) -> int:
|
| 598 |
+
project = b2v.upload_project({**form, "name": title[:255]}, parts)
|
| 599 |
+
pid = int(project["id"])
|
| 600 |
+
try:
|
| 601 |
+
b2v.generate(pid)
|
| 602 |
+
except b2v.B2VError:
|
| 603 |
+
try:
|
| 604 |
+
b2v.delete_project(pid)
|
| 605 |
+
except b2v.B2VError:
|
| 606 |
+
log.warning("could not remove blog2video project %s after generate failed", pid)
|
| 607 |
+
raise
|
| 608 |
+
return pid
|
| 609 |
+
|
| 610 |
+
a = _start(ctx, plan, title, opts.aspect_ratio, opts.template, "upload", call, logo)
|
| 611 |
+
return serialize_artifact(a)
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
# Read
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
@router.get("/config")
|
| 618 |
+
def video_config(ctx: Ctx = Depends(get_ctx)):
|
| 619 |
+
return {"configured": b2v.configured(), **video_limits.report(ctx.db, ctx.workspace)}
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
@router.get("/quota")
|
| 623 |
+
def quota(ctx: Ctx = Depends(get_ctx)):
|
| 624 |
+
return video_usage(ctx.db, ctx.workspace)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _with_posters(t: dict) -> dict:
|
| 628 |
+
"""Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
|
| 629 |
+
have none; the picker then shows the template's colors); designer templates carry their own image."""
|
| 630 |
+
if t.get("preview_image_url") and not t.get("preview_url"):
|
| 631 |
+
return {**t, "preview_url": t["preview_image_url"]}
|
| 632 |
+
if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"):
|
| 633 |
+
return t
|
| 634 |
+
base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
|
| 635 |
+
return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
@router.get("/catalog")
|
| 639 |
+
def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 640 |
+
"""What the wizard offers this workspace: built-in and designer templates, its own ready custom templates,
|
| 641 |
+
built-in and its own styles, music, languages. Nothing another workspace made."""
|
| 642 |
+
with upstream():
|
| 643 |
+
builtin = b2v.builtin_templates()
|
| 644 |
+
try:
|
| 645 |
+
crafted = b2v.crafted_templates()
|
| 646 |
+
except b2v.B2VError:
|
| 647 |
+
crafted = []
|
| 648 |
+
mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
|
| 649 |
+
for t in b2v_access.my_templates(ctx, ready_only=True)]
|
| 650 |
+
styles = b2v_access.visible_styles(ctx)
|
| 651 |
+
music = b2v.music_tracks()
|
| 652 |
+
return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
|
| 653 |
+
"crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)],
|
| 654 |
+
"my_templates": mine, "video_styles": styles,
|
| 655 |
+
"music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def _restore_missing_artifacts(ctx: Ctx) -> None:
|
| 659 |
+
"""A video whose Library item is gone but which was never deleted (b2v_videos is the record) gets its item back,
|
| 660 |
+
rebuilt from the row. E.g. items removed by the Library's delete before it also removed the blog2video video."""
|
| 661 |
+
orphans = ctx.db.scalars(select(B2VVideo).where(
|
| 662 |
+
B2VVideo.workspace_id == ctx.workspace.id, B2VVideo.artifact_id.is_(None),
|
| 663 |
+
B2VVideo.b2v_video_id.is_not(None), B2VVideo.status != "deleted",
|
| 664 |
+
B2VVideo.quota_state != "refunded")).all()
|
| 665 |
+
for row in orphans:
|
| 666 |
+
status = ("ready" if row.status in video_quota.FINISHED else
|
| 667 |
+
"failed" if row.status in video_quota.FAILED else
|
| 668 |
+
row.status if row.status in JOB_STATUSES else STATUS_MAP.get(row.status, "generating"))
|
| 669 |
+
a = Artifact(workspace_id=ctx.workspace.id, type="video", status=status,
|
| 670 |
+
content_json={k: v for k, v in {
|
| 671 |
+
"provider": "blog2video", "b2v_video_id": row.b2v_video_id, "b2v_status": row.status,
|
| 672 |
+
"title": row.title or "Video", "aspect_ratio": row.aspect_ratio,
|
| 673 |
+
"preview_url": row.preview_url, "video_url": row.video_url}.items() if v is not None})
|
| 674 |
+
ctx.db.add(a)
|
| 675 |
+
ctx.db.flush()
|
| 676 |
+
row.artifact_id = a.id
|
| 677 |
+
if orphans:
|
| 678 |
+
ctx.db.commit()
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def _needs_title(row: B2VVideo) -> bool:
|
| 682 |
+
return not row.title or row.title == row.source_url or bool(re.match(r"https?://", row.title))
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
def _set_title(ctx: Ctx, row: B2VVideo, a: Artifact | None, name: str | None) -> None:
|
| 686 |
+
"""blog2video names a video after reading its source; until then we only had the link."""
|
| 687 |
+
name = (name or "").strip()[:300]
|
| 688 |
+
if not name or re.match(r"https?://", name) or row.title == name:
|
| 689 |
+
return
|
| 690 |
+
row.title = name
|
| 691 |
+
if a is not None:
|
| 692 |
+
a.content_json = {**(a.content_json or {}), "title": name}
|
| 693 |
+
ctx.db.commit()
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
def _refresh_titles(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]]) -> None:
|
| 697 |
+
"""Real titles for videos still titled by their link, from blog2video's video list (one call)."""
|
| 698 |
+
todo = {row.b2v_video_id: (a, row) for a, row in pairs
|
| 699 |
+
if row.created_via == "v1" and row.b2v_video_id and _needs_title(row)}
|
| 700 |
+
if not todo or not b2v.configured():
|
| 701 |
+
return
|
| 702 |
+
try:
|
| 703 |
+
listed = b2v.request("GET", "/api/v1/videos", params={"external_user_id": str(ctx.workspace.id), "limit": 100})
|
| 704 |
+
except b2v.B2VError as e:
|
| 705 |
+
log.warning("could not refresh video titles: %s", e)
|
| 706 |
+
return
|
| 707 |
+
for item in (listed or {}).get("items") or []:
|
| 708 |
+
if item.get("video_id") in todo:
|
| 709 |
+
a, row = todo[item["video_id"]]
|
| 710 |
+
_set_title(ctx, row, a, item.get("name"))
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
@router.get("")
|
| 714 |
+
def list_videos(ctx: Ctx = Depends(get_ctx), limit: int = 30, offset: int = 0):
|
| 715 |
+
"""The workspace's videos, newest first, with what the list rows show: title, status, source, scenes, date."""
|
| 716 |
+
_restore_missing_artifacts(ctx)
|
| 717 |
+
arts = [a for a in ctx.db.scalars(
|
| 718 |
+
select(Artifact).where(Artifact.workspace_id == ctx.workspace.id, Artifact.type == "video")
|
| 719 |
+
.order_by(Artifact.created_at.desc()).limit(min(limit, 100)).offset(offset)
|
| 720 |
+
).all() if (a.content_json or {}).get("provider") == "blog2video"]
|
| 721 |
+
rows = {r.artifact_id: r for r in ctx.db.scalars(
|
| 722 |
+
select(B2VVideo).where(B2VVideo.artifact_id.in_([a.id for a in arts])))} if arts else {}
|
| 723 |
+
_refresh_titles(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
|
| 724 |
+
_refresh_busy(ctx, [(a, rows[a.id]) for a in arts if a.id in rows])
|
| 725 |
+
out = []
|
| 726 |
+
for a in arts:
|
| 727 |
+
row, content = rows.get(a.id), a.content_json or {}
|
| 728 |
+
out.append({**serialize_artifact(a), "source_url": row.source_url if row else None,
|
| 729 |
+
"scenes": content.get("scenes"), "b2v_status": (row.status if row else content.get("b2v_status"))})
|
| 730 |
+
return out
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def _refresh_busy(ctx: Ctx, pairs: list[tuple[Artifact, B2VVideo]], limit: int = 10) -> None:
|
| 734 |
+
"""Ask blog2video about videos still generating, rendering or in an edit job, so one that finished (or failed)
|
| 735 |
+
while nobody had it open shows its real state. A few per page; a blog2video hiccup leaves the stored status."""
|
| 736 |
+
busy = [(a, r) for a, r in pairs if a.status in BUSY_STATUSES and r.b2v_video_id is not None][:limit]
|
| 737 |
+
for a, row in busy:
|
| 738 |
+
try:
|
| 739 |
+
sync_status(ctx, a, row, b2v.status(row.b2v_video_id, row.created_via))
|
| 740 |
+
except b2v.B2VError as e:
|
| 741 |
+
log.info("status refresh for video %s skipped: %s", row.b2v_video_id, e)
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def _legacy(a: Artifact) -> dict:
|
| 745 |
+
content = a.content_json or {}
|
| 746 |
+
return {"legacy": True, "status": content.get("b2v_status"), "ready": bool(content.get("video_url")),
|
| 747 |
+
"video_url": content.get("video_url"), "preview_url": content.get("preview_url"),
|
| 748 |
+
"artifact_status": a.status, "artifact": serialize_artifact(a)}
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
@router.get("/{artifact_id}/status")
|
| 752 |
+
def video_status(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 753 |
+
a, vid, row = b2v_access.owned_video(ctx, artifact_id)
|
| 754 |
+
if row is None:
|
| 755 |
+
return _legacy(a)
|
| 756 |
+
try:
|
| 757 |
+
with upstream():
|
| 758 |
+
s = b2v.status(vid, row.created_via)
|
| 759 |
+
except HTTPException as e:
|
| 760 |
+
if e.status_code != 404:
|
| 761 |
+
raise
|
| 762 |
+
s = {"status": "failed", "error": "The video service lost this video."}
|
| 763 |
+
sync_status(ctx, a, row, s)
|
| 764 |
+
return {**s, "video_id": vid, "artifact_status": a.status}
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def preview_url(ctx: Ctx, a: Artifact, vid: int, row: B2VVideo | None = None) -> str | None:
|
| 768 |
+
"""The public live-preview link (no key needed, safe for the browser). Minted once and kept."""
|
| 769 |
+
url = (row.preview_url if row else None) or (a.content_json or {}).get("preview_url")
|
| 770 |
+
if url:
|
| 771 |
+
return url
|
| 772 |
+
try:
|
| 773 |
+
url = (b2v.preview_token(vid) or {}).get("preview_url")
|
| 774 |
+
except b2v.B2VError as e:
|
| 775 |
+
log.warning("preview token for video %s failed: %s", vid, e)
|
| 776 |
+
return None
|
| 777 |
+
store(ctx, a, row, preview_url=url)
|
| 778 |
+
return url
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
def _media_url(project_id: int, asset: dict | None, subdir: str, filename: str) -> str:
|
| 782 |
+
if asset and asset.get("r2_url"):
|
| 783 |
+
return asset["r2_url"]
|
| 784 |
+
return f"{settings.b2v_api_base_url.rstrip('/')}/media/projects/{project_id}/{subdir}/{filename}"
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
def _enrich(project: dict) -> dict:
|
| 788 |
+
"""What the scene rows show, worked out once here: each scene's audio and image links (blog2video only gives
|
| 789 |
+
file names), its layout and font sizes, and totals for the preview caption."""
|
| 790 |
+
pid = project.get("id")
|
| 791 |
+
assets = [a for a in project.get("assets") or [] if not a.get("excluded")]
|
| 792 |
+
latest: dict[tuple[str, str], dict] = {}
|
| 793 |
+
for a in sorted(assets, key=lambda a: a.get("id") or 0): # a regenerated file is a newer asset, same name
|
| 794 |
+
latest[(a.get("asset_type"), a.get("filename"))] = a
|
| 795 |
+
total, images = 0.0, 0
|
| 796 |
+
scenes = []
|
| 797 |
+
for sc in sorted(project.get("scenes") or [], key=lambda s: s.get("order") or 0):
|
| 798 |
+
try:
|
| 799 |
+
code = json.loads(sc.get("remotion_code") or "{}")
|
| 800 |
+
except ValueError:
|
| 801 |
+
code = {}
|
| 802 |
+
custom = str(project.get("template") or "").startswith("custom_")
|
| 803 |
+
props = (code.get("layoutConfig") if custom else code.get("layoutProps")) or {}
|
| 804 |
+
audio = None
|
| 805 |
+
if sc.get("voiceover_path"):
|
| 806 |
+
name = re.split(r"[/\\]", sc["voiceover_path"])[-1]
|
| 807 |
+
asset = latest.get(("audio", name))
|
| 808 |
+
audio = _media_url(pid, asset, "audio", name) + (f"?v={asset['id']}" if asset else "")
|
| 809 |
+
pics = []
|
| 810 |
+
# Stills sit in assignedImage(s); a stock clip in assignedVideo(s). Both fill the scene's visual slot.
|
| 811 |
+
for key in ("assignedImage", "assignedImages", "image", "images", "assignedVideo", "assignedVideos"):
|
| 812 |
+
vals = props.get(key)
|
| 813 |
+
clip = key.startswith("assignedVideo")
|
| 814 |
+
for name in ([vals] if isinstance(vals, str) else vals if isinstance(vals, list) else []):
|
| 815 |
+
if isinstance(name, str) and name and not name.startswith("http"):
|
| 816 |
+
first, second = ("video", "image") if clip else ("image", "video")
|
| 817 |
+
asset = latest.get((first, name)) or latest.get((second, name))
|
| 818 |
+
kind = asset.get("asset_type") if asset else first
|
| 819 |
+
pics.append({"filename": name, "asset_id": asset.get("id") if asset else None, "kind": kind,
|
| 820 |
+
"url": _media_url(pid, asset, "videos" if kind == "video" else "images", name)})
|
| 821 |
+
images += len(pics)
|
| 822 |
+
total += float(sc.get("duration_seconds") or 0) + float(sc.get("extra_hold_seconds") or 0)
|
| 823 |
+
scenes.append({**sc, "audio_url": audio, "images": pics,
|
| 824 |
+
"layout": code.get("layout") or sc.get("preferred_layout"),
|
| 825 |
+
"title_font_size": props.get("titleFontSize"),
|
| 826 |
+
"desc_font_size": props.get("descriptionFontSize")})
|
| 827 |
+
return {**project, "scenes": scenes,
|
| 828 |
+
"summary": {"scenes": len(scenes), "duration_seconds": round(total),
|
| 829 |
+
"images": sum(1 for a in assets if a.get("asset_type") == "image") or images,
|
| 830 |
+
"clips": sum(1 for a in assets if a.get("asset_type") == "video")}}
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
@router.get("/{artifact_id}")
|
| 834 |
+
def get_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 835 |
+
a, vid, row = b2v_access.owned_video(ctx, artifact_id)
|
| 836 |
+
if row is None:
|
| 837 |
+
return _legacy(a)
|
| 838 |
+
with upstream():
|
| 839 |
+
project = b2v.get_project(vid)
|
| 840 |
+
if row is not None:
|
| 841 |
+
_set_title(ctx, row, a, project.get("name"))
|
| 842 |
+
store(ctx, a, row, video_url=project.get("r2_video_url"), scenes=len(project.get("scenes") or []) or None)
|
| 843 |
+
return {"video_id": vid, "status": project.get("status"), "project": _enrich(project),
|
| 844 |
+
"preview_url": preview_url(ctx, a, vid, row), "video_url": project.get("r2_video_url"),
|
| 845 |
+
"artifact": serialize_artifact(a)}
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
@router.delete("/{artifact_id}")
|
| 849 |
+
def delete_video(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 850 |
+
"""Removes it from Notestack and from blog2video."""
|
| 851 |
+
a, _, _ = b2v_access.owned_video(ctx, artifact_id)
|
| 852 |
+
remove_video(ctx, a)
|
| 853 |
+
return {"ok": True}
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def remove_video(ctx: Ctx, a: Artifact) -> None:
|
| 857 |
+
"""Delete the video on blog2video, then the artifact. The b2v_videos row stays as the record that it was made
|
| 858 |
+
(status "deleted"): a lifetime allowance (Free) must not get a video back because one was deleted."""
|
| 859 |
+
row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id, B2VVideo.workspace_id == ctx.workspace.id))
|
| 860 |
+
if row is not None and row.b2v_video_id is not None and b2v.configured():
|
| 861 |
+
try:
|
| 862 |
+
b2v.delete_project(row.b2v_video_id)
|
| 863 |
+
except b2v.NotFound:
|
| 864 |
+
pass
|
| 865 |
+
except b2v.B2VError as e:
|
| 866 |
+
raise HTTPException(502, str(e)) from None
|
| 867 |
+
if row is not None:
|
| 868 |
+
row.status, row.artifact_id = "deleted", None
|
| 869 |
+
ctx.db.delete(a)
|
| 870 |
+
ctx.db.commit()
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
# Script review (status awaiting_script_review): read, rewrite with AI, approve
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
class DraftScene(BaseModel):
|
| 877 |
+
id: int
|
| 878 |
+
title: str = Field(max_length=255)
|
| 879 |
+
display_text: str | None = Field(None, max_length=3000)
|
| 880 |
+
narration_text: str = Field(max_length=6000)
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
class ReviewPreviewIn(BaseModel):
|
| 884 |
+
title: str = Field(min_length=1, max_length=255)
|
| 885 |
+
display_text: str = Field(min_length=1, max_length=3000)
|
| 886 |
+
narration_text: str = Field("", max_length=6000)
|
| 887 |
+
draft_scenes: list[DraftScene] = Field(max_length=200)
|
| 888 |
+
revision: int = Field(0, ge=0)
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
class ReviewAiIn(ReviewPreviewIn):
|
| 892 |
+
instruction: str = Field(min_length=2, max_length=1000)
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
class ReviewApproveScene(DraftScene):
|
| 896 |
+
preferred_layout: str | None = Field(None, max_length=100)
|
| 897 |
+
source_fingerprint: str | None = Field(None, max_length=200)
|
| 898 |
+
accepted_ai_instructions: list[str] = Field(default_factory=list, max_length=10)
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
class ReviewApproveIn(BaseModel):
|
| 902 |
+
scenes: list[ReviewApproveScene] = Field(max_length=200)
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
@router.get("/{artifact_id}/script")
|
| 906 |
+
def get_script(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 907 |
+
_, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 908 |
+
with upstream():
|
| 909 |
+
project = b2v.get_project(vid)
|
| 910 |
+
scenes = sorted(project.get("scenes") or [], key=lambda s: s.get("order", 0))
|
| 911 |
+
return {"status": project.get("status"), "scenes": [
|
| 912 |
+
{k: s.get(k) for k in ("id", "order", "title", "display_text", "narration_text", "preferred_layout")}
|
| 913 |
+
for s in scenes]}
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
@router.post("/{artifact_id}/script/scenes/{scene_id}/narration-preview")
|
| 917 |
+
def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreviewIn, ctx: Ctx = Depends(get_ctx),
|
| 918 |
+
_: None = Depends(b2v_ready)):
|
| 919 |
+
"""Rewrite one scene's narration to match its edited title and text."""
|
| 920 |
+
_, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 921 |
+
with upstream():
|
| 922 |
+
return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/narration-preview",
|
| 923 |
+
json=body.model_dump(), timeout=60)
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
@router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview")
|
| 927 |
+
def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx),
|
| 928 |
+
_: None = Depends(b2v_ready)):
|
| 929 |
+
"""★ Rewrite one scene from an instruction. Uses one AI edit."""
|
| 930 |
+
_, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 931 |
+
plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites")
|
| 932 |
+
video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
|
| 933 |
+
try:
|
| 934 |
+
with upstream():
|
| 935 |
+
return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview",
|
| 936 |
+
json=body.model_dump(), timeout=60)
|
| 937 |
+
except HTTPException:
|
| 938 |
+
video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits")
|
| 939 |
+
raise
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
@router.post("/{artifact_id}/script/approve")
|
| 943 |
+
def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
|
| 944 |
+
_: None = Depends(b2v_ready)):
|
| 945 |
+
a, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
|
| 946 |
+
with upstream():
|
| 947 |
+
result = b2v.project_call("POST", vid, "script-review/approve", json=body.model_dump(), timeout=60)
|
| 948 |
+
store(ctx, a, status="generating")
|
| 949 |
+
return result
|
backend/app/routers/voice.py
CHANGED
|
@@ -12,6 +12,7 @@ from app.config import settings
|
|
| 12 |
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
|
| 13 |
from app.services import tts
|
| 14 |
from app.services.jobs import create_job, record_usage, serialize_job
|
|
|
|
| 15 |
from app.services.plans import effective_plan, plan_limit_error
|
| 16 |
from app.services.storage import storage
|
| 17 |
|
|
@@ -206,10 +207,15 @@ async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 206 |
voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
|
| 207 |
except tts.TTSError as exc:
|
| 208 |
raise HTTPException(502, str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if body.use_as in ("host_a", "host_b"):
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 214 |
|
| 215 |
|
|
@@ -369,5 +375,7 @@ async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
|
|
| 369 |
vp = _profile(ctx)
|
| 370 |
if vp and vp.host_voices:
|
| 371 |
vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
|
|
|
|
|
|
|
| 372 |
ctx.db.commit()
|
| 373 |
return _serialize(ctx)
|
|
|
|
| 12 |
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
|
| 13 |
from app.services import tts
|
| 14 |
from app.services.jobs import create_job, record_usage, serialize_job
|
| 15 |
+
from app.services.notestack_voices import forget_video_voice
|
| 16 |
from app.services.plans import effective_plan, plan_limit_error
|
| 17 |
from app.services.storage import storage
|
| 18 |
|
|
|
|
| 207 |
voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
|
| 208 |
except tts.TTSError as exc:
|
| 209 |
raise HTTPException(502, str(exc)) from exc
|
| 210 |
+
vp = _ensure_profile(ctx)
|
| 211 |
+
voices = dict(vp.host_voices or {})
|
| 212 |
+
# Listed, so it can be added to the workspace's video voices too.
|
| 213 |
+
added = [v for v in voices.get("added") or [] if v.get("voice_id") != voice_id]
|
| 214 |
+
voices["added"] = [*added, {"voice_id": voice_id, "name": body.name[:100]}]
|
| 215 |
if body.use_as in ("host_a", "host_b"):
|
| 216 |
+
voices[body.use_as] = voice_id
|
| 217 |
+
vp.host_voices = voices
|
| 218 |
+
ctx.db.commit()
|
| 219 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 220 |
|
| 221 |
|
|
|
|
| 375 |
vp = _profile(ctx)
|
| 376 |
if vp and vp.host_voices:
|
| 377 |
vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
|
| 378 |
+
if consent.elevenlabs_voice_id:
|
| 379 |
+
forget_video_voice(ctx.db, ctx.workspace.id, consent.elevenlabs_voice_id)
|
| 380 |
ctx.db.commit()
|
| 381 |
return _serialize(ctx)
|
backend/app/services/artifacts.py
CHANGED
|
@@ -63,8 +63,11 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
|
|
| 63 |
"document_id": str(a.document_id) if a.document_id else None,
|
| 64 |
"content": content,
|
| 65 |
"storage_key": a.storage_key,
|
| 66 |
-
|
| 67 |
-
"
|
|
|
|
|
|
|
|
|
|
| 68 |
"slide_urls": [storage.presign_get(k) for k in slides],
|
| 69 |
"created_at": a.created_at.isoformat() if a.created_at else None,
|
| 70 |
"job": serialize_job(job) if job else None,
|
|
|
|
| 63 |
"document_id": str(a.document_id) if a.document_id else None,
|
| 64 |
"content": content,
|
| 65 |
"storage_key": a.storage_key,
|
| 66 |
+
# blog2video videos live on blog2video's storage (video_url once rendered)
|
| 67 |
+
"url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
|
| 68 |
+
"download_url": (storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key
|
| 69 |
+
else content.get("video_url")),
|
| 70 |
+
"provider": content.get("provider"),
|
| 71 |
"slide_urls": [storage.presign_get(k) for k in slides],
|
| 72 |
"created_at": a.created_at.isoformat() if a.created_at else None,
|
| 73 |
"job": serialize_job(job) if job else None,
|
backend/app/services/auth_identity.py
CHANGED
|
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class IdentityError(Exception):
|
|
@@ -65,7 +66,9 @@ def _bootstrap_workspace(db: Session, user: User) -> None:
|
|
| 65 |
db.add(workspace)
|
| 66 |
db.flush()
|
| 67 |
db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def resolve_or_create_google_user(
|
|
|
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
|
| 12 |
+
from app.services.plans import PLANS
|
| 13 |
|
| 14 |
|
| 15 |
class IdentityError(Exception):
|
|
|
|
| 66 |
db.add(workspace)
|
| 67 |
db.flush()
|
| 68 |
db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
|
| 69 |
+
free = PLANS["free"]
|
| 70 |
+
db.add(Subscription(workspace_id=workspace.id, plan="free", video_plan=free.id, video_limit=free.videos,
|
| 71 |
+
videos_period_start=datetime.now(UTC)))
|
| 72 |
|
| 73 |
|
| 74 |
def resolve_or_create_google_user(
|
backend/app/services/b2v_access.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Who owns what on our one blog2video account.
|
| 2 |
+
|
| 3 |
+
blog2video's ownership checks stop at the account: any video, custom template, custom voice or style made with our
|
| 4 |
+
key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
|
| 5 |
+
belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
|
| 6 |
+
edit body has its references checked before it is forwarded (check_refs).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
|
| 11 |
+
from fastapi import HTTPException
|
| 12 |
+
from sqlalchemy import select
|
| 13 |
+
|
| 14 |
+
from app.auth import Ctx
|
| 15 |
+
from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
|
| 16 |
+
from app.services import blog2video as b2v
|
| 17 |
+
from app.services.notestack_voices import is_notestack_voice
|
| 18 |
+
from app.services.plans import Plan, effective_plan
|
| 19 |
+
from app.services.video_limits import require_premium
|
| 20 |
+
|
| 21 |
+
BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
|
| 22 |
+
# Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
|
| 23 |
+
# exist (matched by name, the first is the default). Each sets the video length; the wizard has no length picker and
|
| 24 |
+
# only says roughly how long the video comes out.
|
| 25 |
+
HOUSE_STYLES = [ # (name on blog2video, video length, what the wizard says it is: about three lines)
|
| 26 |
+
("Overview", "short", "A clear, neutral rundown of the key points. It gets straight to the main idea, one point per "
|
| 27 |
+
"scene in plain words. Best for quick social posts and teasers. Usually under a minute long."),
|
| 28 |
+
("Deep dive", "medium", "Sets up the context first, then works through the reasoning, examples and trade-offs, each "
|
| 29 |
+
"scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
|
| 30 |
+
"2 minutes long."),
|
| 31 |
+
]
|
| 32 |
+
PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def not_found(what: str = "Not found") -> HTTPException:
|
| 36 |
+
return HTTPException(404, what)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Videos
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def owned_video(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo | None]:
|
| 43 |
+
"""The artifact, blog2video's id, and our ledger row (None for videos made before the API key).
|
| 44 |
+
|
| 45 |
+
The row is the reference: its b2v_video_id (and created_via) is what every blog2video call uses. The artifact's
|
| 46 |
+
content_json only keeps a copy for the Library, and is read only for legacy videos, which have no row."""
|
| 47 |
+
a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id,
|
| 48 |
+
Artifact.type == "video"))
|
| 49 |
+
content = (a.content_json or {}) if a else {}
|
| 50 |
+
if not a or content.get("provider") != "blog2video":
|
| 51 |
+
raise not_found("Video not found")
|
| 52 |
+
row = ctx.db.scalar(select(B2VVideo).where(B2VVideo.artifact_id == a.id,
|
| 53 |
+
B2VVideo.workspace_id == ctx.workspace.id))
|
| 54 |
+
if row is not None and row.b2v_video_id is not None:
|
| 55 |
+
return a, row.b2v_video_id, row
|
| 56 |
+
if row is None and content.get("b2v_video_id"):
|
| 57 |
+
return a, int(content["b2v_video_id"]), None # legacy
|
| 58 |
+
raise not_found("Video not found") # the create never got an id from blog2video
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def video_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> tuple[Artifact, int, B2VVideo]:
|
| 62 |
+
"""A video this workspace owns and blog2video can still reach with our key."""
|
| 63 |
+
a, vid, row = owned_video(ctx, artifact_id)
|
| 64 |
+
if row is None:
|
| 65 |
+
raise HTTPException(410, {"code": "video_legacy",
|
| 66 |
+
"message": "This video was made before the video service changed and can not be "
|
| 67 |
+
"edited any more. Its links still work."})
|
| 68 |
+
return a, vid, row
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# Templates, voices, styles
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate:
|
| 75 |
+
row = ctx.db.scalar(select(B2VTemplate).where(B2VTemplate.b2v_template_id == template_id,
|
| 76 |
+
B2VTemplate.workspace_id == ctx.workspace.id))
|
| 77 |
+
if not row:
|
| 78 |
+
raise not_found("Template not found")
|
| 79 |
+
return row
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice:
|
| 83 |
+
row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id,
|
| 84 |
+
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
| 85 |
+
if not row:
|
| 86 |
+
raise not_found("Voice not found")
|
| 87 |
+
return row
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
|
| 91 |
+
row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
|
| 92 |
+
B2VStyle.workspace_id == ctx.workspace.id))
|
| 93 |
+
if not row:
|
| 94 |
+
raise not_found("Style not found")
|
| 95 |
+
return row
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _custom_id(ref: str, prefix: str) -> int | None:
|
| 99 |
+
raw = ref.removeprefix(prefix)
|
| 100 |
+
return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def crafted_ids() -> set[str]:
|
| 104 |
+
out = set()
|
| 105 |
+
for t in b2v.crafted_templates():
|
| 106 |
+
tid = t.get("id") if isinstance(t, dict) else t
|
| 107 |
+
if isinstance(tid, str) and tid.startswith("crafted_"):
|
| 108 |
+
out.add(tid)
|
| 109 |
+
return out
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
|
| 113 |
+
if template == "default":
|
| 114 |
+
return
|
| 115 |
+
if (cid := _custom_id(template, "custom_")) is not None:
|
| 116 |
+
row = owned_template(ctx, cid)
|
| 117 |
+
if not row.ready:
|
| 118 |
+
raise HTTPException(400, "That template is still being made.")
|
| 119 |
+
return
|
| 120 |
+
if template.startswith("crafted_"):
|
| 121 |
+
# blog2video silently falls back to "default" for an unknown id, so check it exists.
|
| 122 |
+
if template not in crafted_ids():
|
| 123 |
+
raise not_found("Template not found")
|
| 124 |
+
require_premium(ctx.db, ctx.workspace, "Designer templates", plan)
|
| 125 |
+
return
|
| 126 |
+
if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
|
| 127 |
+
raise not_found("Template not found")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def house_styles() -> list[dict]:
|
| 131 |
+
"""HOUSE_STYLES found among the account's custom styles, in HOUSE_STYLES order, with the length each sets."""
|
| 132 |
+
by_name = {str(s.get("name") or "").strip().lower(): s for s in (b2v.video_styles() or {}).get("styles") or []
|
| 133 |
+
if s.get("kind") == "custom" and s.get("custom_id") is not None}
|
| 134 |
+
out = []
|
| 135 |
+
for name, length, blurb in HOUSE_STYLES:
|
| 136 |
+
if s := by_name.get(name.lower()):
|
| 137 |
+
out.append({"id": f"custom:{s['custom_id']}", "custom_id": s["custom_id"], "name": s.get("name"),
|
| 138 |
+
"description": blurb, "kind": "house", "length": length})
|
| 139 |
+
return out
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def house_length(style: str | None) -> str | None:
|
| 143 |
+
"""The video length a house style sets; None for any other style."""
|
| 144 |
+
if not style or not style.startswith("custom:"):
|
| 145 |
+
return None
|
| 146 |
+
return next((s["length"] for s in house_styles() if s["id"] == style), None)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def check_style(ctx: Ctx, style: str) -> None:
|
| 150 |
+
if style in BUILTIN_STYLES:
|
| 151 |
+
return
|
| 152 |
+
if (sid := _custom_id(style, "custom:")) is not None:
|
| 153 |
+
if any(s["custom_id"] == sid for s in house_styles()):
|
| 154 |
+
return
|
| 155 |
+
owned_style(ctx, sid)
|
| 156 |
+
return
|
| 157 |
+
raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
|
| 161 |
+
"""A built-in voice (premium if blog2video marks it paid), or one of this workspace's custom or Notestack
|
| 162 |
+
voices."""
|
| 163 |
+
prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
|
| 164 |
+
if voice_id in prebuilt:
|
| 165 |
+
if prebuilt[voice_id].get("plan") == "paid":
|
| 166 |
+
require_premium(ctx.db, ctx.workspace, "This voice", plan)
|
| 167 |
+
return
|
| 168 |
+
if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id,
|
| 169 |
+
B2VCustomVoice.workspace_id == ctx.workspace.id)):
|
| 170 |
+
require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
|
| 171 |
+
return
|
| 172 |
+
# One of this workspace's Notestack voices (clone, designed, added): same ElevenLabs account, premium like custom.
|
| 173 |
+
if is_notestack_voice(ctx.db, ctx.workspace.id, voice_id):
|
| 174 |
+
require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
|
| 175 |
+
return
|
| 176 |
+
raise not_found("Voice not found")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
|
| 180 |
+
"""Every id in a create or edit body must be this workspace's, and premium options need a paid plan."""
|
| 181 |
+
plan = plan or effective_plan(ctx.db, ctx.workspace)
|
| 182 |
+
if body.get("template"):
|
| 183 |
+
check_template(ctx, str(body["template"]), plan)
|
| 184 |
+
if body.get("video_style"):
|
| 185 |
+
check_style(ctx, str(body["video_style"]))
|
| 186 |
+
if body.get("custom_voice_id") and body.get("voice_gender") != "none":
|
| 187 |
+
check_voice(ctx, str(body["custom_voice_id"]), plan)
|
| 188 |
+
if str(body.get("video_length") or "") in PREMIUM_LENGTHS:
|
| 189 |
+
require_premium(ctx.db, ctx.workspace, "Longer videos", plan)
|
| 190 |
+
if body.get("voice_emotion"):
|
| 191 |
+
require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan)
|
| 192 |
+
return plan
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# Catalog, filtered to what this workspace may see
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def visible_styles(ctx: Ctx) -> list[dict]:
|
| 199 |
+
"""The house styles when the account has them; otherwise built-in styles (read-only) and this workspace's custom
|
| 200 |
+
styles. Never "Your Style", never others'."""
|
| 201 |
+
if house := house_styles():
|
| 202 |
+
return [{k: v for k, v in s.items() if k != "length"} for s in house]
|
| 203 |
+
mine = set(ctx.db.scalars(select(B2VStyle.b2v_style_id).where(B2VStyle.workspace_id == ctx.workspace.id)))
|
| 204 |
+
out = []
|
| 205 |
+
for s in (b2v.video_styles() or {}).get("styles") or []:
|
| 206 |
+
if s.get("kind") == "builtin" and s.get("id") in BUILTIN_STYLES:
|
| 207 |
+
out.append({"id": s["id"], "name": s.get("name"), "description": s.get("description"), "kind": "builtin"})
|
| 208 |
+
elif s.get("kind") == "custom" and s.get("custom_id") in mine:
|
| 209 |
+
out.append({"id": s["id"], "custom_id": s["custom_id"], "name": s.get("name"),
|
| 210 |
+
"description": s.get("description"), "guidance": s.get("guidance"),
|
| 211 |
+
"version": s.get("version"), "kind": "custom"})
|
| 212 |
+
return out
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def my_templates(ctx: Ctx, ready_only: bool = False) -> list[B2VTemplate]:
|
| 216 |
+
q = select(B2VTemplate).where(B2VTemplate.workspace_id == ctx.workspace.id)
|
| 217 |
+
if ready_only:
|
| 218 |
+
q = q.where(B2VTemplate.ready.is_(True))
|
| 219 |
+
return list(ctx.db.scalars(q.order_by(B2VTemplate.created_at.desc())))
|
backend/app/services/billing.py
CHANGED
|
@@ -13,13 +13,14 @@ from app.config import settings
|
|
| 13 |
from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
|
| 14 |
from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
|
| 15 |
from app.services.usage import month_usage
|
|
|
|
| 16 |
|
| 17 |
CYCLES = {"monthly", "annual"}
|
| 18 |
LOW_FUEL = 0.8 # meters at or above this share of the allowance start nudging
|
| 19 |
|
| 20 |
METERS = {
|
| 21 |
"audio_minutes": ("Audio overviews", "min"),
|
| 22 |
-
"
|
| 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/jobs.py
CHANGED
|
@@ -46,6 +46,12 @@ def serialize_job(job: Job) -> dict:
|
|
| 46 |
# Producer side
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def create_job(
|
| 50 |
db: Session,
|
| 51 |
workspace_id: uuid.UUID,
|
|
|
|
| 46 |
# Producer side
|
| 47 |
|
| 48 |
|
| 49 |
+
def pending_job(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
|
| 50 |
+
"""A job of this kind is already queued or running for the workspace."""
|
| 51 |
+
return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
|
| 52 |
+
Job.status.in_(("queued", "running"))).limit(1)) is not None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def create_job(
|
| 56 |
db: Session,
|
| 57 |
workspace_id: uuid.UUID,
|
backend/app/services/notestack_voices.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
|
| 2 |
+
from the ElevenLabs library. blog2video speaks with the same ElevenLabs account, so these can be used in videos too
|
| 3 |
+
(as `custom_voice_id`); video_voices lists them and b2v_access accepts them."""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import delete, select
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from app.models import UserSavedVoice, VoiceConsent, VoiceProfile
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def notestack_voices(db: Session, workspace_id: uuid.UUID) -> list[dict]:
|
| 14 |
+
"""[{voice_id, name, kind}], kind: clone | designed | library. The clone first, no repeats."""
|
| 15 |
+
out: list[dict] = []
|
| 16 |
+
consent = db.scalar(select(VoiceConsent).where(VoiceConsent.workspace_id == workspace_id,
|
| 17 |
+
VoiceConsent.revoked_at.is_(None))
|
| 18 |
+
.order_by(VoiceConsent.created_at.desc()))
|
| 19 |
+
if consent and consent.elevenlabs_voice_id:
|
| 20 |
+
out.append({"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "kind": "clone"})
|
| 21 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 22 |
+
voices = (vp.host_voices if vp else None) or {}
|
| 23 |
+
for kind, key in (("designed", "custom"), ("library", "added")):
|
| 24 |
+
for v in voices.get(key) or []:
|
| 25 |
+
if v.get("voice_id") and all(o["voice_id"] != v["voice_id"] for o in out):
|
| 26 |
+
out.append({"voice_id": v["voice_id"], "name": v.get("name") or "Voice", "kind": kind})
|
| 27 |
+
return out
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> bool:
|
| 31 |
+
return any(v["voice_id"] == voice_id for v in notestack_voices(db, workspace_id))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def forget_video_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> None:
|
| 35 |
+
"""A Notestack voice that is gone (clone revoked) leaves the workspace's saved video voices too."""
|
| 36 |
+
db.execute(delete(UserSavedVoice).where(UserSavedVoice.workspace_id == workspace_id,
|
| 37 |
+
UserSavedVoice.voice_id == voice_id))
|
backend/app/services/plans.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
| 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_focus.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The video wizard's focus cards: the topics the workspace already has for the source, shown as they are.
|
| 2 |
+
|
| 3 |
+
Each post is tagged by the background `topics` job (generate.extract_topics); a post still without tags is tagged
|
| 4 |
+
here on demand. A source's tags make its pool, each scored by weight and by how much of the source shares it (a
|
| 5 |
+
chat's tags are those of the posts its answers cite). The cards are the best merged topics (the Topic Map's, with a
|
| 6 |
+
summary), spread over the source's posts, then the posts' own tags (a name only) when there are fewer than three.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
from collections import Counter, defaultdict
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from sqlalchemy import func, select
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
|
| 16 |
+
from app.config import settings
|
| 17 |
+
from app.corpus import slugify
|
| 18 |
+
from app.llm import run
|
| 19 |
+
from app.llm.provider import fast_lm
|
| 20 |
+
from app.llm.signatures import ExtractTopics
|
| 21 |
+
from app.models import Citation, Document, DocumentTopic, Message, Topic
|
| 22 |
+
from app.pipeline.generate import save_topic_tags
|
| 23 |
+
from app.routers.sources import is_locked
|
| 24 |
+
from app.services.jobs import create_job, pending_job
|
| 25 |
+
|
| 26 |
+
INLINE_TAG_CAP = 5 # untagged posts tagged while the wizard waits; the rest go to the background job
|
| 27 |
+
TAG_TEXT = 6000 # characters of a post read when tagging it on demand (the background job reads 12000)
|
| 28 |
+
CARDS = 3
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class Candidate:
|
| 33 |
+
name: str
|
| 34 |
+
summary: str
|
| 35 |
+
score: float
|
| 36 |
+
sources: set[str] # the posts (or chats) that carry it
|
| 37 |
+
merged: bool # a topic of the topic table (has a summary), not only a post's own tag
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# Tags
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def ensure_tags(db: Session, workspace_id: uuid.UUID, docs: list[Document]) -> None:
|
| 44 |
+
"""Tag the newest few untagged posts now, in parallel, and leave the rest to the background job, which also
|
| 45 |
+
merges these new tags into the topic table. A failed call leaves that post untagged: never an error here."""
|
| 46 |
+
untagged = [d for d in docs if not (d.metadata_json or {}).get("topics") and not is_locked(d)]
|
| 47 |
+
if not untagged or not settings.llm_api_key:
|
| 48 |
+
return
|
| 49 |
+
untagged.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
|
| 50 |
+
now = untagged[:INLINE_TAG_CAP]
|
| 51 |
+
known = sorted(db.scalars(select(Topic.name).where(Topic.workspace_id == workspace_id)))[:200]
|
| 52 |
+
outs = run.predict_many(ExtractTopics, [{"title": d.title, "text": (d.clean_text or "")[:TAG_TEXT],
|
| 53 |
+
"known_topics": known} for d in now],
|
| 54 |
+
db=db, workspace_id=workspace_id, lm=fast_lm())
|
| 55 |
+
for doc, out in zip(now, outs, strict=True):
|
| 56 |
+
if out is not None:
|
| 57 |
+
save_topic_tags(doc, out)
|
| 58 |
+
db.commit()
|
| 59 |
+
# With no ids the job tags every post still untagged (the rest of these too) and rebuilds the topic table.
|
| 60 |
+
if not pending_job(db, workspace_id, "topics"):
|
| 61 |
+
create_job(db, workspace_id, "topics", {}, max_attempts=2)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def chat_citations(db: Session, workspace_id: uuid.UUID,
|
| 65 |
+
chat_ids: list[uuid.UUID]) -> tuple[dict[str, dict[uuid.UUID, float]], list[Document]]:
|
| 66 |
+
"""For each chat, the posts its answers cite (weighted by how often, the most cited 1.0), and those posts.
|
| 67 |
+
Locked posts are left out: the chat itself is fine to use."""
|
| 68 |
+
rows = db.execute(
|
| 69 |
+
select(Message.chat_id, Citation.document_id, func.count())
|
| 70 |
+
.join(Citation, Citation.message_id == Message.id)
|
| 71 |
+
.where(Message.chat_id.in_(chat_ids))
|
| 72 |
+
.group_by(Message.chat_id, Citation.document_id)
|
| 73 |
+
).all()
|
| 74 |
+
docs = {d.id: d for d in db.scalars(select(Document).where(
|
| 75 |
+
Document.id.in_({doc_id for _, doc_id, _ in rows}), Document.workspace_id == workspace_id)) if not is_locked(d)}
|
| 76 |
+
counts: dict[str, dict[uuid.UUID, int]] = defaultdict(dict)
|
| 77 |
+
for chat_id, doc_id, n in rows:
|
| 78 |
+
if doc_id in docs:
|
| 79 |
+
counts[str(chat_id)][doc_id] = n
|
| 80 |
+
cited = {chat: {d: n / max(per.values()) for d, n in per.items()} for chat, per in counts.items()}
|
| 81 |
+
return cited, list(docs.values())
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_pool(db: Session, workspace_id: uuid.UUID, sources: dict[str, dict[uuid.UUID, float]],
|
| 85 |
+
docs: list[Document]) -> list[Candidate]:
|
| 86 |
+
"""Every tag of the source's posts, best first. A source is a post (itself, weight 1) or a chat (the posts it
|
| 87 |
+
cites). Score: the tag's weight summed over the sources that carry it, times the share of sources that do, so a
|
| 88 |
+
theme running through the selection beats one post's strongest tag."""
|
| 89 |
+
tags = _doc_tags(db, workspace_id, docs)
|
| 90 |
+
score: dict[str, float] = defaultdict(float)
|
| 91 |
+
carried: dict[str, set[str]] = defaultdict(set)
|
| 92 |
+
summaries: dict[str, str] = {}
|
| 93 |
+
merged: dict[str, bool] = defaultdict(bool)
|
| 94 |
+
for source, weights in sources.items():
|
| 95 |
+
best: dict[str, float] = {}
|
| 96 |
+
for doc_id, w in weights.items():
|
| 97 |
+
for name, (tag_weight, summary, is_merged) in tags.get(doc_id, {}).items():
|
| 98 |
+
best[name] = max(best.get(name, 0.0), tag_weight * w)
|
| 99 |
+
summaries.setdefault(name, summary)
|
| 100 |
+
merged[name] |= is_merged
|
| 101 |
+
for name, v in best.items():
|
| 102 |
+
score[name] += v
|
| 103 |
+
carried[name].add(source)
|
| 104 |
+
total = max(len(sources), 1)
|
| 105 |
+
pool = [Candidate(name, summaries[name], score[name] * len(carried[name]) / total, carried[name], merged[name])
|
| 106 |
+
for name in score]
|
| 107 |
+
return sorted(pool, key=lambda c: (-c.score, c.name))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _doc_tags(db: Session, workspace_id: uuid.UUID,
|
| 111 |
+
docs: list[Document]) -> dict[uuid.UUID, dict[str, tuple[float, str, bool]]]:
|
| 112 |
+
"""Post -> {topic name: (weight, summary, merged)}: the topic table's (merged) topics and the post's own tags. The
|
| 113 |
+
merge folds a post's specific tags into a few broad workspace topics ("Content Repurposing" into "Finance
|
| 114 |
+
Substacks & Audience Growth"), which alone leaves a single post one or two angles; the merged names are what lets
|
| 115 |
+
posts share a tag. An own tag is named as the topic table's matching topic if any, and skipped if already there."""
|
| 116 |
+
out: dict[uuid.UUID, dict[str, tuple[float, str, bool]]] = defaultdict(dict)
|
| 117 |
+
rows = db.execute(
|
| 118 |
+
select(DocumentTopic.document_id, DocumentTopic.weight, Topic.name, Topic.summary)
|
| 119 |
+
.join(Topic, Topic.id == DocumentTopic.topic_id)
|
| 120 |
+
.where(DocumentTopic.document_id.in_([d.id for d in docs]), Topic.workspace_id == workspace_id)
|
| 121 |
+
).all()
|
| 122 |
+
for doc_id, weight, name, summary in rows:
|
| 123 |
+
out[doc_id][name] = (float(weight or 0.5), summary or "", True)
|
| 124 |
+
own = [d for d in docs if (d.metadata_json or {}).get("topics")]
|
| 125 |
+
if own:
|
| 126 |
+
known = {t.slug: t for t in db.scalars(select(Topic).where(Topic.workspace_id == workspace_id))}
|
| 127 |
+
for d in own:
|
| 128 |
+
have = {slugify(n, 120) for n in out[d.id]}
|
| 129 |
+
for t in d.metadata_json["topics"]:
|
| 130 |
+
topic = known.get(slugify(t["name"], 120))
|
| 131 |
+
name = topic.name if topic else t["name"]
|
| 132 |
+
if slugify(name, 120) in have:
|
| 133 |
+
continue
|
| 134 |
+
have.add(slugify(name, 120))
|
| 135 |
+
summary = (topic.summary or "") if topic else ""
|
| 136 |
+
out[d.id][name] = (float(t.get("weight") or 0.5), summary, topic is not None)
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def pick_cards(pool: list[Candidate], n: int = CARDS) -> list[Candidate]:
|
| 141 |
+
"""The cards: merged topics first, then own tags. Within each, best score first, halved for each card already on
|
| 142 |
+
every post the topic has, so several posts' cards do not all come from one post."""
|
| 143 |
+
chosen: list[Candidate] = []
|
| 144 |
+
covered: Counter[str] = Counter() # source -> cards on it so far
|
| 145 |
+
|
| 146 |
+
def worth(c: Candidate) -> float:
|
| 147 |
+
return c.score * 0.5 ** min(covered[s] for s in c.sources)
|
| 148 |
+
|
| 149 |
+
for group in ([c for c in pool if c.merged], [c for c in pool if not c.merged]):
|
| 150 |
+
rest = list(enumerate(group)) # the pool's order breaks ties
|
| 151 |
+
while rest and len(chosen) < n:
|
| 152 |
+
i, best = max(rest, key=lambda ic: (worth(ic[1]), -ic[0]))
|
| 153 |
+
chosen.append(best)
|
| 154 |
+
covered.update(best.sources)
|
| 155 |
+
rest.remove((i, best))
|
| 156 |
+
return chosen
|
backend/app/services/video_limits.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-workspace shares of what blog2video only limits account-wide: AI edits, custom templates, template AI
|
| 2 |
+
operations, voice designs, voice samples, custom voices (plans.VIDEO_LIMITS), plus premium (★) video options.
|
| 3 |
+
|
| 4 |
+
blog2video checks every limit against our own (Pro) account, so every workspace passes its checks. These counters
|
| 5 |
+
stop a workspace before it uses up everyone's share. Videos themselves are counted in video_quota.py.
|
| 6 |
+
|
| 7 |
+
A take is one conditional UPDATE (used + n <= limit), so two requests can not both take the last unit.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import UTC, datetime
|
| 12 |
+
|
| 13 |
+
from sqlalchemy import case, select, update
|
| 14 |
+
from sqlalchemy.exc import IntegrityError
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
|
| 17 |
+
from app.models import UsageCounter, Workspace
|
| 18 |
+
from app.services.plans import VIDEO_LIMITS, Plan, effective_plan, plan_limit_error
|
| 19 |
+
|
| 20 |
+
LABELS = {
|
| 21 |
+
"ai_edits": ("AI edits", "this month"),
|
| 22 |
+
"templates": ("custom templates", "in total"),
|
| 23 |
+
"template_ai_daily": ("AI template steps", "today"),
|
| 24 |
+
"voice_designs_daily": ("voice designs", "today"),
|
| 25 |
+
"voice_samples_daily": ("voice samples", "today"),
|
| 26 |
+
"custom_voices": ("custom voices", "at a time"),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def period_key(metric: str, now: datetime | None = None) -> str:
|
| 31 |
+
now = now or datetime.now(UTC)
|
| 32 |
+
kind = VIDEO_LIMITS[metric]
|
| 33 |
+
return now.strftime("%Y-%m-%d") if kind == "day" else now.strftime("%Y-%m") if kind == "month" else "all"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _ensure_row(db: Session, workspace_id: uuid.UUID, period: str, metric: str) -> None:
|
| 37 |
+
key = {"workspace_id": workspace_id, "period": period, "metric": metric}
|
| 38 |
+
if db.get(UsageCounter, key) is not None:
|
| 39 |
+
return
|
| 40 |
+
db.add(UsageCounter(**key, used=0))
|
| 41 |
+
try:
|
| 42 |
+
db.commit()
|
| 43 |
+
except IntegrityError: # a concurrent request made it first
|
| 44 |
+
db.rollback()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def used(db: Session, workspace_id: uuid.UUID, metric: str) -> int:
|
| 48 |
+
row = db.get(UsageCounter, {"workspace_id": workspace_id, "period": period_key(metric), "metric": metric})
|
| 49 |
+
return row.used if row else 0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def take(db: Session, workspace: Workspace, metric: str, n: int = 1, plan: Plan | None = None) -> None:
|
| 53 |
+
"""Use n units of metric, or raise the 402 that opens the upgrade popup."""
|
| 54 |
+
plan = plan or effective_plan(db, workspace)
|
| 55 |
+
limit = int(plan.video_limits.get(metric, 0))
|
| 56 |
+
period = period_key(metric)
|
| 57 |
+
_ensure_row(db, workspace.id, period, metric)
|
| 58 |
+
where = [UsageCounter.workspace_id == workspace.id, UsageCounter.period == period, UsageCounter.metric == metric]
|
| 59 |
+
if limit >= 0: # -1 = unlimited, still counted
|
| 60 |
+
where.append(UsageCounter.used + n <= limit)
|
| 61 |
+
taken = db.execute(update(UsageCounter).where(*where).values(used=UsageCounter.used + n)).rowcount
|
| 62 |
+
db.commit()
|
| 63 |
+
if taken != 1:
|
| 64 |
+
label, when = LABELS[metric]
|
| 65 |
+
message = (f"Your plan does not include {label}." if limit == 0
|
| 66 |
+
else f"You have used all {limit} {label} {when}.")
|
| 67 |
+
raise plan_limit_error(plan, f"video_limits.{metric}", message)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> None:
|
| 71 |
+
"""Undo a take whose blog2video call failed (or a custom voice that was deleted)."""
|
| 72 |
+
db.execute(update(UsageCounter)
|
| 73 |
+
.where(UsageCounter.workspace_id == workspace_id, UsageCounter.period == period_key(metric),
|
| 74 |
+
UsageCounter.metric == metric)
|
| 75 |
+
.values(used=case((UsageCounter.used > n, UsageCounter.used - n), else_=0)))
|
| 76 |
+
db.commit()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def require_premium(db: Session, workspace: Workspace, what: str, plan: Plan | None = None) -> Plan:
|
| 80 |
+
plan = plan or effective_plan(db, workspace)
|
| 81 |
+
if not plan.video_premium:
|
| 82 |
+
raise plan_limit_error(plan, "video_premium", f"{what} comes with a paid plan.")
|
| 83 |
+
return plan
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def report(db: Session, workspace: Workspace) -> dict:
|
| 87 |
+
"""What the wizard shows: premium or not, and each counter's used / limit."""
|
| 88 |
+
plan = effective_plan(db, workspace)
|
| 89 |
+
rows = {(r.period, r.metric): r.used for r in db.scalars(
|
| 90 |
+
select(UsageCounter).where(UsageCounter.workspace_id == workspace.id))}
|
| 91 |
+
return {"premium": plan.video_premium,
|
| 92 |
+
"limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))}
|
| 93 |
+
for m in VIDEO_LIMITS}}
|
backend/app/services/video_quota.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Video allowance, kept on the workspace's subscriptions row and enforced only by us.
|
| 2 |
+
|
| 3 |
+
blog2video charges every video to our own account; it does not know our workspaces. So we:
|
| 4 |
+
- reserve one video (videos_used + 1, only while below video_limit) before asking blog2video for it;
|
| 5 |
+
- refund it exactly once if the create is refused or generation fails (the b2v_videos row records which);
|
| 6 |
+
- keep video_plan and video_limit equal to the effective plan, and set videos_used back to 0 each month on plans
|
| 7 |
+
with a monthly allowance: on Stripe renewal (invoice.paid, see stripe_billing.handle_event), and monthly for
|
| 8 |
+
workspaces with no renewal to wait for (billing disabled, annual plans between renewals).
|
| 9 |
+
- Free's allowance is a lifetime total (plan.videos_monthly False): never reset. When a workspace drops to such a
|
| 10 |
+
plan, videos_used becomes its lifetime count of videos that were not refunded.
|
| 11 |
+
|
| 12 |
+
videos_used is only ever written with a single SQL UPDATE here, never through the ORM, so a sync can not
|
| 13 |
+
overwrite a concurrent reserve or refund.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import calendar
|
| 17 |
+
import logging
|
| 18 |
+
import uuid
|
| 19 |
+
from collections.abc import Callable
|
| 20 |
+
from datetime import UTC, datetime, timedelta
|
| 21 |
+
|
| 22 |
+
from sqlalchemy import case, func, select, update
|
| 23 |
+
from sqlalchemy.orm import Session
|
| 24 |
+
|
| 25 |
+
from app.config import settings
|
| 26 |
+
from app.db import SessionLocal
|
| 27 |
+
from app.models import Artifact, B2VVideo, Subscription, Workspace
|
| 28 |
+
from app.services import blog2video as b2v
|
| 29 |
+
from app.services.plans import PLANS, effective_plan
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
LIVE = {"active", "trialing", "past_due"}
|
| 34 |
+
RENEWAL_WINDOW = timedelta(days=45) # a Stripe renewal this close to the period start does the reset instead
|
| 35 |
+
FAILED = {"failed", "error"}
|
| 36 |
+
FINISHED = {"generated", "done"}
|
| 37 |
+
SWEEP_AFTER = timedelta(minutes=30) # the sweep only looks at videos older than this (users who closed the tab)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def add_month(when: datetime) -> datetime:
|
| 41 |
+
year, month = (when.year + 1, 1) if when.month == 12 else (when.year, when.month + 1)
|
| 42 |
+
return when.replace(year=year, month=month, day=min(when.day, calendar.monthrange(year, month)[1]))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _aware(when: datetime | None) -> datetime | None:
|
| 46 |
+
return when.replace(tzinfo=UTC) if when and when.tzinfo is None else when
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _row(db: Session, workspace_id: uuid.UUID) -> Subscription:
|
| 50 |
+
sub = db.scalar(select(Subscription).where(Subscription.workspace_id == workspace_id))
|
| 51 |
+
if not sub:
|
| 52 |
+
sub = Subscription(workspace_id=workspace_id, plan="free", status="active")
|
| 53 |
+
db.add(sub)
|
| 54 |
+
db.flush()
|
| 55 |
+
return sub
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def sync_video_quota(db: Session, workspace: Workspace) -> Subscription:
|
| 59 |
+
"""Make video_plan and video_limit match the workspace's effective plan. Cheap when nothing changed."""
|
| 60 |
+
plan = effective_plan(db, workspace)
|
| 61 |
+
sub = _row(db, workspace.id)
|
| 62 |
+
changed = False
|
| 63 |
+
if sub.video_plan != plan.id and not plan.videos_monthly:
|
| 64 |
+
# Onto a lifetime allowance: everything made so far counts (a Writer who made 10 can't make more on Free).
|
| 65 |
+
made = db.scalar(select(func.count(B2VVideo.id)).where(B2VVideo.workspace_id == workspace.id,
|
| 66 |
+
B2VVideo.quota_state != "refunded")) or 0
|
| 67 |
+
db.execute(update(Subscription).where(Subscription.id == sub.id).values(videos_used=made))
|
| 68 |
+
changed = True
|
| 69 |
+
if sub.video_plan != plan.id or sub.video_limit != plan.videos:
|
| 70 |
+
sub.video_plan, sub.video_limit = plan.id, plan.videos
|
| 71 |
+
changed = True
|
| 72 |
+
if sub.videos_period_start is None:
|
| 73 |
+
sub.videos_period_start = datetime.now(UTC)
|
| 74 |
+
changed = True
|
| 75 |
+
if changed:
|
| 76 |
+
db.commit()
|
| 77 |
+
return sub
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _renews_by_stripe(sub: Subscription) -> bool:
|
| 81 |
+
start, end = _aware(sub.videos_period_start), _aware(sub.current_period_end)
|
| 82 |
+
return bool(sub.provider_subscription_id and sub.status in LIVE and start and end
|
| 83 |
+
and end <= start + RENEWAL_WINDOW)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def monthly(sub: Subscription) -> bool:
|
| 87 |
+
plan = PLANS.get(sub.video_plan)
|
| 88 |
+
return plan.videos_monthly if plan else True
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def resets_at(sub: Subscription) -> datetime | None:
|
| 92 |
+
start = _aware(sub.videos_period_start)
|
| 93 |
+
if not start or not monthly(sub):
|
| 94 |
+
return None
|
| 95 |
+
return _aware(sub.current_period_end) if _renews_by_stripe(sub) else add_month(start)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def video_usage(db: Session, workspace: Workspace) -> dict:
|
| 99 |
+
sub = sync_video_quota(db, workspace)
|
| 100 |
+
db.refresh(sub) # videos_used changes under us (reserves and refunds from other requests)
|
| 101 |
+
reset = resets_at(sub)
|
| 102 |
+
start = _aware(sub.videos_period_start)
|
| 103 |
+
return {"used": sub.videos_used, "limit": sub.video_limit, "plan": sub.video_plan,
|
| 104 |
+
"period_start": start.isoformat() if start else None, "resets_at": reset.isoformat() if reset else None}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def reset_videos(db: Session, workspace_id: uuid.UUID, at: datetime | None = None) -> None:
|
| 108 |
+
db.execute(update(Subscription).where(Subscription.workspace_id == workspace_id)
|
| 109 |
+
.values(videos_used=0, videos_period_start=at or datetime.now(UTC)))
|
| 110 |
+
db.commit()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def reset_due_video_periods(session_factory: Callable[[], Session] = SessionLocal) -> int:
|
| 114 |
+
"""Monthly reset for workspaces a Stripe renewal will not reset in time. Runs from the worker's PERIODIC list."""
|
| 115 |
+
now = datetime.now(UTC)
|
| 116 |
+
count = 0
|
| 117 |
+
with session_factory() as db:
|
| 118 |
+
for sub in db.scalars(select(Subscription)):
|
| 119 |
+
start = _aware(sub.videos_period_start)
|
| 120 |
+
if start is None or _renews_by_stripe(sub) or not monthly(sub):
|
| 121 |
+
continue
|
| 122 |
+
if add_month(start) <= now:
|
| 123 |
+
reset_videos(db, sub.workspace_id, now)
|
| 124 |
+
count += 1
|
| 125 |
+
return count
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def reserve(db: Session, workspace_id: uuid.UUID) -> bool:
|
| 129 |
+
"""Take one video from the allowance, atomically: two requests can not both take the last one.
|
| 130 |
+
False = none left (do not call blog2video)."""
|
| 131 |
+
taken = db.execute(update(Subscription)
|
| 132 |
+
.where(Subscription.workspace_id == workspace_id,
|
| 133 |
+
Subscription.videos_used < Subscription.video_limit)
|
| 134 |
+
.values(videos_used=Subscription.videos_used + 1)).rowcount
|
| 135 |
+
db.commit()
|
| 136 |
+
return taken == 1
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def unreserve(db: Session, workspace_id: uuid.UUID) -> None:
|
| 140 |
+
"""Give back a reservation that never got a b2v_videos row."""
|
| 141 |
+
db.execute(update(Subscription).where(Subscription.workspace_id == workspace_id)
|
| 142 |
+
.values(videos_used=case((Subscription.videos_used > 0, Subscription.videos_used - 1), else_=0)))
|
| 143 |
+
db.commit()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def refund(db: Session, row: B2VVideo, status: str | None = None) -> bool:
|
| 147 |
+
"""Give the workspace its video back, exactly once per row, however many callers race here, and never once
|
| 148 |
+
it finished generating ('kept'). True if this call did the refund."""
|
| 149 |
+
values = {"quota_state": "refunded", "updated_at": datetime.now(UTC)}
|
| 150 |
+
if status:
|
| 151 |
+
values["status"] = status
|
| 152 |
+
won = db.execute(update(B2VVideo).where(B2VVideo.id == row.id, B2VVideo.quota_state == "charged")
|
| 153 |
+
.values(**values)).rowcount == 1
|
| 154 |
+
if won:
|
| 155 |
+
db.execute(update(Subscription).where(Subscription.workspace_id == row.workspace_id)
|
| 156 |
+
.values(videos_used=case((Subscription.videos_used > 0, Subscription.videos_used - 1),
|
| 157 |
+
else_=0)))
|
| 158 |
+
db.commit()
|
| 159 |
+
db.refresh(row)
|
| 160 |
+
return won
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def record_status(db: Session, row: B2VVideo, status: str) -> None:
|
| 164 |
+
"""Remember blog2video's latest status, refunding when it says the video failed. Once generation finished the
|
| 165 |
+
charge is kept (quota_state 'kept'): a later failed edit does not give the video back."""
|
| 166 |
+
if status in FAILED:
|
| 167 |
+
refund(db, row, "failed")
|
| 168 |
+
elif status and row.status != status:
|
| 169 |
+
values = {"status": status, "updated_at": datetime.now(UTC)}
|
| 170 |
+
if status in FINISHED:
|
| 171 |
+
values["quota_state"] = case((B2VVideo.quota_state == "charged", "kept"), else_=B2VVideo.quota_state)
|
| 172 |
+
db.execute(update(B2VVideo).where(B2VVideo.id == row.id).values(**values))
|
| 173 |
+
db.commit()
|
| 174 |
+
db.refresh(row)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def sweep_failed_videos(session_factory: Callable[[], Session] = SessionLocal) -> int:
|
| 178 |
+
"""Refund videos that failed while nobody was polling (the user closed the tab). Runs from PERIODIC."""
|
| 179 |
+
if not b2v.configured():
|
| 180 |
+
return 0
|
| 181 |
+
cutoff = datetime.now(UTC) - SWEEP_AFTER
|
| 182 |
+
refunded = 0
|
| 183 |
+
with session_factory() as db:
|
| 184 |
+
rows = db.scalars(select(B2VVideo).where(B2VVideo.quota_state == "charged",
|
| 185 |
+
B2VVideo.status.not_in(FINISHED),
|
| 186 |
+
B2VVideo.created_at < cutoff)).all()
|
| 187 |
+
for row in rows:
|
| 188 |
+
if row.b2v_video_id is None:
|
| 189 |
+
# The create never got an answer and the request died before refunding.
|
| 190 |
+
refunded += refund(db, row, "failed")
|
| 191 |
+
continue
|
| 192 |
+
try:
|
| 193 |
+
s = b2v.status(row.b2v_video_id, row.created_via)
|
| 194 |
+
except b2v.NotFound:
|
| 195 |
+
s = {"status": "failed", "error": "Video not found"}
|
| 196 |
+
except b2v.B2VError as e:
|
| 197 |
+
log.warning("video sweep: status of %s failed: %s", row.b2v_video_id, e)
|
| 198 |
+
continue
|
| 199 |
+
raw = str(s.get("status") or "")
|
| 200 |
+
if s.get("error") and raw not in FAILED:
|
| 201 |
+
raw = "failed"
|
| 202 |
+
if raw in FAILED:
|
| 203 |
+
refunded += refund(db, row, "failed")
|
| 204 |
+
if row.artifact_id and (a := db.get(Artifact, row.artifact_id)):
|
| 205 |
+
a.status = "failed"
|
| 206 |
+
a.content_json = {**(a.content_json or {}), "b2v_status": raw, "error": s.get("error")}
|
| 207 |
+
db.commit()
|
| 208 |
+
elif raw:
|
| 209 |
+
record_status(db, row, raw)
|
| 210 |
+
return refunded
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
AI_EDITS_ALERT_BELOW = 200
|
| 214 |
+
TEMPLATE_SLOTS_ALERT_BELOW = 3
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def check_capacity() -> dict | None:
|
| 218 |
+
"""Alert when our blog2video account runs low: our workspaces' limits added up can exceed it. Hourly."""
|
| 219 |
+
if not b2v.configured():
|
| 220 |
+
return None
|
| 221 |
+
try:
|
| 222 |
+
account = b2v.account() or {}
|
| 223 |
+
except b2v.B2VError as e:
|
| 224 |
+
log.error("ALERT blog2video capacity check failed: %s", e)
|
| 225 |
+
return None
|
| 226 |
+
low = []
|
| 227 |
+
limit, used = account.get("video_limit"), account.get("videos_used_this_period")
|
| 228 |
+
if not account.get("can_create_video") or (
|
| 229 |
+
limit is not None and used is not None and limit - used < settings.b2v_capacity_alert_below):
|
| 230 |
+
low.append(f"videos {used}/{limit} used")
|
| 231 |
+
ai = (account.get("ai_edit_allowance_remaining") or 0) + (account.get("ai_edit_credits") or 0)
|
| 232 |
+
if ai < AI_EDITS_ALERT_BELOW:
|
| 233 |
+
low.append(f"{ai} AI edits left")
|
| 234 |
+
slots, made = account.get("custom_template_limit"), account.get("custom_templates_created")
|
| 235 |
+
if slots is not None and made is not None and slots - made < TEMPLATE_SLOTS_ALERT_BELOW:
|
| 236 |
+
low.append(f"template slots {made}/{slots} used")
|
| 237 |
+
if low:
|
| 238 |
+
log.error("ALERT blog2video account (plan %s) is running low: %s", account.get("plan"), "; ".join(low))
|
| 239 |
+
return account
|
backend/app/support/corpus.py
CHANGED
|
@@ -61,10 +61,13 @@ def _parse(path: Path) -> Doc:
|
|
| 61 |
|
| 62 |
def _plans_doc() -> Doc:
|
| 63 |
"""Built from services/plans.py so prices and limits can never drift from what the app enforces."""
|
| 64 |
-
from app.services.plans import
|
| 65 |
|
| 66 |
-
|
| 67 |
-
for pid in
|
|
|
|
|
|
|
|
|
|
| 68 |
p = PLANS[pid]
|
| 69 |
price = "free" if not p.price_monthly_usd else f"${p.price_monthly_usd:g} a month"
|
| 70 |
if p.price_monthly_usd:
|
|
@@ -76,7 +79,7 @@ def _plans_doc() -> Doc:
|
|
| 76 |
f"{p.tagline}. Price: {price}.",
|
| 77 |
f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
|
| 78 |
f"{p.audio_minutes} minutes of audio a month, "
|
| 79 |
-
f"{p.
|
| 80 |
f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
|
| 81 |
"Includes: " + "; ".join(p.features) + ".",
|
| 82 |
]
|
|
|
|
| 61 |
|
| 62 |
def _plans_doc() -> Doc:
|
| 63 |
"""Built from services/plans.py so prices and limits can never drift from what the app enforces."""
|
| 64 |
+
from app.services.plans import PLANS, annual_prices, plan_order
|
| 65 |
|
| 66 |
+
order = plan_order() # the plans table's (editable without a deploy), so the names and count come from it too
|
| 67 |
+
names = [PLANS[pid].name for pid in order]
|
| 68 |
+
listed = ", ".join(names[:-1]) + f" and {names[-1]}" if len(names) > 1 else "".join(names)
|
| 69 |
+
lines = ["# Plans", f"Notestack has {len(names)} plans: {listed}. Prices are in US dollars."]
|
| 70 |
+
for pid in order:
|
| 71 |
p = PLANS[pid]
|
| 72 |
price = "free" if not p.price_monthly_usd else f"${p.price_monthly_usd:g} a month"
|
| 73 |
if p.price_monthly_usd:
|
|
|
|
| 79 |
f"{p.tagline}. Price: {price}.",
|
| 80 |
f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
|
| 81 |
f"{p.audio_minutes} minutes of audio a month, "
|
| 82 |
+
f"{p.videos} videos {'a month' if p.videos_monthly else 'in total'}, {kits} Launch Kits a month. "
|
| 83 |
f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
|
| 84 |
"Includes: " + "; ".join(p.features) + ".",
|
| 85 |
]
|
backend/app/worker.py
CHANGED
|
@@ -31,6 +31,7 @@ from app.db import SessionLocal, engine
|
|
| 31 |
from app.models import (
|
| 32 |
Artifact,
|
| 33 |
Chat,
|
|
|
|
| 34 |
Job,
|
| 35 |
Message,
|
| 36 |
Source,
|
|
@@ -48,10 +49,18 @@ from app.pipeline import generate, launchkit, media
|
|
| 48 |
from app.pipeline.generate import NothingToDo
|
| 49 |
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 50 |
from app.pipeline.memory import learn_from_message
|
| 51 |
-
from app.services import launchpad, tts
|
| 52 |
from app.services.email import email_service
|
| 53 |
from app.services.email_verification import purge_old_codes
|
| 54 |
-
from app.services.jobs import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
from app.services.plans import effective_plan
|
| 56 |
from app.services.renderer import PermanentJobError, request_render
|
| 57 |
from app.services.storage import keys, storage
|
|
@@ -71,11 +80,6 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
|
|
| 71 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 72 |
|
| 73 |
|
| 74 |
-
def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
|
| 75 |
-
return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
|
| 76 |
-
Job.status.in_(("queued", "running"))).limit(1)) is not None
|
| 77 |
-
|
| 78 |
-
|
| 79 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 80 |
"""New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
|
| 81 |
first import builds the writing voice, so none of these need a button press."""
|
|
@@ -83,10 +87,10 @@ def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[
|
|
| 83 |
return
|
| 84 |
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 85 |
create_job(db, workspace_id, "ideas", {"document_ids": changed}, max_attempts=2)
|
| 86 |
-
if not
|
| 87 |
create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
|
| 88 |
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 89 |
-
if not (vp and vp.profile_json) and not
|
| 90 |
create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
|
| 91 |
|
| 92 |
|
|
@@ -276,7 +280,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
|
|
| 276 |
"summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
|
| 277 |
"mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"),
|
| 278 |
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 279 |
-
"video": _artifact_handler(media.make_video, "
|
| 280 |
"quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
|
| 281 |
"carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
|
| 282 |
"launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
|
|
@@ -357,6 +361,19 @@ def single_flight(name: str):
|
|
| 357 |
conn.commit()
|
| 358 |
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
|
| 361 |
"""Sends one daily batch per scheduled campaign at its send hour (UTC)."""
|
| 362 |
now = datetime.now(UTC)
|
|
@@ -413,7 +430,11 @@ PERIODIC = [
|
|
| 413 |
Periodic("publish_due", 30, launchpad.publish_due),
|
| 414 |
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 415 |
Periodic("update_email_batch", 300, update_email_batch),
|
|
|
|
| 416 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
|
|
|
|
|
|
|
|
|
| 417 |
]
|
| 418 |
|
| 419 |
|
|
|
|
| 31 |
from app.models import (
|
| 32 |
Artifact,
|
| 33 |
Chat,
|
| 34 |
+
Document,
|
| 35 |
Job,
|
| 36 |
Message,
|
| 37 |
Source,
|
|
|
|
| 49 |
from app.pipeline.generate import NothingToDo
|
| 50 |
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 51 |
from app.pipeline.memory import learn_from_message
|
| 52 |
+
from app.services import launchpad, tts, video_quota
|
| 53 |
from app.services.email import email_service
|
| 54 |
from app.services.email_verification import purge_old_codes
|
| 55 |
+
from app.services.jobs import (
|
| 56 |
+
claim_next,
|
| 57 |
+
create_job,
|
| 58 |
+
pending_job,
|
| 59 |
+
record_usage,
|
| 60 |
+
retry_or_fail,
|
| 61 |
+
stale_jobs,
|
| 62 |
+
update_job,
|
| 63 |
+
)
|
| 64 |
from app.services.plans import effective_plan
|
| 65 |
from app.services.renderer import PermanentJobError, request_render
|
| 66 |
from app.services.storage import keys, storage
|
|
|
|
| 80 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 84 |
"""New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
|
| 85 |
first import builds the writing voice, so none of these need a button press."""
|
|
|
|
| 87 |
return
|
| 88 |
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 89 |
create_job(db, workspace_id, "ideas", {"document_ids": changed}, max_attempts=2)
|
| 90 |
+
if not pending_job(db, workspace_id, "resurface_scan"):
|
| 91 |
create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
|
| 92 |
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 93 |
+
if not (vp and vp.profile_json) and not pending_job(db, workspace_id, "voice_profile"):
|
| 94 |
create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
|
| 95 |
|
| 96 |
|
|
|
|
| 280 |
"summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
|
| 281 |
"mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"),
|
| 282 |
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 283 |
+
"video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
|
| 284 |
"quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
|
| 285 |
"carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
|
| 286 |
"launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
|
|
|
|
| 361 |
conn.commit()
|
| 362 |
|
| 363 |
|
| 364 |
+
def topics_backfill(session_factory: Callable[[], Session] = SessionLocal) -> None:
|
| 365 |
+
"""Every indexed post gets topics: one whose tagging failed (the model timed out, rate limited, answered nothing)
|
| 366 |
+
or that came before posts were tagged on import is asked for again here, one topics job per workspace that has
|
| 367 |
+
some, unless one is already queued. A post that keeps failing stops being asked (generate.MAX_TAG_FAILURES)."""
|
| 368 |
+
if not settings.llm_api_key:
|
| 369 |
+
return
|
| 370 |
+
with session_factory() as db:
|
| 371 |
+
rows = db.execute(select(Document.workspace_id, Document.metadata_json).where(Document.path.is_not(None)))
|
| 372 |
+
for workspace_id in {ws for ws, meta in rows if generate.needs_topics(meta)}:
|
| 373 |
+
if not pending_job(db, workspace_id, "topics"):
|
| 374 |
+
create_job(db, workspace_id, "topics", {}, max_attempts=2)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
|
| 378 |
"""Sends one daily batch per scheduled campaign at its send hour (UTC)."""
|
| 379 |
now = datetime.now(UTC)
|
|
|
|
| 430 |
Periodic("publish_due", 30, launchpad.publish_due),
|
| 431 |
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 432 |
Periodic("update_email_batch", 300, update_email_batch),
|
| 433 |
+
Periodic("topics_backfill", 900, topics_backfill),
|
| 434 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
| 435 |
+
Periodic("video_period_reset", 3600, video_quota.reset_due_video_periods),
|
| 436 |
+
Periodic("video_refund_sweep", 600, video_quota.sweep_failed_videos),
|
| 437 |
+
Periodic("video_capacity_check", 3600, video_quota.check_capacity),
|
| 438 |
]
|
| 439 |
|
| 440 |
|
backend/requirements.txt
CHANGED
|
@@ -13,6 +13,7 @@ requests==2.*
|
|
| 13 |
boto3==1.35.*
|
| 14 |
resend==2.*
|
| 15 |
httpx==0.28.*
|
|
|
|
| 16 |
dspy==2.6.*
|
| 17 |
rank-bm25==0.2.*
|
| 18 |
feedparser==6.*
|
|
|
|
| 13 |
boto3==1.35.*
|
| 14 |
resend==2.*
|
| 15 |
httpx==0.28.*
|
| 16 |
+
python-multipart==0.0.*
|
| 17 |
dspy==2.6.*
|
| 18 |
rank-bm25==0.2.*
|
| 19 |
feedparser==6.*
|