Spaces:
Runtime error
Runtime error
Merge pull request #10 from FireBird-Technologies/feat/new_promo_vid
Browse files- frontend/src/components/FeatureShowcase.tsx +241 -43
- frontend/src/pages/Landing.tsx +17 -3
- frontend/src/styles/showcase.css +207 -12
- marketing/notestack-brief.md +113 -0
- renderer/public/demo-vo/durations.json +5 -8
- renderer/scripts/make_demo_music.py +21 -21
- renderer/scripts/make_demo_vo.py +8 -11
- renderer/src/compositions/LandingDemo.tsx +565 -287
- renderer/src/compositions/demoVo.ts +1 -1
frontend/src/components/FeatureShowcase.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import
|
| 2 |
import { Link } from "react-router-dom";
|
| 3 |
|
| 4 |
/** Landing page feature tour: one full section per feature, alternating sides, each with a small
|
|
@@ -33,36 +33,180 @@ function ChatVisual() {
|
|
| 33 |
);
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
function MapVisual() {
|
| 37 |
-
const
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
[0, 1],
|
| 46 |
[0, 2],
|
|
|
|
| 47 |
[0, 4],
|
| 48 |
-
[1,
|
| 49 |
-
[2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
];
|
| 51 |
return (
|
| 52 |
-
<div className="fx fx-map">
|
| 53 |
-
<
|
| 54 |
-
{
|
| 55 |
-
<
|
|
|
|
|
|
|
| 56 |
))}
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</text>
|
| 64 |
</g>
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</svg>
|
| 67 |
</div>
|
| 68 |
);
|
|
@@ -89,19 +233,57 @@ function AudioVisual() {
|
|
| 89 |
}
|
| 90 |
|
| 91 |
function VideoVisual() {
|
|
|
|
| 92 |
return (
|
| 93 |
<div className="fx fx-video">
|
| 94 |
-
<div className="fx-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
<div className="fx-frame v169">
|
| 99 |
-
<span className="mono">
|
| 100 |
-
<b>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
);
|
|
@@ -178,23 +360,31 @@ const FEATURES: Feature[] = [
|
|
| 178 |
eyebrow: "Research notebook",
|
| 179 |
title: "Ask your archive anything. It answers with receipts.",
|
| 180 |
body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
|
| 181 |
-
points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "
|
| 182 |
visual: <ChatVisual />,
|
| 183 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
{
|
| 185 |
id: "map",
|
| 186 |
-
eyebrow: "
|
| 187 |
-
title: "
|
| 188 |
-
body: "
|
| 189 |
-
points: ["
|
| 190 |
visual: <MapVisual />,
|
| 191 |
},
|
| 192 |
{
|
| 193 |
id: "audio",
|
| 194 |
eyebrow: "Audio overview",
|
| 195 |
title: "Two hosts talk through your ideas.",
|
| 196 |
-
body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick from a library of
|
| 197 |
-
points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "
|
| 198 |
visual: <AudioVisual />,
|
| 199 |
},
|
| 200 |
{
|
|
@@ -205,6 +395,14 @@ const FEATURES: Feature[] = [
|
|
| 205 |
points: ["Landscape for YouTube, vertical for Reels and TikTok", "Templates, stock footage, captions and 16 languages", "Square audiograms from any audio overview"],
|
| 206 |
visual: <VideoVisual />,
|
| 207 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
{
|
| 209 |
id: "kit",
|
| 210 |
eyebrow: "Launch Kit",
|
|
@@ -214,12 +412,12 @@ const FEATURES: Feature[] = [
|
|
| 214 |
visual: <KitVisual />,
|
| 215 |
},
|
| 216 |
{
|
| 217 |
-
id: "
|
| 218 |
-
eyebrow: "
|
| 219 |
-
title: "
|
| 220 |
-
body: "
|
| 221 |
-
points: ["
|
| 222 |
-
visual: <
|
| 223 |
},
|
| 224 |
{
|
| 225 |
id: "pad",
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
| 2 |
import { Link } from "react-router-dom";
|
| 3 |
|
| 4 |
/** Landing page feature tour: one full section per feature, alternating sides, each with a small
|
|
|
|
| 33 |
);
|
| 34 |
}
|
| 35 |
|
| 36 |
+
const SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
|
| 37 |
+
const SHAPE_MS = 3500; // how long each shape is shown on its own
|
| 38 |
+
const MORPH_MS = 1300;
|
| 39 |
+
|
| 40 |
+
/** Where the four outer topics sit in each shape (the hub, Gravity, stays put so the dive lines up). */
|
| 41 |
+
const LAYOUTS: [number, number][][] = [
|
| 42 |
+
[[74, 30], [20, 24], [26, 82], [78, 76]], // orbit: around the hub
|
| 43 |
+
[[55, 56], [42, 74], [16, 62], [18, 30]], // spiral: winding out
|
| 44 |
+
[[56, 36], [72, 48], [66, 68], [84, 78]], // figure: one long chain of stars
|
| 45 |
+
[[72, 28], [84, 36], [20, 76], [30, 86]], // cluster: two tight groups
|
| 46 |
+
];
|
| 47 |
+
|
| 48 |
+
/** Cycles the shape on a timer while the visual is on screen. Clicking a pill jumps to that shape and pauses
|
| 49 |
+
* the cycle for a while. With reduced motion the shape only changes when the reader clicks. */
|
| 50 |
+
function useShapeCycle(ref: RefObject<HTMLElement>) {
|
| 51 |
+
const [view, setView] = useState({ shape: 0, prev: 0, p: 1, t: 0 });
|
| 52 |
+
const go = useRef<(i: number) => void>(() => undefined);
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
const node = ref.current;
|
| 55 |
+
if (!node) return;
|
| 56 |
+
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 57 |
+
let raf = 0;
|
| 58 |
+
let last = 0;
|
| 59 |
+
let shape = 0;
|
| 60 |
+
let prev = 0;
|
| 61 |
+
let t0 = -MORPH_MS;
|
| 62 |
+
let nextAuto = 0;
|
| 63 |
+
const show = (now: number) => setView({ shape, prev, p: Math.min(1, (now - t0) / MORPH_MS), t: now / 1000 });
|
| 64 |
+
const jump = (i: number, now: number) => {
|
| 65 |
+
if (i === shape) return;
|
| 66 |
+
prev = shape;
|
| 67 |
+
shape = i;
|
| 68 |
+
t0 = now;
|
| 69 |
+
};
|
| 70 |
+
go.current = (i) => {
|
| 71 |
+
const now = performance.now();
|
| 72 |
+
jump(i, now);
|
| 73 |
+
nextAuto = now + 9000;
|
| 74 |
+
show(now);
|
| 75 |
+
if (still) setTimeout(() => show(performance.now() + MORPH_MS), MORPH_MS);
|
| 76 |
+
};
|
| 77 |
+
const tick = (now: number) => {
|
| 78 |
+
if (now >= nextAuto) {
|
| 79 |
+
jump((shape + 1) % SHAPES.length, now);
|
| 80 |
+
nextAuto = now + SHAPE_MS;
|
| 81 |
+
}
|
| 82 |
+
if (now - last > 33) {
|
| 83 |
+
last = now;
|
| 84 |
+
show(now);
|
| 85 |
+
}
|
| 86 |
+
raf = requestAnimationFrame(tick);
|
| 87 |
+
};
|
| 88 |
+
const io = new IntersectionObserver(([entry]) => {
|
| 89 |
+
cancelAnimationFrame(raf);
|
| 90 |
+
if (entry.isIntersecting && !still) {
|
| 91 |
+
nextAuto = performance.now() + SHAPE_MS;
|
| 92 |
+
raf = requestAnimationFrame(tick);
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
io.observe(node);
|
| 96 |
+
return () => {
|
| 97 |
+
io.disconnect();
|
| 98 |
+
cancelAnimationFrame(raf);
|
| 99 |
+
};
|
| 100 |
+
}, [ref]);
|
| 101 |
+
return { view, go };
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
function MapVisual() {
|
| 105 |
+
const ref = useRef<HTMLDivElement>(null);
|
| 106 |
+
const { view, go } = useShapeCycle(ref);
|
| 107 |
+
const e = view.p < 0.5 ? 2 * view.p * view.p : 1 - (-2 * view.p + 2) ** 2 / 2;
|
| 108 |
+
// Three levels of depth: topics, the sub-topics inside Gravity, and the posts behind each sub-topic.
|
| 109 |
+
const hub = { x: 38, y: 52, r: 9, label: "Gravity" };
|
| 110 |
+
const names = ["Quantum", "Entropy", "Optics", "Relativity"];
|
| 111 |
+
const outer = names.map((label, i) => {
|
| 112 |
+
const [fx, fy] = LAYOUTS[view.prev][i];
|
| 113 |
+
const [tx, ty] = LAYOUTS[view.shape][i];
|
| 114 |
+
return { label, r: [7, 6, 6, 7][i], x: fx + (tx - fx) * e, y: fy + (ty - fy) * e };
|
| 115 |
+
});
|
| 116 |
+
const topics = [hub, ...outer];
|
| 117 |
+
const links = [
|
| 118 |
[0, 1],
|
| 119 |
[0, 2],
|
| 120 |
+
[0, 3],
|
| 121 |
[0, 4],
|
| 122 |
+
[1, 4],
|
| 123 |
+
[2, 3],
|
| 124 |
+
];
|
| 125 |
+
const subs = [
|
| 126 |
+
{ x: 52, y: 40, label: "Spacetime", posts: [[59, 35], [58, 45], [47, 35]] },
|
| 127 |
+
{ x: 24, y: 62, label: "Black holes", posts: [[17, 66], [22, 70], [17, 57]] },
|
| 128 |
+
{ x: 50, y: 67, label: "Orbits", posts: [[58, 70], [46, 73], [56, 62]] },
|
| 129 |
];
|
| 130 |
return (
|
| 131 |
+
<div className="fx fx-map" ref={ref}>
|
| 132 |
+
<div className="fx-pills mono" role="group" aria-label="Shape of the map">
|
| 133 |
+
{SHAPES.map((name, i) => (
|
| 134 |
+
<button key={name} type="button" className={i === view.shape ? "on" : ""} onClick={() => go.current(i)}>
|
| 135 |
+
{name}
|
| 136 |
+
</button>
|
| 137 |
))}
|
| 138 |
+
</div>
|
| 139 |
+
<div className="fx-crumb mono">
|
| 140 |
+
<span>Archive</span>
|
| 141 |
+
<span className="fx-crumb-deep"> › Gravity › Spacetime</span>
|
| 142 |
+
</div>
|
| 143 |
+
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
| 144 |
+
<g className="fx-cam">
|
| 145 |
+
<g className="fx-dim">
|
| 146 |
+
{links.map(([a, b], i) => (
|
| 147 |
+
<line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={topics[a].x} y1={topics[a].y} x2={topics[b].x} y2={topics[b].y} />
|
| 148 |
+
))}
|
| 149 |
+
{links.map(([a, b], i) => {
|
| 150 |
+
const u = (view.t * 0.3 + i * 0.37) % 1; // a pulse of light travelling along the link
|
| 151 |
+
return <circle key={`p${i}`} className="fx-pulse" r="1" fill="#fff" fillOpacity={Math.sin(u * Math.PI)} cx={topics[b].x + (topics[a].x - topics[b].x) * u} cy={topics[b].y + (topics[a].y - topics[b].y) * u} />;
|
| 152 |
+
})}
|
| 153 |
+
{outer.map((n, i) => (
|
| 154 |
+
<g key={n.label}>
|
| 155 |
+
{[0, 1, 2].map((k) => {
|
| 156 |
+
const angle = i * 1.7 + k * 2.1;
|
| 157 |
+
return <circle key={k} className="fx-sat" cx={n.x + Math.cos(angle) * (n.r + 6)} cy={n.y + Math.sin(angle) * (n.r + 6)} r="1.1" />;
|
| 158 |
+
})}
|
| 159 |
+
<g className="fx-node" style={{ ["--d" as string]: `${0.5 + i * 0.12}s` }}>
|
| 160 |
+
<circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" style={{ ["--t" as string]: `${(i + 1) * 0.7}s` }} />
|
| 161 |
+
<circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
|
| 162 |
+
<text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
|
| 163 |
+
{n.label}
|
| 164 |
+
</text>
|
| 165 |
+
</g>
|
| 166 |
+
</g>
|
| 167 |
+
))}
|
| 168 |
+
</g>
|
| 169 |
+
<g className="fx-deep">
|
| 170 |
+
<ellipse className="fx-ring" cx={hub.x} cy={hub.y} rx="15" ry="12.5" />
|
| 171 |
+
{subs.map((sub) => (
|
| 172 |
+
<g key={sub.label}>
|
| 173 |
+
<line className="fx-deep-edge" x1={hub.x} y1={hub.y} x2={sub.x} y2={sub.y} />
|
| 174 |
+
{sub.posts.map(([px, py]) => (
|
| 175 |
+
<g key={`${px}-${py}`}>
|
| 176 |
+
<line className="fx-deep-edge" x1={sub.x} y1={sub.y} x2={px} y2={py} />
|
| 177 |
+
<circle className="fx-post" cx={px} cy={py} r="0.9" />
|
| 178 |
+
</g>
|
| 179 |
+
))}
|
| 180 |
+
<circle className="fx-halo" cx={sub.x} cy={sub.y} r="3.4" />
|
| 181 |
+
<circle cx={sub.x} cy={sub.y} r="1.5" fill="#fff" />
|
| 182 |
+
<text className="fx-sub" x={sub.x} y={sub.y + 5.4} textAnchor="middle">
|
| 183 |
+
{sub.label}
|
| 184 |
+
</text>
|
| 185 |
+
</g>
|
| 186 |
+
))}
|
| 187 |
+
</g>
|
| 188 |
+
<g className="fx-node" style={{ ["--d" as string]: "0.3s" }}>
|
| 189 |
+
<circle cx={hub.x} cy={hub.y} r={hub.r * 0.9} className="fx-halo" />
|
| 190 |
+
<circle cx={hub.x} cy={hub.y} r={hub.r * 0.3} fill="#fff" />
|
| 191 |
+
<text x={hub.x} y={hub.y - hub.r * 0.9 - 2} textAnchor="middle">
|
| 192 |
+
{hub.label}
|
| 193 |
</text>
|
| 194 |
</g>
|
| 195 |
+
</g>
|
| 196 |
+
<g className="fx-panel">
|
| 197 |
+
<rect x="68" y="12" width="30" height="34" rx="2" />
|
| 198 |
+
<text className="fx-panel-h" x="71" y="19">
|
| 199 |
+
Gravity
|
| 200 |
+
</text>
|
| 201 |
+
<text className="fx-panel-k" x="71" y="26">
|
| 202 |
+
FROM YOUR POSTS
|
| 203 |
+
</text>
|
| 204 |
+
{["Why Things Fall", "Curved Space", "Notes on Orbits"].map((title, i) => (
|
| 205 |
+
<text key={title} className="fx-panel-t" x="71" y={32 + i * 5}>
|
| 206 |
+
{title}
|
| 207 |
+
</text>
|
| 208 |
+
))}
|
| 209 |
+
</g>
|
| 210 |
</svg>
|
| 211 |
</div>
|
| 212 |
);
|
|
|
|
| 233 |
}
|
| 234 |
|
| 235 |
function VideoVisual() {
|
| 236 |
+
const scenes = ["Price is a signal", "The numbers", "Why it worked"];
|
| 237 |
return (
|
| 238 |
<div className="fx fx-video">
|
| 239 |
+
<div className="fx-scenes">
|
| 240 |
+
{scenes.map((title, i) => (
|
| 241 |
+
<span key={title} className={i === 1 ? "on" : ""}>
|
| 242 |
+
<i className="mono">{i + 1}</i>
|
| 243 |
+
{title}
|
| 244 |
+
</span>
|
| 245 |
+
))}
|
| 246 |
</div>
|
| 247 |
<div className="fx-frame v169">
|
| 248 |
+
<span className="mono">Live preview</span>
|
| 249 |
+
<b>Paid readers, doubled</b>
|
| 250 |
+
</div>
|
| 251 |
+
<div className="fx-render mono">
|
| 252 |
+
<i /> Rendering MP4
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function MemoryVisual() {
|
| 259 |
+
const notes = [
|
| 260 |
+
["audience", "I write for indie founders"],
|
| 261 |
+
["tone", "Short sentences, no jargon"],
|
| 262 |
+
["avoid", "Never name competitors"],
|
| 263 |
+
];
|
| 264 |
+
return (
|
| 265 |
+
<div className="fx fx-memory">
|
| 266 |
+
{notes.map(([key, value]) => (
|
| 267 |
+
<div key={key} className="fx-profile">
|
| 268 |
+
<span className="mono">{key}</span>
|
| 269 |
+
<p>{value}</p>
|
| 270 |
+
</div>
|
| 271 |
+
))}
|
| 272 |
+
<div className="fx-clone mono">
|
| 273 |
+
<span className="fx-rec" /> Saved from your last chat
|
| 274 |
</div>
|
| 275 |
+
</div>
|
| 276 |
+
);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function HelpVisual() {
|
| 280 |
+
return (
|
| 281 |
+
<div className="fx fx-help">
|
| 282 |
+
<div className="fx-bubble">How do I make a video from one post?</div>
|
| 283 |
+
<p className="fx-answer">Open Create, choose Video, then pick the post, a template and a voice. You can edit every scene before you render.</p>
|
| 284 |
+
<div className="fx-source">
|
| 285 |
+
<span className="mono">Still stuck?</span>
|
| 286 |
+
<span className="fx-line">Send it to the team. We will email you back.</span>
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
);
|
|
|
|
| 360 |
eyebrow: "Research notebook",
|
| 361 |
title: "Ask your archive anything. It answers with receipts.",
|
| 362 |
body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
|
| 363 |
+
points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "Thumbs up or down on any answer"],
|
| 364 |
visual: <ChatVisual />,
|
| 365 |
},
|
| 366 |
+
{
|
| 367 |
+
id: "memory",
|
| 368 |
+
eyebrow: "Memory",
|
| 369 |
+
title: "It remembers who you write for.",
|
| 370 |
+
body: "Notestack keeps short notes about your audience, tone and preferences, learned as you chat, and uses them in every answer and draft. Read, edit or delete any note in Settings.",
|
| 371 |
+
points: ["Notes saved from your conversations", "Edit or delete any note, anytime", "Shared across your notebooks"],
|
| 372 |
+
visual: <MemoryVisual />,
|
| 373 |
+
},
|
| 374 |
{
|
| 375 |
id: "map",
|
| 376 |
+
eyebrow: "Mind Constellation",
|
| 377 |
+
title: "Fly through the galaxy of your ideas.",
|
| 378 |
+
body: "Open a notebook as a constellation: topics orbit the ideas they share, and every star lists the posts it comes from. Switch the shape, dive in and see what you keep coming back to.",
|
| 379 |
+
points: ["Orbit, spiral, figure or cluster layouts", "Scroll to dive in, click a star to fly to it", "Every star lists the posts behind it"],
|
| 380 |
visual: <MapVisual />,
|
| 381 |
},
|
| 382 |
{
|
| 383 |
id: "audio",
|
| 384 |
eyebrow: "Audio overview",
|
| 385 |
title: "Two hosts talk through your ideas.",
|
| 386 |
+
body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick two hosts from a library of voices, or use a voice you made yourself.",
|
| 387 |
+
points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Square audiograms from any overview"],
|
| 388 |
visual: <AudioVisual />,
|
| 389 |
},
|
| 390 |
{
|
|
|
|
| 395 |
points: ["Landscape for YouTube, vertical for Reels and TikTok", "Templates, stock footage, captions and 16 languages", "Square audiograms from any audio overview"],
|
| 396 |
visual: <VideoVisual />,
|
| 397 |
},
|
| 398 |
+
{
|
| 399 |
+
id: "voice",
|
| 400 |
+
eyebrow: "Voices",
|
| 401 |
+
title: "Sounds like you, on the page and out loud.",
|
| 402 |
+
body: "Notestack learns your writing voice from your own posts. For narration, describe a voice, pick its options, or record a couple of minutes to clone your own, then reuse it in every audio overview and video.",
|
| 403 |
+
points: ["Describe it, pick options or clone yours", "Fine tune stability, similarity and style", "Consented cloning you can revoke anytime"],
|
| 404 |
+
visual: <VoiceVisual />,
|
| 405 |
+
},
|
| 406 |
{
|
| 407 |
id: "kit",
|
| 408 |
eyebrow: "Launch Kit",
|
|
|
|
| 412 |
visual: <KitVisual />,
|
| 413 |
},
|
| 414 |
{
|
| 415 |
+
id: "help",
|
| 416 |
+
eyebrow: "Help",
|
| 417 |
+
title: "Stuck? Ask Notestack.",
|
| 418 |
+
body: "A help assistant in the app answers how-to questions about every screen. If it can't, send the question to the team and we will email you back.",
|
| 419 |
+
points: ["Answers about any feature, in plain words", "Hand off to a person in one click", "Always one click away"],
|
| 420 |
+
visual: <HelpVisual />,
|
| 421 |
},
|
| 422 |
{
|
| 423 |
id: "pad",
|
frontend/src/pages/Landing.tsx
CHANGED
|
@@ -9,9 +9,11 @@ import { blogPosts } from "../content/blogPosts";
|
|
| 9 |
import { useAuth } from "../hooks/useAuth";
|
| 10 |
import {
|
| 11 |
ArrowRightIcon,
|
|
|
|
| 12 |
HelmetIcon,
|
| 13 |
LaunchWindowIcon,
|
| 14 |
LaunchpadIcon,
|
|
|
|
| 15 |
RocketIcon,
|
| 16 |
SignalIcon,
|
| 17 |
TelescopeIcon,
|
|
@@ -19,7 +21,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 |
{
|
|
@@ -28,6 +30,12 @@ const FEATURES = [
|
|
| 28 |
title: "Ask your archive anything",
|
| 29 |
body: "Every answer cites the exact passage it came from. If you never wrote it, Notestack says so.",
|
| 30 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
{
|
| 32 |
icon: SignalIcon,
|
| 33 |
label: "Audio overview",
|
|
@@ -48,9 +56,15 @@ const FEATURES = [
|
|
| 48 |
},
|
| 49 |
{
|
| 50 |
icon: HelmetIcon,
|
| 51 |
-
label: "
|
| 52 |
title: "Sounds like you",
|
| 53 |
-
body: "Notestack learns your tone from your
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
},
|
| 55 |
{
|
| 56 |
icon: LaunchpadIcon,
|
|
|
|
| 9 |
import { useAuth } from "../hooks/useAuth";
|
| 10 |
import {
|
| 11 |
ArrowRightIcon,
|
| 12 |
+
ConstellationIcon,
|
| 13 |
HelmetIcon,
|
| 14 |
LaunchWindowIcon,
|
| 15 |
LaunchpadIcon,
|
| 16 |
+
OrbitIcon,
|
| 17 |
RocketIcon,
|
| 18 |
SignalIcon,
|
| 19 |
TelescopeIcon,
|
|
|
|
| 21 |
|
| 22 |
export const PENDING_SOURCE_KEY = "ns_pending_source";
|
| 23 |
// Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
|
| 24 |
+
const DEMO_VERSION = 5;
|
| 25 |
|
| 26 |
const FEATURES = [
|
| 27 |
{
|
|
|
|
| 30 |
title: "Ask your archive anything",
|
| 31 |
body: "Every answer cites the exact passage it came from. If you never wrote it, Notestack says so.",
|
| 32 |
},
|
| 33 |
+
{
|
| 34 |
+
icon: ConstellationIcon,
|
| 35 |
+
label: "Mind Constellation",
|
| 36 |
+
title: "Fly through your ideas",
|
| 37 |
+
body: "Open a notebook as a galaxy of topics. Switch the shape, dive in and see the posts behind every star.",
|
| 38 |
+
},
|
| 39 |
{
|
| 40 |
icon: SignalIcon,
|
| 41 |
label: "Audio overview",
|
|
|
|
| 56 |
},
|
| 57 |
{
|
| 58 |
icon: HelmetIcon,
|
| 59 |
+
label: "Voices",
|
| 60 |
title: "Sounds like you",
|
| 61 |
+
body: "Notestack learns your tone from your posts, and you can describe, pick or clone a voice for narration.",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
icon: OrbitIcon,
|
| 65 |
+
label: "Memory",
|
| 66 |
+
title: "Remembers who you write for",
|
| 67 |
+
body: "Short notes about your audience and style, learned from your chats and yours to edit anytime.",
|
| 68 |
},
|
| 69 |
{
|
| 70 |
icon: LaunchpadIcon,
|
frontend/src/styles/showcase.css
CHANGED
|
@@ -185,8 +185,8 @@
|
|
| 185 |
stroke: var(--white);
|
| 186 |
stroke-opacity: 0.3;
|
| 187 |
stroke-width: 0.4;
|
| 188 |
-
stroke-dasharray:
|
| 189 |
-
stroke-dashoffset:
|
| 190 |
transition-delay: var(--d);
|
| 191 |
}
|
| 192 |
|
|
@@ -216,6 +216,141 @@
|
|
| 216 |
|
| 217 |
.showcase-row.arrived .fx-halo {
|
| 218 |
animation: pulse-halo 4s ease-in-out infinite;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
| 220 |
|
| 221 |
@keyframes pulse-halo {
|
|
@@ -290,10 +425,31 @@
|
|
| 290 |
|
| 291 |
/* Video */
|
| 292 |
.fx-video {
|
| 293 |
-
grid-template-columns: 0.
|
| 294 |
align-items: center;
|
| 295 |
}
|
| 296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
.fx-frame {
|
| 298 |
display: grid;
|
| 299 |
gap: 6px;
|
|
@@ -311,22 +467,51 @@
|
|
| 311 |
line-height: 1.2;
|
| 312 |
}
|
| 313 |
|
| 314 |
-
.v916 {
|
| 315 |
-
aspect-ratio: 9 / 16;
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
.v169 {
|
| 319 |
aspect-ratio: 16 / 9;
|
| 320 |
}
|
| 321 |
|
| 322 |
-
.
|
| 323 |
-
|
|
|
|
|
|
|
| 324 |
}
|
| 325 |
|
| 326 |
-
.fx-
|
| 327 |
display: block;
|
| 328 |
-
height:
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
}
|
| 331 |
|
| 332 |
/* Launch Kit */
|
|
@@ -469,9 +654,19 @@
|
|
| 469 |
|
| 470 |
.fx-wave span,
|
| 471 |
.fx-halo,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
.fx-rec {
|
| 473 |
animation: none !important;
|
| 474 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
}
|
| 476 |
|
| 477 |
/* Demo video sound controls */
|
|
|
|
| 185 |
stroke: var(--white);
|
| 186 |
stroke-opacity: 0.3;
|
| 187 |
stroke-width: 0.4;
|
| 188 |
+
stroke-dasharray: 150;
|
| 189 |
+
stroke-dashoffset: 150;
|
| 190 |
transition-delay: var(--d);
|
| 191 |
}
|
| 192 |
|
|
|
|
| 216 |
|
| 217 |
.showcase-row.arrived .fx-halo {
|
| 218 |
animation: pulse-halo 4s ease-in-out infinite;
|
| 219 |
+
animation-delay: var(--t, 0s);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* Depth: the camera dives into Gravity, the other topics fall back, its sub-topics and posts light up,
|
| 223 |
+
then it pulls back out. One 14 s loop, started once the row has arrived. */
|
| 224 |
+
.fx-cam {
|
| 225 |
+
transform-origin: 38px 52px;
|
| 226 |
+
transform-box: view-box;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.fx-deep,
|
| 230 |
+
.fx-panel,
|
| 231 |
+
.fx-crumb-deep {
|
| 232 |
+
opacity: 0;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.showcase-row.arrived .fx-cam {
|
| 236 |
+
animation: fx-dive 14s ease-in-out 2.5s infinite;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.showcase-row.arrived .fx-dim {
|
| 240 |
+
animation: fx-dim 14s ease-in-out 2.5s infinite;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.showcase-row.arrived .fx-deep,
|
| 244 |
+
.showcase-row.arrived .fx-panel,
|
| 245 |
+
.showcase-row.arrived .fx-crumb-deep {
|
| 246 |
+
animation: fx-in 14s ease-in-out 2.5s infinite;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
@keyframes fx-dive {
|
| 250 |
+
0%,
|
| 251 |
+
26%,
|
| 252 |
+
86%,
|
| 253 |
+
100% {
|
| 254 |
+
transform: none;
|
| 255 |
+
}
|
| 256 |
+
40%,
|
| 257 |
+
72% {
|
| 258 |
+
transform: translate(-4px, -2px) scale(2.1);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
@keyframes fx-dim {
|
| 263 |
+
0%,
|
| 264 |
+
26%,
|
| 265 |
+
86%,
|
| 266 |
+
100% {
|
| 267 |
+
opacity: 1;
|
| 268 |
+
}
|
| 269 |
+
40%,
|
| 270 |
+
72% {
|
| 271 |
+
opacity: 0.1;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
@keyframes fx-in {
|
| 276 |
+
0%,
|
| 277 |
+
32%,
|
| 278 |
+
80%,
|
| 279 |
+
100% {
|
| 280 |
+
opacity: 0;
|
| 281 |
+
}
|
| 282 |
+
42%,
|
| 283 |
+
70% {
|
| 284 |
+
opacity: 1;
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.fx-sat {
|
| 289 |
+
fill: var(--white);
|
| 290 |
+
opacity: 0.5;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.fx-ring {
|
| 294 |
+
fill: none;
|
| 295 |
+
stroke: var(--blue);
|
| 296 |
+
stroke-opacity: 0.5;
|
| 297 |
+
stroke-width: 0.25;
|
| 298 |
+
stroke-dasharray: 1.2 1.6;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.fx-deep-edge {
|
| 302 |
+
stroke: var(--white);
|
| 303 |
+
stroke-opacity: 0.35;
|
| 304 |
+
stroke-width: 0.2;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.fx-post {
|
| 308 |
+
fill: var(--white);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.fx-sub {
|
| 312 |
+
fill: var(--white);
|
| 313 |
+
font: 500 2.4px var(--font-body);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.fx-panel rect {
|
| 317 |
+
fill: rgba(0, 0, 0, 0.88);
|
| 318 |
+
stroke: var(--blue);
|
| 319 |
+
stroke-opacity: 0.5;
|
| 320 |
+
stroke-width: 0.25;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.fx-panel text {
|
| 324 |
+
fill: var(--white);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.fx-panel-h {
|
| 328 |
+
font: 600 3.8px var(--font-display);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.fx-panel-k {
|
| 332 |
+
fill: var(--blue) !important;
|
| 333 |
+
font: 500 1.9px var(--font-mono);
|
| 334 |
+
letter-spacing: 0.2px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.fx-panel-t {
|
| 338 |
+
font: 500 2.7px var(--font-body);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.fx-crumb {
|
| 342 |
+
display: flex;
|
| 343 |
+
min-height: 1.2em;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.fx-pulse {
|
| 347 |
+
opacity: 0;
|
| 348 |
+
filter: drop-shadow(0 0 1.5px var(--blue));
|
| 349 |
+
transition: opacity 1s ease 1.5s;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.showcase-row.arrived .fx-pulse {
|
| 353 |
+
opacity: 0.9;
|
| 354 |
}
|
| 355 |
|
| 356 |
@keyframes pulse-halo {
|
|
|
|
| 425 |
|
| 426 |
/* Video */
|
| 427 |
.fx-video {
|
| 428 |
+
grid-template-columns: 0.8fr 1.3fr;
|
| 429 |
align-items: center;
|
| 430 |
}
|
| 431 |
|
| 432 |
+
.fx-scenes {
|
| 433 |
+
display: grid;
|
| 434 |
+
gap: 8px;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.fx-scenes span {
|
| 438 |
+
display: flex;
|
| 439 |
+
align-items: center;
|
| 440 |
+
gap: 10px;
|
| 441 |
+
padding: 9px 12px;
|
| 442 |
+
border-radius: 10px;
|
| 443 |
+
border: 1px solid var(--line-strong);
|
| 444 |
+
font-size: 0.8rem;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.fx-scenes span.on {
|
| 448 |
+
border-color: var(--blue);
|
| 449 |
+
background: var(--blue-10);
|
| 450 |
+
box-shadow: 0 0 18px var(--blue-20);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
.fx-frame {
|
| 454 |
display: grid;
|
| 455 |
gap: 6px;
|
|
|
|
| 467 |
line-height: 1.2;
|
| 468 |
}
|
| 469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
.v169 {
|
| 471 |
aspect-ratio: 16 / 9;
|
| 472 |
}
|
| 473 |
|
| 474 |
+
.fx-render {
|
| 475 |
+
grid-column: 1 / -1;
|
| 476 |
+
display: grid;
|
| 477 |
+
gap: 6px;
|
| 478 |
}
|
| 479 |
|
| 480 |
+
.fx-render i {
|
| 481 |
display: block;
|
| 482 |
+
height: 4px;
|
| 483 |
+
border-radius: 99px;
|
| 484 |
+
background: linear-gradient(90deg, var(--blue) 70%, var(--surface-2) 70%);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
/* Mind Constellation shapes */
|
| 488 |
+
.fx-pills {
|
| 489 |
+
display: flex;
|
| 490 |
+
gap: 6px;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.fx-pills button {
|
| 494 |
+
padding: 3px 10px;
|
| 495 |
+
border-radius: 99px;
|
| 496 |
+
border: 1px solid var(--line-strong);
|
| 497 |
+
background: transparent;
|
| 498 |
+
color: var(--text-2);
|
| 499 |
+
font: inherit;
|
| 500 |
+
cursor: pointer;
|
| 501 |
+
transition:
|
| 502 |
+
border-color 0.3s ease,
|
| 503 |
+
background 0.3s ease,
|
| 504 |
+
color 0.3s ease;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.fx-pills button:hover {
|
| 508 |
+
color: var(--white);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.fx-pills button.on {
|
| 512 |
+
border-color: var(--blue);
|
| 513 |
+
background: var(--blue-10);
|
| 514 |
+
color: var(--white);
|
| 515 |
}
|
| 516 |
|
| 517 |
/* Launch Kit */
|
|
|
|
| 654 |
|
| 655 |
.fx-wave span,
|
| 656 |
.fx-halo,
|
| 657 |
+
.fx-cam,
|
| 658 |
+
.fx-dim,
|
| 659 |
+
.fx-deep,
|
| 660 |
+
.fx-panel,
|
| 661 |
+
.fx-crumb-deep,
|
| 662 |
.fx-rec {
|
| 663 |
animation: none !important;
|
| 664 |
}
|
| 665 |
+
|
| 666 |
+
.fx-pulse {
|
| 667 |
+
display: none;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
}
|
| 671 |
|
| 672 |
/* Demo video sound controls */
|
marketing/notestack-brief.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Notestack AI: content brief for Blog2Video and BlogHub
|
| 2 |
+
|
| 3 |
+
## One line
|
| 4 |
+
|
| 5 |
+
**Notestack** ([notestack.ai](https://notestack.ai)) is the ultimate NotebookLM alternative for writers, bloggers and newsletter authors: it turns everything you have published into a research notebook that answers from your own words, then helps you turn those words into audio, video and posts.
|
| 6 |
+
|
| 7 |
+
## Short description (about 50 words)
|
| 8 |
+
|
| 9 |
+
Notestack connects to your blog, newsletter or website and pulls your whole archive into orbit. Ask it anything and it answers from what you actually wrote, citing the exact lines. Then turn any post into a two host audio overview, short videos, quote cards and a ready to post Launch Kit, all in your own voice.
|
| 10 |
+
|
| 11 |
+
## Long description (about 150 words)
|
| 12 |
+
|
| 13 |
+
Most writers have years of ideas scattered across hundreds of posts, and almost none of it gets used again. Notestack fixes that. Paste the address of your Substack, Ghost, WordPress or Medium publication, any RSS feed, or any website (even ones without a feed), or upload markdown and PDF files. Notestack reads every post and keeps them in sync as you publish.
|
| 14 |
+
|
| 15 |
+
From there, your archive becomes a research notebook. Ask a question and get an answer built only from your writing, with citations to the exact lines, so nothing is made up. Explore a map of the topics you keep returning to and the ones that went quiet. Turn any post into a two host audio overview, a 9:16 short, a 16:9 explainer or an audiogram. Generate a Launch Kit with an X thread, LinkedIn post, Substack Note, Bluesky post, SEO pack and carousel, then schedule it all from one calendar.
|
| 16 |
+
|
| 17 |
+
## Why it is the NotebookLM alternative for writers
|
| 18 |
+
|
| 19 |
+
| | Notestack | NotebookLM |
|
| 20 |
+
| --- | --- | --- |
|
| 21 |
+
| Getting your writing in | Syncs a whole blog, newsletter or site, plus md, txt, html and pdf | Add sources one at a time |
|
| 22 |
+
| Staying current | New posts arrive automatically | Re-add sources by hand |
|
| 23 |
+
| Citations | Exact line ranges, checked before you see them | Passage level citations |
|
| 24 |
+
| Publishing | Launch Kits and auto posting to X, LinkedIn and Bluesky | Not a publishing tool |
|
| 25 |
+
| Your voice | Learns your writing voice from your posts | General purpose writing |
|
| 26 |
+
|
| 27 |
+
Full comparison: [Notestack vs NotebookLM](https://notestack.ai/notebooklm-alternative)
|
| 28 |
+
|
| 29 |
+
## Key features
|
| 30 |
+
|
| 31 |
+
- **Connect any source:** Substack (full archive), Ghost, WordPress, Medium, any RSS feed, whole websites, single URLs, and markdown, text, HTML or PDF uploads.
|
| 32 |
+
- **Grounded research notebooks:** answers come only from your posts, with citations to the exact lines. If your archive does not cover a question, it says so.
|
| 33 |
+
- **Topic map:** a constellation of your ideas, showing which topics are rising and which have gone dormant.
|
| 34 |
+
- **Voice profile:** learns how you write, so every draft sounds like you. Optional consented voice cloning for audio.
|
| 35 |
+
- **Audio overviews:** two hosts talk through any notebook or post.
|
| 36 |
+
- **Video studio:** 9:16 shorts, 16:9 explainers, 1:1 audiograms and quote cards in your brand colors.
|
| 37 |
+
- **Launch Kit:** hooks, X thread, LinkedIn post, Substack Notes, Bluesky post, SEO pack and carousel from one post.
|
| 38 |
+
- **Launchpad:** a calendar that auto posts to X, LinkedIn and Bluesky, with email reminders for everything else.
|
| 39 |
+
- **Resurfacing:** finds evergreen posts worth sharing again.
|
| 40 |
+
|
| 41 |
+
## Pricing
|
| 42 |
+
|
| 43 |
+
| Plan | Price | Includes |
|
| 44 |
+
| --- | --- | --- |
|
| 45 |
+
| Free | $0 | 1 source, first 5 posts indexed, 3 audio minutes, 1 video minute, 2 Launch Kits a month |
|
| 46 |
+
| Writer | $24.99 a month ($18.99 a month billed annually) | 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 Launch Kits |
|
| 47 |
+
| Studio | $48.99 a month ($36.99 a month billed annually) | 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited Launch Kits |
|
| 48 |
+
|
| 49 |
+
Annual billing saves 24%. Current pricing: [notestack.ai/pricing](https://notestack.ai/pricing)
|
| 50 |
+
|
| 51 |
+
## Who it is for
|
| 52 |
+
|
| 53 |
+
- Newsletter writers on Substack, Ghost or beehiiv who want to reuse their archive
|
| 54 |
+
- Bloggers and content marketers with years of posts
|
| 55 |
+
- Researchers and consultants who publish long form thinking
|
| 56 |
+
- Anyone who has searched for a NotebookLM alternative built around their own writing
|
| 57 |
+
|
| 58 |
+
## How it fits with Blog2Video, PDF2Video and BlogHub
|
| 59 |
+
|
| 60 |
+
All four are made by Firebird Technologies and work together:
|
| 61 |
+
|
| 62 |
+
- **[Notestack](https://notestack.ai):** research and repurpose your whole archive (the thinking layer).
|
| 63 |
+
- **[Blog2Video](https://blog2video.app):** turn a single blog post or URL into a video.
|
| 64 |
+
- **[PDF2Video](https://pdf2vid.com):** turn a PDF, paper or deck into a narrated video.
|
| 65 |
+
- **[BlogHub](https://bloghub.app):** a free directory where readers discover blogs and newsletters.
|
| 66 |
+
|
| 67 |
+
A natural flow: write and publish, list your publication on BlogHub so readers can find it, use Notestack to research and repurpose the archive, and use Blog2Video for standalone videos of single posts.
|
| 68 |
+
|
| 69 |
+
**Positioning rule:** Notestack is the NotebookLM alternative. BlogHub is a blog and newsletter directory, and Blog2Video and PDF2Video are video tools. Do not describe BlogHub, Blog2Video or PDF2Video as NotebookLM alternatives.
|
| 70 |
+
|
| 71 |
+
## Links to use
|
| 72 |
+
|
| 73 |
+
| Anchor text | URL |
|
| 74 |
+
| --- | --- |
|
| 75 |
+
| Notestack | https://notestack.ai |
|
| 76 |
+
| the ultimate NotebookLM alternative for writers | https://notestack.ai/notebooklm-alternative |
|
| 77 |
+
| Notestack vs NotebookLM | https://notestack.ai/notebooklm-alternative |
|
| 78 |
+
| best NotebookLM alternatives | https://notestack.ai/blogs/notebooklm-alternatives |
|
| 79 |
+
| NotebookLM vs ChatGPT | https://notestack.ai/blogs/notebooklm-vs-chatgpt |
|
| 80 |
+
| best AI for research | https://notestack.ai/blogs/best-ai-for-research |
|
| 81 |
+
| AI tools for writers | https://notestack.ai/blogs/ai-tools-for-writers |
|
| 82 |
+
| AI second brain for writers | https://notestack.ai/blogs/ai-second-brain-for-writers |
|
| 83 |
+
| Notestack pricing | https://notestack.ai/pricing |
|
| 84 |
+
|
| 85 |
+
Vary the anchor text between posts, and link where it helps the reader (for example, when a post talks about reusing old posts, researching your own writing, or NotebookLM).
|
| 86 |
+
|
| 87 |
+
## Ready to paste blurbs
|
| 88 |
+
|
| 89 |
+
**Blog2Video (end of a post):**
|
| 90 |
+
|
| 91 |
+
> Want to go beyond one video? [Notestack](https://notestack.ai) reads your entire archive, answers questions from your own posts with exact citations, and turns them into audio overviews, shorts and ready to post Launch Kits. It is the [NotebookLM alternative built for writers](https://notestack.ai/notebooklm-alternative).
|
| 92 |
+
|
| 93 |
+
**BlogHub (listing page or newsletter):**
|
| 94 |
+
|
| 95 |
+
> Listed your blog here? Put the archive to work with [Notestack](https://notestack.ai). Connect your blog or newsletter and ask it anything, with every answer cited to your own posts, then repurpose your best writing into audio, video and social posts.
|
| 96 |
+
|
| 97 |
+
**Short social or sidebar version:**
|
| 98 |
+
|
| 99 |
+
> Your knowledge, in orbit. [Notestack](https://notestack.ai): the NotebookLM alternative for writers.
|
| 100 |
+
|
| 101 |
+
## Tone and style
|
| 102 |
+
|
| 103 |
+
- Warm, curious, a little space themed ("your knowledge, in orbit", "explore", "launch"), never hypey.
|
| 104 |
+
- Lead with the feeling (rediscovering your own ideas), then the proof (answers cite your exact lines).
|
| 105 |
+
- No em dashes. Use a colon, comma or a new sentence instead.
|
| 106 |
+
- Brand name: **Notestack** in text; "Notestack AI" is fine in headlines and video outros.
|
| 107 |
+
- Tagline: **Your knowledge, in orbit.**
|
| 108 |
+
|
| 109 |
+
## Media
|
| 110 |
+
|
| 111 |
+
- Launch trailer (82 s): `marketing/notestack-launch.mp4` in the Notestack repo
|
| 112 |
+
- Product demo (47 s): https://notestack.ai/demo.mp4
|
| 113 |
+
- Logo files: `marketing/logo/` (mark, and lockups for dark and light backgrounds)
|
renderer/public/demo-vo/durations.json
CHANGED
|
@@ -1,10 +1,7 @@
|
|
| 1 |
{
|
| 2 |
-
"warp": 3.
|
| 3 |
-
"
|
| 4 |
-
"
|
| 5 |
-
"
|
| 6 |
-
"
|
| 7 |
-
"launchkit": 3.32,
|
| 8 |
-
"launchpad": 4.05,
|
| 9 |
-
"outro": 2.48
|
| 10 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"warp": 3.61,
|
| 3 |
+
"sync": 2.25,
|
| 4 |
+
"map": 5.17,
|
| 5 |
+
"kit": 2.95,
|
| 6 |
+
"outro": 2.8
|
|
|
|
|
|
|
|
|
|
| 7 |
}
|
renderer/scripts/make_demo_music.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
Pure additive synthesis, no noise sources. Writes renderer/public/demo-vo/music.wav (stereo, 44.1 kHz)."""
|
| 3 |
|
| 4 |
import wave
|
|
@@ -61,7 +61,7 @@ def bell(freq: float, start: float, gain: float, pan: float) -> None:
|
|
| 61 |
return
|
| 62 |
length = min(n - i0, int(2.4 * SR))
|
| 63 |
tt = np.arange(length) / SR
|
| 64 |
-
decay = np.exp(-tt *
|
| 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)
|
|
@@ -70,21 +70,21 @@ def bell(freq: float, start: float, gain: float, pan: float) -> None:
|
|
| 70 |
right[i0:i0 + length] += tone * (1 + pan)
|
| 71 |
|
| 72 |
|
| 73 |
-
# Progression in D
|
| 74 |
CHORDS = [
|
| 75 |
-
(0.0, [50, 57,
|
| 76 |
-
(5.2, [
|
| 77 |
-
(10.4, [43, 50,
|
| 78 |
-
(15.6, [45, 52,
|
| 79 |
-
(20.8, [50, 57,
|
| 80 |
-
(26.0, [
|
| 81 |
-
(31.2, [43, 50, 55,
|
| 82 |
-
(36.4, [50, 57,
|
| 83 |
]
|
| 84 |
CHORD_LEN = 5.2
|
| 85 |
|
| 86 |
# Filter brightness rises slowly through the piece and blooms at the outro.
|
| 87 |
-
brightness = 0.
|
| 88 |
brightness *= 1 + 0.08 * np.sin(2 * np.pi * 0.05 * t)
|
| 89 |
|
| 90 |
for start, notes in CHORDS:
|
|
@@ -97,14 +97,14 @@ for start, notes in CHORDS:
|
|
| 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.
|
| 101 |
left[sl] += sub
|
| 102 |
right[sl] += sub
|
| 103 |
|
| 104 |
-
# Bell arpeggio from the second scene on:
|
| 105 |
-
step = 60 /
|
| 106 |
rng = np.random.default_rng(3)
|
| 107 |
-
beat =
|
| 108 |
k = 0
|
| 109 |
while beat < TOTAL - 3.5:
|
| 110 |
chord = [c for c in CHORDS if c[0] <= beat][-1][1]
|
|
@@ -112,8 +112,8 @@ while beat < TOTAL - 3.5:
|
|
| 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.
|
| 116 |
-
bell(hz(midi), beat, gain=0.
|
| 117 |
beat += step
|
| 118 |
k += 1
|
| 119 |
|
|
@@ -132,13 +132,13 @@ 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 *
|
| 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(
|
| 140 |
ph = rng.uniform(0, 2 * np.pi, 2)
|
| 141 |
-
d = np.exp(-it * rng.uniform(
|
| 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))
|
|
|
|
| 1 |
+
"""Sombre space score for the landing demo: dark minor pads, slow sparse bells, a deep drone, long dim reverb.
|
| 2 |
Pure additive synthesis, no noise sources. Writes renderer/public/demo-vo/music.wav (stereo, 44.1 kHz)."""
|
| 3 |
|
| 4 |
import wave
|
|
|
|
| 61 |
return
|
| 62 |
length = min(n - i0, int(2.4 * SR))
|
| 63 |
tt = np.arange(length) / SR
|
| 64 |
+
decay = np.exp(-tt * 1.5)
|
| 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)
|
|
|
|
| 70 |
right[i0:i0 + length] += tone * (1 + pan)
|
| 71 |
|
| 72 |
|
| 73 |
+
# Progression in D minor (aeolian), ~5.2 s per chord, falling to a bare resolved chord for the outro.
|
| 74 |
CHORDS = [
|
| 75 |
+
(0.0, [50, 57, 60, 64, 65]), # Dm9 D A C E F
|
| 76 |
+
(5.2, [46, 53, 57, 60, 65]), # Bbmaj7 Bb F A C F
|
| 77 |
+
(10.4, [43, 50, 53, 58, 62]), # Gm7 G D F Bb D
|
| 78 |
+
(15.6, [45, 52, 55, 60, 64]), # A7sus feel A E G C E
|
| 79 |
+
(20.8, [50, 57, 60, 65, 69]), # Dm add11 D A C F A
|
| 80 |
+
(26.0, [46, 53, 57, 60, 64]), # Bbmaj7#11 Bb F A C E
|
| 81 |
+
(31.2, [43, 50, 55, 58, 62]), # Gm6 G D G Bb D
|
| 82 |
+
(36.4, [50, 57, 60, 64, 65]), # Dm9 outro D A C E F
|
| 83 |
]
|
| 84 |
CHORD_LEN = 5.2
|
| 85 |
|
| 86 |
# Filter brightness rises slowly through the piece and blooms at the outro.
|
| 87 |
+
brightness = 0.17 + 0.08 * np.clip(t / TOTAL, 0, 1) + 0.04 * 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:
|
|
|
|
| 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.15
|
| 101 |
left[sl] += sub
|
| 102 |
right[sl] += sub
|
| 103 |
|
| 104 |
+
# Bell arpeggio from the second scene on: slow quarter notes at 56 bpm, up and down the chord an octave up.
|
| 105 |
+
step = 60 / 56
|
| 106 |
rng = np.random.default_rng(3)
|
| 107 |
+
beat = 4.0
|
| 108 |
k = 0
|
| 109 |
while beat < TOTAL - 3.5:
|
| 110 |
chord = [c for c in CHORDS if c[0] <= beat][-1][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.3: # many rests so it does not feel mechanical
|
| 116 |
+
bell(hz(midi), beat, gain=0.036 * accent * thin, pan=0.5 * np.sin(k * 0.9))
|
| 117 |
beat += step
|
| 118 |
k += 1
|
| 119 |
|
|
|
|
| 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 * 4.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(120, 1300, 90):
|
| 140 |
ph = rng.uniform(0, 2 * np.pi, 2)
|
| 141 |
+
d = np.exp(-it * rng.uniform(0.8, 1.6))
|
| 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))
|
renderer/scripts/make_demo_vo.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 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
|
| 4 |
-
|
| 5 |
make_demo_music.py writes a 47.5 s score; raise its TOTAL and rerun it if the demo grows past that.
|
| 6 |
|
| 7 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
|
|
@@ -17,18 +17,15 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo"
|
|
| 17 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 18 |
|
| 19 |
LINES = {
|
| 20 |
-
"warp": "
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"launchkit": "Your pulse quickens. You can't wait to share this.",
|
| 26 |
-
"launchpad": "Three, two, one... and you're flying.",
|
| 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.
|
| 32 |
ws = uuid.UUID(int=0)
|
| 33 |
keys = list(LINES)
|
| 34 |
durations = {}
|
|
|
|
| 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, one feature at a time; the voice stays sparse and frames the film as a
|
| 4 |
+
journey of knowledge and discovery. Also writes src/compositions/demoVo.ts; scenes stretch to fit their lines.
|
| 5 |
make_demo_music.py writes a 47.5 s score; raise its TOTAL and rerun it if the demo grows past that.
|
| 6 |
|
| 7 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
|
|
|
|
| 17 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 18 |
|
| 19 |
LINES = {
|
| 20 |
+
"warp": "Every great journey begins with a single question.",
|
| 21 |
+
"sync": "Gather everything you know.",
|
| 22 |
+
"map": "Somewhere in all you have written, a whole world is waiting to be discovered.",
|
| 23 |
+
"kit": "Knowledge was always meant to travel further.",
|
| 24 |
+
"outro": "Notestack. Begin your journey.",
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
|
| 28 |
+
vs = tts.VoiceSettings(stability=0.6, similarity_boost=0.8, style=0.2, speed=0.9)
|
| 29 |
ws = uuid.UUID(int=0)
|
| 30 |
keys = list(LINES)
|
| 31 |
durations = {}
|
renderer/src/compositions/LandingDemo.tsx
CHANGED
|
@@ -4,41 +4,53 @@ import { theme } from "../theme";
|
|
| 4 |
import { DEMO_VO_SECONDS } from "./demoVo";
|
| 5 |
import { Lockup } from "./Lockup";
|
| 6 |
|
| 7 |
-
/** Landing page product demo:
|
| 8 |
-
* Scenes:
|
| 9 |
-
*
|
|
|
|
| 10 |
|
| 11 |
export const DEMO_FPS = 30;
|
| 12 |
const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
|
| 13 |
|
| 14 |
-
// Narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"):
|
| 15 |
-
//
|
| 16 |
-
// needs more room, so it always ends with a breath before the
|
| 17 |
const BASE_SCENES = [
|
| 18 |
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 19 |
-
{ id: "
|
| 20 |
-
{ id: "
|
| 21 |
-
{ id: "
|
| 22 |
-
{ id: "audio", len: 5.
|
| 23 |
-
{ id: "
|
| 24 |
-
{ id: "
|
|
|
|
| 25 |
{ id: "outro", len: 4.2, vo: 0.5 },
|
| 26 |
] as const;
|
|
|
|
| 27 |
const XFADE = 0.5; // seconds of overlap between scenes
|
| 28 |
-
const BREATH = 0.8; // gap between the end of
|
| 29 |
|
| 30 |
// Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them.
|
| 31 |
-
const VO_SECONDS: Record<
|
| 32 |
-
const SCENES = BASE_SCENES.map((s) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
| 35 |
const VO_WINDOWS: [number, number][] = (() => {
|
| 36 |
let cursor = 0;
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
cursor += scene.len - XFADE;
|
| 40 |
-
|
| 41 |
-
|
| 42 |
})();
|
| 43 |
|
| 44 |
const MUSIC = 0.5;
|
|
@@ -158,9 +170,9 @@ function Caption({ eyebrow, title, delay = 0 }: { eyebrow: string; title: string
|
|
| 158 |
const { fps } = useVideoConfig();
|
| 159 |
const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
|
| 160 |
return (
|
| 161 |
-
<div style={{ position: "absolute", left: 140, top:
|
| 162 |
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
|
| 163 |
-
<h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth:
|
| 164 |
</div>
|
| 165 |
);
|
| 166 |
}
|
|
@@ -177,6 +189,83 @@ function typed(text: string, frame: number, start: number, cps = 26): string {
|
|
| 177 |
return text.slice(0, n);
|
| 178 |
}
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
// Scenes
|
| 181 |
|
| 182 |
function Warp() {
|
|
@@ -204,207 +293,126 @@ function Warp() {
|
|
| 204 |
);
|
| 205 |
}
|
| 206 |
|
| 207 |
-
const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
const frame = useCurrentFrame();
|
| 212 |
const { fps } = useVideoConfig();
|
| 213 |
-
const url = typed("yourblog.com", frame,
|
| 214 |
-
const
|
| 215 |
-
const
|
| 216 |
-
const
|
| 217 |
return (
|
| 218 |
<AbsoluteFill>
|
| 219 |
-
<WarpField speed={1
|
| 220 |
-
<Caption eyebrow="
|
| 221 |
-
<
|
| 222 |
-
<div style={{ .
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
<span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
|
| 226 |
-
</div>
|
| 227 |
-
<div
|
| 228 |
-
style={{
|
| 229 |
-
position: "relative",
|
| 230 |
-
padding: "24px 44px",
|
| 231 |
-
borderRadius: 999,
|
| 232 |
-
background: BLUE,
|
| 233 |
-
color: theme.white,
|
| 234 |
-
fontFamily: theme.body,
|
| 235 |
-
fontWeight: 600,
|
| 236 |
-
fontSize: 32,
|
| 237 |
-
transform: `scale(${1 - 0.06 * Math.sin(press * Math.PI)})`,
|
| 238 |
-
boxShadow: `0 0 ${30 + press * 40}px ${BLUE}`,
|
| 239 |
-
}}
|
| 240 |
-
>
|
| 241 |
-
Start Exploring
|
| 242 |
-
<div style={{ position: "absolute", inset: -ring * 60, borderRadius: 999, border: `2px solid ${BLUE}`, opacity: 1 - ring }} />
|
| 243 |
-
</div>
|
| 244 |
</div>
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
| 247 |
{SOURCES.map((name, i) => {
|
| 248 |
-
const on = spring({ frame: frame -
|
| 249 |
return (
|
| 250 |
-
<
|
| 251 |
-
key={name}
|
| 252 |
-
style={{
|
| 253 |
-
padding: "12px 24px",
|
| 254 |
-
borderRadius: 999,
|
| 255 |
-
border: `1px solid ${on > 0.5 ? BLUE : "rgba(255,255,255,0.18)"}`,
|
| 256 |
-
background: on > 0.5 ? `${BLUE}26` : "rgba(0,0,0,0.5)",
|
| 257 |
-
boxShadow: on > 0.5 ? `0 0 18px ${BLUE}66` : "none",
|
| 258 |
-
fontFamily: theme.mono,
|
| 259 |
-
fontSize: 24,
|
| 260 |
-
letterSpacing: 1,
|
| 261 |
-
color: theme.white,
|
| 262 |
-
opacity: 0.25 + on * 0.75,
|
| 263 |
-
transform: `translateY(${(1 - on) * 16}px)`,
|
| 264 |
-
}}
|
| 265 |
-
>
|
| 266 |
{name}
|
| 267 |
-
</
|
| 268 |
);
|
| 269 |
})}
|
| 270 |
</div>
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
style={{
|
| 274 |
-
...panel,
|
| 275 |
-
display: "flex",
|
| 276 |
-
alignItems: "center",
|
| 277 |
-
gap: 22,
|
| 278 |
-
padding: "18px 28px",
|
| 279 |
-
borderRadius: 16,
|
| 280 |
-
opacity: drop,
|
| 281 |
-
transform: `translateY(${(1 - drop) * -120}px) rotate(${(1 - drop) * -6}deg)`,
|
| 282 |
-
}}
|
| 283 |
-
>
|
| 284 |
-
<div style={{ width: 54, height: 66, borderRadius: 8, border: `2px solid ${BLUE}`, display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 18, color: BLUE }}>
|
| 285 |
-
.md
|
| 286 |
-
</div>
|
| 287 |
-
<div style={{ display: "grid", gap: 4 }}>
|
| 288 |
-
<span style={{ fontFamily: theme.body, fontSize: 30, color: theme.white }}>notes.md, essays.md, drafts.md</span>
|
| 289 |
-
<span style={{ fontFamily: theme.mono, fontSize: 20, color: "rgba(255,255,255,0.6)" }}>No feed? Drop in markdown, text, HTML or PDF</span>
|
| 290 |
-
</div>
|
| 291 |
</div>
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
return (
|
| 310 |
-
<AbsoluteFill>
|
| 311 |
-
<WarpField speed={0.8} />
|
| 312 |
-
<Caption eyebrow="Step 02" title="Shimmering. Impossible to look away." />
|
| 313 |
-
<svg width={width} height={height} style={{ position: "absolute" }}>
|
| 314 |
-
<defs>
|
| 315 |
-
<radialGradient id="planet" cx="40%" cy="35%" r="70%">
|
| 316 |
-
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
|
| 317 |
-
<stop offset="35%" stopColor={BLUE} />
|
| 318 |
-
<stop offset="100%" stopColor="#000000" />
|
| 319 |
-
</radialGradient>
|
| 320 |
-
</defs>
|
| 321 |
-
<circle cx={cx} cy={cy} r={150 * planet} fill="url(#planet)" style={{ filter: `drop-shadow(0 0 60px ${BLUE})` }} />
|
| 322 |
-
{[260, 360].map((r, i) => (
|
| 323 |
-
<ellipse key={r} cx={cx} cy={cy} rx={r * 1.6} ry={r * 0.45} fill="none" stroke={i ? "#ffffff" : BLUE} strokeOpacity={0.25} strokeWidth={2} />
|
| 324 |
-
))}
|
| 325 |
-
</svg>
|
| 326 |
-
{POSTS.map((title, i) => {
|
| 327 |
-
const start = 8 + i * 6;
|
| 328 |
-
const p = interpolate(frame, [start, start + 36], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
|
| 329 |
-
const ring = i % 2 ? 360 : 260;
|
| 330 |
-
const angle = (i / POSTS.length) * Math.PI * 2 + frame * 0.012 * (i % 2 ? 1 : -1);
|
| 331 |
-
const tx = cx + Math.cos(angle) * ring * 1.6;
|
| 332 |
-
const ty = cy + Math.sin(angle) * ring * 0.45;
|
| 333 |
-
const fromX = width + 200;
|
| 334 |
-
const fromY = random(`py${i}`) * height;
|
| 335 |
-
const x = interpolate(p, [0, 1], [fromX, tx]);
|
| 336 |
-
const y = interpolate(p, [0, 1], [fromY, ty]);
|
| 337 |
-
const front = Math.sin(angle) > 0;
|
| 338 |
-
return (
|
| 339 |
-
<div
|
| 340 |
-
key={title}
|
| 341 |
-
style={{
|
| 342 |
-
...panel,
|
| 343 |
-
position: "absolute",
|
| 344 |
-
left: x,
|
| 345 |
-
top: y,
|
| 346 |
-
transform: `translate(-50%, -50%) scale(${front ? 1 : 0.8})`,
|
| 347 |
-
padding: "12px 20px",
|
| 348 |
-
borderRadius: 12,
|
| 349 |
-
fontFamily: theme.body,
|
| 350 |
-
fontSize: 22,
|
| 351 |
-
color: theme.white,
|
| 352 |
-
opacity: p * (front ? 1 : 0.55),
|
| 353 |
-
zIndex: front ? 2 : 0,
|
| 354 |
-
whiteSpace: "nowrap",
|
| 355 |
-
}}
|
| 356 |
-
>
|
| 357 |
-
{title}
|
| 358 |
-
</div>
|
| 359 |
-
);
|
| 360 |
-
})}
|
| 361 |
-
<div style={{ position: "absolute", left: 140, bottom: 120, fontFamily: theme.mono, color: theme.white }}>
|
| 362 |
-
<span style={{ fontSize: 96, fontFamily: theme.display, textShadow: `0 0 30px ${BLUE}` }}>{count}</span>
|
| 363 |
-
<span style={{ fontSize: 28, marginLeft: 20, color: "rgba(255,255,255,0.7)", letterSpacing: 3 }}>POSTS IN ORBIT</span>
|
| 364 |
-
</div>
|
| 365 |
</AbsoluteFill>
|
| 366 |
);
|
| 367 |
}
|
| 368 |
|
| 369 |
-
|
|
|
|
| 370 |
const frame = useCurrentFrame();
|
| 371 |
const { fps } = useVideoConfig();
|
| 372 |
-
const q = typed("What have I written about
|
| 373 |
-
const answer = spring({ frame: frame -
|
| 374 |
const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
|
| 375 |
-
const
|
|
|
|
|
|
|
| 376 |
return (
|
| 377 |
<AbsoluteFill>
|
| 378 |
-
<WarpField speed={0.
|
| 379 |
-
<Caption eyebrow="
|
| 380 |
-
<
|
| 381 |
-
<div style={{
|
| 382 |
-
<div style={{ justifySelf: "end", padding: "14px
|
| 383 |
{q || " "}
|
| 384 |
</div>
|
| 385 |
<div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
|
| 386 |
{steps.map((s, i) => (
|
| 387 |
-
<span key={s} style={{ opacity:
|
| 388 |
{s}
|
| 389 |
</span>
|
| 390 |
))}
|
| 391 |
</div>
|
| 392 |
-
<p style={{ margin: 0, fontFamily: theme.body, fontSize:
|
| 393 |
-
You
|
| 394 |
-
<Marker n={1} glow={cite} /> because readers took the work more seriously{" "}
|
| 395 |
-
<Marker n={2} glow={cite} />.
|
| 396 |
</p>
|
| 397 |
</div>
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
{
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
</div>
|
| 407 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</AbsoluteFill>
|
| 409 |
);
|
| 410 |
}
|
|
@@ -417,142 +425,410 @@ function Marker({ n, glow }: { n: number; glow: number }) {
|
|
| 417 |
);
|
| 418 |
}
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
function Audio() {
|
| 421 |
const frame = useCurrentFrame();
|
| 422 |
const { fps } = useVideoConfig();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
const lines = [
|
| 424 |
-
{ who: "A", text: "So the big idea here is that
|
| 425 |
-
{ who: "B", text: "Right, and
|
| 426 |
-
{ who: "A", text: "Which she wrote about back in March, in
|
| 427 |
];
|
| 428 |
-
const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame -
|
| 429 |
return (
|
| 430 |
<AbsoluteFill>
|
| 431 |
<WarpField speed={0.7} count={260} />
|
| 432 |
-
<Caption eyebrow="
|
| 433 |
-
<
|
| 434 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
{Array.from({ length: 64 }).map((_, i) => {
|
| 436 |
const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
|
| 437 |
-
const env = spring({ frame: frame -
|
| 438 |
-
return <div key={i} style={{ width: 14, height: 12 + v *
|
| 439 |
})}
|
| 440 |
</div>
|
| 441 |
-
<div style={{ display: "grid", gap:
|
| 442 |
{lines.map((l, i) => {
|
| 443 |
const on = i === active;
|
| 444 |
-
const seen = i <= active;
|
| 445 |
return (
|
| 446 |
-
<div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity:
|
| 447 |
-
<span style={{ width:
|
| 448 |
-
{l.who}
|
| 449 |
-
</span>
|
| 450 |
{l.text}
|
| 451 |
</div>
|
| 452 |
);
|
| 453 |
})}
|
| 454 |
</div>
|
| 455 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
</AbsoluteFill>
|
| 457 |
);
|
| 458 |
}
|
| 459 |
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
const frame = useCurrentFrame();
|
| 462 |
const { fps } = useVideoConfig();
|
| 463 |
-
const
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
];
|
| 469 |
return (
|
| 470 |
<AbsoluteFill>
|
| 471 |
-
<WarpField speed={0.
|
| 472 |
-
<Caption eyebrow="
|
| 473 |
-
<
|
| 474 |
-
{
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
return (
|
| 478 |
-
<div key={
|
| 479 |
-
<
|
| 480 |
-
<
|
|
|
|
| 481 |
</div>
|
| 482 |
);
|
| 483 |
})}
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
{t}
|
| 489 |
</span>
|
| 490 |
))}
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
</AbsoluteFill>
|
| 493 |
);
|
| 494 |
}
|
| 495 |
|
| 496 |
-
|
|
|
|
| 497 |
const frame = useCurrentFrame();
|
| 498 |
-
const {
|
| 499 |
-
const
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
return (
|
| 502 |
<AbsoluteFill>
|
| 503 |
-
<WarpField speed={0.
|
| 504 |
-
<Caption eyebrow="
|
| 505 |
-
<
|
| 506 |
-
{
|
| 507 |
-
const
|
| 508 |
return (
|
| 509 |
-
<div key={
|
| 510 |
-
{
|
| 511 |
-
|
| 512 |
</div>
|
| 513 |
);
|
| 514 |
})}
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
<stop offset="0%" stopColor={BLUE} stopOpacity="0" />
|
| 520 |
-
<stop offset="70%" stopColor={BLUE} stopOpacity="0.8" />
|
| 521 |
-
<stop offset="100%" stopColor="#ffffff" stopOpacity="1" />
|
| 522 |
-
</linearGradient>
|
| 523 |
-
</defs>
|
| 524 |
-
{(() => {
|
| 525 |
-
// A launch arc from the lower right: curved trail, hot head, exhaust sparks.
|
| 526 |
-
const pt = (u: number) => ({ x: 1360 + 300 * u + 120 * u * u, y: 1000 - 1050 * u + 120 * u * (1 - u) });
|
| 527 |
-
const head = pt(rocket);
|
| 528 |
-
const tail = pt(Math.max(0, rocket - 0.35));
|
| 529 |
-
const path = Array.from({ length: 24 }, (_, i) => pt(Math.max(0, rocket - 0.35) + ((rocket - Math.max(0, rocket - 0.35)) * i) / 23));
|
| 530 |
return (
|
| 531 |
-
<>
|
| 532 |
-
|
| 533 |
-
{
|
| 534 |
-
<
|
| 535 |
)}
|
| 536 |
-
|
| 537 |
-
Array.from({ length: 16 }).map((_, i) => {
|
| 538 |
-
const u = Math.max(0, rocket - random(`s${i}`) * 0.3);
|
| 539 |
-
const q = pt(u);
|
| 540 |
-
const jitter = (random(`j${i}`) - 0.5) * 60;
|
| 541 |
-
return <circle key={i} cx={q.x + jitter} cy={q.y + 20} r={2 + random(`k${i}`) * 3} fill="#ffffff" opacity={0.3 + 0.5 * random(`o${i}`)} />;
|
| 542 |
-
})}
|
| 543 |
-
{rocket > 0 && <circle cx={head.x} cy={head.y} r={16} fill="#ffffff" style={{ filter: `drop-shadow(0 0 28px ${BLUE}) drop-shadow(0 0 8px #ffffff)` }} />}
|
| 544 |
-
{rocket === 0 && <circle cx={tail.x} cy={tail.y} r={10} fill="#ffffff" opacity={interpolate(frame, [30, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })} style={{ filter: `drop-shadow(0 0 16px ${BLUE})` }} />}
|
| 545 |
-
</>
|
| 546 |
);
|
| 547 |
-
})
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
<
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
</AbsoluteFill>
|
| 557 |
);
|
| 558 |
}
|
|
@@ -580,14 +856,15 @@ function Outro() {
|
|
| 580 |
);
|
| 581 |
}
|
| 582 |
|
| 583 |
-
const RENDER: Record<
|
| 584 |
warp: Warp,
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
audio: Audio,
|
| 589 |
-
|
| 590 |
-
|
|
|
|
| 591 |
outro: Outro,
|
| 592 |
};
|
| 593 |
|
|
@@ -596,7 +873,7 @@ export function LandingDemo() {
|
|
| 596 |
return (
|
| 597 |
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 598 |
<SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
|
| 599 |
-
{SCENES.map((scene
|
| 600 |
const from = cursor;
|
| 601 |
cursor += S(scene.len - XFADE);
|
| 602 |
const Scene = RENDER[scene.id];
|
|
@@ -605,10 +882,11 @@ export function LandingDemo() {
|
|
| 605 |
<FadeScene len={scene.len}>
|
| 606 |
<Scene />
|
| 607 |
</FadeScene>
|
| 608 |
-
|
| 609 |
-
<
|
| 610 |
-
|
| 611 |
-
|
|
|
|
| 612 |
</Sequence>
|
| 613 |
);
|
| 614 |
})}
|
|
|
|
| 4 |
import { DEMO_VO_SECONDS } from "./demoVo";
|
| 5 |
import { Lockup } from "./Lockup";
|
| 6 |
|
| 7 |
+
/** Landing page product demo: the real app, one feature at a time. 1920x1080, 30 fps, ~47 s.
|
| 8 |
+
* Scenes: intro, sync, ask, map, audio, video, edit, launch kit, outro. Each product scene is a
|
| 9 |
+
* window with a cursor that does the thing; the narrator adds a sparse journey-of-discovery line here and there.
|
| 10 |
+
* Strict palette: black, #217cff, white. */
|
| 11 |
|
| 12 |
export const DEMO_FPS = 30;
|
| 13 |
const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
|
| 14 |
|
| 15 |
+
// Narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"): a few scenes (intro, sync, map,
|
| 16 |
+
// launch kit, outro) speak, starting `vo` seconds in. Scenes keep their minimum length for the animation and stretch when a
|
| 17 |
+
// line needs more room, so it always ends with a breath before the scene changes.
|
| 18 |
const BASE_SCENES = [
|
| 19 |
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 20 |
+
{ id: "sync", len: 5.0, vo: 0.5 },
|
| 21 |
+
{ id: "ask", len: 6.0 },
|
| 22 |
+
{ id: "map", len: 6.5, vo: 0.6 },
|
| 23 |
+
{ id: "audio", len: 5.5 },
|
| 24 |
+
{ id: "video", len: 6.5 },
|
| 25 |
+
{ id: "edit", len: 6.0 },
|
| 26 |
+
{ id: "kit", len: 5.5, vo: 0.5 },
|
| 27 |
{ id: "outro", len: 4.2, vo: 0.5 },
|
| 28 |
] as const;
|
| 29 |
+
type SceneId = (typeof BASE_SCENES)[number]["id"];
|
| 30 |
const XFADE = 0.5; // seconds of overlap between scenes
|
| 31 |
+
const BREATH = 0.8; // gap between the end of a line and the scene change (includes the crossfade)
|
| 32 |
|
| 33 |
// Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them.
|
| 34 |
+
const VO_SECONDS: Partial<Record<SceneId, number>> = DEMO_VO_SECONDS;
|
| 35 |
+
const SCENES = BASE_SCENES.map((s) => {
|
| 36 |
+
const vo = "vo" in s ? s.vo : undefined;
|
| 37 |
+
const line = VO_SECONDS[s.id];
|
| 38 |
+
return { id: s.id, vo, len: vo !== undefined && line ? Math.max(s.len, vo + line + BREATH) : s.len };
|
| 39 |
+
});
|
| 40 |
|
| 41 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
| 42 |
const VO_WINDOWS: [number, number][] = (() => {
|
| 43 |
let cursor = 0;
|
| 44 |
+
const windows: [number, number][] = [];
|
| 45 |
+
for (const scene of SCENES) {
|
| 46 |
+
const line = VO_SECONDS[scene.id];
|
| 47 |
+
if (scene.vo !== undefined && line) {
|
| 48 |
+
const start = cursor + scene.vo;
|
| 49 |
+
windows.push([Math.round(start * DEMO_FPS), Math.round((start + line) * DEMO_FPS)]);
|
| 50 |
+
}
|
| 51 |
cursor += scene.len - XFADE;
|
| 52 |
+
}
|
| 53 |
+
return windows;
|
| 54 |
})();
|
| 55 |
|
| 56 |
const MUSIC = 0.5;
|
|
|
|
| 170 |
const { fps } = useVideoConfig();
|
| 171 |
const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
|
| 172 |
return (
|
| 173 |
+
<div style={{ position: "absolute", left: 140, top: 96, opacity: s, transform: `translateY(${(1 - s) * 24}px)` }}>
|
| 174 |
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
|
| 175 |
+
<h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth: 1400 }}>{title}</h2>
|
| 176 |
</div>
|
| 177 |
);
|
| 178 |
}
|
|
|
|
| 189 |
return text.slice(0, n);
|
| 190 |
}
|
| 191 |
|
| 192 |
+
const clamp01 = (frame: number, a: number, b: number) => interpolate(frame, [a, b], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
| 193 |
+
/** 0 to 1 and back over 10 frames from `at`: the press of a clicked control. */
|
| 194 |
+
const pulse = (frame: number, at: number) => Math.sin(clamp01(frame, at, at + 10) * Math.PI);
|
| 195 |
+
|
| 196 |
+
// App window and cursor
|
| 197 |
+
|
| 198 |
+
/** Where a window's content area starts in scene coordinates, so cursor targets can be written in window space. */
|
| 199 |
+
const WX = 141;
|
| 200 |
+
const WY = 303;
|
| 201 |
+
const at = (x: number, y: number) => ({ x: WX + x, y: WY + y });
|
| 202 |
+
|
| 203 |
+
/** A browser-style app window: 1640 x 668 of content under a 52 px title bar. */
|
| 204 |
+
function Win({ children }: { children: ReactNode }) {
|
| 205 |
+
return (
|
| 206 |
+
<div style={{ ...panel, position: "absolute", left: 140, top: 250, width: 1640, height: 720, overflow: "hidden", background: "rgba(3,7,16,0.9)" }}>
|
| 207 |
+
<div style={{ height: 52, display: "flex", alignItems: "center", gap: 10, padding: "0 22px", borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
| 208 |
+
{[0, 1, 2].map((i) => (
|
| 209 |
+
<span key={i} style={{ width: 13, height: 13, borderRadius: "50%", background: "rgba(255,255,255,0.22)" }} />
|
| 210 |
+
))}
|
| 211 |
+
<span style={{ marginLeft: 22, padding: "5px 22px", borderRadius: 999, background: "rgba(255,255,255,0.08)", fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.6)" }}>notestack.ai/app</span>
|
| 212 |
+
</div>
|
| 213 |
+
<div style={{ position: "absolute", left: 0, top: 52, right: 0, bottom: 0 }}>{children}</div>
|
| 214 |
+
</div>
|
| 215 |
+
);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function Chip({ children, on = false, press = 0, style }: { children: ReactNode; on?: boolean; press?: number; style?: CSSProperties }) {
|
| 219 |
+
return (
|
| 220 |
+
<span
|
| 221 |
+
style={{
|
| 222 |
+
display: "inline-flex",
|
| 223 |
+
alignItems: "center",
|
| 224 |
+
justifyContent: "center",
|
| 225 |
+
borderRadius: 999,
|
| 226 |
+
border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.2)"}`,
|
| 227 |
+
background: on ? `${BLUE}33` : "rgba(255,255,255,0.04)",
|
| 228 |
+
boxShadow: on ? `0 0 22px ${BLUE}77` : "none",
|
| 229 |
+
fontFamily: theme.body,
|
| 230 |
+
fontSize: 26,
|
| 231 |
+
color: theme.white,
|
| 232 |
+
transform: `scale(${1 - 0.06 * press})`,
|
| 233 |
+
...style,
|
| 234 |
+
}}
|
| 235 |
+
>
|
| 236 |
+
{children}
|
| 237 |
+
</span>
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function Label({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
| 242 |
+
return <span style={{ position: "absolute", fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, textTransform: "uppercase", color: "rgba(255,255,255,0.55)", ...style }}>{children}</span>;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
type Key = { f: number; x: number; y: number };
|
| 246 |
+
|
| 247 |
+
/** A mouse cursor that glides through `keys` (scene coordinates) and ripples on each frame in `clicks`. */
|
| 248 |
+
function Cursor({ keys, clicks = [] }: { keys: Key[]; clicks?: number[] }) {
|
| 249 |
+
const frame = useCurrentFrame();
|
| 250 |
+
const fs = keys.map((k) => k.f);
|
| 251 |
+
const opts = { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease } as const;
|
| 252 |
+
const x = interpolate(frame, fs, keys.map((k) => k.x), opts);
|
| 253 |
+
const y = interpolate(frame, fs, keys.map((k) => k.y), opts);
|
| 254 |
+
const down = clicks.reduce((m, c) => Math.max(m, pulse(frame, c)), 0);
|
| 255 |
+
return (
|
| 256 |
+
<>
|
| 257 |
+
{clicks.map((c) => {
|
| 258 |
+
const p = clamp01(frame, c, c + 16);
|
| 259 |
+
if (p <= 0 || p >= 1) return null;
|
| 260 |
+
return <div key={c} style={{ position: "absolute", left: x - 36 * p, top: y - 36 * p, width: 72 * p, height: 72 * p, borderRadius: "50%", border: `3px solid ${BLUE}`, opacity: 1 - p }} />;
|
| 261 |
+
})}
|
| 262 |
+
<svg width={40} height={44} viewBox="0 0 24 34" style={{ position: "absolute", left: x, top: y, transform: `scale(${1 - 0.12 * down})`, transformOrigin: "0 0", filter: `drop-shadow(0 0 10px ${BLUE})`, zIndex: 50 }}>
|
| 263 |
+
<path d="M1 1 L1 26 L7.5 20.5 L12 31 L16.5 29 L12 18.5 L21 18.5 Z" fill="#ffffff" stroke="#000000" strokeWidth={1.6} strokeLinejoin="round" />
|
| 264 |
+
</svg>
|
| 265 |
+
</>
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
// Scenes
|
| 270 |
|
| 271 |
function Warp() {
|
|
|
|
| 293 |
);
|
| 294 |
}
|
| 295 |
|
| 296 |
+
const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "RSS", "Markdown"];
|
| 297 |
|
| 298 |
+
const POSTS = [
|
| 299 |
+
"Why Things Fall", "Curved Space", "Notes on Orbits", "What Is Entropy?",
|
| 300 |
+
"The Quantum Leap", "Light as a Wave", "Time Is Strange", "Black Hole Basics",
|
| 301 |
+
];
|
| 302 |
+
const POST_DATES = ["Mar 12", "Mar 04", "Feb 21", "Feb 09", "Jan 30", "Jan 18", "Jan 03", "Dec 20"];
|
| 303 |
+
|
| 304 |
+
/** 01: paste a link, the archive syncs in. */
|
| 305 |
+
function Sync() {
|
| 306 |
const frame = useCurrentFrame();
|
| 307 |
const { fps } = useVideoConfig();
|
| 308 |
+
const url = typed("yourblog.com", frame, 8, 14);
|
| 309 |
+
const press = pulse(frame, 52);
|
| 310 |
+
const count = Math.round(142 * ease(clamp01(frame, 58, 120)));
|
| 311 |
+
const bar = clamp01(frame, 54, 84);
|
| 312 |
return (
|
| 313 |
<AbsoluteFill>
|
| 314 |
+
<WarpField speed={1} />
|
| 315 |
+
<Caption eyebrow="01 · Sync" title="Point it at your writing." />
|
| 316 |
+
<Win>
|
| 317 |
+
<div style={{ position: "absolute", left: 80, top: 56, width: 1060, height: 84, borderRadius: 999, border: `1px solid ${BLUE}88`, background: "rgba(255,255,255,0.05)", display: "flex", alignItems: "center", paddingLeft: 38, fontFamily: theme.body, fontSize: 38, color: url ? theme.white : "rgba(255,255,255,0.4)" }}>
|
| 318 |
+
{url || "yourblog.com"}
|
| 319 |
+
<span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
</div>
|
| 321 |
+
<div style={{ position: "absolute", left: 1160, top: 56, width: 220, height: 84, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 32, color: theme.white, transform: `scale(${1 - 0.06 * press})`, boxShadow: `0 0 ${24 + press * 40}px ${BLUE}` }}>
|
| 322 |
+
Sync
|
| 323 |
+
</div>
|
| 324 |
+
<div style={{ position: "absolute", left: 80, top: 158, width: 1060 * bar, height: 4, borderRadius: 2, background: BLUE, boxShadow: `0 0 14px ${BLUE}`, opacity: bar > 0 && bar < 1 ? 1 : 0 }} />
|
| 325 |
+
<div style={{ position: "absolute", left: 80, top: 186, display: "flex", gap: 12 }}>
|
| 326 |
{SOURCES.map((name, i) => {
|
| 327 |
+
const on = spring({ frame: frame - 14 - i * 5, fps, config: { damping: 16 } });
|
| 328 |
return (
|
| 329 |
+
<Chip key={name} on={on > 0.5} style={{ padding: "8px 20px", fontSize: 21, fontFamily: theme.mono, opacity: 0.3 + on * 0.7 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
{name}
|
| 331 |
+
</Chip>
|
| 332 |
);
|
| 333 |
})}
|
| 334 |
</div>
|
| 335 |
+
<div style={{ position: "absolute", right: 80, top: 176, fontFamily: theme.mono, color: theme.white, display: "flex", alignItems: "baseline", gap: 14 }}>
|
| 336 |
+
<span style={{ fontFamily: theme.display, fontSize: 56, textShadow: `0 0 24px ${BLUE}` }}>{count}</span>
|
| 337 |
+
<span style={{ fontSize: 20, letterSpacing: 3, color: "rgba(255,255,255,0.6)" }}>POSTS SYNCED</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
+
<div style={{ position: "absolute", left: 80, top: 280, width: 1480, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18 }}>
|
| 340 |
+
{POSTS.map((title, i) => {
|
| 341 |
+
const s = spring({ frame: frame - 60 - i * 5, fps, config: { damping: 14 } });
|
| 342 |
+
return (
|
| 343 |
+
<div key={title} style={{ display: "flex", alignItems: "center", gap: 20, height: 74, padding: "0 26px", borderRadius: 14, border: "1px solid rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.04)", opacity: s, transform: `translateY(${(1 - s) * 30}px)`, fontFamily: theme.body, fontSize: 28, color: theme.white }}>
|
| 344 |
+
<span style={{ width: 30, height: 30, borderRadius: "50%", background: BLUE, display: "grid", placeItems: "center", fontSize: 18, boxShadow: `0 0 14px ${BLUE}` }}>✓</span>
|
| 345 |
+
<span style={{ flex: 1 }}>{title}</span>
|
| 346 |
+
<span style={{ fontFamily: theme.mono, fontSize: 20, color: "rgba(255,255,255,0.5)" }}>{POST_DATES[i]}</span>
|
| 347 |
+
</div>
|
| 348 |
+
);
|
| 349 |
+
})}
|
| 350 |
+
</div>
|
| 351 |
+
</Win>
|
| 352 |
+
<Cursor
|
| 353 |
+
keys={[{ f: 0, x: 1560, y: 880 }, { f: 42, ...at(1270, 98) }, { f: 74, ...at(1270, 98) }, { f: 100, ...at(1100, 470) }]}
|
| 354 |
+
clicks={[52]}
|
| 355 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
</AbsoluteFill>
|
| 357 |
);
|
| 358 |
}
|
| 359 |
|
| 360 |
+
/** 02: ask a question, click the citation, see the exact lines. */
|
| 361 |
+
function Ask() {
|
| 362 |
const frame = useCurrentFrame();
|
| 363 |
const { fps } = useVideoConfig();
|
| 364 |
+
const q = typed("What have I written about gravity?", frame, 8, 30);
|
| 365 |
+
const answer = spring({ frame: frame - 66, fps, config: { damping: 18 } });
|
| 366 |
const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
|
| 367 |
+
const open = spring({ frame: frame - 114, fps, config: { damping: 18 } });
|
| 368 |
+
const steps = ["Searching for gravity|spacetime", "Reading Why Things Fall, lines 12 to 20", "Reading Curved Space, lines 4 to 9"];
|
| 369 |
+
const context = ["Newton called it a force pulling things down.", "Einstein saw something different: gravity is", "the curvature of spacetime itself.", "Mass tells space how to bend.", "Objects follow the straightest path."];
|
| 370 |
return (
|
| 371 |
<AbsoluteFill>
|
| 372 |
+
<WarpField speed={0.7} count={300} />
|
| 373 |
+
<Caption eyebrow="02 · Ask" title="Every answer, cited to the line." />
|
| 374 |
+
<Win>
|
| 375 |
+
<div style={{ position: "absolute", left: 80, top: 36, width: 880, display: "grid", gap: 20 }}>
|
| 376 |
+
<div style={{ justifySelf: "end", padding: "14px 24px", borderRadius: "18px 18px 4px 18px", background: `${BLUE}22`, border: `1px solid ${BLUE}66`, fontFamily: theme.body, fontSize: 30, color: theme.white, minWidth: 40, minHeight: 46 }}>
|
| 377 |
{q || " "}
|
| 378 |
</div>
|
| 379 |
<div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
|
| 380 |
{steps.map((s, i) => (
|
| 381 |
+
<span key={s} style={{ opacity: clamp01(frame, 40 + i * 8, 48 + i * 8) }}>
|
| 382 |
{s}
|
| 383 |
</span>
|
| 384 |
))}
|
| 385 |
</div>
|
| 386 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 32, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
|
| 387 |
+
You wrote that <b>gravity is the curvature of spacetime</b> <Marker n={1} glow={cite} /> and that orbits are objects falling around that curve <Marker n={2} glow={cite} />.
|
|
|
|
|
|
|
| 388 |
</p>
|
| 389 |
</div>
|
| 390 |
+
{[
|
| 391 |
+
{ n: 1, label: "Why Things Fall · L12 to 14" },
|
| 392 |
+
{ n: 2, label: "Curved Space · L4 to 9" },
|
| 393 |
+
].map((c, i) => (
|
| 394 |
+
<Chip key={c.n} on={cite > 0.5 && (i === 0 ? frame >= 112 : false)} press={i === 0 ? pulse(frame, 112) : 0} style={{ position: "absolute", left: 80 + i * 450, top: 460, width: 420, height: 60, fontFamily: theme.mono, fontSize: 21, opacity: cite, gap: 10 }}>
|
| 395 |
+
<span style={{ color: BLUE }}>[{c.n}]</span> {c.label}
|
| 396 |
+
</Chip>
|
| 397 |
+
))}
|
| 398 |
+
<div style={{ ...panel, position: "absolute", left: 1010, top: 36, width: 560, padding: 30, opacity: open, transform: `translateX(${(1 - open) * 60}px)` }}>
|
| 399 |
+
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] WHY THINGS FALL · L12 TO 14</p>
|
| 400 |
+
{context.map((l, i) => {
|
| 401 |
+
const hit = i >= 1 && i <= 3;
|
| 402 |
+
return (
|
| 403 |
+
<div key={l} style={{ display: "flex", gap: 18, marginTop: 12, fontFamily: theme.body, fontSize: 25, color: hit ? theme.white : "rgba(255,255,255,0.45)", background: hit ? `${BLUE}26` : "transparent", boxShadow: hit ? `inset 4px 0 0 ${BLUE}` : "none", padding: "6px 12px", borderRadius: 8 }}>
|
| 404 |
+
<span style={{ fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", paddingTop: 4 }}>{11 + i}</span>
|
| 405 |
+
{l}
|
| 406 |
+
</div>
|
| 407 |
+
);
|
| 408 |
+
})}
|
| 409 |
+
<p style={{ margin: "22px 0 0", fontFamily: theme.mono, fontSize: 19, color: BLUE }}>✓ Line range verified</p>
|
| 410 |
</div>
|
| 411 |
+
</Win>
|
| 412 |
+
<Cursor
|
| 413 |
+
keys={[{ f: 0, x: 1560, y: 880 }, { f: 100, ...at(260, 490) }, { f: 126, ...at(260, 490) }, { f: 160, ...at(1250, 560) }]}
|
| 414 |
+
clicks={[112]}
|
| 415 |
+
/>
|
| 416 |
</AbsoluteFill>
|
| 417 |
);
|
| 418 |
}
|
|
|
|
| 425 |
);
|
| 426 |
}
|
| 427 |
|
| 428 |
+
const TOPICS = [
|
| 429 |
+
"Gravity", "Spacetime", "Black holes", "Orbits", "Relativity", "Quantum", "Entropy", "Optics",
|
| 430 |
+
"Waves", "Energy", "Chaos", "Cosmology", "Fields", "Symmetry", "Time",
|
| 431 |
+
];
|
| 432 |
+
const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
|
| 433 |
+
type Shape = "orbit" | "spiral" | "figure" | "cluster";
|
| 434 |
+
|
| 435 |
+
/** Where topic `i` sits in each shape of the Mind Constellation (window coordinates). */
|
| 436 |
+
function place(shape: Shape, i: number): { x: number; y: number } {
|
| 437 |
+
const cx = 620;
|
| 438 |
+
const cy = 350;
|
| 439 |
+
const n = TOPICS.length;
|
| 440 |
+
if (shape === "orbit") {
|
| 441 |
+
if (i === 0) return { x: cx, y: cy };
|
| 442 |
+
const r = [170, 270, 360][(i - 1) % 3];
|
| 443 |
+
const a = i * 2.4;
|
| 444 |
+
return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
|
| 445 |
+
}
|
| 446 |
+
if (shape === "spiral") {
|
| 447 |
+
const t = i / (n - 1);
|
| 448 |
+
const a = t * Math.PI * 2.4 + 0.4;
|
| 449 |
+
const r = 110 + t * 260;
|
| 450 |
+
return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
|
| 451 |
+
}
|
| 452 |
+
if (shape === "figure") {
|
| 453 |
+
// A constellation figure: one long wave of stars across the sky.
|
| 454 |
+
return { x: 140 + i * 78, y: cy + (i % 2 ? -1 : 1) * (90 + (i % 3) * 30) };
|
| 455 |
+
}
|
| 456 |
+
const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }];
|
| 457 |
+
const c = centers[i % 3];
|
| 458 |
+
const k = Math.floor(i / 3);
|
| 459 |
+
const a = k * 1.25 + (i % 3);
|
| 460 |
+
const r = 36 + k * 24;
|
| 461 |
+
return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r };
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/** Frame windows in which the layout morphs to the next shape (after each pill click). */
|
| 465 |
+
const MORPHS: [Shape, number, number][] = [["spiral", 58, 82], ["figure", 94, 118], ["cluster", 126, 150]];
|
| 466 |
+
|
| 467 |
+
function mapPos(i: number, frame: number): { x: number; y: number } {
|
| 468 |
+
let { x, y } = place("orbit", i);
|
| 469 |
+
for (const [shape, from, to] of MORPHS) {
|
| 470 |
+
const t = ease(clamp01(frame, from, to));
|
| 471 |
+
const next = place(shape, i);
|
| 472 |
+
x += (next.x - x) * t;
|
| 473 |
+
y += (next.y - y) * t;
|
| 474 |
+
}
|
| 475 |
+
return { x, y };
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
/** 03: the Mind Constellation, switching shapes and diving into a topic. */
|
| 479 |
+
function MindMap() {
|
| 480 |
+
const frame = useCurrentFrame();
|
| 481 |
+
const { fps } = useVideoConfig();
|
| 482 |
+
const activeShape = frame < 56 ? 0 : frame < 92 ? 1 : frame < 124 ? 2 : 3;
|
| 483 |
+
const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } });
|
| 484 |
+
const hubWin = place("cluster", 0);
|
| 485 |
+
const hub = at(hubWin.x, hubWin.y);
|
| 486 |
+
const selected = frame >= 158;
|
| 487 |
+
const ring = clamp01(frame, 4, 40);
|
| 488 |
+
const dive = 1 + 0.14 * ease(clamp01(frame, 158, 195)); // the camera drifts in on the picked star
|
| 489 |
+
const burst = ease(clamp01(frame, 158, 182));
|
| 490 |
+
return (
|
| 491 |
+
<AbsoluteFill>
|
| 492 |
+
<WarpField speed={0.6} count={260} />
|
| 493 |
+
<Caption eyebrow="03 · Map" title="See your whole archive." />
|
| 494 |
+
<Win>
|
| 495 |
+
<svg width={1640} height={668} style={{ position: "absolute", inset: 0 }}>
|
| 496 |
+
<g transform={`translate(${hubWin.x} ${hubWin.y}) scale(${dive}) translate(${-hubWin.x} ${-hubWin.y})`}>
|
| 497 |
+
{/* Orbit rings draw in, then fade as the stars leave them. */}
|
| 498 |
+
{[170, 270, 360].map((r, i) => (
|
| 499 |
+
<ellipse key={r} cx={620} cy={350} rx={r * 1.5} ry={r * 0.58} fill="none" stroke={i % 2 ? "#ffffff" : BLUE} strokeOpacity={0.22 * ring * (1 - ease(clamp01(frame, 58, 82)))} strokeWidth={1.5} strokeDasharray="6 10" />
|
| 500 |
+
))}
|
| 501 |
+
{TOPICS.map((_, i) => {
|
| 502 |
+
if (i === 0) return null;
|
| 503 |
+
const parent = i < 4 ? 0 : i - 3;
|
| 504 |
+
const from = mapPos(i, frame);
|
| 505 |
+
const to = mapPos(parent, frame);
|
| 506 |
+
const on = clamp01(frame, 6 + i * 3, 24 + i * 3);
|
| 507 |
+
const lit = !selected || parent === 0;
|
| 508 |
+
const t = (frame * 0.018 + i * 0.37) % 1; // a pulse of light travelling toward the hub
|
| 509 |
+
return (
|
| 510 |
+
<g key={i} opacity={lit ? 1 : 0.25}>
|
| 511 |
+
<line x1={from.x} y1={from.y} x2={to.x} y2={to.y} pathLength={1} stroke={i % 2 ? BLUE : "#ffffff"} strokeOpacity={selected && parent === 0 ? 0.8 : 0.3} strokeWidth={selected && parent === 0 ? 2.5 : 1.5} strokeDasharray={1} strokeDashoffset={1 - on} />
|
| 512 |
+
{on >= 1 && <circle cx={from.x + (to.x - from.x) * t} cy={from.y + (to.y - from.y) * t} r={3.5} fill="#ffffff" opacity={Math.sin(t * Math.PI)} style={{ filter: `drop-shadow(0 0 6px ${BLUE})` }} />}
|
| 513 |
+
</g>
|
| 514 |
+
);
|
| 515 |
+
})}
|
| 516 |
+
{TOPICS.map((name, i) => {
|
| 517 |
+
const base = mapPos(i, frame);
|
| 518 |
+
const p = { x: base.x + Math.sin(frame * 0.03 + i * 1.9) * 5, y: base.y + Math.cos(frame * 0.025 + i * 1.3) * 5 };
|
| 519 |
+
const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } });
|
| 520 |
+
const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born;
|
| 521 |
+
const twinkle = 1 + 0.25 * Math.sin(frame * 0.14 + i * 1.7);
|
| 522 |
+
const near = i === 0 || (i >= 1 && i <= 3);
|
| 523 |
+
const dim = selected && !near ? 0.3 : 1;
|
| 524 |
+
return (
|
| 525 |
+
<g key={name} opacity={dim}>
|
| 526 |
+
<circle cx={p.x} cy={p.y} r={r * 2.6 * twinkle} fill={BLUE} opacity={0.18 * born} style={{ filter: "blur(8px)" }} />
|
| 527 |
+
<circle cx={p.x} cy={p.y} r={r} fill="#ffffff" style={{ filter: `drop-shadow(0 0 10px ${BLUE})` }} />
|
| 528 |
+
{i === 0 && selected && <circle cx={p.x} cy={p.y} r={r + 14 + 22 * burst} fill="none" stroke={BLUE} strokeWidth={3} opacity={1 - 0.6 * burst} />}
|
| 529 |
+
<text x={p.x} y={p.y + r + 26} textAnchor="middle" fontFamily={theme.body} fontSize={i === 0 ? 26 : 21} fill="#ffffff" opacity={0.85 * born}>
|
| 530 |
+
{name}
|
| 531 |
+
</text>
|
| 532 |
+
</g>
|
| 533 |
+
);
|
| 534 |
+
})}
|
| 535 |
+
</g>
|
| 536 |
+
</svg>
|
| 537 |
+
<div style={{ position: "absolute", left: 40, top: 26, display: "flex", gap: 10 }}>
|
| 538 |
+
{MAP_SHAPES.map((name, i) => (
|
| 539 |
+
<Chip key={name} on={i === activeShape} press={i === 0 ? 0 : pulse(frame, [0, 56, 92, 124][i])} style={{ width: 120, height: 48, fontSize: 22 }}>
|
| 540 |
+
{name}
|
| 541 |
+
</Chip>
|
| 542 |
+
))}
|
| 543 |
+
</div>
|
| 544 |
+
<p style={{ position: "absolute", left: 40, bottom: 20, margin: 0, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", opacity: 1 - clamp01(frame, 150, 165) }}>Scroll to dive in. Drag to move. Click a star to fly to it.</p>
|
| 545 |
+
<div style={{ ...panel, position: "absolute", right: 40, top: 26, width: 380, padding: 28, opacity: panelIn, transform: `translateX(${(1 - panelIn) * 60}px)` }}>
|
| 546 |
+
<p style={{ margin: 0, fontFamily: theme.display, fontSize: 40, color: theme.white }}>Gravity</p>
|
| 547 |
+
<Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>Orbiting here</Label>
|
| 548 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
|
| 549 |
+
{["Spacetime", "Black holes", "Orbits"].map((t) => (
|
| 550 |
+
<Chip key={t} on style={{ padding: "4px 16px", fontSize: 20 }}>{t}</Chip>
|
| 551 |
+
))}
|
| 552 |
+
</div>
|
| 553 |
+
<Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>From your posts</Label>
|
| 554 |
+
{["Why Things Fall", "Curved Space", "Notes on Orbits"].map((t) => (
|
| 555 |
+
<p key={t} style={{ margin: "10px 0 0", fontFamily: theme.body, fontSize: 24, color: theme.white }}>{t}</p>
|
| 556 |
+
))}
|
| 557 |
+
</div>
|
| 558 |
+
</Win>
|
| 559 |
+
<Cursor
|
| 560 |
+
keys={[
|
| 561 |
+
{ f: 0, x: 1560, y: 880 },
|
| 562 |
+
{ f: 42, ...at(230, 50) },
|
| 563 |
+
{ f: 58, ...at(230, 50) },
|
| 564 |
+
{ f: 84, ...at(360, 50) },
|
| 565 |
+
{ f: 94, ...at(360, 50) },
|
| 566 |
+
{ f: 114, ...at(490, 50) },
|
| 567 |
+
{ f: 126, ...at(490, 50) },
|
| 568 |
+
{ f: 152, x: hub.x, y: hub.y },
|
| 569 |
+
{ f: 172, x: hub.x + 30, y: hub.y + 120 },
|
| 570 |
+
]}
|
| 571 |
+
clicks={[56, 92, 124, 156]}
|
| 572 |
+
/>
|
| 573 |
+
</AbsoluteFill>
|
| 574 |
+
);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/** 04: pick a format, create, play. */
|
| 578 |
function Audio() {
|
| 579 |
const frame = useCurrentFrame();
|
| 580 |
const { fps } = useVideoConfig();
|
| 581 |
+
const formats = ["Deep dive", "Brief", "Debate"];
|
| 582 |
+
const picked = frame >= 22 ? 2 : 0;
|
| 583 |
+
const bar = clamp01(frame, 52, 84);
|
| 584 |
+
const playing = clamp01(frame, 84, 96);
|
| 585 |
const lines = [
|
| 586 |
+
{ who: "A", text: "So the big idea here is that gravity is curved spacetime." },
|
| 587 |
+
{ who: "B", text: "Right, and orbits are just objects falling around the curve." },
|
| 588 |
+
{ who: "A", text: "Which she wrote about back in March, in Curved Space." },
|
| 589 |
];
|
| 590 |
+
const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24));
|
| 591 |
return (
|
| 592 |
<AbsoluteFill>
|
| 593 |
<WarpField speed={0.7} count={260} />
|
| 594 |
+
<Caption eyebrow="04 · Listen" title="Turn it into a conversation." />
|
| 595 |
+
<Win>
|
| 596 |
+
<Label style={{ left: 80, top: 36 }}>Format</Label>
|
| 597 |
+
{formats.map((f, i) => (
|
| 598 |
+
<Chip key={f} on={i === picked} press={i === 2 ? pulse(frame, 22) : 0} style={{ position: "absolute", left: 80 + i * 210, top: 80, width: 190, height: 56 }}>
|
| 599 |
+
{f}
|
| 600 |
+
</Chip>
|
| 601 |
+
))}
|
| 602 |
+
<div style={{ position: "absolute", left: 80, top: 170, width: 300, height: 72, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 30, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 50)})`, boxShadow: `0 0 ${24 + pulse(frame, 50) * 40}px ${BLUE}` }}>
|
| 603 |
+
{bar > 0 && bar < 1 ? "Recording..." : "Create audio"}
|
| 604 |
+
</div>
|
| 605 |
+
<div style={{ position: "absolute", left: 420, top: 202, width: 900 * bar, height: 6, borderRadius: 3, background: BLUE, boxShadow: `0 0 14px ${BLUE}`, opacity: bar > 0 && bar < 1 ? 1 : 0 }} />
|
| 606 |
+
<div style={{ position: "absolute", left: 80, top: 290, width: 1480, height: 170, display: "flex", alignItems: "center", justifyContent: "center", gap: 10, opacity: playing }}>
|
| 607 |
{Array.from({ length: 64 }).map((_, i) => {
|
| 608 |
const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
|
| 609 |
+
const env = spring({ frame: frame - 90, fps, config: { damping: 20 } });
|
| 610 |
+
return <div key={i} style={{ width: 14, height: 12 + v * 150 * env, borderRadius: 7, background: i % 2 ? theme.white : BLUE, boxShadow: `0 0 18px ${BLUE}` }} />;
|
| 611 |
})}
|
| 612 |
</div>
|
| 613 |
+
<div style={{ position: "absolute", left: 80, top: 480, display: "grid", gap: 12 }}>
|
| 614 |
{lines.map((l, i) => {
|
| 615 |
const on = i === active;
|
|
|
|
| 616 |
return (
|
| 617 |
+
<div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity: frame >= 92 + i * 24 ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 32, color: theme.white }}>
|
| 618 |
+
<span style={{ width: 46, height: 46, borderRadius: "50%", display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 20, background: l.who === "A" ? BLUE : theme.white, color: l.who === "A" ? theme.white : theme.black, boxShadow: on ? `0 0 26px ${BLUE}` : "none" }}>{l.who}</span>
|
|
|
|
|
|
|
| 619 |
{l.text}
|
| 620 |
</div>
|
| 621 |
);
|
| 622 |
})}
|
| 623 |
</div>
|
| 624 |
+
</Win>
|
| 625 |
+
<Cursor
|
| 626 |
+
keys={[{ f: 0, x: 1560, y: 880 }, { f: 18, ...at(595, 108) }, { f: 30, ...at(595, 108) }, { f: 46, ...at(230, 206) }, { f: 60, ...at(230, 206) }, { f: 100, ...at(900, 560) }]}
|
| 627 |
+
clicks={[22, 50]}
|
| 628 |
+
/>
|
| 629 |
</AbsoluteFill>
|
| 630 |
);
|
| 631 |
}
|
| 632 |
|
| 633 |
+
const VIDEO_STYLES = ["16:9 Explainer", "9:16 Short", "1:1 Square"];
|
| 634 |
+
const TEMPLATE_LOOKS: CSSProperties[] = [
|
| 635 |
+
{ background: "linear-gradient(135deg, #000, #0b1730)" },
|
| 636 |
+
{ background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` },
|
| 637 |
+
{ background: "linear-gradient(135deg, #ffffff, #cfe0ff)" },
|
| 638 |
+
{ background: "linear-gradient(135deg, #0b1730, #217cff55)" },
|
| 639 |
+
];
|
| 640 |
+
const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "Studio"];
|
| 641 |
+
const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"];
|
| 642 |
+
|
| 643 |
+
/** 05: wizard: pick a style, a template and a voice, create. */
|
| 644 |
+
function VideoCreate() {
|
| 645 |
const frame = useCurrentFrame();
|
| 646 |
const { fps } = useVideoConfig();
|
| 647 |
+
const style = frame >= 34 ? 1 : 0;
|
| 648 |
+
const tpl = frame >= 74 ? 1 : 0;
|
| 649 |
+
const voice = frame >= 114 ? 1 : 0;
|
| 650 |
+
const go = pulse(frame, 162);
|
| 651 |
+
const busy = clamp01(frame, 164, 194);
|
|
|
|
| 652 |
return (
|
| 653 |
<AbsoluteFill>
|
| 654 |
+
<WarpField speed={0.8} count={260} />
|
| 655 |
+
<Caption eyebrow="05 · Make" title="Pick a look. Pick a voice." />
|
| 656 |
+
<Win>
|
| 657 |
+
<Label style={{ left: 80, top: 30 }}>Video style</Label>
|
| 658 |
+
{VIDEO_STYLES.map((t, i) => (
|
| 659 |
+
<Chip key={t} on={i === style} press={i === 1 ? pulse(frame, 34) : 0} style={{ position: "absolute", left: 80 + i * 250, top: 70, width: 230, height: 56 }}>
|
| 660 |
+
{t}
|
| 661 |
+
</Chip>
|
| 662 |
+
))}
|
| 663 |
+
<Label style={{ left: 80, top: 160 }}>Template</Label>
|
| 664 |
+
{TEMPLATE_NAMES.map((name, i) => {
|
| 665 |
+
const s = spring({ frame: frame - 6 - i * 5, fps, config: { damping: 16 } });
|
| 666 |
+
const sel = i === tpl;
|
| 667 |
return (
|
| 668 |
+
<div key={name} style={{ position: "absolute", left: 80 + i * 300, top: 200, width: 270, height: 190, borderRadius: 16, border: `2px solid ${sel ? BLUE : "rgba(255,255,255,0.14)"}`, boxShadow: sel ? `0 0 30px ${BLUE}88` : "none", overflow: "hidden", opacity: s, transform: `scale(${(0.9 + 0.1 * s) * (i === 1 ? 1 - 0.04 * pulse(frame, 74) : 1)})`, ...TEMPLATE_LOOKS[i] }}>
|
| 669 |
+
<div style={{ position: "absolute", left: 20, top: 24, width: 120, height: 12, borderRadius: 6, background: i === 2 ? "#000" : "#fff", opacity: 0.9 }} />
|
| 670 |
+
<div style={{ position: "absolute", left: 20, top: 52, width: 190, height: 8, borderRadius: 4, background: i === 2 ? "#000" : "#fff", opacity: 0.4 }} />
|
| 671 |
+
<div style={{ position: "absolute", left: 20, bottom: 16, fontFamily: theme.body, fontSize: 22, color: i === 2 ? "#000" : "#fff" }}>{name}</div>
|
| 672 |
</div>
|
| 673 |
);
|
| 674 |
})}
|
| 675 |
+
<Label style={{ left: 80, top: 430 }}>Narration voice</Label>
|
| 676 |
+
{VOICES.map((v, i) => (
|
| 677 |
+
<Chip key={v} on={i === voice} press={i === 1 ? pulse(frame, 114) : 0} style={{ position: "absolute", left: 80 + i * 370, top: 470, width: 340, height: 64 }}>
|
| 678 |
+
{i < 2 && <span style={{ marginRight: 12, color: BLUE }}>▶</span>}
|
| 679 |
+
{v}
|
| 680 |
+
</Chip>
|
| 681 |
+
))}
|
| 682 |
+
<div style={{ position: "absolute", left: 1240, top: 560, width: 340, height: 76, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 30, color: theme.white, overflow: "hidden", transform: `scale(${1 - 0.06 * go})`, boxShadow: `0 0 ${24 + go * 40}px ${BLUE}` }}>
|
| 683 |
+
<div style={{ position: "absolute", left: 0, bottom: 0, height: 6, width: `${busy * 100}%`, background: "#fff" }} />
|
| 684 |
+
{busy > 0 ? "Making your video..." : "Create video"}
|
| 685 |
+
</div>
|
| 686 |
+
</Win>
|
| 687 |
+
<Cursor
|
| 688 |
+
keys={[
|
| 689 |
+
{ f: 0, x: 1560, y: 880 },
|
| 690 |
+
{ f: 30, ...at(450, 98) },
|
| 691 |
+
{ f: 38, ...at(450, 98) },
|
| 692 |
+
{ f: 70, ...at(515, 295) },
|
| 693 |
+
{ f: 78, ...at(515, 295) },
|
| 694 |
+
{ f: 110, ...at(630, 502) },
|
| 695 |
+
{ f: 118, ...at(630, 502) },
|
| 696 |
+
{ f: 158, ...at(1410, 598) },
|
| 697 |
+
{ f: 190, ...at(1410, 598) },
|
| 698 |
+
]}
|
| 699 |
+
clicks={[34, 74, 114, 162]}
|
| 700 |
+
/>
|
| 701 |
+
</AbsoluteFill>
|
| 702 |
+
);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
const SCENE_TITLES = ["Gravity is geometry", "Inverse square", "Orbits", "Try it yourself"];
|
| 706 |
+
|
| 707 |
+
/** 06: the scene editor, then render the MP4. */
|
| 708 |
+
function VideoEdit() {
|
| 709 |
+
const frame = useCurrentFrame();
|
| 710 |
+
const { fps } = useVideoConfig();
|
| 711 |
+
const active = frame >= 32 ? 1 : 0;
|
| 712 |
+
const since = frame >= 32 ? frame - 32 : frame;
|
| 713 |
+
const pop = spring({ frame: since, fps, config: { damping: 16 } });
|
| 714 |
+
const render = clamp01(frame, 110, 164);
|
| 715 |
+
const ready = frame >= 166;
|
| 716 |
+
const tabs = ["Edit Scenes", "Images/footage", "Audio", "Settings"];
|
| 717 |
+
return (
|
| 718 |
+
<AbsoluteFill>
|
| 719 |
+
<WarpField speed={0.7} count={260} />
|
| 720 |
+
<Caption eyebrow="06 · Edit" title="Tweak any scene, then render." />
|
| 721 |
+
<Win>
|
| 722 |
+
{tabs.map((t, i) => (
|
| 723 |
+
<span key={t} style={{ position: "absolute", left: 80 + i * 210, top: 12, width: 190, height: 48, display: "grid", placeItems: "center", borderRadius: 12, fontFamily: theme.body, fontSize: 23, color: theme.white, background: i === 0 ? `${BLUE}33` : "transparent", border: `1px solid ${i === 0 ? BLUE : "rgba(255,255,255,0.14)"}` }}>
|
| 724 |
{t}
|
| 725 |
</span>
|
| 726 |
))}
|
| 727 |
+
<div style={{ position: "absolute", left: 1340, top: 10, width: 220, height: 52, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 24, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 108)})`, boxShadow: `0 0 ${20 + pulse(frame, 108) * 40}px ${BLUE}` }}>
|
| 728 |
+
Download MP4
|
| 729 |
+
</div>
|
| 730 |
+
{SCENE_TITLES.map((t, i) => {
|
| 731 |
+
const s = spring({ frame: frame - 4 - i * 5, fps, config: { damping: 16 } });
|
| 732 |
+
const on = i === active;
|
| 733 |
+
return (
|
| 734 |
+
<div key={t} style={{ position: "absolute", left: 80, top: 80 + i * 120, width: 400, height: 106, borderRadius: 14, border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.14)"}`, background: on ? `${BLUE}26` : "rgba(255,255,255,0.04)", boxShadow: on ? `0 0 24px ${BLUE}66` : "none", display: "flex", alignItems: "center", gap: 18, padding: "0 20px", opacity: s, transform: `translateX(${(1 - s) * -40}px) scale(${i === 1 ? 1 - 0.03 * pulse(frame, 32) : 1})` }}>
|
| 735 |
+
<span style={{ fontFamily: theme.mono, fontSize: 22, color: BLUE }}>{i + 1}</span>
|
| 736 |
+
<div style={{ width: 96, height: 60, borderRadius: 8, background: i % 2 ? `linear-gradient(135deg, ${BLUE}, #0a2a66)` : "linear-gradient(135deg, #000, #0b1730)", border: "1px solid rgba(255,255,255,0.2)" }} />
|
| 737 |
+
<span style={{ fontFamily: theme.body, fontSize: 25, color: theme.white }}>{t}</span>
|
| 738 |
+
</div>
|
| 739 |
+
);
|
| 740 |
+
})}
|
| 741 |
+
<div style={{ position: "absolute", left: 520, top: 80, width: 1040, height: 560, borderRadius: 18, overflow: "hidden", border: "1px solid rgba(255,255,255,0.18)", background: `radial-gradient(70% 70% at 30% 20%, ${BLUE}33, #000 70%)`, boxShadow: `0 0 50px ${BLUE}33` }}>
|
| 742 |
+
{active === 0 ? (
|
| 743 |
+
<div style={{ position: "absolute", left: 70, top: 160, opacity: pop, transform: `translateY(${(1 - pop) * 30}px)` }}>
|
| 744 |
+
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 22, letterSpacing: 5, color: BLUE }}>SCENE 1</p>
|
| 745 |
+
<p style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 86, lineHeight: 1.05, color: theme.white, textShadow: `0 0 30px ${BLUE}` }}>Gravity is<br />a curve.</p>
|
| 746 |
+
</div>
|
| 747 |
+
) : (
|
| 748 |
+
<>
|
| 749 |
+
<p style={{ position: "absolute", left: 70, top: 56, margin: 0, fontFamily: theme.display, fontSize: 52, color: theme.white, opacity: pop }}>Gravity fades with distance</p>
|
| 750 |
+
{[1, 0.25, 0.11].map((h, i) => (
|
| 751 |
+
<div key={i} style={{ position: "absolute", left: 90 + i * 250, bottom: 90, width: 170, height: 340 * h * ease(clamp01(since, 8 + i * 7, 34 + i * 7)), borderRadius: "12px 12px 0 0", background: i === 0 ? "#ffffff" : BLUE, boxShadow: `0 0 30px ${BLUE}` }} />
|
| 752 |
+
))}
|
| 753 |
+
<p style={{ position: "absolute", left: 70, bottom: 30, margin: 0, fontFamily: theme.body, fontSize: 26, color: "rgba(255,255,255,0.85)", opacity: pop }}>Double the distance and gravity drops to a quarter.</p>
|
| 754 |
+
</>
|
| 755 |
+
)}
|
| 756 |
+
{render > 0 && (
|
| 757 |
+
<div style={{ ...panel, position: "absolute", left: 270, top: 190, width: 500, padding: "28px 32px", background: "rgba(0,0,0,0.92)", opacity: clamp01(frame, 110, 118) }}>
|
| 758 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, color: theme.white }}>{ready ? "✓ Your MP4 is ready" : "Rendering MP4"}</p>
|
| 759 |
+
<div style={{ marginTop: 20, height: 10, borderRadius: 5, background: "rgba(255,255,255,0.14)" }}>
|
| 760 |
+
<div style={{ width: `${(ready ? 1 : render) * 100}%`, height: "100%", borderRadius: 5, background: BLUE, boxShadow: `0 0 16px ${BLUE}` }} />
|
| 761 |
+
</div>
|
| 762 |
+
</div>
|
| 763 |
+
)}
|
| 764 |
+
</div>
|
| 765 |
+
</Win>
|
| 766 |
+
<Cursor
|
| 767 |
+
keys={[
|
| 768 |
+
{ f: 0, x: 1560, y: 880 },
|
| 769 |
+
{ f: 26, ...at(280, 373) },
|
| 770 |
+
{ f: 38, ...at(280, 373) },
|
| 771 |
+
{ f: 100, ...at(1450, 36) },
|
| 772 |
+
{ f: 124, ...at(1450, 36) },
|
| 773 |
+
{ f: 150, ...at(1250, 480) },
|
| 774 |
+
]}
|
| 775 |
+
clicks={[32, 108]}
|
| 776 |
+
/>
|
| 777 |
</AbsoluteFill>
|
| 778 |
);
|
| 779 |
}
|
| 780 |
|
| 781 |
+
/** 07: the Launch Kit appears, then Launchpad schedules it across the week. */
|
| 782 |
+
function LaunchKit() {
|
| 783 |
const frame = useCurrentFrame();
|
| 784 |
+
const { fps } = useVideoConfig();
|
| 785 |
+
const cards = [
|
| 786 |
+
{ label: "X THREAD", body: "Gravity is not a force. It is geometry. A thread on how Einstein changed everything." },
|
| 787 |
+
{ label: "LINKEDIN", body: "Most of us learned gravity wrong. Here is the picture physicists actually use." },
|
| 788 |
+
{ label: "SUBSTACK NOTES", body: "Mass tells space how to bend. Space tells mass how to move." },
|
| 789 |
+
{ label: "BLUESKY", body: "Orbits are just falling, forever, around a curve." },
|
| 790 |
+
];
|
| 791 |
+
const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
|
| 792 |
+
const slots: Record<number, string> = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" };
|
| 793 |
return (
|
| 794 |
<AbsoluteFill>
|
| 795 |
+
<WarpField speed={0.9} count={260} />
|
| 796 |
+
<Caption eyebrow="07 · Launch" title="A week of posts, scheduled." />
|
| 797 |
+
<Win>
|
| 798 |
+
{cards.map((c, i) => {
|
| 799 |
+
const s = spring({ frame: frame - 8 - i * 7, fps, config: { damping: 14 } });
|
| 800 |
return (
|
| 801 |
+
<div key={c.label} style={{ position: "absolute", left: 80 + i * 390, top: 30, width: 350, height: 230, padding: 26, borderRadius: 18, border: "1px solid rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.04)", opacity: s, transform: `translateY(${(1 - s) * 140}px) rotate(${interpolate(s, [0, 1], [(i - 1.5) * 10, 0])}deg)`, display: "grid", alignContent: "start", gap: 14 }}>
|
| 802 |
+
<span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
|
| 803 |
+
<span style={{ fontFamily: theme.body, fontSize: 26, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
|
| 804 |
</div>
|
| 805 |
);
|
| 806 |
})}
|
| 807 |
+
<Label style={{ left: 80, top: 300 }}>Launchpad</Label>
|
| 808 |
+
{days.map((d, i) => {
|
| 809 |
+
const lit = frame >= 76 + i * 6;
|
| 810 |
+
const s = spring({ frame: frame - 76 - i * 6, fps, config: { damping: 12 } });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 811 |
return (
|
| 812 |
+
<div key={d} style={{ position: "absolute", left: 80 + i * 215, top: 345, width: 195, height: 130, borderRadius: 14, border: `1px solid ${lit ? BLUE : "rgba(255,255,255,0.14)"}`, background: lit ? `${BLUE}33` : "rgba(255,255,255,0.03)", boxShadow: lit ? `0 0 22px ${BLUE}88` : "none", padding: 14, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
| 813 |
+
{d}
|
| 814 |
+
{lit && (
|
| 815 |
+
<div style={{ marginTop: 22, padding: "8px 0", textAlign: "center", borderRadius: 999, background: theme.white, color: theme.black, fontFamily: theme.body, fontWeight: 600, fontSize: 21, transform: `scale(${0.6 + 0.4 * s})`, boxShadow: `0 0 14px ${BLUE}` }}>{slots[i]}</div>
|
| 816 |
)}
|
| 817 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
);
|
| 819 |
+
})}
|
| 820 |
+
<div style={{ position: "absolute", left: 80, top: 540, display: "flex", gap: 14 }}>
|
| 821 |
+
{["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
|
| 822 |
+
<span key={t} style={{ padding: "10px 18px", border: `1px solid ${BLUE}88`, borderRadius: 999, fontFamily: theme.mono, fontSize: 20, letterSpacing: 2, color: "rgba(255,255,255,0.75)", opacity: clamp01(frame, 40 + i * 6, 50 + i * 6) }}>
|
| 823 |
+
{t}
|
| 824 |
+
</span>
|
| 825 |
+
))}
|
| 826 |
+
</div>
|
| 827 |
+
<div style={{ position: "absolute", left: 1300, top: 530, width: 280, height: 70, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 28, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 68)})`, boxShadow: `0 0 ${24 + pulse(frame, 68) * 40}px ${BLUE}` }}>
|
| 828 |
+
Schedule all
|
| 829 |
+
</div>
|
| 830 |
+
</Win>
|
| 831 |
+
<Cursor keys={[{ f: 0, x: 1560, y: 880 }, { f: 62, ...at(1440, 566) }, { f: 100, ...at(1440, 566) }, { f: 140, ...at(1100, 600) }]} clicks={[68]} />
|
| 832 |
</AbsoluteFill>
|
| 833 |
);
|
| 834 |
}
|
|
|
|
| 856 |
);
|
| 857 |
}
|
| 858 |
|
| 859 |
+
const RENDER: Record<SceneId, () => JSX.Element> = {
|
| 860 |
warp: Warp,
|
| 861 |
+
sync: Sync,
|
| 862 |
+
ask: Ask,
|
| 863 |
+
map: MindMap,
|
| 864 |
audio: Audio,
|
| 865 |
+
video: VideoCreate,
|
| 866 |
+
edit: VideoEdit,
|
| 867 |
+
kit: LaunchKit,
|
| 868 |
outro: Outro,
|
| 869 |
};
|
| 870 |
|
|
|
|
| 873 |
return (
|
| 874 |
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 875 |
<SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
|
| 876 |
+
{SCENES.map((scene) => {
|
| 877 |
const from = cursor;
|
| 878 |
cursor += S(scene.len - XFADE);
|
| 879 |
const Scene = RENDER[scene.id];
|
|
|
|
| 882 |
<FadeScene len={scene.len}>
|
| 883 |
<Scene />
|
| 884 |
</FadeScene>
|
| 885 |
+
{scene.vo !== undefined && (
|
| 886 |
+
<Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
|
| 887 |
+
<SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
|
| 888 |
+
</Sequence>
|
| 889 |
+
)}
|
| 890 |
</Sequence>
|
| 891 |
);
|
| 892 |
})}
|
renderer/src/compositions/demoVo.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
// Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
|
| 2 |
-
export const DEMO_VO_SECONDS = {"warp": 3.
|
|
|
|
| 1 |
// Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
|
| 2 |
+
export const DEMO_VO_SECONDS = {"warp": 3.61, "sync": 2.25, "map": 5.17, "kit": 2.95, "outro": 2.8} as const;
|