Arslan1997 commited on
Commit
3f79c45
·
1 Parent(s): 3d10303

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
backend/app/routers/billing.py CHANGED
@@ -1,12 +1,21 @@
1
- from fastapi import APIRouter, Depends, HTTPException
 
 
 
2
 
3
  from app.auth import Ctx, get_ctx
4
  from app.config import settings
5
- from app.services.plans import PLANS, effective_plan, plan_dict
 
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
- raise HTTPException(501, "Checkout provider not wired yet")
 
 
 
 
 
 
 
 
 
 
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 HTTPException(402, {"code": "plan_limit", "message": 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)
 
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
- if not effective_plan(ctx.db, ctx.workspace).voice_cloning:
250
- raise HTTPException(402, {"code": "plan_limit", "message": "Voice cloning is on the Writer and Studio plans."})
 
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 HTTPException(402, {"code": "plan_limit",
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 = 2;
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.74,
3
- "paste": 8.1,
4
- "orbit": 6.3,
5
- "research": 4.29,
6
- "audio": 3.74,
7
- "launchkit": 6.53,
8
- "launchpad": 3.22,
9
- "outro": 2.48
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.36,
3
- "launch": 2.85,
4
- "sources": 8.0,
5
- "research": 4.94,
6
- "map": 5.96,
7
- "voice": 4.52,
8
- "audio": 3.45,
9
- "video": 6.61,
10
- "launchkit": 6.38,
11
- "launchpad": 5.65,
12
- "resurface": 3.82,
13
- "outro": 5.07
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": "Every writer carries a universe of ideas. Most of it... still uncharted.",
18
- "launch": "So we built a ship. Let's go exploring.",
19
- "sources": "First stop: your sources. Any blog, newsletter, website, or markdown file, pulled into orbit.",
20
- "research": "Ask your archive anything. Every answer is traced to the exact lines you wrote.",
21
- "map": "Chart the constellations in your thinking. See what's rising, and what has gone dark.",
22
- "voice": "Teach it your voice, so everything it makes still sounds like you.",
23
- "audio": "Turn any notebook into a two host audio overview.",
24
- "video": "Shorts, explainers, audiograms and quote cards, all rendered from your own words.",
25
- "launchkit": "One post becomes a thread, a LinkedIn post, notes, an SEO pack and a carousel.",
26
- "launchpad": "Schedule everything, and post straight to X, LinkedIn and Bluesky.",
27
- "resurface": "And your best old posts? We bring them back into orbit.",
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
- # A touch more energy than the landing demo: this one is a trailer.
33
- vs = tts.VoiceSettings(stability=0.5, similarity_boost=0.8, style=0.35, speed=0.95)
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.1, 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,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.74, paste: 8.1, orbit: 6.3, research: 4.29, audio: 3.74, launchkit: 6.53, launchpad: 3.22, outro: 2.48,
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
- <div style={{ width: 120, height: 120, borderRadius: 30, background: theme.white, display: "grid", placeItems: "center", opacity: s, transform: `scale(${s})`, boxShadow: `0 0 80px ${BLUE}` }}>
567
- <div style={{ width: 44, height: 44, borderRadius: "50%", background: theme.black, boxShadow: `18px -18px 0 -10px ${theme.black}` }} />
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: 96, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
548
- Every writer carries a universe.
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
- Most of it is <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>
 
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 = 92.0;
5
  export const LAUNCH_SCENES = [
6
- { id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.36 },
7
- { id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen: 2.85 },
8
- { id: "sources", start: 12.0, len: 10.0, vo: 0.35, voLen: 8.0 },
9
- { id: "research", start: 22.0, len: 6.0, vo: 0.35, voLen: 4.94 },
10
- { id: "map", start: 28.0, len: 8.0, vo: 0.35, voLen: 5.96 },
11
- { id: "voice", start: 36.0, len: 6.0, vo: 0.35, voLen: 4.52 },
12
- { id: "audio", start: 42.0, len: 6.0, vo: 0.35, voLen: 3.45 },
13
- { id: "video", start: 48.0, len: 8.0, vo: 0.35, voLen: 6.61 },
14
- { id: "launchkit", start: 56.0, len: 8.0, vo: 0.35, voLen: 6.38 },
15
- { id: "launchpad", start: 64.0, len: 8.0, vo: 0.35, voLen: 5.65 },
16
- { id: "resurface", start: 72.0, len: 6.0, vo: 0.35, voLen: 3.82 },
17
- { id: "montage", start: 78.0, len: 4.0, vo: null, voLen: null },
18
- { id: "outro", start: 82.0, len: 10.0, vo: 1.2, voLen: 5.07 },
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"];