Spaces:
Runtime error
Runtime error
Commit ·
d149013
1
Parent(s): f74c934
launching
Browse files- backend/app/services/plans.py +19 -8
- docs/DESIGN.md +5 -5
- frontend/src/components/PricingTiers.tsx +51 -18
- frontend/src/styles/showcase.css +65 -0
- renderer/src/compositions/LandingDemo.tsx +32 -1
backend/app/services/plans.py
CHANGED
|
@@ -7,6 +7,8 @@ from sqlalchemy.orm import Session
|
|
| 7 |
from app.config import settings
|
| 8 |
from app.models import Subscription, Workspace
|
| 9 |
|
|
|
|
|
|
|
| 10 |
|
| 11 |
@dataclass(frozen=True)
|
| 12 |
class Plan:
|
|
@@ -31,24 +33,25 @@ PLANS: dict[str, Plan] = {
|
|
| 31 |
tagline="For trying Notestack on your archive",
|
| 32 |
price_monthly_usd=0,
|
| 33 |
sources=1,
|
| 34 |
-
indexed_posts=
|
| 35 |
-
audio_minutes=
|
| 36 |
-
video_minutes=
|
| 37 |
-
launch_kits=
|
| 38 |
voice_cloning=False,
|
| 39 |
brand_kit=False,
|
| 40 |
features=(
|
| 41 |
-
"1 source,
|
| 42 |
"Grounded research chat with citations",
|
| 43 |
-
"
|
| 44 |
-
"
|
|
|
|
| 45 |
),
|
| 46 |
),
|
| 47 |
"writer": Plan(
|
| 48 |
id="writer",
|
| 49 |
name="Writer",
|
| 50 |
tagline="For writers publishing every week",
|
| 51 |
-
price_monthly_usd=
|
| 52 |
sources=3,
|
| 53 |
indexed_posts=500,
|
| 54 |
audio_minutes=60,
|
|
@@ -89,9 +92,17 @@ PLANS: dict[str, Plan] = {
|
|
| 89 |
}
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def plan_dict(plan: Plan) -> dict:
|
| 93 |
data = asdict(plan)
|
| 94 |
data["features"] = list(plan.features)
|
|
|
|
|
|
|
| 95 |
return data
|
| 96 |
|
| 97 |
|
|
|
|
| 7 |
from app.config import settings
|
| 8 |
from app.models import Subscription, Workspace
|
| 9 |
|
| 10 |
+
ANNUAL_DISCOUNT = 0.25 # annual billing: 25% off the monthly price
|
| 11 |
+
|
| 12 |
|
| 13 |
@dataclass(frozen=True)
|
| 14 |
class Plan:
|
|
|
|
| 33 |
tagline="For trying Notestack on your archive",
|
| 34 |
price_monthly_usd=0,
|
| 35 |
sources=1,
|
| 36 |
+
indexed_posts=5,
|
| 37 |
+
audio_minutes=3,
|
| 38 |
+
video_minutes=1,
|
| 39 |
+
launch_kits=2,
|
| 40 |
voice_cloning=False,
|
| 41 |
brand_kit=False,
|
| 42 |
features=(
|
| 43 |
+
"1 source, your 5 latest posts",
|
| 44 |
"Grounded research chat with citations",
|
| 45 |
+
"3 min of audio overviews a month",
|
| 46 |
+
"1 min of video a month",
|
| 47 |
+
"2 Launch Kits a month",
|
| 48 |
),
|
| 49 |
),
|
| 50 |
"writer": Plan(
|
| 51 |
id="writer",
|
| 52 |
name="Writer",
|
| 53 |
tagline="For writers publishing every week",
|
| 54 |
+
price_monthly_usd=25,
|
| 55 |
sources=3,
|
| 56 |
indexed_posts=500,
|
| 57 |
audio_minutes=60,
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
|
| 95 |
+
def annual_prices(plan: Plan) -> tuple[float, float]:
|
| 96 |
+
"""(effective monthly price, total billed per year) on annual billing."""
|
| 97 |
+
per_month = round(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT), 2)
|
| 98 |
+
return per_month, round(per_month * 12, 2)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
def plan_dict(plan: Plan) -> dict:
|
| 102 |
data = asdict(plan)
|
| 103 |
data["features"] = list(plan.features)
|
| 104 |
+
data["price_annual_monthly_usd"], data["price_annual_usd"] = annual_prices(plan)
|
| 105 |
+
data["annual_discount"] = ANNUAL_DISCOUNT
|
| 106 |
return data
|
| 107 |
|
| 108 |
|
docs/DESIGN.md
CHANGED
|
@@ -205,12 +205,12 @@ signatures so the schema contract is fixed early.
|
|
| 205 |
|
| 206 |
| | Free | Writer | Studio |
|
| 207 |
| --- | --- | --- | --- |
|
| 208 |
-
| Price (when billing turns on) | $0 | $
|
| 209 |
| Sources | 1 | 3 | 10 |
|
| 210 |
-
| Indexed posts |
|
| 211 |
-
| Audio overview minutes / mo |
|
| 212 |
-
| Video render minutes / mo |
|
| 213 |
-
| Launch Kits / mo |
|
| 214 |
| Voice profile | yes | yes | yes |
|
| 215 |
| Voice cloning (with consent) | no | yes | yes |
|
| 216 |
| Brand kit, no watermark | no | yes | yes |
|
|
|
|
| 205 |
|
| 206 |
| | Free | Writer | Studio |
|
| 207 |
| --- | --- | --- | --- |
|
| 208 |
+
| Price (when billing turns on) | $0 | $25/mo ($18.75/mo billed annually) | $49/mo ($36.75/mo billed annually) |
|
| 209 |
| Sources | 1 | 3 | 10 |
|
| 210 |
+
| Indexed posts | 5 | 500 | 5,000 |
|
| 211 |
+
| Audio overview minutes / mo | 3 | 60 | 240 |
|
| 212 |
+
| Video render minutes / mo | 1 | 30 | 120 |
|
| 213 |
+
| Launch Kits / mo | 2 | 50 | unlimited |
|
| 214 |
| Voice profile | yes | yes | yes |
|
| 215 |
| Voice cloning (with consent) | no | yes | yes |
|
| 216 |
| Brand kit, no watermark | no | yes | yes |
|
frontend/src/components/PricingTiers.tsx
CHANGED
|
@@ -8,9 +8,15 @@ type Plan = {
|
|
| 8 |
name: string;
|
| 9 |
tagline: string;
|
| 10 |
price_monthly_usd: number;
|
|
|
|
|
|
|
| 11 |
features: string[];
|
| 12 |
};
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
// Mirrors backend app/services/plans.py so the page renders before (or without) the API.
|
| 15 |
const FALLBACK: Plan[] = [
|
| 16 |
{
|
|
@@ -19,17 +25,18 @@ const FALLBACK: Plan[] = [
|
|
| 19 |
tagline: "For trying Notestack on your archive",
|
| 20 |
price_monthly_usd: 0,
|
| 21 |
features: [
|
| 22 |
-
"1 source,
|
| 23 |
"Grounded research chat with citations",
|
| 24 |
-
"
|
| 25 |
-
"
|
|
|
|
| 26 |
],
|
| 27 |
},
|
| 28 |
{
|
| 29 |
id: "writer",
|
| 30 |
name: "Writer",
|
| 31 |
tagline: "For writers publishing every week",
|
| 32 |
-
price_monthly_usd:
|
| 33 |
features: [
|
| 34 |
"3 sources, 500 indexed posts",
|
| 35 |
"60 min of audio overviews a month",
|
|
@@ -55,9 +62,22 @@ const FALLBACK: Plan[] = [
|
|
| 55 |
},
|
| 56 |
];
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
export default function PricingTiers() {
|
| 59 |
const [plans, setPlans] = useState<Plan[]>(FALLBACK);
|
| 60 |
const [billingEnabled, setBillingEnabled] = useState(false);
|
|
|
|
| 61 |
|
| 62 |
useEffect(() => {
|
| 63 |
api<{ billing_enabled: boolean; plans: Plan[] }>("/api/billing/plans")
|
|
@@ -72,33 +92,46 @@ export default function PricingTiers() {
|
|
| 72 |
|
| 73 |
return (
|
| 74 |
<div className="pricing">
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
<
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
<div className="pricing-grid">
|
| 82 |
{plans.map((plan) => {
|
| 83 |
const featured = plan.id === "writer";
|
|
|
|
|
|
|
| 84 |
return (
|
| 85 |
<article key={plan.id} className={`card tier${featured ? " tier-featured active" : ""}`}>
|
| 86 |
{featured && <span className="badge tier-flag">Most popular</span>}
|
| 87 |
<h3>{plan.name}</h3>
|
| 88 |
<p className="muted tier-tagline">{plan.tagline}</p>
|
| 89 |
<p className="tier-price">
|
| 90 |
-
{
|
| 91 |
-
<>
|
| 92 |
-
<span className="tier-amount">${plan.price_monthly_usd}</span>
|
| 93 |
-
<span className="muted">/month</span>
|
| 94 |
-
</>
|
| 95 |
) : (
|
| 96 |
<>
|
| 97 |
-
<span className="tier-
|
| 98 |
-
<span className="
|
|
|
|
| 99 |
</>
|
| 100 |
)}
|
| 101 |
</p>
|
|
|
|
|
|
|
|
|
|
| 102 |
<ul className="tier-features">
|
| 103 |
{plan.features.map((f) => (
|
| 104 |
<li key={f}>
|
|
@@ -108,7 +141,7 @@ export default function PricingTiers() {
|
|
| 108 |
))}
|
| 109 |
</ul>
|
| 110 |
<Link to="/auth?mode=signup" className={`btn ${featured ? "btn-primary" : ""} tier-cta`}>
|
| 111 |
-
{billingEnabled &&
|
| 112 |
</Link>
|
| 113 |
</article>
|
| 114 |
);
|
|
|
|
| 8 |
name: string;
|
| 9 |
tagline: string;
|
| 10 |
price_monthly_usd: number;
|
| 11 |
+
price_annual_monthly_usd?: number;
|
| 12 |
+
price_annual_usd?: number;
|
| 13 |
features: string[];
|
| 14 |
};
|
| 15 |
|
| 16 |
+
type Cycle = "monthly" | "annual";
|
| 17 |
+
|
| 18 |
+
const ANNUAL_DISCOUNT = 0.25;
|
| 19 |
+
|
| 20 |
// Mirrors backend app/services/plans.py so the page renders before (or without) the API.
|
| 21 |
const FALLBACK: Plan[] = [
|
| 22 |
{
|
|
|
|
| 25 |
tagline: "For trying Notestack on your archive",
|
| 26 |
price_monthly_usd: 0,
|
| 27 |
features: [
|
| 28 |
+
"1 source, your 5 latest posts",
|
| 29 |
"Grounded research chat with citations",
|
| 30 |
+
"3 min of audio overviews a month",
|
| 31 |
+
"1 min of video a month",
|
| 32 |
+
"2 Launch Kits a month",
|
| 33 |
],
|
| 34 |
},
|
| 35 |
{
|
| 36 |
id: "writer",
|
| 37 |
name: "Writer",
|
| 38 |
tagline: "For writers publishing every week",
|
| 39 |
+
price_monthly_usd: 25,
|
| 40 |
features: [
|
| 41 |
"3 sources, 500 indexed posts",
|
| 42 |
"60 min of audio overviews a month",
|
|
|
|
| 62 |
},
|
| 63 |
];
|
| 64 |
|
| 65 |
+
function money(n: number): string {
|
| 66 |
+
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function annualMonthly(plan: Plan): number {
|
| 70 |
+
return plan.price_annual_monthly_usd ?? Math.round(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT) * 100) / 100;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function annualTotal(plan: Plan): number {
|
| 74 |
+
return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
export default function PricingTiers() {
|
| 78 |
const [plans, setPlans] = useState<Plan[]>(FALLBACK);
|
| 79 |
const [billingEnabled, setBillingEnabled] = useState(false);
|
| 80 |
+
const [cycle, setCycle] = useState<Cycle>("monthly");
|
| 81 |
|
| 82 |
useEffect(() => {
|
| 83 |
api<{ billing_enabled: boolean; plans: Plan[] }>("/api/billing/plans")
|
|
|
|
| 92 |
|
| 93 |
return (
|
| 94 |
<div className="pricing">
|
| 95 |
+
<div className="pricing-top">
|
| 96 |
+
{!billingEnabled && (
|
| 97 |
+
<p className="pricing-note">
|
| 98 |
+
<span className="badge">Early access</span>
|
| 99 |
+
<span className="muted">Every plan is free while we are in early access. No card needed.</span>
|
| 100 |
+
</p>
|
| 101 |
+
)}
|
| 102 |
+
<div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
|
| 103 |
+
<button type="button" role="radio" aria-checked={cycle === "monthly"} className={cycle === "monthly" ? "on" : ""} onClick={() => setCycle("monthly")}>
|
| 104 |
+
Monthly
|
| 105 |
+
</button>
|
| 106 |
+
<button type="button" role="radio" aria-checked={cycle === "annual"} className={cycle === "annual" ? "on" : ""} onClick={() => setCycle("annual")}>
|
| 107 |
+
Annual <span className="save mono">Save 25%</span>
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
<div className="pricing-grid">
|
| 112 |
{plans.map((plan) => {
|
| 113 |
const featured = plan.id === "writer";
|
| 114 |
+
const free = plan.price_monthly_usd === 0;
|
| 115 |
+
const annual = cycle === "annual" && !free;
|
| 116 |
return (
|
| 117 |
<article key={plan.id} className={`card tier${featured ? " tier-featured active" : ""}`}>
|
| 118 |
{featured && <span className="badge tier-flag">Most popular</span>}
|
| 119 |
<h3>{plan.name}</h3>
|
| 120 |
<p className="muted tier-tagline">{plan.tagline}</p>
|
| 121 |
<p className="tier-price">
|
| 122 |
+
{free ? (
|
| 123 |
+
<span className="tier-amount">Free</span>
|
|
|
|
|
|
|
|
|
|
| 124 |
) : (
|
| 125 |
<>
|
| 126 |
+
{annual && <span className="tier-was muted">{money(plan.price_monthly_usd)}</span>}
|
| 127 |
+
<span className="tier-amount">{money(annual ? annualMonthly(plan) : plan.price_monthly_usd)}</span>
|
| 128 |
+
<span className="muted">/month</span>
|
| 129 |
</>
|
| 130 |
)}
|
| 131 |
</p>
|
| 132 |
+
<p className="mono muted tier-billing">
|
| 133 |
+
{free ? "Free forever" : annual ? `${money(annualTotal(plan))} billed yearly` : "Billed monthly"}
|
| 134 |
+
</p>
|
| 135 |
<ul className="tier-features">
|
| 136 |
{plan.features.map((f) => (
|
| 137 |
<li key={f}>
|
|
|
|
| 141 |
))}
|
| 142 |
</ul>
|
| 143 |
<Link to="/auth?mode=signup" className={`btn ${featured ? "btn-primary" : ""} tier-cta`}>
|
| 144 |
+
{billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
|
| 145 |
</Link>
|
| 146 |
</article>
|
| 147 |
);
|
frontend/src/styles/showcase.css
CHANGED
|
@@ -519,3 +519,68 @@
|
|
| 519 |
.demo-unmute:hover {
|
| 520 |
background: var(--blue);
|
| 521 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
.demo-unmute:hover {
|
| 520 |
background: var(--blue);
|
| 521 |
}
|
| 522 |
+
|
| 523 |
+
/* Pricing: billing cycle toggle */
|
| 524 |
+
.pricing-top {
|
| 525 |
+
display: flex;
|
| 526 |
+
flex-wrap: wrap;
|
| 527 |
+
align-items: center;
|
| 528 |
+
justify-content: space-between;
|
| 529 |
+
gap: 12px;
|
| 530 |
+
margin-bottom: 20px;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.pricing-top .pricing-note {
|
| 534 |
+
margin: 0;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.cycle-toggle {
|
| 538 |
+
display: inline-flex;
|
| 539 |
+
padding: 4px;
|
| 540 |
+
border: 1px solid var(--line-strong);
|
| 541 |
+
border-radius: 999px;
|
| 542 |
+
background: rgba(0, 0, 0, 0.6);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.cycle-toggle button {
|
| 546 |
+
display: inline-flex;
|
| 547 |
+
align-items: center;
|
| 548 |
+
gap: 8px;
|
| 549 |
+
padding: 7px 16px;
|
| 550 |
+
border: none;
|
| 551 |
+
border-radius: 999px;
|
| 552 |
+
background: transparent;
|
| 553 |
+
color: var(--text-2);
|
| 554 |
+
font: 600 0.875rem var(--font-body);
|
| 555 |
+
cursor: pointer;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.cycle-toggle button.on {
|
| 559 |
+
background: var(--blue);
|
| 560 |
+
color: var(--white);
|
| 561 |
+
box-shadow: var(--glow-sm);
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.cycle-toggle .save {
|
| 565 |
+
padding: 1px 8px;
|
| 566 |
+
border-radius: 999px;
|
| 567 |
+
border: 1px solid var(--blue-60);
|
| 568 |
+
font-size: 0.65rem;
|
| 569 |
+
color: var(--white);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.cycle-toggle button.on .save {
|
| 573 |
+
border-color: rgba(255, 255, 255, 0.6);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.tier-was {
|
| 577 |
+
margin-right: 8px;
|
| 578 |
+
font-size: 1.1rem;
|
| 579 |
+
text-decoration: line-through;
|
| 580 |
+
text-decoration-color: var(--blue);
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.tier-billing {
|
| 584 |
+
font-size: 0.72rem;
|
| 585 |
+
margin-top: -4px;
|
| 586 |
+
}
|
renderer/src/compositions/LandingDemo.tsx
CHANGED
|
@@ -23,6 +23,37 @@ const SCENES = [
|
|
| 23 |
] as const;
|
| 24 |
const XFADE = 0.5; // seconds of overlap between scenes
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1));
|
| 27 |
|
| 28 |
const BLUE = theme.blue;
|
|
@@ -518,7 +549,7 @@ export function LandingDemo() {
|
|
| 518 |
let cursor = 0;
|
| 519 |
return (
|
| 520 |
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 521 |
-
<SoundTrack src={staticFile("demo-vo/
|
| 522 |
{SCENES.map((scene, i) => {
|
| 523 |
const from = cursor;
|
| 524 |
cursor += S(scene.len - XFADE);
|
|
|
|
| 23 |
] as const;
|
| 24 |
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.79, paste: 3.27, 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. */
|
| 32 |
+
const VO_WINDOWS: [number, number][] = (() => {
|
| 33 |
+
let cursor = 0;
|
| 34 |
+
return SCENES.map((scene) => {
|
| 35 |
+
const start = cursor + scene.vo;
|
| 36 |
+
cursor += scene.len - XFADE;
|
| 37 |
+
return [Math.round(start * 30), Math.round((start + VO_SECONDS[scene.id]) * 30)] as [number, number];
|
| 38 |
+
});
|
| 39 |
+
})();
|
| 40 |
+
|
| 41 |
+
const MUSIC = 0.5;
|
| 42 |
+
const MUSIC_DUCKED = 0.17;
|
| 43 |
+
|
| 44 |
+
/** Music level: full between lines, eased down around each line (0.3 s ramps). */
|
| 45 |
+
function musicVolume(frame: number): number {
|
| 46 |
+
const ramp = 9;
|
| 47 |
+
let level = MUSIC;
|
| 48 |
+
for (const [a, b] of VO_WINDOWS) {
|
| 49 |
+
if (frame >= a - ramp && frame <= b + ramp) {
|
| 50 |
+
const into = interpolate(frame, [a - ramp, a, b, b + ramp], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
| 51 |
+
level = Math.min(level, MUSIC - (MUSIC - MUSIC_DUCKED) * into);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
return level;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1));
|
| 58 |
|
| 59 |
const BLUE = theme.blue;
|
|
|
|
| 549 |
let cursor = 0;
|
| 550 |
return (
|
| 551 |
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 552 |
+
<SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
|
| 553 |
{SCENES.map((scene, i) => {
|
| 554 |
const from = cursor;
|
| 555 |
cursor += S(scene.len - XFADE);
|