Spaces:
Runtime error
Runtime error
Commit ·
3f79c45
1
Parent(s): 3d10303
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/app/routers/billing.py +32 -5
- backend/app/routers/sources.py +2 -2
- backend/app/routers/voice.py +4 -3
- backend/app/services/billing.py +180 -0
- backend/app/services/plans.py +29 -0
- backend/app/services/usage.py +2 -4
- backend/tests/test_billing.py +77 -0
- frontend/src/pages/Landing.tsx +1 -1
- renderer/public/demo-vo/durations.json +8 -8
- renderer/public/launch-vo/durations.json +12 -12
- renderer/scripts/make_demo_music.py +175 -0
- renderer/scripts/make_demo_vo.py +42 -0
- renderer/scripts/make_launch_vo.py +15 -13
- renderer/src/compositions/LandingDemo.tsx +5 -6
- renderer/src/compositions/LaunchTrailer.tsx +3 -3
- renderer/src/compositions/launchTimeline.ts +14 -14
backend/app/routers/billing.py
CHANGED
|
@@ -1,12 +1,21 @@
|
|
| 1 |
-
from
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from app.auth import Ctx, get_ctx
|
| 4 |
from app.config import settings
|
| 5 |
-
from app.services.
|
|
|
|
| 6 |
|
| 7 |
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
@router.get("/plans")
|
| 11 |
def list_plans():
|
| 12 |
return {"billing_enabled": settings.billing_enabled, "plans": [plan_dict(p) for p in PLANS.values()]}
|
|
@@ -18,15 +27,33 @@ def my_plan(ctx: Ctx = Depends(get_ctx)):
|
|
| 18 |
return {"billing_enabled": settings.billing_enabled, "plan": plan_dict(plan)}
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
@router.post("/checkout")
|
| 22 |
-
def checkout(ctx: Ctx = Depends(get_ctx)):
|
| 23 |
if not settings.billing_enabled:
|
| 24 |
raise HTTPException(409, {"code": "billing_disabled", "message": "Checkout is not available yet."})
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
@router.post("/webhook")
|
| 29 |
-
def webhook():
|
|
|
|
|
|
|
| 30 |
if not settings.billing_enabled:
|
| 31 |
return {"ignored": True}
|
| 32 |
raise HTTPException(501, "Webhook not wired yet")
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
|
| 6 |
from app.auth import Ctx, get_ctx
|
| 7 |
from app.config import settings
|
| 8 |
+
from app.services.billing import billing_status, create_checkout_url, create_portal_url
|
| 9 |
+
from app.services.plans import PLAN_ORDER, PLANS, effective_plan, plan_dict
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
| 12 |
|
| 13 |
|
| 14 |
+
class CheckoutIn(BaseModel):
|
| 15 |
+
plan: Literal["writer", "studio"]
|
| 16 |
+
cycle: Literal["monthly", "annual"] = "monthly"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
@router.get("/plans")
|
| 20 |
def list_plans():
|
| 21 |
return {"billing_enabled": settings.billing_enabled, "plans": [plan_dict(p) for p in PLANS.values()]}
|
|
|
|
| 27 |
return {"billing_enabled": settings.billing_enabled, "plan": plan_dict(plan)}
|
| 28 |
|
| 29 |
|
| 30 |
+
@router.get("/status")
|
| 31 |
+
def status(ctx: Ctx = Depends(get_ctx)):
|
| 32 |
+
"""Plan, meters and nudges for the upgrade popup, the sidebar fuel gauge and the nudge dock."""
|
| 33 |
+
return billing_status(ctx.db, ctx.workspace)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
@router.post("/checkout")
|
| 37 |
+
def checkout(body: CheckoutIn, ctx: Ctx = Depends(get_ctx)):
|
| 38 |
if not settings.billing_enabled:
|
| 39 |
raise HTTPException(409, {"code": "billing_disabled", "message": "Checkout is not available yet."})
|
| 40 |
+
current = effective_plan(ctx.db, ctx.workspace)
|
| 41 |
+
if PLAN_ORDER.index(body.plan) <= PLAN_ORDER.index(current.id):
|
| 42 |
+
raise HTTPException(409, {"code": "already_on_plan", "message": f"You are already on {current.name}."})
|
| 43 |
+
return {"url": create_checkout_url(ctx.db, ctx.user, ctx.workspace, PLANS[body.plan], body.cycle)}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/portal")
|
| 47 |
+
def portal(ctx: Ctx = Depends(get_ctx)):
|
| 48 |
+
if not settings.billing_enabled:
|
| 49 |
+
raise HTTPException(409, {"code": "billing_disabled", "message": "Billing is not available yet."})
|
| 50 |
+
return {"url": create_portal_url(ctx.db, ctx.workspace)}
|
| 51 |
|
| 52 |
|
| 53 |
@router.post("/webhook")
|
| 54 |
+
async def webhook(request: Request):
|
| 55 |
+
"""TODO(stripe): verify the Stripe-Signature header against STRIPE_WEBHOOK_SECRET, then map events to
|
| 56 |
+
services.billing.apply_subscription (workspace id from client_reference_id, plan from metadata)."""
|
| 57 |
if not settings.billing_enabled:
|
| 58 |
return {"ignored": True}
|
| 59 |
raise HTTPException(501, "Webhook not wired yet")
|
backend/app/routers/sources.py
CHANGED
|
@@ -10,7 +10,7 @@ from app.corpus import INDEX, Corpus, CorpusError
|
|
| 10 |
from app.models import Document, Job, NotebookDocument, Source, Upload
|
| 11 |
from app.pipeline.ingest import IMPORTS_FEED, FeedNotFound, discover_feed, imports_source, rebuild_index
|
| 12 |
from app.services.jobs import create_job, serialize_job
|
| 13 |
-
from app.services.plans import effective_plan
|
| 14 |
from app.services.storage import storage
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
|
@@ -73,7 +73,7 @@ async def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 73 |
.where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
|
| 74 |
)
|
| 75 |
if count >= plan.sources:
|
| 76 |
-
raise
|
| 77 |
source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
|
| 78 |
site_url=found.site_url, title=found.title)
|
| 79 |
ctx.db.add(source)
|
|
|
|
| 10 |
from app.models import Document, Job, NotebookDocument, Source, Upload
|
| 11 |
from app.pipeline.ingest import IMPORTS_FEED, FeedNotFound, discover_feed, imports_source, rebuild_index
|
| 12 |
from app.services.jobs import create_job, serialize_job
|
| 13 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 14 |
from app.services.storage import storage
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
|
|
|
| 73 |
.where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
|
| 74 |
)
|
| 75 |
if count >= plan.sources:
|
| 76 |
+
raise plan_limit_error(plan, "sources", f"Your plan includes {plan.sources} sources.")
|
| 77 |
source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
|
| 78 |
site_url=found.site_url, title=found.title)
|
| 79 |
ctx.db.add(source)
|
backend/app/routers/voice.py
CHANGED
|
@@ -12,7 +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
|
| 16 |
from app.services.storage import storage
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
|
@@ -246,8 +246,9 @@ async def quota(ctx: Ctx = Depends(get_ctx)):
|
|
| 246 |
|
| 247 |
@router.post("/consent")
|
| 248 |
def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
|
| 249 |
-
|
| 250 |
-
|
|
|
|
| 251 |
if not body.agreed or body.consent_text.strip() != CONSENT_TEXT:
|
| 252 |
raise HTTPException(400, "Please read and accept the consent statement.")
|
| 253 |
if not settings.elevenlabs_api_key:
|
|
|
|
| 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 |
|
| 18 |
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
|
|
|
| 246 |
|
| 247 |
@router.post("/consent")
|
| 248 |
def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
|
| 249 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 250 |
+
if not plan.voice_cloning:
|
| 251 |
+
raise plan_limit_error(plan, "voice_cloning", "Voice cloning is on the Writer and Studio plans.")
|
| 252 |
if not body.agreed or body.consent_text.strip() != CONSENT_TEXT:
|
| 253 |
raise HTTPException(400, "Please read and accept the consent statement.")
|
| 254 |
if not settings.elevenlabs_api_key:
|
backend/app/services/billing.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upgrade flow: plan status with meters, nudges, and the seams where the payment provider plugs in.
|
| 2 |
+
|
| 3 |
+
Stripe goes in create_checkout_url, create_portal_url and the webhook route; everything around it
|
| 4 |
+
(meters, nudges, subscription bookkeeping, 402 payloads) is provider agnostic.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datetime import UTC, datetime
|
| 8 |
+
|
| 9 |
+
from fastapi import HTTPException
|
| 10 |
+
from sqlalchemy import func, select
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.config import settings
|
| 14 |
+
from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
|
| 15 |
+
from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
|
| 16 |
+
from app.services.usage import month_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 |
+
"video_minutes": ("Video renders", "min"),
|
| 24 |
+
"launch_kits": ("Launch Kits", ""),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _meters(plan: Plan, used: dict, sources: int) -> list[dict]:
|
| 29 |
+
rows = []
|
| 30 |
+
for key, (label, unit) in METERS.items():
|
| 31 |
+
limit = getattr(plan, key)
|
| 32 |
+
rows.append({"key": key, "label": label, "unit": unit, "used": used[key], "limit": limit,
|
| 33 |
+
"pct": 0 if limit < 0 else round(min(1.0, used[key] / max(limit, 1)), 3)})
|
| 34 |
+
rows.append({"key": "sources", "label": "Sources", "unit": "", "used": sources, "limit": plan.sources,
|
| 35 |
+
"pct": round(min(1.0, sources / max(plan.sources, 1)), 3)})
|
| 36 |
+
return rows
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _count(db: Session, model, workspace_id, *where) -> int:
|
| 40 |
+
return db.scalar(select(func.count()).select_from(model).where(model.workspace_id == workspace_id, *where)) or 0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _nudge(nid: str, tone: str, title: str, body: str, cta: str, to: str | None = None, priority: int = 50) -> dict:
|
| 44 |
+
"""tone: limit (out of or low on fuel), upgrade (locked perk), action (do more with what you have).
|
| 45 |
+
to is an app route, or None when the CTA opens the upgrade popup."""
|
| 46 |
+
return {"id": nid, "tone": tone, "title": title, "body": body, "cta": cta, "to": to, "priority": priority}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dict], can_upgrade: bool) -> list[dict]:
|
| 50 |
+
ws = workspace.id
|
| 51 |
+
out: list[dict] = []
|
| 52 |
+
month = datetime.now(UTC).strftime("%Y-%m")
|
| 53 |
+
|
| 54 |
+
if can_upgrade:
|
| 55 |
+
for m in meters:
|
| 56 |
+
if m["limit"] < 0 or m["key"] == "sources":
|
| 57 |
+
continue
|
| 58 |
+
label = m["label"].lower()
|
| 59 |
+
if m["pct"] >= 1:
|
| 60 |
+
out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
|
| 61 |
+
f"You have used all {m['limit']} {m['unit'] or label}. Upgrade to keep launching.",
|
| 62 |
+
"Refuel now", priority=100))
|
| 63 |
+
elif m["pct"] >= LOW_FUEL:
|
| 64 |
+
left = round(m["limit"] - m["used"], 1)
|
| 65 |
+
out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
|
| 66 |
+
f"Only {left:g} {m['unit'] or 'left'} left this month. Upgrade before you stall mid launch.",
|
| 67 |
+
"See plans", priority=90))
|
| 68 |
+
if not plan.voice_cloning:
|
| 69 |
+
out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
|
| 70 |
+
"Writer unlocks voice cloning, so every audio overview sounds like you.",
|
| 71 |
+
"Unlock voice", priority=40))
|
| 72 |
+
if not plan.brand_kit:
|
| 73 |
+
out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
|
| 74 |
+
"Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit", priority=30))
|
| 75 |
+
|
| 76 |
+
sources = _count(db, Source, ws)
|
| 77 |
+
if sources == 0:
|
| 78 |
+
out.append(_nudge("act-source", "action", "Connect your archive",
|
| 79 |
+
"Paste your Substack or blog URL and Notestack reads every post.", "Connect a source",
|
| 80 |
+
"/app/sources", priority=95))
|
| 81 |
+
return sorted(out, key=lambda n: -n["priority"])
|
| 82 |
+
|
| 83 |
+
if _count(db, Notebook, ws) == 0:
|
| 84 |
+
out.append(_nudge("act-notebook", "action", "Open your first notebook",
|
| 85 |
+
"Ask your archive anything and get answers with citations to your own posts.",
|
| 86 |
+
"Create a notebook", "/app/notebooks", priority=80))
|
| 87 |
+
kits = next(m for m in meters if m["key"] == "launch_kits")
|
| 88 |
+
if kits["used"] == 0:
|
| 89 |
+
left = "Unlimited" if kits["limit"] < 0 else f"{kits['limit']}"
|
| 90 |
+
out.append(_nudge(f"act-kit-{month}", "action", "Launch your latest post",
|
| 91 |
+
f"{left} Launch Kits this month, none used yet. One click turns a post into threads, "
|
| 92 |
+
"LinkedIn posts, quote cards and a short video.", "Build a Launch Kit",
|
| 93 |
+
"/app/launch-kit", priority=70))
|
| 94 |
+
elif kits["limit"] > 0 and kits["used"] < kits["limit"]:
|
| 95 |
+
left = kits["limit"] - kits["used"]
|
| 96 |
+
out.append(_nudge(f"act-kit-left-{month}-{kits['used']}", "action",
|
| 97 |
+
f"{left} Launch Kit{'s' if left != 1 else ''} left this month",
|
| 98 |
+
"They reset on the 1st. Pick an older post and give it a second orbit.",
|
| 99 |
+
"Use one now", "/app/launch-kit", priority=45))
|
| 100 |
+
if _count(db, Artifact, ws, Artifact.type == "audio_overview") == 0:
|
| 101 |
+
out.append(_nudge("act-audio", "action", "Hear your archive",
|
| 102 |
+
"Make a two host audio overview of any notebook in about a minute.", "Make audio",
|
| 103 |
+
"/app/studio", priority=55))
|
| 104 |
+
if not db.scalar(select(VoiceProfile.id).where(VoiceProfile.workspace_id == ws)):
|
| 105 |
+
out.append(_nudge("act-voice", "action", "Teach Notestack your voice",
|
| 106 |
+
"A voice profile keeps every draft sounding like you wrote it.", "Build voice profile",
|
| 107 |
+
"/app/voice", priority=50))
|
| 108 |
+
if _count(db, CalendarItem, ws) == 0:
|
| 109 |
+
out.append(_nudge("act-launchpad", "action", "Fill your launch window",
|
| 110 |
+
"Schedule this week's posts on the Launchpad and let them go out on their own.",
|
| 111 |
+
"Open Launchpad", "/app/launchpad", priority=35))
|
| 112 |
+
out.append(_nudge(f"act-resurface-{month}", "action", "Old posts, new orbit",
|
| 113 |
+
"Resurfacing finds evergreen posts worth sharing again this month.", "Find evergreen posts",
|
| 114 |
+
"/app/resurface", priority=20))
|
| 115 |
+
return sorted(out, key=lambda n: -n["priority"])
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def billing_status(db: Session, workspace: Workspace) -> dict:
|
| 119 |
+
plan = effective_plan(db, workspace)
|
| 120 |
+
used = month_usage(db, workspace.id)
|
| 121 |
+
sources = _count(db, Source, workspace.id)
|
| 122 |
+
meters = _meters(plan, used, sources)
|
| 123 |
+
upgrade = next_plan(plan)
|
| 124 |
+
can_upgrade = settings.billing_enabled and upgrade is not None
|
| 125 |
+
sub = db.query(Subscription).filter_by(workspace_id=workspace.id).one_or_none()
|
| 126 |
+
return {
|
| 127 |
+
"billing_enabled": settings.billing_enabled,
|
| 128 |
+
"plan": plan_dict(plan),
|
| 129 |
+
"plans": [plan_dict(p) for p in PLANS.values()],
|
| 130 |
+
"next_plan": upgrade.id if upgrade else None,
|
| 131 |
+
"can_upgrade": can_upgrade,
|
| 132 |
+
"has_billing_account": bool(sub and sub.provider_customer_id),
|
| 133 |
+
"period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
|
| 134 |
+
"meters": meters,
|
| 135 |
+
"since": used["since"],
|
| 136 |
+
"nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def apply_subscription(db: Session, workspace_id, *, plan: str, status: str, provider: str = "stripe",
|
| 141 |
+
customer_id: str | None = None, subscription_id: str | None = None,
|
| 142 |
+
current_period_end: datetime | None = None) -> Subscription:
|
| 143 |
+
"""Upsert the workspace subscription. Call this from the webhook for checkout.session.completed,
|
| 144 |
+
customer.subscription.updated and customer.subscription.deleted (status 'canceled' drops to Free)."""
|
| 145 |
+
if plan not in PLANS:
|
| 146 |
+
raise ValueError(f"Unknown plan {plan}")
|
| 147 |
+
sub = db.query(Subscription).filter_by(workspace_id=workspace_id).one_or_none()
|
| 148 |
+
if not sub:
|
| 149 |
+
sub = Subscription(workspace_id=workspace_id)
|
| 150 |
+
db.add(sub)
|
| 151 |
+
sub.plan, sub.status, sub.provider = plan, status, provider
|
| 152 |
+
sub.provider_customer_id = customer_id or sub.provider_customer_id
|
| 153 |
+
sub.provider_subscription_id = subscription_id or sub.provider_subscription_id
|
| 154 |
+
sub.current_period_end = current_period_end
|
| 155 |
+
db.commit()
|
| 156 |
+
return sub
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def return_urls() -> tuple[str, str]:
|
| 160 |
+
"""(success_url, cancel_url) for the hosted checkout page."""
|
| 161 |
+
base = settings.frontend_url.rstrip("/")
|
| 162 |
+
return f"{base}/app?checkout=success", f"{base}/app?checkout=cancel"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def create_checkout_url(db: Session, user: User, workspace: Workspace, plan: Plan, cycle: str) -> str:
|
| 166 |
+
"""Return the hosted checkout URL for plan on the monthly or annual cycle.
|
| 167 |
+
|
| 168 |
+
TODO(stripe): create a Checkout Session in subscription mode with the price for (plan.id, cycle),
|
| 169 |
+
customer_email=user.email, client_reference_id=str(workspace.id), metadata={"plan": plan.id},
|
| 170 |
+
success_url/cancel_url from return_urls(), and return session.url.
|
| 171 |
+
"""
|
| 172 |
+
raise HTTPException(501, {"code": "checkout_not_wired", "message": "Checkout is almost ready. Check back soon."})
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def create_portal_url(db: Session, workspace: Workspace) -> str:
|
| 176 |
+
"""Return the customer portal URL for managing or cancelling a subscription.
|
| 177 |
+
|
| 178 |
+
TODO(stripe): billing_portal.Session.create(customer=sub.provider_customer_id, return_url=.../app/settings).
|
| 179 |
+
"""
|
| 180 |
+
raise HTTPException(501, {"code": "portal_not_wired", "message": "Billing management is almost ready."})
|
backend/app/services/plans.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from dataclasses import asdict, dataclass
|
| 4 |
|
|
|
|
| 5 |
from sqlalchemy.orm import Session
|
| 6 |
|
| 7 |
from app.config import settings
|
|
@@ -126,3 +127,31 @@ def effective_plan(db: Session, workspace: Workspace) -> Plan:
|
|
| 126 |
if not sub or sub.status not in {"active", "trialing"}:
|
| 127 |
return PLANS["free"]
|
| 128 |
return PLANS.get(sub.plan, PLANS["free"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from dataclasses import asdict, dataclass
|
| 4 |
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
|
| 8 |
from app.config import settings
|
|
|
|
| 127 |
if not sub or sub.status not in {"active", "trialing"}:
|
| 128 |
return PLANS["free"]
|
| 129 |
return PLANS.get(sub.plan, PLANS["free"])
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
PLAN_ORDER = ["free", "writer", "studio"]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def next_plan(plan: Plan) -> Plan | None:
|
| 136 |
+
i = PLAN_ORDER.index(plan.id)
|
| 137 |
+
return PLANS[PLAN_ORDER[i + 1]] if i + 1 < len(PLAN_ORDER) else None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def upgrade_for(plan: Plan, kind: str | None = None) -> str | None:
|
| 141 |
+
"""Cheapest plan above this one that raises the limit named by kind (or simply the next tier)."""
|
| 142 |
+
for pid in PLAN_ORDER[PLAN_ORDER.index(plan.id) + 1:]:
|
| 143 |
+
candidate = PLANS[pid]
|
| 144 |
+
if kind is None:
|
| 145 |
+
return pid
|
| 146 |
+
if kind == "voice_cloning" and candidate.voice_cloning:
|
| 147 |
+
return pid
|
| 148 |
+
current, better = getattr(plan, kind, 0), getattr(candidate, kind, 0)
|
| 149 |
+
if better < 0 or (isinstance(better, (int, float)) and better > current):
|
| 150 |
+
return pid
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def plan_limit_error(plan: Plan, kind: str, message: str) -> HTTPException:
|
| 155 |
+
"""402 the frontend turns into the out of fuel popup."""
|
| 156 |
+
return HTTPException(402, {"code": "plan_limit", "message": message, "kind": kind,
|
| 157 |
+
"plan": plan.id, "upgrade_to": upgrade_for(plan, kind)})
|
backend/app/services/usage.py
CHANGED
|
@@ -2,12 +2,11 @@
|
|
| 2 |
|
| 3 |
from datetime import UTC, datetime
|
| 4 |
|
| 5 |
-
from fastapi import HTTPException
|
| 6 |
from sqlalchemy import func, select
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
|
| 9 |
from app.models import Artifact, UsageEvent, Workspace
|
| 10 |
-
from app.services.plans import Plan, effective_plan
|
| 11 |
|
| 12 |
|
| 13 |
def month_start(now: datetime | None = None) -> datetime:
|
|
@@ -59,6 +58,5 @@ def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1)
|
|
| 59 |
"launch_kits": plan.launch_kits}[kind]
|
| 60 |
if limit >= 0 and used[kind] + amount > limit:
|
| 61 |
label = kind.replace("_", " ")
|
| 62 |
-
raise
|
| 63 |
-
"message": f"This would go over your {limit} {label} this month."})
|
| 64 |
return plan
|
|
|
|
| 2 |
|
| 3 |
from datetime import UTC, datetime
|
| 4 |
|
|
|
|
| 5 |
from sqlalchemy import func, select
|
| 6 |
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:
|
|
|
|
| 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.")
|
|
|
|
| 62 |
return plan
|
backend/tests/test_billing.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upgrade flow: status meters, nudges, checkout guards and the 402 payload behind the out of fuel popup."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from app.models import Artifact, Notebook, Workspace
|
| 7 |
+
from app.services.billing import apply_subscription
|
| 8 |
+
from tests.conftest import last_code
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture()
|
| 12 |
+
def auth(client):
|
| 13 |
+
client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42"})
|
| 14 |
+
r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()})
|
| 15 |
+
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture()
|
| 19 |
+
def billing_on(monkeypatch):
|
| 20 |
+
monkeypatch.setattr(settings, "billing_enabled", True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_status_without_billing_has_no_upgrade_nudges(client, auth):
|
| 24 |
+
body = client.get("/api/billing/status", headers=auth).json()
|
| 25 |
+
assert body["plan"]["id"] == "studio" and body["can_upgrade"] is False
|
| 26 |
+
assert {m["key"] for m in body["meters"]} == {"audio_minutes", "video_minutes", "launch_kits", "sources"}
|
| 27 |
+
assert all(n["tone"] == "action" for n in body["nudges"])
|
| 28 |
+
assert body["nudges"][0]["id"] == "act-source"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_session):
|
| 32 |
+
ws = db_session.query(Workspace).one()
|
| 33 |
+
for _ in range(2):
|
| 34 |
+
db_session.add(Artifact(workspace_id=ws.id, type="launch_kit", status="ready"))
|
| 35 |
+
db_session.commit()
|
| 36 |
+
|
| 37 |
+
body = client.get("/api/billing/status", headers=auth).json()
|
| 38 |
+
assert body["plan"]["id"] == "free" and body["can_upgrade"] and body["next_plan"] == "writer"
|
| 39 |
+
kits = next(m for m in body["meters"] if m["key"] == "launch_kits")
|
| 40 |
+
assert kits["used"] == 2 and kits["pct"] == 1
|
| 41 |
+
ids = [n["id"] for n in body["nudges"]]
|
| 42 |
+
assert ids[0].startswith("empty-launch_kits") and "perk-voice" in ids
|
| 43 |
+
assert "—" not in str(body)
|
| 44 |
+
|
| 45 |
+
nb = Notebook(workspace_id=ws.id, title="Pricing")
|
| 46 |
+
db_session.add(nb)
|
| 47 |
+
db_session.commit()
|
| 48 |
+
r = client.post("/api/artifacts/generate", headers=auth,
|
| 49 |
+
json={"type": "audio_overview", "notebook_id": str(nb.id), "minutes": 6})
|
| 50 |
+
detail = r.json()["detail"]
|
| 51 |
+
assert r.status_code == 402 and detail["kind"] == "audio_minutes" and detail["upgrade_to"] == "writer"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_checkout_guards(client, auth, billing_on, db_session):
|
| 55 |
+
assert client.post("/api/billing/checkout", headers=auth, json={"plan": "writer"}).status_code == 501
|
| 56 |
+
assert client.post("/api/billing/checkout", headers=auth, json={"plan": "free"}).status_code == 422
|
| 57 |
+
ws = db_session.query(Workspace).one()
|
| 58 |
+
apply_subscription(db_session, ws.id, plan="studio", status="active", customer_id="cus_1")
|
| 59 |
+
r = client.post("/api/billing/checkout", headers=auth, json={"plan": "writer", "cycle": "annual"})
|
| 60 |
+
assert r.status_code == 409 and r.json()["detail"]["code"] == "already_on_plan"
|
| 61 |
+
body = client.get("/api/billing/status", headers=auth).json()
|
| 62 |
+
assert body["plan"]["id"] == "studio" and body["has_billing_account"] and not body["can_upgrade"]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_checkout_disabled_without_billing(client, auth):
|
| 66 |
+
r = client.post("/api/billing/checkout", headers=auth, json={"plan": "writer"})
|
| 67 |
+
assert r.status_code == 409 and r.json()["detail"]["code"] == "billing_disabled"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_plan_limit_error_names_the_upgrade():
|
| 71 |
+
from app.services.plans import PLANS, plan_limit_error, upgrade_for
|
| 72 |
+
|
| 73 |
+
assert upgrade_for(PLANS["free"], "voice_cloning") == "writer"
|
| 74 |
+
assert upgrade_for(PLANS["writer"], "launch_kits") == "studio"
|
| 75 |
+
assert upgrade_for(PLANS["studio"], "audio_minutes") is None
|
| 76 |
+
detail = plan_limit_error(PLANS["free"], "audio_minutes", "x").detail
|
| 77 |
+
assert detail["code"] == "plan_limit" and detail["upgrade_to"] == "writer" and detail["kind"] == "audio_minutes"
|
frontend/src/pages/Landing.tsx
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
| 19 |
|
| 20 |
export const PENDING_SOURCE_KEY = "ns_pending_source";
|
| 21 |
// Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
|
| 22 |
-
const DEMO_VERSION =
|
| 23 |
|
| 24 |
const FEATURES = [
|
| 25 |
{
|
|
|
|
| 19 |
|
| 20 |
export const PENDING_SOURCE_KEY = "ns_pending_source";
|
| 21 |
// Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
|
| 22 |
+
const DEMO_VERSION = 3;
|
| 23 |
|
| 24 |
const FEATURES = [
|
| 25 |
{
|
renderer/public/demo-vo/durations.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
{
|
| 2 |
-
"warp": 3.
|
| 3 |
-
"paste": 8.
|
| 4 |
-
"orbit":
|
| 5 |
-
"research": 4.
|
| 6 |
-
"audio":
|
| 7 |
-
"launchkit":
|
| 8 |
-
"launchpad": 3.
|
| 9 |
-
"outro": 2.
|
| 10 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"warp": 3.69,
|
| 3 |
+
"paste": 8.34,
|
| 4 |
+
"orbit": 5.96,
|
| 5 |
+
"research": 4.39,
|
| 6 |
+
"audio": 4.57,
|
| 7 |
+
"launchkit": 5.83,
|
| 8 |
+
"launchpad": 3.14,
|
| 9 |
+
"outro": 2.56
|
| 10 |
}
|
renderer/public/launch-vo/durations.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
{
|
| 2 |
-
"intro": 5.
|
| 3 |
-
"launch":
|
| 4 |
-
"sources":
|
| 5 |
-
"research": 4.
|
| 6 |
-
"map":
|
| 7 |
-
"voice": 4.
|
| 8 |
-
"audio": 3.
|
| 9 |
-
"video":
|
| 10 |
-
"launchkit":
|
| 11 |
-
"launchpad":
|
| 12 |
-
"resurface":
|
| 13 |
-
"outro": 5.
|
| 14 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"intro": 5.78,
|
| 3 |
+
"launch": 3.03,
|
| 4 |
+
"sources": 6.14,
|
| 5 |
+
"research": 4.16,
|
| 6 |
+
"map": 6.12,
|
| 7 |
+
"voice": 4.57,
|
| 8 |
+
"audio": 3.69,
|
| 9 |
+
"video": 4.44,
|
| 10 |
+
"launchkit": 4.7,
|
| 11 |
+
"launchpad": 3.32,
|
| 12 |
+
"resurface": 4.23,
|
| 13 |
+
"outro": 5.17
|
| 14 |
}
|
renderer/scripts/make_demo_music.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ambient space score for the landing demo: warm pads, a bell arpeggio, a soft sub, long reverb.
|
| 2 |
+
Pure additive synthesis, no noise sources. Writes renderer/public/demo-vo/music.wav (stereo, 44.1 kHz)."""
|
| 3 |
+
|
| 4 |
+
import wave
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
SR = 44100
|
| 10 |
+
TOTAL = 47.5
|
| 11 |
+
OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo" / "music.wav"
|
| 12 |
+
|
| 13 |
+
n = int(SR * TOTAL)
|
| 14 |
+
t = np.arange(n) / SR
|
| 15 |
+
left = np.zeros(n)
|
| 16 |
+
right = np.zeros(n)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def hz(midi: float) -> float:
|
| 20 |
+
return 440.0 * 2 ** ((midi - 69) / 12)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def env(start: float, dur: float, attack: float, release: float) -> np.ndarray:
|
| 24 |
+
"""Smooth (raised cosine) attack and release, zero outside the note."""
|
| 25 |
+
e = np.zeros(n)
|
| 26 |
+
i0, i1 = int(start * SR), min(n, int((start + dur + release) * SR))
|
| 27 |
+
if i0 >= n:
|
| 28 |
+
return e
|
| 29 |
+
tt = t[i0:i1] - start
|
| 30 |
+
a = np.clip(tt / attack, 0, 1)
|
| 31 |
+
a = 0.5 - 0.5 * np.cos(np.pi * a)
|
| 32 |
+
r = np.clip((start + dur + release - t[i0:i1]) / release, 0, 1)
|
| 33 |
+
r = 0.5 - 0.5 * np.cos(np.pi * r)
|
| 34 |
+
e[i0:i1] = a * r
|
| 35 |
+
return e
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def pad_voice(freq: float, e: np.ndarray, brightness: np.ndarray, pan: float, gain: float) -> None:
|
| 39 |
+
"""Soft saw-like tone: a few harmonics with rolloff, two detuned copies for width, slow vibrato."""
|
| 40 |
+
idx = np.nonzero(e)[0]
|
| 41 |
+
if not len(idx):
|
| 42 |
+
return
|
| 43 |
+
sl = slice(idx[0], idx[-1] + 1)
|
| 44 |
+
tt = t[sl]
|
| 45 |
+
vib = 1 + 0.0015 * np.sin(2 * np.pi * 0.21 * tt + freq)
|
| 46 |
+
tone = np.zeros(len(tt))
|
| 47 |
+
for cents, ph in ((-7, 0.0), (7, 1.3)):
|
| 48 |
+
f = freq * 2 ** (cents / 1200) * vib
|
| 49 |
+
phase = 2 * np.pi * np.cumsum(f) / SR + ph
|
| 50 |
+
for h in range(1, 7):
|
| 51 |
+
tone += np.sin(h * phase) * (brightness[sl] ** (h - 1)) / h**1.4
|
| 52 |
+
tone *= e[sl] * gain
|
| 53 |
+
left[sl] += tone * (1 - pan)
|
| 54 |
+
right[sl] += tone * (1 + pan)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def bell(freq: float, start: float, gain: float, pan: float) -> None:
|
| 58 |
+
"""Glassy bell: fundamental plus an inharmonic partial, fast decay."""
|
| 59 |
+
i0 = int(start * SR)
|
| 60 |
+
if i0 >= n:
|
| 61 |
+
return
|
| 62 |
+
length = min(n - i0, int(2.4 * SR))
|
| 63 |
+
tt = np.arange(length) / SR
|
| 64 |
+
decay = np.exp(-tt * 2.2)
|
| 65 |
+
tone = np.sin(2 * np.pi * freq * tt) + 0.35 * np.sin(2 * np.pi * freq * 2.76 * tt) * np.exp(-tt * 5)
|
| 66 |
+
tone += 0.12 * np.sin(2 * np.pi * freq * 5.4 * tt) * np.exp(-tt * 9)
|
| 67 |
+
attack = np.clip(tt / 0.004, 0, 1)
|
| 68 |
+
tone *= decay * attack * gain
|
| 69 |
+
left[i0:i0 + length] += tone * (1 - pan)
|
| 70 |
+
right[i0:i0 + length] += tone * (1 + pan)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# Progression in D (lydian colour), ~5.2 s per chord, ending on a wide resolved chord for the outro.
|
| 74 |
+
CHORDS = [
|
| 75 |
+
(0.0, [50, 57, 61, 64, 69]), # Dmaj9 (no 3rd low) D A C# E A
|
| 76 |
+
(5.2, [47, 54, 57, 61, 66]), # Bm11 feel B F# A C# F#
|
| 77 |
+
(10.4, [43, 50, 54, 57, 62]), # Gmaj7 add9 G D F# A D
|
| 78 |
+
(15.6, [45, 52, 56, 59, 64]), # A6/9 feel A E G# B E
|
| 79 |
+
(20.8, [50, 57, 61, 64, 68]), # Dmaj7#11 D A C# E G#
|
| 80 |
+
(26.0, [47, 54, 57, 62, 66]), # Bm9 B F# A D F#
|
| 81 |
+
(31.2, [43, 50, 55, 59, 64]), # G6/9 G D G B E
|
| 82 |
+
(36.4, [50, 57, 62, 64, 69]), # Dsus2 add6 outro D A D E A
|
| 83 |
+
]
|
| 84 |
+
CHORD_LEN = 5.2
|
| 85 |
+
|
| 86 |
+
# Filter brightness rises slowly through the piece and blooms at the outro.
|
| 87 |
+
brightness = 0.28 + 0.14 * np.clip(t / TOTAL, 0, 1) + 0.1 * np.clip((t - 36) / 4, 0, 1)
|
| 88 |
+
brightness *= 1 + 0.08 * np.sin(2 * np.pi * 0.05 * t)
|
| 89 |
+
|
| 90 |
+
for start, notes in CHORDS:
|
| 91 |
+
last = start == CHORDS[-1][0]
|
| 92 |
+
dur = (TOTAL - start - 3.0) if last else CHORD_LEN
|
| 93 |
+
e = env(start, dur, attack=1.8, release=2.6 if not last else 3.0)
|
| 94 |
+
for i, midi in enumerate(notes):
|
| 95 |
+
pad_voice(hz(midi), e, brightness, pan=(i - 2) * 0.22, gain=0.055 if i else 0.07)
|
| 96 |
+
# Sub bass: root an octave down, pure sine, gentle.
|
| 97 |
+
root = hz(notes[0] - 12)
|
| 98 |
+
idx = np.nonzero(e)[0]
|
| 99 |
+
sl = slice(idx[0], idx[-1] + 1)
|
| 100 |
+
sub = np.sin(2 * np.pi * root * t[sl]) * e[sl] * 0.11
|
| 101 |
+
left[sl] += sub
|
| 102 |
+
right[sl] += sub
|
| 103 |
+
|
| 104 |
+
# Bell arpeggio from the second scene on: eighth notes at 88 bpm, up and down the chord an octave up.
|
| 105 |
+
step = 60 / 88 / 2
|
| 106 |
+
rng = np.random.default_rng(3)
|
| 107 |
+
beat = 3.4
|
| 108 |
+
k = 0
|
| 109 |
+
while beat < TOTAL - 3.5:
|
| 110 |
+
chord = [c for c in CHORDS if c[0] <= beat][-1][1]
|
| 111 |
+
pattern = chord[1:] + chord[-2:0:-1]
|
| 112 |
+
midi = pattern[k % len(pattern)] + 12
|
| 113 |
+
accent = 1.0 if k % 4 == 0 else 0.7
|
| 114 |
+
thin = 0.55 if 20 < beat < 26 else 1.0 # breathe during the audio overview scene
|
| 115 |
+
if rng.random() > 0.12: # a few rests so it does not feel mechanical
|
| 116 |
+
bell(hz(midi), beat, gain=0.045 * accent * thin, pan=0.5 * np.sin(k * 0.9))
|
| 117 |
+
beat += step
|
| 118 |
+
k += 1
|
| 119 |
+
|
| 120 |
+
# Ping pong delay on everything (dotted eighth), dark feedback.
|
| 121 |
+
delay = int(SR * step * 1.5)
|
| 122 |
+
fb = 0.3
|
| 123 |
+
dl, dr = np.zeros(n), np.zeros(n)
|
| 124 |
+
for i in range(3):
|
| 125 |
+
shift = delay * (i + 1)
|
| 126 |
+
g = fb ** (i + 1)
|
| 127 |
+
src_l, src_r = (right, left) if i % 2 == 0 else (left, right)
|
| 128 |
+
dl[shift:] += src_l[:-shift] * g
|
| 129 |
+
dr[shift:] += src_r[:-shift] * g
|
| 130 |
+
left += dl * 0.5
|
| 131 |
+
right += dr * 0.5
|
| 132 |
+
|
| 133 |
+
# Long reverb: convolution with a smooth decaying impulse (built from low passed, decaying partials,
|
| 134 |
+
# not white noise, so it stays silky).
|
| 135 |
+
ir_len = int(SR * 3.2)
|
| 136 |
+
it = np.arange(ir_len) / SR
|
| 137 |
+
ir_l = np.zeros(ir_len)
|
| 138 |
+
ir_r = np.zeros(ir_len)
|
| 139 |
+
for f in rng.uniform(180, 2400, 90):
|
| 140 |
+
ph = rng.uniform(0, 2 * np.pi, 2)
|
| 141 |
+
d = np.exp(-it * rng.uniform(1.1, 2.2))
|
| 142 |
+
ir_l += np.sin(2 * np.pi * f * it + ph[0]) * d
|
| 143 |
+
ir_r += np.sin(2 * np.pi * f * it + ph[1]) * d
|
| 144 |
+
ir_l /= np.max(np.abs(ir_l))
|
| 145 |
+
ir_r /= np.max(np.abs(ir_r))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def convolve(x: np.ndarray, ir: np.ndarray) -> np.ndarray:
|
| 149 |
+
size = 1 << int(np.ceil(np.log2(len(x) + len(ir))))
|
| 150 |
+
y = np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(ir, size), size)
|
| 151 |
+
return y[: len(x)]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
wet_l = convolve(left, ir_l)
|
| 155 |
+
wet_r = convolve(right, ir_r)
|
| 156 |
+
wet_l /= np.max(np.abs(wet_l)) + 1e-9
|
| 157 |
+
wet_r /= np.max(np.abs(wet_r)) + 1e-9
|
| 158 |
+
dry = max(np.max(np.abs(left)), np.max(np.abs(right)))
|
| 159 |
+
mix_l = left / dry * 0.75 + wet_l * 0.45
|
| 160 |
+
mix_r = right / dry * 0.75 + wet_r * 0.45
|
| 161 |
+
|
| 162 |
+
# Master: fade in, fade out, gentle soft clip, normalize.
|
| 163 |
+
master = np.minimum(1, t / 2.5) * np.minimum(1, (TOTAL - t) / 3.0)
|
| 164 |
+
mix_l *= master
|
| 165 |
+
mix_r *= master
|
| 166 |
+
peak = max(np.max(np.abs(mix_l)), np.max(np.abs(mix_r)))
|
| 167 |
+
mix_l = np.tanh(mix_l / peak * 1.1) * 0.8
|
| 168 |
+
mix_r = np.tanh(mix_r / peak * 1.1) * 0.8
|
| 169 |
+
stereo = np.stack([mix_l, mix_r], axis=1)
|
| 170 |
+
with wave.open(str(OUT), "wb") as w:
|
| 171 |
+
w.setnchannels(2)
|
| 172 |
+
w.setsampwidth(2)
|
| 173 |
+
w.setframerate(SR)
|
| 174 |
+
w.writeframes((stereo * 32767).astype("<i2").tobytes())
|
| 175 |
+
print("music ok", OUT)
|
renderer/scripts/make_demo_vo.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
|
| 2 |
+
|
| 3 |
+
The screens show the product; the voice says what it feels like. Then copy the printed durations
|
| 4 |
+
into VO_SECONDS in src/compositions/LandingDemo.tsx (and lengthen a scene if its line no longer fits).
|
| 5 |
+
make_demo_music.py writes the 47.5 s score; rerun it with a larger TOTAL if the demo grows past that.
|
| 6 |
+
|
| 7 |
+
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import uuid
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from app.services import tts
|
| 15 |
+
|
| 16 |
+
OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo"
|
| 17 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
LINES = {
|
| 20 |
+
"warp": "Captain's log. Your knowledge... is now in orbit.",
|
| 21 |
+
"paste": "Just point us at your writing. Any blog, any site, any folder of notes. Then sit back.",
|
| 22 |
+
"orbit": "Watch years of work come home. Every idea you've had, finally within reach.",
|
| 23 |
+
"research": "Ask, and hear your own best thinking answer back. With proof.",
|
| 24 |
+
"audio": "Hear your ideas as a conversation. Like meeting your work for the first time.",
|
| 25 |
+
"launchkit": "No more blank page. Next week's posts are already here, and they sound like you.",
|
| 26 |
+
"launchpad": "Press schedule, and feel the weight lift off.",
|
| 27 |
+
"outro": "Notestack. Clear skies ahead.",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
|
| 31 |
+
vs = tts.VoiceSettings(stability=0.55, similarity_boost=0.8, style=0.3, speed=0.93)
|
| 32 |
+
ws = uuid.UUID(int=0)
|
| 33 |
+
keys = list(LINES)
|
| 34 |
+
durations = {}
|
| 35 |
+
for i, key in enumerate(keys):
|
| 36 |
+
prev_text = LINES[keys[i - 1]] if i else None
|
| 37 |
+
next_text = LINES[keys[i + 1]] if i + 1 < len(keys) else None
|
| 38 |
+
clip = tts.synthesize(ws, LINES[key], voice_id, vs, prev_text, next_text)
|
| 39 |
+
(OUT / f"{key}.mp3").write_bytes(clip)
|
| 40 |
+
durations[key] = round(tts.mp3_seconds(clip), 2)
|
| 41 |
+
(OUT / "durations.json").write_text(json.dumps(durations, indent=2))
|
| 42 |
+
print(json.dumps(durations))
|
renderer/scripts/make_launch_vo.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
"""Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
|
| 2 |
|
|
|
|
|
|
|
| 3 |
Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
|
| 4 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
|
| 5 |
"""
|
|
@@ -14,23 +16,23 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "launch-vo"
|
|
| 14 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 15 |
|
| 16 |
LINES = {
|
| 17 |
-
"intro": "
|
| 18 |
-
"launch": "
|
| 19 |
-
"sources": "
|
| 20 |
-
"research": "Ask
|
| 21 |
-
"map": "
|
| 22 |
-
"voice": "
|
| 23 |
-
"audio": "
|
| 24 |
-
"video": "
|
| 25 |
-
"launchkit": "One post
|
| 26 |
-
"launchpad": "
|
| 27 |
-
"resurface": "And
|
| 28 |
"outro": "Notestack A.I. The galaxies of your untapped potential. Coming soon.",
|
| 29 |
}
|
| 30 |
|
| 31 |
voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
|
| 32 |
-
#
|
| 33 |
-
vs = tts.VoiceSettings(stability=0.5, similarity_boost=0.8, style=0.
|
| 34 |
ws = uuid.UUID(int=0)
|
| 35 |
keys = list(LINES)
|
| 36 |
durations = {}
|
|
|
|
| 1 |
"""Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
|
| 2 |
|
| 3 |
+
The screens show what each feature does; the voice carries what it feels like to use it.
|
| 4 |
+
|
| 5 |
Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
|
| 6 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
|
| 7 |
"""
|
|
|
|
| 16 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 17 |
|
| 18 |
LINES = {
|
| 19 |
+
"intro": "You have written more than you remember. Whole worlds of ideas... still uncharted.",
|
| 20 |
+
"launch": "Take a breath. We're going exploring.",
|
| 21 |
+
"sources": "Watch years of your writing drift back into view. Nothing lost. Nothing forgotten.",
|
| 22 |
+
"research": "Ask a question, and hear your own best thinking answer back.",
|
| 23 |
+
"map": "See the shape of your mind. The ideas you keep coming home to, and the ones still waiting.",
|
| 24 |
+
"voice": "Everything it makes sounds like you. On your best day.",
|
| 25 |
+
"audio": "Hear your ideas come alive, as a conversation.",
|
| 26 |
+
"video": "Watch your words move, glow, and find new eyes.",
|
| 27 |
+
"launchkit": "One post. A whole week of posts. And never a blank page.",
|
| 28 |
+
"launchpad": "Press schedule, and feel the weight lift off.",
|
| 29 |
+
"resurface": "And the gems you forgot you wrote? They find their way back to you.",
|
| 30 |
"outro": "Notestack A.I. The galaxies of your untapped potential. Coming soon.",
|
| 31 |
}
|
| 32 |
|
| 33 |
voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
|
| 34 |
+
# Warm and unhurried: this is about a feeling, not a feature list.
|
| 35 |
+
vs = tts.VoiceSettings(stability=0.5, similarity_boost=0.8, style=0.4, speed=0.93)
|
| 36 |
ws = uuid.UUID(int=0)
|
| 37 |
keys = list(LINES)
|
| 38 |
durations = {}
|
renderer/src/compositions/LandingDemo.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import type { CSSProperties, ReactNode } from "react";
|
| 2 |
-
import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
| 3 |
import { theme } from "../theme";
|
| 4 |
|
| 5 |
/** Landing page product demo: a trip through the Notestack galaxy. 1920x1080, 30 fps, ~32 s.
|
|
@@ -16,7 +16,7 @@ const SCENES = [
|
|
| 16 |
{ id: "paste", len: 9.4, vo: 0.4 },
|
| 17 |
{ id: "orbit", len: 7.6, vo: 0.4 },
|
| 18 |
{ id: "research", len: 5.6, vo: 0.4 },
|
| 19 |
-
{ id: "audio", len: 5.
|
| 20 |
{ id: "launchkit", len: 7.8, vo: 0.4 },
|
| 21 |
{ id: "launchpad", len: 4.6, vo: 0.4 },
|
| 22 |
{ id: "outro", len: 4.2, vo: 0.5 },
|
|
@@ -25,7 +25,7 @@ const XFADE = 0.5; // seconds of overlap between scenes
|
|
| 25 |
|
| 26 |
// Narration clip lengths (public/demo-vo/durations.json) so the music can duck under the voice.
|
| 27 |
const VO_SECONDS: Record<(typeof SCENES)[number]["id"], number> = {
|
| 28 |
-
warp: 3.
|
| 29 |
};
|
| 30 |
|
| 31 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
|
@@ -563,9 +563,8 @@ function Outro() {
|
|
| 563 |
<AbsoluteFill>
|
| 564 |
<WarpField speed={interpolate(frame, [0, 60, 114], [0.5, 2, 12])} streak={interpolate(frame, [70, 114], [0, 1], { extrapolateLeft: "clamp" })} />
|
| 565 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 30 }}>
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
</div>
|
| 569 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 120, color: theme.white, opacity: s, textShadow: `0 0 40px ${BLUE}` }}>Notestack</h1>
|
| 570 |
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.8)", opacity: cta }}>Your knowledge, in orbit.</p>
|
| 571 |
<p style={{ margin: 0, padding: "18px 40px", borderRadius: 999, background: BLUE, color: theme.white, fontFamily: theme.body, fontWeight: 600, fontSize: 32, opacity: cta, boxShadow: `0 0 40px ${BLUE}` }}>
|
|
|
|
| 1 |
import type { CSSProperties, ReactNode } from "react";
|
| 2 |
+
import { AbsoluteFill, Audio as SoundTrack, Easing, Img, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
| 3 |
import { theme } from "../theme";
|
| 4 |
|
| 5 |
/** Landing page product demo: a trip through the Notestack galaxy. 1920x1080, 30 fps, ~32 s.
|
|
|
|
| 16 |
{ id: "paste", len: 9.4, vo: 0.4 },
|
| 17 |
{ id: "orbit", len: 7.6, vo: 0.4 },
|
| 18 |
{ id: "research", len: 5.6, vo: 0.4 },
|
| 19 |
+
{ id: "audio", len: 5.8, vo: 0.4 },
|
| 20 |
{ id: "launchkit", len: 7.8, vo: 0.4 },
|
| 21 |
{ id: "launchpad", len: 4.6, vo: 0.4 },
|
| 22 |
{ id: "outro", len: 4.2, vo: 0.5 },
|
|
|
|
| 25 |
|
| 26 |
// Narration clip lengths (public/demo-vo/durations.json) so the music can duck under the voice.
|
| 27 |
const VO_SECONDS: Record<(typeof SCENES)[number]["id"], number> = {
|
| 28 |
+
warp: 3.69, paste: 8.34, orbit: 5.96, research: 4.39, audio: 4.57, launchkit: 5.83, launchpad: 3.14, outro: 2.56,
|
| 29 |
};
|
| 30 |
|
| 31 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
|
|
|
| 563 |
<AbsoluteFill>
|
| 564 |
<WarpField speed={interpolate(frame, [0, 60, 114], [0.5, 2, 12])} streak={interpolate(frame, [70, 114], [0, 1], { extrapolateLeft: "clamp" })} />
|
| 565 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 30 }}>
|
| 566 |
+
{/* The navbar mark (frontend/public/logo.svg, copied to public/logo.svg). */}
|
| 567 |
+
<Img src={staticFile("logo.svg")} width={128} height={128} style={{ borderRadius: 30, opacity: s, transform: `scale(${s})`, boxShadow: `0 0 80px ${BLUE}` }} />
|
|
|
|
| 568 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 120, color: theme.white, opacity: s, textShadow: `0 0 40px ${BLUE}` }}>Notestack</h1>
|
| 569 |
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.8)", opacity: cta }}>Your knowledge, in orbit.</p>
|
| 570 |
<p style={{ margin: 0, padding: "18px 40px", borderRadius: 999, background: BLUE, color: theme.white, fontFamily: theme.body, fontWeight: 600, fontSize: 32, opacity: cta, boxShadow: `0 0 40px ${BLUE}` }}>
|
renderer/src/compositions/LaunchTrailer.tsx
CHANGED
|
@@ -544,11 +544,11 @@ function Intro() {
|
|
| 544 |
<Galaxy seed="far-4" x={260} y={200} size={60} tilt={0.7} />
|
| 545 |
</div>
|
| 546 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
|
| 547 |
-
<h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize:
|
| 548 |
-
|
| 549 |
</h1>
|
| 550 |
<h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
|
| 551 |
-
|
| 552 |
</h2>
|
| 553 |
<p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
|
| 554 |
</AbsoluteFill>
|
|
|
|
| 544 |
<Galaxy seed="far-4" x={260} y={200} size={60} tilt={0.7} />
|
| 545 |
</div>
|
| 546 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
|
| 547 |
+
<h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize: 80, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
|
| 548 |
+
You have written more than you remember.
|
| 549 |
</h1>
|
| 550 |
<h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
|
| 551 |
+
Whole worlds of ideas, <span style={{ color: WHITE, textShadow: `0 0 30px ${BLUE}, 0 0 70px ${BLUE}` }}>still uncharted.</span>
|
| 552 |
</h2>
|
| 553 |
<p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
|
| 554 |
</AbsoluteFill>
|
renderer/src/compositions/launchTimeline.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
// Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
|
| 2 |
// 120 bpm; every scene starts on a bar line so cuts land on the beat.
|
| 3 |
export const LAUNCH_BPM = 120;
|
| 4 |
-
export const LAUNCH_SECONDS =
|
| 5 |
export const LAUNCH_SCENES = [
|
| 6 |
-
{ id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.
|
| 7 |
-
{ id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen:
|
| 8 |
-
{ id: "sources", start: 12.0, len:
|
| 9 |
-
{ id: "research", start:
|
| 10 |
-
{ id: "map", start:
|
| 11 |
-
{ id: "voice", start:
|
| 12 |
-
{ id: "audio", start:
|
| 13 |
-
{ id: "video", start:
|
| 14 |
-
{ id: "launchkit", start:
|
| 15 |
-
{ id: "launchpad", start:
|
| 16 |
-
{ id: "resurface", start:
|
| 17 |
-
{ id: "montage", start:
|
| 18 |
-
{ id: "outro", start:
|
| 19 |
] as const;
|
| 20 |
export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];
|
|
|
|
| 1 |
// Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
|
| 2 |
// 120 bpm; every scene starts on a bar line so cuts land on the beat.
|
| 3 |
export const LAUNCH_BPM = 120;
|
| 4 |
+
export const LAUNCH_SECONDS = 84.0;
|
| 5 |
export const LAUNCH_SCENES = [
|
| 6 |
+
{ id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.78 },
|
| 7 |
+
{ id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen: 3.03 },
|
| 8 |
+
{ id: "sources", start: 12.0, len: 8.0, vo: 0.35, voLen: 6.14 },
|
| 9 |
+
{ id: "research", start: 20.0, len: 6.0, vo: 0.35, voLen: 4.16 },
|
| 10 |
+
{ id: "map", start: 26.0, len: 8.0, vo: 0.35, voLen: 6.12 },
|
| 11 |
+
{ id: "voice", start: 34.0, len: 6.0, vo: 0.35, voLen: 4.57 },
|
| 12 |
+
{ id: "audio", start: 40.0, len: 6.0, vo: 0.35, voLen: 3.69 },
|
| 13 |
+
{ id: "video", start: 46.0, len: 6.0, vo: 0.35, voLen: 4.44 },
|
| 14 |
+
{ id: "launchkit", start: 52.0, len: 6.0, vo: 0.35, voLen: 4.7 },
|
| 15 |
+
{ id: "launchpad", start: 58.0, len: 6.0, vo: 0.35, voLen: 3.32 },
|
| 16 |
+
{ id: "resurface", start: 64.0, len: 6.0, vo: 0.35, voLen: 4.23 },
|
| 17 |
+
{ id: "montage", start: 70.0, len: 4.0, vo: null, voLen: null },
|
| 18 |
+
{ id: "outro", start: 74.0, len: 10.0, vo: 1.2, voLen: 5.17 },
|
| 19 |
] as const;
|
| 20 |
export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];
|