Spaces:
Runtime error
Runtime error
Commit ·
f74c934
1
Parent(s): 34b0226
added blogs
Browse files- .env.example +2 -2
- backend/app/config.py +2 -2
- backend/app/llm/signatures.py +6 -2
- backend/app/pipeline/media.py +24 -6
- backend/app/pipeline/research.py +1 -1
- backend/app/routers/voice.py +166 -18
- backend/app/services/tts.py +223 -42
- backend/app/worker.py +33 -7
- backend/tests/test_buildout.py +91 -1
- frontend/src/api/endpoints.ts +25 -5
- frontend/src/api/types.ts +43 -2
- frontend/src/components/ArtifactCard.tsx +2 -27
- frontend/src/components/FeatureShowcase.tsx +261 -0
- frontend/src/components/Markdown.tsx +242 -0
- frontend/src/components/SkyCanvas.tsx +91 -2
- frontend/src/lib/sound.ts +76 -16
- frontend/src/pages/Landing.tsx +112 -28
- frontend/src/pages/MissionControl.tsx +17 -2
- frontend/src/pages/NotebookView.tsx +8 -6
- frontend/src/pages/TopicMap.tsx +137 -6
- frontend/src/pages/VoiceProfile.tsx +467 -96
- frontend/src/styles/app.css +188 -0
- frontend/src/styles/pages.css +689 -0
- frontend/src/styles/showcase.css +521 -0
- frontend/tsconfig.tsbuildinfo +1 -1
- renderer/public/demo-vo/durations.json +10 -0
- renderer/src/Root.tsx +2 -0
- renderer/src/compositions/LandingDemo.tsx +540 -0
.env.example
CHANGED
|
@@ -70,8 +70,8 @@ JOB_STALE_SECONDS=900
|
|
| 70 |
# ---- ElevenLabs (audio overviews, video narration, voice cloning)
|
| 71 |
ELEVENLABS_API_KEY=
|
| 72 |
ELEVENLABS_MODEL=eleven_multilingual_v2
|
| 73 |
-
ELEVENLABS_VOICE_A=
|
| 74 |
-
ELEVENLABS_VOICE_B=
|
| 75 |
|
| 76 |
# ---- Launchpad social publishing
|
| 77 |
# Bluesky needs nothing here (users connect with an app password).
|
|
|
|
| 70 |
# ---- ElevenLabs (audio overviews, video narration, voice cloning)
|
| 71 |
ELEVENLABS_API_KEY=
|
| 72 |
ELEVENLABS_MODEL=eleven_multilingual_v2
|
| 73 |
+
ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
|
| 74 |
+
ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
|
| 75 |
|
| 76 |
# ---- Launchpad social publishing
|
| 77 |
# Bluesky needs nothing here (users connect with an app password).
|
backend/app/config.py
CHANGED
|
@@ -78,8 +78,8 @@ class Settings(BaseSettings):
|
|
| 78 |
# ElevenLabs (premade voices by default; a consented clone can replace host A)
|
| 79 |
elevenlabs_api_key: str = ""
|
| 80 |
elevenlabs_model: str = "eleven_multilingual_v2"
|
| 81 |
-
elevenlabs_voice_a: str = "
|
| 82 |
-
elevenlabs_voice_b: str = "
|
| 83 |
|
| 84 |
# Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
|
| 85 |
social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
|
|
|
|
| 78 |
# ElevenLabs (premade voices by default; a consented clone can replace host A)
|
| 79 |
elevenlabs_api_key: str = ""
|
| 80 |
elevenlabs_model: str = "eleven_multilingual_v2"
|
| 81 |
+
elevenlabs_voice_a: str = "EXAVITQu4vr4xnSDxMaL" # Sarah (premade)
|
| 82 |
+
elevenlabs_voice_b: str = "JBFqnCBsd6RMkjVDRZzb" # George (premade)
|
| 83 |
|
| 84 |
# Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
|
| 85 |
social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
|
backend/app/llm/signatures.py
CHANGED
|
@@ -115,7 +115,10 @@ class ResearchArchive(dspy.Signature):
|
|
| 115 |
lines before relying on them. Answer ONLY from text you read. Every factual sentence needs a
|
| 116 |
[n] marker, and every marker needs a citation with the file path and the line range you read.
|
| 117 |
If the archive does not cover the question, say so plainly and set unsupported=true.
|
| 118 |
-
Write in plain, warm prose
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
question: str = dspy.InputField()
|
| 121 |
archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
|
|
@@ -190,7 +193,8 @@ class SummarizeNotebook(dspy.Signature):
|
|
| 190 |
|
| 191 |
title: str = dspy.InputField()
|
| 192 |
passages: list[str] = dspy.InputField(desc="Posts with path and numbered lines")
|
| 193 |
-
summary: str = dspy.OutputField(desc="3 to 6 paragraphs
|
|
|
|
| 194 |
themes: list[str] = dspy.OutputField(desc="3 to 7 short theme names")
|
| 195 |
citations: list[FileCitation] = dspy.OutputField()
|
| 196 |
|
|
|
|
| 115 |
lines before relying on them. Answer ONLY from text you read. Every factual sentence needs a
|
| 116 |
[n] marker, and every marker needs a citation with the file path and the line range you read.
|
| 117 |
If the archive does not cover the question, say so plainly and set unsupported=true.
|
| 118 |
+
Write in plain, warm prose formatted as Markdown: short paragraphs separated by blank lines,
|
| 119 |
+
bullet or numbered lists when comparing or listing things, **bold** for the key idea, and a
|
| 120 |
+
### heading only when the answer has distinct parts. Put [n] markers right after the sentence
|
| 121 |
+
or list item they support. Never use em dashes."""
|
| 122 |
|
| 123 |
question: str = dspy.InputField()
|
| 124 |
archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
|
|
|
|
| 193 |
|
| 194 |
title: str = dspy.InputField()
|
| 195 |
passages: list[str] = dspy.InputField(desc="Posts with path and numbered lines")
|
| 196 |
+
summary: str = dspy.OutputField(desc="Markdown: 3 to 6 short paragraphs or sections (### headings allowed), "
|
| 197 |
+
"blank lines between blocks, [n] markers after supported sentences")
|
| 198 |
themes: list[str] = dspy.OutputField(desc="3 to 7 short theme names")
|
| 199 |
citations: list[FileCitation] = dspy.OutputField()
|
| 200 |
|
backend/app/pipeline/media.py
CHANGED
|
@@ -39,6 +39,13 @@ def host_voices(db: Session, workspace_id: uuid.UUID) -> tuple[str, str]:
|
|
| 39 |
return voices.get("host_a") or settings.elevenlabs_voice_a, voices.get("host_b") or settings.elevenlabs_voice_b
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def _require_tts() -> None:
|
| 43 |
if not settings.elevenlabs_api_key:
|
| 44 |
raise PermanentJobError("Audio needs ELEVENLABS_API_KEY in .env")
|
|
@@ -64,20 +71,30 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 64 |
raise RuntimeError("The script came back empty")
|
| 65 |
tools = tools_for(corpus, docs)
|
| 66 |
voice_a, voice_b = host_voices(db, artifact.workspace_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
audio = bytearray()
|
| 68 |
segments = []
|
| 69 |
t = 0.0
|
| 70 |
-
for
|
| 71 |
-
voice = voice_a if line.get("speaker") == "host_a" else voice_b
|
| 72 |
-
clip = tts.synthesize(artifact.workspace_id, line["text"], voice)
|
| 73 |
seconds = tts.mp3_seconds(clip)
|
| 74 |
segments.append({"speaker": line.get("speaker", "host_a"), "text": line["text"],
|
| 75 |
"start": round(t, 2), "end": round(t + seconds, 2),
|
| 76 |
"sources": verify_refs(tools, line.get("sources") or [])})
|
| 77 |
audio += clip
|
| 78 |
t += seconds
|
| 79 |
-
update_job(db, job, progress=0.2 + 0.75 * (i + 1) / len(lines),
|
| 80 |
-
message=f"Recording line {i + 1} of {len(lines)}")
|
| 81 |
key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
|
| 82 |
storage.put_bytes(key, bytes(audio), "audio/mpeg")
|
| 83 |
artifact.storage_key = key
|
|
@@ -129,13 +146,14 @@ def make_video(db: Session, job: Job, artifact: Artifact) -> None:
|
|
| 129 |
audio = bytearray()
|
| 130 |
cursor = 0.0
|
| 131 |
voice_a, _ = host_voices(db, artifact.workspace_id)
|
|
|
|
| 132 |
out_scenes = []
|
| 133 |
for i, s in enumerate(scenes):
|
| 134 |
narration = (s.get("narration") or "").strip()
|
| 135 |
duration = _clamp(s.get("duration_hint_s"), 2.0, 20.0)
|
| 136 |
if narration and settings.elevenlabs_api_key:
|
| 137 |
update_job(db, job, progress=0.15 + 0.5 * i / len(scenes), message=f"Narrating scene {i + 1}")
|
| 138 |
-
clip, words = tts.synthesize_with_timestamps(artifact.workspace_id, narration, voice_a)
|
| 139 |
seconds = tts.mp3_seconds(clip)
|
| 140 |
# Pad with silence so each clip starts exactly when its scene does.
|
| 141 |
audio += _silence(cursor - tts.mp3_seconds(bytes(audio)))
|
|
|
|
| 39 |
return voices.get("host_a") or settings.elevenlabs_voice_a, voices.get("host_b") or settings.elevenlabs_voice_b
|
| 40 |
|
| 41 |
|
| 42 |
+
def host_settings(db: Session, workspace_id: uuid.UUID) -> dict[str, tts.VoiceSettings]:
|
| 43 |
+
"""Per host delivery settings (stability, similarity, style, speed) saved on the Voice page."""
|
| 44 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 45 |
+
saved = ((vp.host_voices if vp else None) or {}).get("settings") or {}
|
| 46 |
+
return {h: tts.VoiceSettings.from_dict(saved.get(h)) for h in ("host_a", "host_b")}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
def _require_tts() -> None:
|
| 50 |
if not settings.elevenlabs_api_key:
|
| 51 |
raise PermanentJobError("Audio needs ELEVENLABS_API_KEY in .env")
|
|
|
|
| 71 |
raise RuntimeError("The script came back empty")
|
| 72 |
tools = tools_for(corpus, docs)
|
| 73 |
voice_a, voice_b = host_voices(db, artifact.workspace_id)
|
| 74 |
+
delivery = host_settings(db, artifact.workspace_id)
|
| 75 |
+
script = [
|
| 76 |
+
tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
|
| 77 |
+
settings=delivery["host_b" if ln.get("speaker") == "host_b" else "host_a"])
|
| 78 |
+
for ln in lines
|
| 79 |
+
]
|
| 80 |
+
update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")
|
| 81 |
+
|
| 82 |
+
def progress(done: int) -> None:
|
| 83 |
+
if done % 3 == 0 or done == len(script):
|
| 84 |
+
update_job(db, job, progress=0.2 + 0.75 * done / len(script),
|
| 85 |
+
message=f"Recorded {done} of {len(script)} lines")
|
| 86 |
+
|
| 87 |
+
clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
|
| 88 |
audio = bytearray()
|
| 89 |
segments = []
|
| 90 |
t = 0.0
|
| 91 |
+
for line, clip in zip(lines, clips, strict=True):
|
|
|
|
|
|
|
| 92 |
seconds = tts.mp3_seconds(clip)
|
| 93 |
segments.append({"speaker": line.get("speaker", "host_a"), "text": line["text"],
|
| 94 |
"start": round(t, 2), "end": round(t + seconds, 2),
|
| 95 |
"sources": verify_refs(tools, line.get("sources") or [])})
|
| 96 |
audio += clip
|
| 97 |
t += seconds
|
|
|
|
|
|
|
| 98 |
key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
|
| 99 |
storage.put_bytes(key, bytes(audio), "audio/mpeg")
|
| 100 |
artifact.storage_key = key
|
|
|
|
| 146 |
audio = bytearray()
|
| 147 |
cursor = 0.0
|
| 148 |
voice_a, _ = host_voices(db, artifact.workspace_id)
|
| 149 |
+
narrator = host_settings(db, artifact.workspace_id)["host_a"]
|
| 150 |
out_scenes = []
|
| 151 |
for i, s in enumerate(scenes):
|
| 152 |
narration = (s.get("narration") or "").strip()
|
| 153 |
duration = _clamp(s.get("duration_hint_s"), 2.0, 20.0)
|
| 154 |
if narration and settings.elevenlabs_api_key:
|
| 155 |
update_job(db, job, progress=0.15 + 0.5 * i / len(scenes), message=f"Narrating scene {i + 1}")
|
| 156 |
+
clip, words = tts.synthesize_with_timestamps(artifact.workspace_id, narration, voice_a, narrator)
|
| 157 |
seconds = tts.mp3_seconds(clip)
|
| 158 |
# Pad with silence so each clip starts exactly when its scene does.
|
| 159 |
audio += _silence(cursor - tts.mp3_seconds(bytes(audio)))
|
backend/app/pipeline/research.py
CHANGED
|
@@ -64,7 +64,7 @@ def verify_citations(tools: ScopedTools, answer: str, citations) -> tuple[str, l
|
|
| 64 |
return f"[{n}]" if n else ""
|
| 65 |
|
| 66 |
text = _MARKER.sub(swap, answer)
|
| 67 |
-
text = re.sub(r"\
|
| 68 |
return text.strip(), verified
|
| 69 |
|
| 70 |
|
|
|
|
| 64 |
return f"[{n}]" if n else ""
|
| 65 |
|
| 66 |
text = _MARKER.sub(swap, answer)
|
| 67 |
+
text = re.sub(r"[ \t]+([.,;:!?])", r"\1", text) # keep line breaks: answers are Markdown
|
| 68 |
return text.strip(), verified
|
| 69 |
|
| 70 |
|
backend/app/routers/voice.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
|
|
| 1 |
import uuid
|
| 2 |
from datetime import UTC, datetime
|
| 3 |
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 5 |
from fastapi.concurrency import run_in_threadpool
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
-
from sqlalchemy import select
|
| 8 |
|
| 9 |
from app.auth import Ctx, get_ctx
|
| 10 |
from app.config import settings
|
| 11 |
-
from app.models import Upload, VoiceConsent, VoiceProfile
|
| 12 |
from app.services import tts
|
| 13 |
-
from app.services.jobs import create_job, serialize_job
|
| 14 |
from app.services.plans import effective_plan
|
| 15 |
from app.services.storage import storage
|
| 16 |
|
|
@@ -21,27 +22,60 @@ CONSENT_TEXT = (
|
|
| 21 |
"ElevenLabs, only for audio and video made in my workspace. I can revoke this at any time, which deletes "
|
| 22 |
"the synthetic voice."
|
| 23 |
)
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class BuildIn(BaseModel):
|
| 27 |
document_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
class ProfileIn(BaseModel):
|
| 31 |
profile: dict | None = None
|
| 32 |
host_voices: dict[str, str] | None = None
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class ConsentIn(BaseModel):
|
| 36 |
-
|
|
|
|
| 37 |
agreed: bool
|
| 38 |
consent_text: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def _profile(ctx: Ctx) -> VoiceProfile | None:
|
| 42 |
return ctx.db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == ctx.workspace.id))
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def _consent(ctx: Ctx) -> VoiceConsent | None:
|
| 46 |
return ctx.db.scalar(
|
| 47 |
select(VoiceConsent).where(VoiceConsent.workspace_id == ctx.workspace.id, VoiceConsent.revoked_at.is_(None))
|
|
@@ -49,25 +83,42 @@ def _consent(ctx: Ctx) -> VoiceConsent | None:
|
|
| 49 |
)
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def _serialize(ctx: Ctx) -> dict:
|
| 53 |
vp = _profile(ctx)
|
| 54 |
consent = _consent(ctx)
|
| 55 |
plan = effective_plan(ctx.db, ctx.workspace)
|
| 56 |
voices = (vp.host_voices if vp else None) or {}
|
|
|
|
|
|
|
|
|
|
| 57 |
return {
|
| 58 |
"profile": vp.profile_json if vp else None,
|
| 59 |
"sample_doc_ids": vp.sample_doc_ids if vp else [],
|
| 60 |
"updated_at": vp.updated_at.isoformat() if vp and vp.updated_at else None,
|
| 61 |
"host_voices": {"host_a": voices.get("host_a") or settings.elevenlabs_voice_a,
|
| 62 |
"host_b": voices.get("host_b") or settings.elevenlabs_voice_b},
|
|
|
|
| 63 |
"clone": {
|
| 64 |
"allowed": plan.voice_cloning,
|
| 65 |
"consent_text": CONSENT_TEXT,
|
| 66 |
-
"status": ("ready" if consent and consent.elevenlabs_voice_id
|
|
|
|
|
|
|
| 67 |
"voice_id": consent.elevenlabs_voice_id if consent else None,
|
| 68 |
"created_at": consent.created_at.isoformat() if consent else None,
|
|
|
|
|
|
|
| 69 |
},
|
| 70 |
"tts_configured": bool(settings.elevenlabs_api_key),
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
|
|
@@ -78,14 +129,17 @@ def get_voice(ctx: Ctx = Depends(get_ctx)):
|
|
| 78 |
|
| 79 |
@router.put("")
|
| 80 |
def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
|
| 81 |
-
vp =
|
| 82 |
-
if not vp:
|
| 83 |
-
vp = VoiceProfile(workspace_id=ctx.workspace.id)
|
| 84 |
-
ctx.db.add(vp)
|
| 85 |
if body.profile is not None:
|
| 86 |
vp.profile_json = body.profile
|
|
|
|
| 87 |
if body.host_voices is not None:
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
ctx.db.commit()
|
| 90 |
return _serialize(ctx)
|
| 91 |
|
|
@@ -101,11 +155,95 @@ def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 101 |
async def voices(ctx: Ctx = Depends(get_ctx)):
|
| 102 |
items = await run_in_threadpool(tts.list_voices)
|
| 103 |
consent = _consent(ctx)
|
| 104 |
-
if consent and consent.elevenlabs_voice_id
|
| 105 |
-
items = [{
|
|
|
|
|
|
|
|
|
|
| 106 |
return items
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
@router.post("/consent")
|
| 110 |
def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
|
| 111 |
if not effective_plan(ctx.db, ctx.workspace).voice_cloning:
|
|
@@ -114,14 +252,24 @@ def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx))
|
|
| 114 |
raise HTTPException(400, "Please read and accept the consent statement.")
|
| 115 |
if not settings.elevenlabs_api_key:
|
| 116 |
raise HTTPException(503, "Voice cloning needs ELEVENLABS_API_KEY in .env")
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
consent_text=CONSENT_TEXT, ip_address=request.client.host if request.client else None)
|
| 122 |
ctx.db.add(consent)
|
| 123 |
ctx.db.commit()
|
| 124 |
-
job = create_job(ctx.db, ctx.workspace.id, "voice_clone", {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return {"job": serialize_job(job), **_serialize(ctx)}
|
| 126 |
|
| 127 |
|
|
@@ -133,7 +281,7 @@ async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
|
|
| 133 |
if consent.elevenlabs_voice_id and settings.elevenlabs_api_key:
|
| 134 |
try:
|
| 135 |
await run_in_threadpool(tts.delete_voice, consent.elevenlabs_voice_id)
|
| 136 |
-
except
|
| 137 |
pass
|
| 138 |
consent.revoked_at = datetime.now(UTC)
|
| 139 |
storage.delete(consent.sample_key)
|
|
|
|
| 1 |
+
import re
|
| 2 |
import uuid
|
| 3 |
from datetime import UTC, datetime
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 6 |
from fastapi.concurrency import run_in_threadpool
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
+
from sqlalchemy import func, select
|
| 9 |
|
| 10 |
from app.auth import Ctx, get_ctx
|
| 11 |
from app.config import settings
|
| 12 |
+
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
|
| 13 |
from app.services import tts
|
| 14 |
+
from app.services.jobs import create_job, record_usage, serialize_job
|
| 15 |
from app.services.plans import effective_plan
|
| 16 |
from app.services.storage import storage
|
| 17 |
|
|
|
|
| 22 |
"ElevenLabs, only for audio and video made in my workspace. I can revoke this at any time, which deletes "
|
| 23 |
"the synthetic voice."
|
| 24 |
)
|
| 25 |
+
PREVIEW_LINE = "Here is how I sound reading your archive. Every line I say comes from something you wrote."
|
| 26 |
|
| 27 |
|
| 28 |
class BuildIn(BaseModel):
|
| 29 |
document_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
|
| 30 |
|
| 31 |
|
| 32 |
+
class DeliveryIn(BaseModel):
|
| 33 |
+
stability: float = Field(0.45, ge=0, le=1)
|
| 34 |
+
similarity_boost: float = Field(0.8, ge=0, le=1)
|
| 35 |
+
style: float = Field(0.2, ge=0, le=1)
|
| 36 |
+
speed: float = Field(1.0, ge=0.7, le=1.2)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
class ProfileIn(BaseModel):
|
| 40 |
profile: dict | None = None
|
| 41 |
host_voices: dict[str, str] | None = None
|
| 42 |
+
delivery: dict[str, DeliveryIn] | None = None # per host: {"host_a": {...}, "host_b": {...}}
|
| 43 |
|
| 44 |
|
| 45 |
class ConsentIn(BaseModel):
|
| 46 |
+
upload_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
|
| 47 |
+
upload_id: uuid.UUID | None = None # single sample (older clients)
|
| 48 |
agreed: bool
|
| 49 |
consent_text: str
|
| 50 |
+
remove_background_noise: bool = True
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class PreviewIn(BaseModel):
|
| 54 |
+
voice_id: str = Field(min_length=5, max_length=60)
|
| 55 |
+
text: str | None = Field(None, max_length=400)
|
| 56 |
+
host: str | None = None # use that host's saved delivery settings
|
| 57 |
+
delivery: DeliveryIn | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class LibraryAddIn(BaseModel):
|
| 61 |
+
public_owner_id: str = Field(min_length=5, max_length=100)
|
| 62 |
+
voice_id: str = Field(min_length=5, max_length=60)
|
| 63 |
+
name: str = Field(min_length=1, max_length=100)
|
| 64 |
+
use_as: str | None = None # "host_a" | "host_b"
|
| 65 |
|
| 66 |
|
| 67 |
def _profile(ctx: Ctx) -> VoiceProfile | None:
|
| 68 |
return ctx.db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == ctx.workspace.id))
|
| 69 |
|
| 70 |
|
| 71 |
+
def _ensure_profile(ctx: Ctx) -> VoiceProfile:
|
| 72 |
+
vp = _profile(ctx)
|
| 73 |
+
if not vp:
|
| 74 |
+
vp = VoiceProfile(workspace_id=ctx.workspace.id)
|
| 75 |
+
ctx.db.add(vp)
|
| 76 |
+
return vp
|
| 77 |
+
|
| 78 |
+
|
| 79 |
def _consent(ctx: Ctx) -> VoiceConsent | None:
|
| 80 |
return ctx.db.scalar(
|
| 81 |
select(VoiceConsent).where(VoiceConsent.workspace_id == ctx.workspace.id, VoiceConsent.revoked_at.is_(None))
|
|
|
|
| 83 |
)
|
| 84 |
|
| 85 |
|
| 86 |
+
def _clone_job(ctx: Ctx, consent: VoiceConsent) -> Job | None:
|
| 87 |
+
return ctx.db.scalar(
|
| 88 |
+
select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_clone")
|
| 89 |
+
.order_by(Job.created_at.desc()).limit(1)
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
def _serialize(ctx: Ctx) -> dict:
|
| 94 |
vp = _profile(ctx)
|
| 95 |
consent = _consent(ctx)
|
| 96 |
plan = effective_plan(ctx.db, ctx.workspace)
|
| 97 |
voices = (vp.host_voices if vp else None) or {}
|
| 98 |
+
delivery = voices.get("settings") or {}
|
| 99 |
+
job = _clone_job(ctx, consent) if consent else None
|
| 100 |
+
preview_key = (job.result or {}).get("preview_key") if job and job.status == "done" else None
|
| 101 |
return {
|
| 102 |
"profile": vp.profile_json if vp else None,
|
| 103 |
"sample_doc_ids": vp.sample_doc_ids if vp else [],
|
| 104 |
"updated_at": vp.updated_at.isoformat() if vp and vp.updated_at else None,
|
| 105 |
"host_voices": {"host_a": voices.get("host_a") or settings.elevenlabs_voice_a,
|
| 106 |
"host_b": voices.get("host_b") or settings.elevenlabs_voice_b},
|
| 107 |
+
"delivery": {h: tts.VoiceSettings.from_dict(delivery.get(h)).body() for h in ("host_a", "host_b")},
|
| 108 |
"clone": {
|
| 109 |
"allowed": plan.voice_cloning,
|
| 110 |
"consent_text": CONSENT_TEXT,
|
| 111 |
+
"status": ("ready" if consent and consent.elevenlabs_voice_id
|
| 112 |
+
else "failed" if consent and job and job.status == "failed"
|
| 113 |
+
else "processing" if consent else "none"),
|
| 114 |
"voice_id": consent.elevenlabs_voice_id if consent else None,
|
| 115 |
"created_at": consent.created_at.isoformat() if consent else None,
|
| 116 |
+
"error": job.error if job and job.status == "failed" else None,
|
| 117 |
+
"preview_url": storage.presign_get(preview_key) if preview_key else None,
|
| 118 |
},
|
| 119 |
"tts_configured": bool(settings.elevenlabs_api_key),
|
| 120 |
+
"model": tts.model_id(),
|
| 121 |
+
"models": tts.MODELS,
|
| 122 |
}
|
| 123 |
|
| 124 |
|
|
|
|
| 129 |
|
| 130 |
@router.put("")
|
| 131 |
def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
|
| 132 |
+
vp = _ensure_profile(ctx)
|
|
|
|
|
|
|
|
|
|
| 133 |
if body.profile is not None:
|
| 134 |
vp.profile_json = body.profile
|
| 135 |
+
voices = dict(vp.host_voices or {})
|
| 136 |
if body.host_voices is not None:
|
| 137 |
+
voices.update({k: v for k, v in body.host_voices.items() if k in ("host_a", "host_b") and v})
|
| 138 |
+
if body.delivery is not None:
|
| 139 |
+
saved = dict(voices.get("settings") or {})
|
| 140 |
+
saved.update({h: d.model_dump() for h, d in body.delivery.items() if h in ("host_a", "host_b")})
|
| 141 |
+
voices["settings"] = saved
|
| 142 |
+
vp.host_voices = voices
|
| 143 |
ctx.db.commit()
|
| 144 |
return _serialize(ctx)
|
| 145 |
|
|
|
|
| 155 |
async def voices(ctx: Ctx = Depends(get_ctx)):
|
| 156 |
items = await run_in_threadpool(tts.list_voices)
|
| 157 |
consent = _consent(ctx)
|
| 158 |
+
if consent and consent.elevenlabs_voice_id:
|
| 159 |
+
items = [{**v, "name": f"{v['name']} (my voice)", "category": "my voice"}
|
| 160 |
+
if v["voice_id"] == consent.elevenlabs_voice_id else v for v in items]
|
| 161 |
+
if not any(v["voice_id"] == consent.elevenlabs_voice_id for v in items):
|
| 162 |
+
items = [{"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "category": "my voice"}, *items]
|
| 163 |
return items
|
| 164 |
|
| 165 |
|
| 166 |
+
@router.get("/library")
|
| 167 |
+
async def library(ctx: Ctx = Depends(get_ctx), search: str = "", gender: str = "", age: str = "",
|
| 168 |
+
accent: str = "", language: str = "", use_case: str = "", page: int = 0):
|
| 169 |
+
if not settings.elevenlabs_api_key:
|
| 170 |
+
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to browse the voice library.")
|
| 171 |
+
try:
|
| 172 |
+
return await run_in_threadpool(lambda: tts.search_library(
|
| 173 |
+
search=search, gender=gender, age=age, accent=accent, language=language, use_case=use_case,
|
| 174 |
+
page=max(page, 0)))
|
| 175 |
+
except tts.TTSError as exc:
|
| 176 |
+
raise HTTPException(502, str(exc)) from exc
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@router.post("/library/add")
|
| 180 |
+
async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
|
| 181 |
+
try:
|
| 182 |
+
voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
|
| 183 |
+
except tts.TTSError as exc:
|
| 184 |
+
raise HTTPException(502, str(exc)) from exc
|
| 185 |
+
if body.use_as in ("host_a", "host_b"):
|
| 186 |
+
vp = _ensure_profile(ctx)
|
| 187 |
+
vp.host_voices = {**(vp.host_voices or {}), body.use_as: voice_id}
|
| 188 |
+
ctx.db.commit()
|
| 189 |
+
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@router.post("/preview")
|
| 193 |
+
async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
|
| 194 |
+
"""Speak a short line in any voice with the given (or a host's saved) delivery. Cached, metered."""
|
| 195 |
+
if not settings.elevenlabs_api_key:
|
| 196 |
+
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to preview voices.")
|
| 197 |
+
if body.delivery:
|
| 198 |
+
vs = tts.VoiceSettings.from_dict(body.delivery.model_dump())
|
| 199 |
+
else:
|
| 200 |
+
saved = (((_profile(ctx) or VoiceProfile()).host_voices or {}).get("settings") or {}).get(body.host or "")
|
| 201 |
+
vs = tts.VoiceSettings.from_dict(saved)
|
| 202 |
+
text = (body.text or PREVIEW_LINE).strip()[:400]
|
| 203 |
+
try:
|
| 204 |
+
clip = await run_in_threadpool(tts.synthesize, ctx.workspace.id, text, body.voice_id, vs)
|
| 205 |
+
except tts.TTSError as exc:
|
| 206 |
+
raise HTTPException(502, str(exc)) from exc
|
| 207 |
+
key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
|
| 208 |
+
storage.put_bytes(key, clip, "audio/mpeg")
|
| 209 |
+
record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="tts", provider="elevenlabs",
|
| 210 |
+
quantity=round(tts.mp3_seconds(clip), 1), unit="seconds")
|
| 211 |
+
return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@router.get("/reading-script")
|
| 215 |
+
def reading_script(ctx: Ctx = Depends(get_ctx)):
|
| 216 |
+
"""About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
|
| 217 |
+
doc = ctx.db.scalar(
|
| 218 |
+
select(Document).where(Document.workspace_id == ctx.workspace.id, Document.clean_text != "")
|
| 219 |
+
.order_by(func.length(Document.clean_text).desc()).limit(1)
|
| 220 |
+
)
|
| 221 |
+
if not doc:
|
| 222 |
+
text = ("Writing is how I think out loud. I start with a question I cannot answer yet, then I follow it "
|
| 223 |
+
"until the answer surprises me. Some weeks that takes a paragraph; some weeks it takes a month. "
|
| 224 |
+
"What matters is that every sentence earns its place, and that the reader leaves with something "
|
| 225 |
+
"they did not have when they arrived.")
|
| 226 |
+
return {"title": "Sample passage", "text": text, "words": len(text.split())}
|
| 227 |
+
paragraphs = [p for p in re.split(r"\n\s*\n", doc.clean_text) if len(p.split()) > 12 and not p.startswith("## ")]
|
| 228 |
+
picked, words = [], 0
|
| 229 |
+
for p in paragraphs:
|
| 230 |
+
picked.append(p.strip())
|
| 231 |
+
words += len(p.split())
|
| 232 |
+
if words >= 280: # roughly two minutes read aloud
|
| 233 |
+
break
|
| 234 |
+
if not picked: # short or list heavy posts: read the text as is
|
| 235 |
+
plain = re.sub(r"^## .*$", "", doc.clean_text, flags=re.M).split()
|
| 236 |
+
picked, words = [" ".join(plain[:280])], min(len(plain), 280)
|
| 237 |
+
return {"title": doc.title, "text": "\n\n".join(picked), "words": words}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@router.get("/quota")
|
| 241 |
+
async def quota(ctx: Ctx = Depends(get_ctx)):
|
| 242 |
+
if not settings.elevenlabs_api_key:
|
| 243 |
+
return {}
|
| 244 |
+
return await run_in_threadpool(tts.subscription)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
@router.post("/consent")
|
| 248 |
def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
|
| 249 |
if not effective_plan(ctx.db, ctx.workspace).voice_cloning:
|
|
|
|
| 252 |
raise HTTPException(400, "Please read and accept the consent statement.")
|
| 253 |
if not settings.elevenlabs_api_key:
|
| 254 |
raise HTTPException(503, "Voice cloning needs ELEVENLABS_API_KEY in .env")
|
| 255 |
+
ids = body.upload_ids or ([body.upload_id] if body.upload_id else [])
|
| 256 |
+
uploads = ctx.db.scalars(select(Upload).where(Upload.id.in_(ids), Upload.workspace_id == ctx.workspace.id)).all()
|
| 257 |
+
uploads = [u for u in uploads if u.status == "complete" and u.content_type.startswith("audio/")]
|
| 258 |
+
if not uploads:
|
| 259 |
+
raise HTTPException(400, "Record or upload at least one audio sample first.")
|
| 260 |
+
# A new clone replaces the previous one.
|
| 261 |
+
old = _consent(ctx)
|
| 262 |
+
if old:
|
| 263 |
+
old.revoked_at = datetime.now(UTC)
|
| 264 |
+
consent = VoiceConsent(workspace_id=ctx.workspace.id, user_id=ctx.user.id, sample_key=uploads[0].key,
|
| 265 |
consent_text=CONSENT_TEXT, ip_address=request.client.host if request.client else None)
|
| 266 |
ctx.db.add(consent)
|
| 267 |
ctx.db.commit()
|
| 268 |
+
job = create_job(ctx.db, ctx.workspace.id, "voice_clone", {
|
| 269 |
+
"consent_id": str(consent.id), "sample_keys": [u.key for u in uploads],
|
| 270 |
+
"remove_background_noise": body.remove_background_noise,
|
| 271 |
+
"replaces_voice_id": old.elevenlabs_voice_id if old else None,
|
| 272 |
+
}, max_attempts=1)
|
| 273 |
return {"job": serialize_job(job), **_serialize(ctx)}
|
| 274 |
|
| 275 |
|
|
|
|
| 281 |
if consent.elevenlabs_voice_id and settings.elevenlabs_api_key:
|
| 282 |
try:
|
| 283 |
await run_in_threadpool(tts.delete_voice, consent.elevenlabs_voice_id)
|
| 284 |
+
except tts.TTSError:
|
| 285 |
pass
|
| 286 |
consent.revoked_at = datetime.now(UTC)
|
| 287 |
storage.delete(consent.sample_key)
|
backend/app/services/tts.py
CHANGED
|
@@ -1,7 +1,23 @@
|
|
| 1 |
-
"""ElevenLabs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import base64
|
| 4 |
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
|
|
@@ -11,17 +27,49 @@ from app.services.storage import keys, storage
|
|
| 11 |
API = "https://api.elevenlabs.io/v1"
|
| 12 |
OUTPUT = "mp3_44100_128"
|
| 13 |
BITRATE = 128_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
PREMADE = [
|
| 16 |
-
{"voice_id": "
|
| 17 |
-
{"voice_id": "
|
| 18 |
-
{"voice_id": "
|
| 19 |
-
{"voice_id": "
|
| 20 |
-
{"voice_id": "
|
| 21 |
-
{"voice_id": "
|
| 22 |
]
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
class TTSError(RuntimeError):
|
| 26 |
pass
|
| 27 |
|
|
@@ -32,44 +80,112 @@ def _headers() -> dict:
|
|
| 32 |
return {"xi-api-key": settings.elevenlabs_api_key}
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def mp3_seconds(data: bytes) -> float:
|
| 36 |
return len(data) * 8 / BITRATE
|
| 37 |
|
| 38 |
|
| 39 |
-
def
|
| 40 |
-
return
|
| 41 |
|
| 42 |
|
| 43 |
-
def _cache_key(workspace_id, text: str, voice_id: str, variant: str) -> str:
|
| 44 |
-
return keys.tts_cache(workspace_id, text, voice_id,
|
| 45 |
-
json.dumps(_voice_settings(), sort_keys=True) + settings.elevenlabs_model + variant)
|
| 46 |
|
| 47 |
|
| 48 |
-
def synthesize(workspace_id, text: str, voice_id: str
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
if storage.exists(key):
|
| 51 |
return storage.get_bytes(key)
|
| 52 |
-
body = {"text": text, "model_id":
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
if
|
| 56 |
-
|
|
|
|
| 57 |
storage.put_bytes(key, resp.content, "audio/mpeg")
|
| 58 |
return resp.content
|
| 59 |
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"""Audio plus word timings [{text, startMs, endMs}] for captions."""
|
| 63 |
-
|
|
|
|
| 64 |
if storage.exists(key):
|
| 65 |
data = json.loads(storage.get_bytes(key))
|
| 66 |
else:
|
| 67 |
-
body = {"text": text, "model_id":
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if resp.status_code >= 400:
|
| 71 |
-
raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
|
| 72 |
-
data = resp.json()
|
| 73 |
storage.put_bytes(key, json.dumps(data).encode(), "application/json")
|
| 74 |
audio = base64.b64decode(data["audio_base64"])
|
| 75 |
return audio, words_from_alignment(data.get("alignment") or {})
|
|
@@ -96,29 +212,94 @@ def words_from_alignment(alignment: dict) -> list[dict]:
|
|
| 96 |
return words
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def list_voices() -> list[dict]:
|
|
|
|
| 100 |
if not settings.elevenlabs_api_key:
|
| 101 |
return PREMADE
|
| 102 |
try:
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
except httpx.HTTPError:
|
| 106 |
return PREMADE
|
| 107 |
-
return [
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
-
def clone_voice(name: str,
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
return resp.json()["voice_id"]
|
| 121 |
|
| 122 |
|
| 123 |
def delete_voice(voice_id: str) -> None:
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ElevenLabs: speech generation, voices (premade, Voice Library, consented clones), cached in storage.
|
| 2 |
+
|
| 3 |
+
Generation
|
| 4 |
+
- `synthesize` renders one line. Lines of a conversation pass the neighbouring text
|
| 5 |
+
(previous_text / next_text) so intonation flows across cuts, and are cached by
|
| 6 |
+
(text, voice, model, settings, context) so re-renders cost nothing.
|
| 7 |
+
- 429 and 5xx responses retry with backoff; a long script renders in parallel (`synthesize_many`).
|
| 8 |
+
|
| 9 |
+
Voices
|
| 10 |
+
- premade voices and Voice Library ("shared") voices are licensed by ElevenLabs for commercial use on
|
| 11 |
+
paid ElevenLabs plans; library voices are added to the account before use.
|
| 12 |
+
- cloning is Instant Voice Cloning from one or more consented samples, with background noise removal.
|
| 13 |
+
"""
|
| 14 |
|
| 15 |
import base64
|
| 16 |
import json
|
| 17 |
+
import time
|
| 18 |
+
from collections.abc import Callable
|
| 19 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
|
| 22 |
import httpx
|
| 23 |
|
|
|
|
| 27 |
API = "https://api.elevenlabs.io/v1"
|
| 28 |
OUTPUT = "mp3_44100_128"
|
| 29 |
BITRATE = 128_000
|
| 30 |
+
MODELS = {
|
| 31 |
+
"eleven_multilingual_v2": "Multilingual v2 (most natural, default)",
|
| 32 |
+
"eleven_turbo_v2_5": "Turbo v2.5 (fast, half the cost)",
|
| 33 |
+
"eleven_flash_v2_5": "Flash v2.5 (fastest, cheapest)",
|
| 34 |
+
}
|
| 35 |
|
| 36 |
+
PREMADE = [ # current ElevenLabs default voices, shown when the account list cannot be fetched
|
| 37 |
+
{"voice_id": "EXAVITQu4vr4xnSDxMaL", "name": "Sarah", "category": "premade"},
|
| 38 |
+
{"voice_id": "JBFqnCBsd6RMkjVDRZzb", "name": "George", "category": "premade"},
|
| 39 |
+
{"voice_id": "CwhRBWXzGAHq8TQ4Fs17", "name": "Roger", "category": "premade"},
|
| 40 |
+
{"voice_id": "cgSgspJ2msm6clMCkdW9", "name": "Jessica", "category": "premade"},
|
| 41 |
+
{"voice_id": "nPczCjzI2devNBz1zQrb", "name": "Brian", "category": "premade"},
|
| 42 |
+
{"voice_id": "IKne3meq5aSn9XLyUdCD", "name": "Charlie", "category": "premade"},
|
| 43 |
]
|
| 44 |
|
| 45 |
|
| 46 |
+
@dataclass(frozen=True)
|
| 47 |
+
class VoiceSettings:
|
| 48 |
+
stability: float = 0.45
|
| 49 |
+
similarity_boost: float = 0.8
|
| 50 |
+
style: float = 0.2
|
| 51 |
+
speed: float = 1.0
|
| 52 |
+
use_speaker_boost: bool = True
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def from_dict(cls, data: dict | None) -> "VoiceSettings":
|
| 56 |
+
data = data or {}
|
| 57 |
+
|
| 58 |
+
def clamp(key: str, lo: float, hi: float) -> float:
|
| 59 |
+
try:
|
| 60 |
+
return max(lo, min(hi, float(data.get(key, getattr(cls, key)))))
|
| 61 |
+
except (TypeError, ValueError):
|
| 62 |
+
return getattr(cls, key)
|
| 63 |
+
|
| 64 |
+
return cls(stability=clamp("stability", 0, 1), similarity_boost=clamp("similarity_boost", 0, 1),
|
| 65 |
+
style=clamp("style", 0, 1), speed=clamp("speed", 0.7, 1.2),
|
| 66 |
+
use_speaker_boost=bool(data.get("use_speaker_boost", True)))
|
| 67 |
+
|
| 68 |
+
def body(self) -> dict:
|
| 69 |
+
return {"stability": self.stability, "similarity_boost": self.similarity_boost, "style": self.style,
|
| 70 |
+
"speed": self.speed, "use_speaker_boost": self.use_speaker_boost}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
class TTSError(RuntimeError):
|
| 74 |
pass
|
| 75 |
|
|
|
|
| 80 |
return {"xi-api-key": settings.elevenlabs_api_key}
|
| 81 |
|
| 82 |
|
| 83 |
+
def _request(method: str, path: str, *, attempts: int = 4, **kwargs) -> httpx.Response:
|
| 84 |
+
"""ElevenLabs call with retries for rate limits and transient errors."""
|
| 85 |
+
delay = 1.5
|
| 86 |
+
for attempt in range(attempts):
|
| 87 |
+
try:
|
| 88 |
+
resp = httpx.request(method, f"{API}{path}", headers=_headers(), timeout=kwargs.pop("timeout", 120),
|
| 89 |
+
**kwargs)
|
| 90 |
+
except httpx.TransportError as exc:
|
| 91 |
+
if attempt == attempts - 1:
|
| 92 |
+
raise TTSError(f"ElevenLabs unreachable: {exc}") from exc
|
| 93 |
+
time.sleep(delay)
|
| 94 |
+
delay *= 2
|
| 95 |
+
continue
|
| 96 |
+
if resp.status_code == 429 or resp.status_code >= 500:
|
| 97 |
+
if attempt == attempts - 1:
|
| 98 |
+
break
|
| 99 |
+
time.sleep(float(resp.headers.get("retry-after") or delay))
|
| 100 |
+
delay *= 2
|
| 101 |
+
continue
|
| 102 |
+
if resp.status_code >= 400:
|
| 103 |
+
raise TTSError(_explain(resp))
|
| 104 |
+
return resp
|
| 105 |
+
raise TTSError(_explain(resp))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _explain(resp: httpx.Response) -> str:
|
| 109 |
+
try:
|
| 110 |
+
detail = resp.json().get("detail")
|
| 111 |
+
message = detail.get("message") if isinstance(detail, dict) else detail
|
| 112 |
+
except ValueError:
|
| 113 |
+
message = resp.text[:300]
|
| 114 |
+
if resp.status_code == 401:
|
| 115 |
+
return "ElevenLabs rejected the API key."
|
| 116 |
+
if resp.status_code == 429:
|
| 117 |
+
return "ElevenLabs is rate limiting or the character quota is used up."
|
| 118 |
+
return f"ElevenLabs {resp.status_code}: {message}"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
def mp3_seconds(data: bytes) -> float:
|
| 122 |
return len(data) * 8 / BITRATE
|
| 123 |
|
| 124 |
|
| 125 |
+
def model_id() -> str:
|
| 126 |
+
return settings.elevenlabs_model if settings.elevenlabs_model in MODELS else "eleven_multilingual_v2"
|
| 127 |
|
| 128 |
|
| 129 |
+
def _cache_key(workspace_id, text: str, voice_id: str, vs: VoiceSettings, variant: str) -> str:
|
| 130 |
+
return keys.tts_cache(workspace_id, text, voice_id, json.dumps(vs.body(), sort_keys=True) + model_id() + variant)
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
+
def synthesize(workspace_id, text: str, voice_id: str, voice_settings: VoiceSettings | None = None,
|
| 134 |
+
previous_text: str | None = None, next_text: str | None = None) -> bytes:
|
| 135 |
+
vs = voice_settings or VoiceSettings()
|
| 136 |
+
context = f"|{(previous_text or '')[-300:]}|{(next_text or '')[:300]}"
|
| 137 |
+
key = _cache_key(workspace_id, text, voice_id, vs, OUTPUT + context)
|
| 138 |
if storage.exists(key):
|
| 139 |
return storage.get_bytes(key)
|
| 140 |
+
body: dict = {"text": text, "model_id": model_id(), "voice_settings": vs.body()}
|
| 141 |
+
if previous_text:
|
| 142 |
+
body["previous_text"] = previous_text[-300:]
|
| 143 |
+
if next_text:
|
| 144 |
+
body["next_text"] = next_text[:300]
|
| 145 |
+
resp = _request("POST", f"/text-to-speech/{voice_id}", params={"output_format": OUTPUT}, json=body)
|
| 146 |
storage.put_bytes(key, resp.content, "audio/mpeg")
|
| 147 |
return resp.content
|
| 148 |
|
| 149 |
|
| 150 |
+
@dataclass
|
| 151 |
+
class Line:
|
| 152 |
+
text: str
|
| 153 |
+
voice_id: str
|
| 154 |
+
settings: VoiceSettings
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def synthesize_many(workspace_id, lines: list[Line], on_done: Callable[[int], None] | None = None,
|
| 158 |
+
workers: int = 4) -> list[bytes]:
|
| 159 |
+
"""Render a whole script in parallel, in order. Each line hears its neighbours for natural flow."""
|
| 160 |
+
def render(i: int) -> bytes:
|
| 161 |
+
line = lines[i]
|
| 162 |
+
prev_text = lines[i - 1].text if i > 0 else None
|
| 163 |
+
next_text = lines[i + 1].text if i + 1 < len(lines) else None
|
| 164 |
+
return synthesize(workspace_id, line.text, line.voice_id, line.settings, prev_text, next_text)
|
| 165 |
+
|
| 166 |
+
out: list[bytes | None] = [None] * len(lines)
|
| 167 |
+
done = 0
|
| 168 |
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
| 169 |
+
futures = {pool.submit(render, i): i for i in range(len(lines))}
|
| 170 |
+
for fut, i in futures.items():
|
| 171 |
+
out[i] = fut.result()
|
| 172 |
+
done += 1
|
| 173 |
+
if on_done:
|
| 174 |
+
on_done(done)
|
| 175 |
+
return [b or b"" for b in out]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def synthesize_with_timestamps(workspace_id, text: str, voice_id: str,
|
| 179 |
+
voice_settings: VoiceSettings | None = None) -> tuple[bytes, list[dict]]:
|
| 180 |
"""Audio plus word timings [{text, startMs, endMs}] for captions."""
|
| 181 |
+
vs = voice_settings or VoiceSettings()
|
| 182 |
+
key = _cache_key(workspace_id, text, voice_id, vs, "timestamps") + ".json"
|
| 183 |
if storage.exists(key):
|
| 184 |
data = json.loads(storage.get_bytes(key))
|
| 185 |
else:
|
| 186 |
+
body = {"text": text, "model_id": model_id(), "voice_settings": vs.body()}
|
| 187 |
+
data = _request("POST", f"/text-to-speech/{voice_id}/with-timestamps", params={"output_format": OUTPUT},
|
| 188 |
+
json=body).json()
|
|
|
|
|
|
|
|
|
|
| 189 |
storage.put_bytes(key, json.dumps(data).encode(), "application/json")
|
| 190 |
audio = base64.b64decode(data["audio_base64"])
|
| 191 |
return audio, words_from_alignment(data.get("alignment") or {})
|
|
|
|
| 212 |
return words
|
| 213 |
|
| 214 |
|
| 215 |
+
# Voices
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _voice(v: dict) -> dict:
|
| 219 |
+
labels = v.get("labels") or {}
|
| 220 |
+
return {
|
| 221 |
+
"voice_id": v["voice_id"],
|
| 222 |
+
"name": v.get("name") or "Voice",
|
| 223 |
+
"category": v.get("category"),
|
| 224 |
+
"preview_url": v.get("preview_url"),
|
| 225 |
+
"description": v.get("description") or labels.get("description"),
|
| 226 |
+
"labels": {k: labels[k] for k in ("accent", "age", "gender", "use_case", "descriptive") if labels.get(k)},
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
def list_voices() -> list[dict]:
|
| 231 |
+
"""Voices on the account: premade, added library voices, clones."""
|
| 232 |
if not settings.elevenlabs_api_key:
|
| 233 |
return PREMADE
|
| 234 |
try:
|
| 235 |
+
data = _request("GET", "/voices", attempts=2, timeout=20).json()
|
| 236 |
+
except TTSError:
|
|
|
|
| 237 |
return PREMADE
|
| 238 |
+
return [_voice(v) for v in data.get("voices", [])]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def search_library(*, search: str = "", gender: str = "", age: str = "", accent: str = "", language: str = "",
|
| 242 |
+
use_case: str = "", page: int = 0, page_size: int = 24) -> dict:
|
| 243 |
+
"""ElevenLabs Voice Library: community voices licensed for commercial use on paid ElevenLabs plans."""
|
| 244 |
+
params = {"page_size": page_size, "page": page, "sort": "trending"}
|
| 245 |
+
for k, v in {"search": search, "gender": gender, "age": age, "accent": accent, "language": language,
|
| 246 |
+
"use_cases": use_case}.items():
|
| 247 |
+
if v:
|
| 248 |
+
params[k] = v
|
| 249 |
+
data = _request("GET", "/shared-voices", params=params, attempts=2, timeout=20).json()
|
| 250 |
+
voices = [
|
| 251 |
+
{
|
| 252 |
+
"public_owner_id": v["public_owner_id"],
|
| 253 |
+
"voice_id": v["voice_id"],
|
| 254 |
+
"name": v.get("name"),
|
| 255 |
+
"description": v.get("description"),
|
| 256 |
+
"preview_url": v.get("preview_url"),
|
| 257 |
+
"gender": v.get("gender"),
|
| 258 |
+
"age": v.get("age"),
|
| 259 |
+
"accent": v.get("accent"),
|
| 260 |
+
"language": v.get("language"),
|
| 261 |
+
"use_case": v.get("use_case"),
|
| 262 |
+
"descriptive": v.get("descriptive"),
|
| 263 |
+
"category": v.get("category"),
|
| 264 |
+
"cloned_by_count": v.get("cloned_by_count"),
|
| 265 |
+
"free_users_allowed": v.get("free_users_allowed", True),
|
| 266 |
+
"notice_period": v.get("notice_period"),
|
| 267 |
+
}
|
| 268 |
+
for v in data.get("voices", [])
|
| 269 |
]
|
| 270 |
+
return {"voices": voices, "has_more": bool(data.get("has_more"))}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def add_library_voice(public_owner_id: str, voice_id: str, name: str) -> str:
|
| 274 |
+
resp = _request("POST", f"/voices/add/{public_owner_id}/{voice_id}", json={"new_name": name[:100]},
|
| 275 |
+
attempts=2, timeout=30)
|
| 276 |
+
return resp.json()["voice_id"]
|
| 277 |
|
| 278 |
|
| 279 |
+
def clone_voice(name: str, samples: list[tuple[str, bytes, str]], *, remove_background_noise: bool = True,
|
| 280 |
+
description: str = "") -> str:
|
| 281 |
+
"""Instant Voice Clone from one or more (filename, bytes, content_type) samples."""
|
| 282 |
+
files = [("files", (fn, data, ct)) for fn, data, ct in samples]
|
| 283 |
+
resp = _request("POST", "/voices/add", attempts=2, timeout=180, files=files, data={
|
| 284 |
+
"name": name[:100],
|
| 285 |
+
"description": description or "Notestack consented voice clone",
|
| 286 |
+
"remove_background_noise": "true" if remove_background_noise else "false",
|
| 287 |
+
"labels": json.dumps({"source": "notestack"}),
|
| 288 |
+
})
|
| 289 |
return resp.json()["voice_id"]
|
| 290 |
|
| 291 |
|
| 292 |
def delete_voice(voice_id: str) -> None:
|
| 293 |
+
_request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def subscription() -> dict:
|
| 297 |
+
"""Character quota, so the UI can warn before a long render."""
|
| 298 |
+
try:
|
| 299 |
+
data = _request("GET", "/user/subscription", attempts=1, timeout=15).json()
|
| 300 |
+
except TTSError:
|
| 301 |
+
return {}
|
| 302 |
+
return {"tier": data.get("tier"), "character_count": data.get("character_count"),
|
| 303 |
+
"character_limit": data.get("character_limit"),
|
| 304 |
+
"can_use_instant_voice_cloning": data.get("can_use_instant_voice_cloning"),
|
| 305 |
+
"voice_slots_used": data.get("voice_slots_used"), "voice_limit": data.get("voice_limit")}
|
backend/app/worker.py
CHANGED
|
@@ -37,6 +37,7 @@ from app.models import (
|
|
| 37 |
Upload,
|
| 38 |
User,
|
| 39 |
VoiceConsent,
|
|
|
|
| 40 |
Workspace,
|
| 41 |
WorkspaceMember,
|
| 42 |
)
|
|
@@ -46,10 +47,10 @@ from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article
|
|
| 46 |
from app.services import launchpad, tts
|
| 47 |
from app.services.email import email_service
|
| 48 |
from app.services.email_verification import purge_old_codes
|
| 49 |
-
from app.services.jobs import claim_next, create_job, retry_or_fail, stale_jobs, update_job
|
| 50 |
from app.services.plans import effective_plan
|
| 51 |
from app.services.renderer import PermanentJobError, request_render
|
| 52 |
-
from app.services.storage import storage
|
| 53 |
|
| 54 |
log = logging.getLogger("notestack.worker")
|
| 55 |
|
|
@@ -131,16 +132,41 @@ def handle_voice_clone(db: Session, job: Job):
|
|
| 131 |
consent = db.get(VoiceConsent, uuid.UUID(job.params["consent_id"]))
|
| 132 |
if not consent or consent.revoked_at:
|
| 133 |
return Done({}, "Consent was revoked")
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
try:
|
| 137 |
-
voice_id = tts.clone_voice(f"
|
| 138 |
-
|
| 139 |
except tts.TTSError as exc:
|
| 140 |
raise PermanentJobError(str(exc)) from exc
|
| 141 |
consent.elevenlabs_voice_id = voice_id
|
| 142 |
db.commit()
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
|
| 146 |
def handle_resurface(db: Session, job: Job):
|
|
|
|
| 37 |
Upload,
|
| 38 |
User,
|
| 39 |
VoiceConsent,
|
| 40 |
+
VoiceProfile,
|
| 41 |
Workspace,
|
| 42 |
WorkspaceMember,
|
| 43 |
)
|
|
|
|
| 47 |
from app.services import launchpad, tts
|
| 48 |
from app.services.email import email_service
|
| 49 |
from app.services.email_verification import purge_old_codes
|
| 50 |
+
from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
|
| 51 |
from app.services.plans import effective_plan
|
| 52 |
from app.services.renderer import PermanentJobError, request_render
|
| 53 |
+
from app.services.storage import keys, storage
|
| 54 |
|
| 55 |
log = logging.getLogger("notestack.worker")
|
| 56 |
|
|
|
|
| 132 |
consent = db.get(VoiceConsent, uuid.UUID(job.params["consent_id"]))
|
| 133 |
if not consent or consent.revoked_at:
|
| 134 |
return Done({}, "Consent was revoked")
|
| 135 |
+
sample_keys = job.params.get("sample_keys") or [consent.sample_key]
|
| 136 |
+
update_job(db, job, progress=0.15, message=f"Sending {len(sample_keys)} recording(s) to ElevenLabs")
|
| 137 |
+
samples = []
|
| 138 |
+
for key in sample_keys:
|
| 139 |
+
head = storage.head(key) or {}
|
| 140 |
+
samples.append((key.rsplit("/", 1)[-1], storage.get_bytes(key), head.get("ContentType") or "audio/webm"))
|
| 141 |
+
owner = db.get(User, consent.user_id)
|
| 142 |
try:
|
| 143 |
+
voice_id = tts.clone_voice(f"{(owner.name if owner and owner.name else 'My')} (Notestack)", samples,
|
| 144 |
+
remove_background_noise=bool(job.params.get("remove_background_noise", True)))
|
| 145 |
except tts.TTSError as exc:
|
| 146 |
raise PermanentJobError(str(exc)) from exc
|
| 147 |
consent.elevenlabs_voice_id = voice_id
|
| 148 |
db.commit()
|
| 149 |
+
|
| 150 |
+
# Make the new voice host A and record a short preview so the writer can judge it straight away.
|
| 151 |
+
update_job(db, job, progress=0.7, message="Recording a preview in your voice")
|
| 152 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id))
|
| 153 |
+
if not vp:
|
| 154 |
+
vp = VoiceProfile(workspace_id=consent.workspace_id)
|
| 155 |
+
db.add(vp)
|
| 156 |
+
vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id}
|
| 157 |
+
db.commit()
|
| 158 |
+
preview_text = job.params.get("preview_text") or (
|
| 159 |
+
"Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.")
|
| 160 |
+
preview_key = None
|
| 161 |
+
try:
|
| 162 |
+
clip = tts.synthesize(consent.workspace_id, preview_text[:600], voice_id)
|
| 163 |
+
preview_key = storage.put_bytes(keys.artifact(consent.workspace_id, consent.id, "voice-preview", "mp3"),
|
| 164 |
+
clip, "audio/mpeg")
|
| 165 |
+
record_usage(db, workspace_id=consent.workspace_id, kind="tts", provider="elevenlabs",
|
| 166 |
+
quantity=round(tts.mp3_seconds(clip), 1), unit="seconds", job=job)
|
| 167 |
+
except tts.TTSError:
|
| 168 |
+
log.warning("voice preview failed for %s", voice_id, exc_info=True)
|
| 169 |
+
return Done({"voice_id": voice_id, "preview_key": preview_key}, "Your voice is ready and set as host A")
|
| 170 |
|
| 171 |
|
| 172 |
def handle_resurface(db: Session, job: Job):
|
backend/tests/test_buildout.py
CHANGED
|
@@ -305,7 +305,16 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
|
|
| 305 |
connect(client, auth, run_jobs)
|
| 306 |
doc = docs(client, auth)[0]
|
| 307 |
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
llm["PodcastScript"] = {"lines": [
|
| 310 |
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 311 |
{"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
|
|
@@ -317,6 +326,11 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
|
|
| 317 |
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 318 |
assert done["status"] == "ready", done["content"]
|
| 319 |
assert done["content"]["duration_s"] == 2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
assert done["url"] and client.get(done["url"].split("8000", 1)[1]).content == b"\x00" * 32000
|
| 321 |
|
| 322 |
ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
|
|
@@ -610,3 +624,79 @@ def test_artifact_ownership(client, auth, db_session):
|
|
| 610 |
db_session.add(other)
|
| 611 |
db_session.commit()
|
| 612 |
assert client.get(f"/api/artifacts/{other.id}", headers=auth).status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
connect(client, auth, run_jobs)
|
| 306 |
doc = docs(client, auth)[0]
|
| 307 |
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 308 |
+
calls = []
|
| 309 |
+
|
| 310 |
+
def fake_synth(ws, text, voice, vs=None, prev=None, nxt=None):
|
| 311 |
+
calls.append((text, voice, prev, nxt, vs.stability if vs else None))
|
| 312 |
+
return b"\x00" * 16000 # 1 second at 128 kbps
|
| 313 |
+
|
| 314 |
+
monkeypatch.setattr(tts, "synthesize", fake_synth)
|
| 315 |
+
client.put("/api/voice", json={"host_voices": {"host_a": "voiceA", "host_b": "voiceB"},
|
| 316 |
+
"delivery": {"host_b": {"stability": 0.9, "similarity_boost": 0.7,
|
| 317 |
+
"style": 0.1, "speed": 1.1}}}, headers=auth)
|
| 318 |
llm["PodcastScript"] = {"lines": [
|
| 319 |
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 320 |
{"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
|
|
|
|
| 326 |
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 327 |
assert done["status"] == "ready", done["content"]
|
| 328 |
assert done["content"]["duration_s"] == 2.0
|
| 329 |
+
# Each line hears its neighbour; hosts use their own voice and delivery settings.
|
| 330 |
+
by_text = {c[0]: c for c in calls}
|
| 331 |
+
assert by_text["Welcome in."][1] == "voiceA" and by_text["Welcome in."][3] == "Prices went up."
|
| 332 |
+
assert by_text["Prices went up."][1] == "voiceB" and by_text["Prices went up."][2] == "Welcome in."
|
| 333 |
+
assert by_text["Prices went up."][4] == 0.9
|
| 334 |
assert done["url"] and client.get(done["url"].split("8000", 1)[1]).content == b"\x00" * 32000
|
| 335 |
|
| 336 |
ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
|
|
|
|
| 624 |
db_session.add(other)
|
| 625 |
db_session.commit()
|
| 626 |
assert client.get(f"/api/artifacts/{other.id}", headers=auth).status_code == 404
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
# Voice pipeline (ElevenLabs stubbed)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_jobs):
|
| 633 |
+
from app.config import settings
|
| 634 |
+
from app.services import tts
|
| 635 |
+
|
| 636 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 637 |
+
monkeypatch.setattr(tts, "search_library", lambda **kw: {"voices": [{
|
| 638 |
+
"public_owner_id": "owner-1", "voice_id": "lib-voice-1",
|
| 639 |
+
"name": "Narrator"}], "has_more": False})
|
| 640 |
+
monkeypatch.setattr(tts, "add_library_voice", lambda owner, vid, name: "added-" + vid)
|
| 641 |
+
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: b"\x00" * 8000)
|
| 642 |
+
|
| 643 |
+
assert client.get("/api/voice/library", params={"gender": "female"}, headers=auth).json()["voices"][0]["name"] == \
|
| 644 |
+
"Narrator"
|
| 645 |
+
r = client.post("/api/voice/library/add", json={
|
| 646 |
+
"public_owner_id": "owner-1", "voice_id": "lib-voice-1", "name": "Narrator",
|
| 647 |
+
"use_as": "host_b"}, headers=auth).json()
|
| 648 |
+
assert r["voice_id"] == "added-lib-voice-1" and r["host_voices"]["host_b"] == "added-lib-voice-1"
|
| 649 |
+
|
| 650 |
+
prev = client.post("/api/voice/preview", json={"voice_id": "added-lib-voice-1", "text": "Hello there",
|
| 651 |
+
"delivery": {"stability": 0.3, "similarity_boost": 0.8,
|
| 652 |
+
"style": 0, "speed": 0.9}}, headers=auth).json()
|
| 653 |
+
assert prev["seconds"] == 0.5 and client.get(prev["url"].split("8000", 1)[1]).status_code == 200
|
| 654 |
+
|
| 655 |
+
connect(client, auth, run_jobs)
|
| 656 |
+
script = client.get("/api/voice/reading-script", headers=auth).json()
|
| 657 |
+
assert script["words"] > 5 and script["title"]
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatch, run_jobs):
|
| 661 |
+
from app.config import settings
|
| 662 |
+
from app.services import tts
|
| 663 |
+
|
| 664 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 665 |
+
sent = {}
|
| 666 |
+
|
| 667 |
+
def fake_clone(name, samples, remove_background_noise=True, description=""):
|
| 668 |
+
sent.update(n=len(samples), noise=remove_background_noise, name=name)
|
| 669 |
+
return "clone-1"
|
| 670 |
+
|
| 671 |
+
monkeypatch.setattr(tts, "clone_voice", fake_clone)
|
| 672 |
+
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: b"\x00" * 16000)
|
| 673 |
+
ids = []
|
| 674 |
+
for i in range(2):
|
| 675 |
+
start = client.post("/api/storage/uploads", json={"filename": f"take{i}.webm", "content_type": "audio/webm",
|
| 676 |
+
"size_bytes": 4}, headers=auth).json()
|
| 677 |
+
client.put(start["upload_url"].split("8000", 1)[1], content=b"RIFF")
|
| 678 |
+
client.post(f"/api/storage/uploads/{start['upload_id']}/complete", headers=auth)
|
| 679 |
+
ids.append(start["upload_id"])
|
| 680 |
+
consent_text = client.get("/api/voice", headers=auth).json()["clone"]["consent_text"]
|
| 681 |
+
r = client.post("/api/voice/consent", json={"upload_ids": ids, "agreed": True, "consent_text": consent_text,
|
| 682 |
+
"remove_background_noise": False}, headers=auth)
|
| 683 |
+
assert r.status_code == 200, r.text
|
| 684 |
+
assert run_jobs()[0].status == "done"
|
| 685 |
+
assert sent == {"n": 2, "noise": False, "name": "Ada Lovelace (Notestack)"}
|
| 686 |
+
voice = client.get("/api/voice", headers=auth).json()
|
| 687 |
+
assert voice["clone"]["status"] == "ready" and voice["host_voices"]["host_a"] == "clone-1"
|
| 688 |
+
assert voice["clone"]["preview_url"]
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def test_tts_retries_rate_limits(monkeypatch):
|
| 692 |
+
from app.config import settings
|
| 693 |
+
from app.services import tts
|
| 694 |
+
|
| 695 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 696 |
+
monkeypatch.setattr(tts.time, "sleep", lambda s: None)
|
| 697 |
+
responses = [httpx.Response(429, json={"detail": "slow down"}), httpx.Response(200, content=b"mp3")]
|
| 698 |
+
monkeypatch.setattr(httpx, "request", lambda *a, **k: responses.pop(0))
|
| 699 |
+
assert tts._request("POST", "/text-to-speech/x").content == b"mp3"
|
| 700 |
+
monkeypatch.setattr(httpx, "request", lambda *a, **k: httpx.Response(401, json={"detail": {"message": "bad"}}))
|
| 701 |
+
with pytest.raises(tts.TTSError, match="API key"):
|
| 702 |
+
tts._request("GET", "/voices")
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -8,10 +8,12 @@ import type {
|
|
| 8 |
Doc,
|
| 9 |
DocDetail,
|
| 10 |
Job,
|
|
|
|
| 11 |
Notebook,
|
| 12 |
NotebookSummary,
|
| 13 |
Page,
|
| 14 |
Platform,
|
|
|
|
| 15 |
ResurfaceItem,
|
| 16 |
Settings,
|
| 17 |
SocialAccounts,
|
|
@@ -20,6 +22,7 @@ import type {
|
|
| 20 |
TopicMap,
|
| 21 |
Usage,
|
| 22 |
Voice,
|
|
|
|
| 23 |
VoiceProfileData,
|
| 24 |
VoiceState,
|
| 25 |
} from "./types";
|
|
@@ -99,14 +102,31 @@ export const topicsApi = {
|
|
| 99 |
|
| 100 |
export const voiceApi = {
|
| 101 |
get: () => api<VoiceState>("/api/voice"),
|
| 102 |
-
update: (body: {
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
build: (document_ids: string[]) => post<Job>("/api/voice/build", { document_ids }),
|
| 105 |
voices: () => api<Voice[]>("/api/voice/voices"),
|
| 106 |
-
consent: async (
|
| 107 |
-
const
|
| 108 |
-
|
|
|
|
| 109 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
revoke: () => del<VoiceState>("/api/voice/consent"),
|
| 111 |
};
|
| 112 |
|
|
|
|
| 8 |
Doc,
|
| 9 |
DocDetail,
|
| 10 |
Job,
|
| 11 |
+
LibraryVoice,
|
| 12 |
Notebook,
|
| 13 |
NotebookSummary,
|
| 14 |
Page,
|
| 15 |
Platform,
|
| 16 |
+
Quota,
|
| 17 |
ResurfaceItem,
|
| 18 |
Settings,
|
| 19 |
SocialAccounts,
|
|
|
|
| 22 |
TopicMap,
|
| 23 |
Usage,
|
| 24 |
Voice,
|
| 25 |
+
Delivery,
|
| 26 |
VoiceProfileData,
|
| 27 |
VoiceState,
|
| 28 |
} from "./types";
|
|
|
|
| 102 |
|
| 103 |
export const voiceApi = {
|
| 104 |
get: () => api<VoiceState>("/api/voice"),
|
| 105 |
+
update: (body: {
|
| 106 |
+
profile?: VoiceProfileData;
|
| 107 |
+
host_voices?: Partial<{ host_a: string; host_b: string }>;
|
| 108 |
+
delivery?: Partial<{ host_a: Delivery; host_b: Delivery }>;
|
| 109 |
+
}) => put<VoiceState>("/api/voice", body),
|
| 110 |
build: (document_ids: string[]) => post<Job>("/api/voice/build", { document_ids }),
|
| 111 |
voices: () => api<Voice[]>("/api/voice/voices"),
|
| 112 |
+
consent: async (files: File[], consent_text: string, remove_background_noise: boolean) => {
|
| 113 |
+
const upload_ids = [];
|
| 114 |
+
for (const f of files) upload_ids.push((await uploadFile(f)).upload_id);
|
| 115 |
+
return post<VoiceState & { job: Job }>("/api/voice/consent", { upload_ids, agreed: true, consent_text, remove_background_noise });
|
| 116 |
},
|
| 117 |
+
library: (p: { search?: string; gender?: string; age?: string; accent?: string; language?: string; use_case?: string; page?: number }) =>
|
| 118 |
+
api<{ voices: LibraryVoice[]; has_more: boolean }>(`/api/voice/library${qs(p)}`),
|
| 119 |
+
addFromLibrary: (v: LibraryVoice, use_as?: "host_a" | "host_b") =>
|
| 120 |
+
post<VoiceState & { voice_id: string }>("/api/voice/library/add", {
|
| 121 |
+
public_owner_id: v.public_owner_id,
|
| 122 |
+
voice_id: v.voice_id,
|
| 123 |
+
name: v.name,
|
| 124 |
+
use_as,
|
| 125 |
+
}),
|
| 126 |
+
preview: (voice_id: string, opts: { text?: string; host?: "host_a" | "host_b"; delivery?: Delivery } = {}) =>
|
| 127 |
+
post<{ url: string; seconds: number }>("/api/voice/preview", { voice_id, ...opts }),
|
| 128 |
+
readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
|
| 129 |
+
quota: () => api<Quota>("/api/voice/quota"),
|
| 130 |
revoke: () => del<VoiceState>("/api/voice/consent"),
|
| 131 |
};
|
| 132 |
|
frontend/src/api/types.ts
CHANGED
|
@@ -142,17 +142,58 @@ export type VoiceState = {
|
|
| 142 |
sample_doc_ids: string[];
|
| 143 |
updated_at: string | null;
|
| 144 |
host_voices: { host_a: string; host_b: string };
|
|
|
|
|
|
|
|
|
|
| 145 |
clone: {
|
| 146 |
allowed: boolean;
|
| 147 |
consent_text: string;
|
| 148 |
-
status: "none" | "processing" | "ready";
|
| 149 |
voice_id: string | null;
|
| 150 |
created_at: string | null;
|
|
|
|
|
|
|
| 151 |
};
|
| 152 |
tts_configured: boolean;
|
| 153 |
};
|
| 154 |
|
| 155 |
-
export type
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
|
| 158 |
|
|
|
|
| 142 |
sample_doc_ids: string[];
|
| 143 |
updated_at: string | null;
|
| 144 |
host_voices: { host_a: string; host_b: string };
|
| 145 |
+
delivery: { host_a: Delivery; host_b: Delivery };
|
| 146 |
+
model: string;
|
| 147 |
+
models: Record<string, string>;
|
| 148 |
clone: {
|
| 149 |
allowed: boolean;
|
| 150 |
consent_text: string;
|
| 151 |
+
status: "none" | "processing" | "ready" | "failed";
|
| 152 |
voice_id: string | null;
|
| 153 |
created_at: string | null;
|
| 154 |
+
error: string | null;
|
| 155 |
+
preview_url: string | null;
|
| 156 |
};
|
| 157 |
tts_configured: boolean;
|
| 158 |
};
|
| 159 |
|
| 160 |
+
export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
|
| 161 |
+
|
| 162 |
+
export type Voice = {
|
| 163 |
+
voice_id: string;
|
| 164 |
+
name: string;
|
| 165 |
+
category?: string | null;
|
| 166 |
+
preview_url?: string | null;
|
| 167 |
+
description?: string | null;
|
| 168 |
+
labels?: Record<string, string>;
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
export type LibraryVoice = {
|
| 172 |
+
public_owner_id: string;
|
| 173 |
+
voice_id: string;
|
| 174 |
+
name: string;
|
| 175 |
+
description: string | null;
|
| 176 |
+
preview_url: string | null;
|
| 177 |
+
gender: string | null;
|
| 178 |
+
age: string | null;
|
| 179 |
+
accent: string | null;
|
| 180 |
+
language: string | null;
|
| 181 |
+
use_case: string | null;
|
| 182 |
+
descriptive: string | null;
|
| 183 |
+
category: string | null;
|
| 184 |
+
cloned_by_count: number | null;
|
| 185 |
+
free_users_allowed: boolean;
|
| 186 |
+
notice_period: number | null;
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
export type Quota = {
|
| 190 |
+
tier?: string;
|
| 191 |
+
character_count?: number;
|
| 192 |
+
character_limit?: number;
|
| 193 |
+
can_use_instant_voice_cloning?: boolean;
|
| 194 |
+
voice_slots_used?: number;
|
| 195 |
+
voice_limit?: number;
|
| 196 |
+
};
|
| 197 |
|
| 198 |
export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
|
| 199 |
|
frontend/src/components/ArtifactCard.tsx
CHANGED
|
@@ -3,28 +3,9 @@ import { Link } from "react-router-dom";
|
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact, Citation, Segment } from "../api/types";
|
| 5 |
import { useJob } from "../hooks/useJob";
|
|
|
|
| 6 |
import { ConfirmButton, errorMessage, formatDate, formatDuration, JobProgress, StatusPill } from "./ui";
|
| 7 |
|
| 8 |
-
/** Answer text with [n] markers turned into buttons that open the cited passage. */
|
| 9 |
-
export function CitedText({ text, citations, onCite }: { text: string; citations: Citation[]; onCite?: (c: Citation) => void }) {
|
| 10 |
-
const byMarker = new Map(citations.map((c) => [c.marker, c]));
|
| 11 |
-
const parts = text.split(/(\[\d+\])/g);
|
| 12 |
-
return (
|
| 13 |
-
<>
|
| 14 |
-
{parts.map((part, i) => {
|
| 15 |
-
const m = part.match(/^\[(\d+)\]$/);
|
| 16 |
-
const cite = m ? byMarker.get(Number(m[1])) : undefined;
|
| 17 |
-
if (!cite) return <span key={i}>{part}</span>;
|
| 18 |
-
return (
|
| 19 |
-
<button key={i} type="button" className="cite-marker mono" title={cite.title} onClick={() => onCite?.(cite)}>
|
| 20 |
-
{cite.marker}
|
| 21 |
-
</button>
|
| 22 |
-
);
|
| 23 |
-
})}
|
| 24 |
-
</>
|
| 25 |
-
);
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
export function CitationList({ citations, onCite }: { citations: Citation[]; onCite?: (c: Citation) => void }) {
|
| 29 |
if (!citations.length) return null;
|
| 30 |
return (
|
|
@@ -80,13 +61,7 @@ function Body({ artifact, onCite }: { artifact: Artifact; onCite?: (c: Citation)
|
|
| 80 |
))}
|
| 81 |
</div>
|
| 82 |
) : null}
|
| 83 |
-
{String(c.summary ?? "")
|
| 84 |
-
.split(/\n\n+/)
|
| 85 |
-
.map((p, i) => (
|
| 86 |
-
<p key={i}>
|
| 87 |
-
<CitedText text={p} citations={c.citations ?? []} onCite={onCite} />
|
| 88 |
-
</p>
|
| 89 |
-
))}
|
| 90 |
<CitationList citations={c.citations ?? []} onCite={onCite} />
|
| 91 |
</div>
|
| 92 |
);
|
|
|
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact, Citation, Segment } from "../api/types";
|
| 5 |
import { useJob } from "../hooks/useJob";
|
| 6 |
+
import { Markdown } from "./Markdown";
|
| 7 |
import { ConfirmButton, errorMessage, formatDate, formatDuration, JobProgress, StatusPill } from "./ui";
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
export function CitationList({ citations, onCite }: { citations: Citation[]; onCite?: (c: Citation) => void }) {
|
| 10 |
if (!citations.length) return null;
|
| 11 |
return (
|
|
|
|
| 61 |
))}
|
| 62 |
</div>
|
| 63 |
) : null}
|
| 64 |
+
<Markdown text={String(c.summary ?? "")} citations={c.citations ?? []} onCite={onCite} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
<CitationList citations={c.citations ?? []} onCite={onCite} />
|
| 66 |
</div>
|
| 67 |
);
|
frontend/src/components/FeatureShowcase.tsx
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } 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
|
| 5 |
+
* product visual that animates when the section arrives (the parent adds .arrived via Landing's observer). */
|
| 6 |
+
|
| 7 |
+
type Feature = {
|
| 8 |
+
id: string;
|
| 9 |
+
eyebrow: string;
|
| 10 |
+
title: string;
|
| 11 |
+
body: string;
|
| 12 |
+
points: string[];
|
| 13 |
+
visual: ReactNode;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function ChatVisual() {
|
| 17 |
+
return (
|
| 18 |
+
<div className="fx fx-chat">
|
| 19 |
+
<div className="fx-bubble fx-q">What have I written about pricing?</div>
|
| 20 |
+
<div className="fx-steps mono">
|
| 21 |
+
<span>Searching for pric(e|ing)|paid tier</span>
|
| 22 |
+
<span>Reading On Pricing, lines 12 to 20</span>
|
| 23 |
+
</div>
|
| 24 |
+
<p className="fx-answer">
|
| 25 |
+
You raised prices twice and the <b>paid tier doubled</b>
|
| 26 |
+
<span className="fx-cite">1</span>, because readers took the work more seriously<span className="fx-cite">2</span>.
|
| 27 |
+
</p>
|
| 28 |
+
<div className="fx-source">
|
| 29 |
+
<span className="mono">[1] On Pricing · lines 12 to 14</span>
|
| 30 |
+
<span className="fx-line">The paid tier doubled after I raised the price to ten dollars.</span>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function MapVisual() {
|
| 37 |
+
const nodes = [
|
| 38 |
+
{ x: 50, y: 48, r: 14, label: "Pricing" },
|
| 39 |
+
{ x: 22, y: 30, r: 9, label: "Habits" },
|
| 40 |
+
{ x: 78, y: 26, r: 10, label: "Audience" },
|
| 41 |
+
{ x: 30, y: 76, r: 8, label: "Craft" },
|
| 42 |
+
{ x: 74, y: 74, r: 11, label: "Paid tier" },
|
| 43 |
+
];
|
| 44 |
+
const edges = [
|
| 45 |
+
[0, 1],
|
| 46 |
+
[0, 2],
|
| 47 |
+
[0, 4],
|
| 48 |
+
[1, 3],
|
| 49 |
+
[2, 4],
|
| 50 |
+
];
|
| 51 |
+
return (
|
| 52 |
+
<div className="fx fx-map">
|
| 53 |
+
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
| 54 |
+
{edges.map(([a, b], i) => (
|
| 55 |
+
<line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={nodes[a].x} y1={nodes[a].y} x2={nodes[b].x} y2={nodes[b].y} />
|
| 56 |
+
))}
|
| 57 |
+
{nodes.map((n, i) => (
|
| 58 |
+
<g key={n.label} className="fx-node" style={{ ["--d" as string]: `${0.3 + i * 0.12}s` }}>
|
| 59 |
+
<circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" />
|
| 60 |
+
<circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
|
| 61 |
+
<text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
|
| 62 |
+
{n.label}
|
| 63 |
+
</text>
|
| 64 |
+
</g>
|
| 65 |
+
))}
|
| 66 |
+
</svg>
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function AudioVisual() {
|
| 72 |
+
return (
|
| 73 |
+
<div className="fx fx-audio">
|
| 74 |
+
<div className="fx-wave" aria-hidden="true">
|
| 75 |
+
{Array.from({ length: 36 }).map((_, i) => (
|
| 76 |
+
<span key={i} style={{ ["--d" as string]: `${(i % 9) * 0.09}s`, ["--h" as string]: `${30 + ((i * 37) % 60)}%` }} />
|
| 77 |
+
))}
|
| 78 |
+
</div>
|
| 79 |
+
<div className="fx-lines">
|
| 80 |
+
<p>
|
| 81 |
+
<span className="fx-host a">A</span>So the big idea is that price is a signal.
|
| 82 |
+
</p>
|
| 83 |
+
<p>
|
| 84 |
+
<span className="fx-host b">B</span>And she has the numbers: the paid tier doubled.
|
| 85 |
+
</p>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function VideoVisual() {
|
| 92 |
+
return (
|
| 93 |
+
<div className="fx fx-video">
|
| 94 |
+
<div className="fx-frame v916">
|
| 95 |
+
<span className="mono">9:16</span>
|
| 96 |
+
<b>I doubled my price.</b>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="fx-frame v169">
|
| 99 |
+
<span className="mono">16:9</span>
|
| 100 |
+
<b>Why price is a signal</b>
|
| 101 |
+
</div>
|
| 102 |
+
<div className="fx-frame v11">
|
| 103 |
+
<span className="mono">1:1</span>
|
| 104 |
+
<i className="fx-mini-wave" />
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function KitVisual() {
|
| 111 |
+
const cards = [
|
| 112 |
+
["X thread", "I doubled my price. My paid tier doubled too."],
|
| 113 |
+
["LinkedIn", "Most writers underprice. I did for two years."],
|
| 114 |
+
["Notes", "Price is a signal. Readers told me so."],
|
| 115 |
+
["Bluesky", "Raised my price to $10. Paid went up."],
|
| 116 |
+
];
|
| 117 |
+
return (
|
| 118 |
+
<div className="fx fx-kit">
|
| 119 |
+
{cards.map(([label, body], i) => (
|
| 120 |
+
<div key={label} className="fx-card" style={{ ["--d" as string]: `${i * 0.12}s`, ["--r" as string]: `${(i - 1.5) * 4}deg` }}>
|
| 121 |
+
<span className="mono">{label}</span>
|
| 122 |
+
<p>{body}</p>
|
| 123 |
+
</div>
|
| 124 |
+
))}
|
| 125 |
+
</div>
|
| 126 |
+
);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function VoiceVisual() {
|
| 130 |
+
const sliders = [
|
| 131 |
+
["Stability", 62],
|
| 132 |
+
["Similarity", 80],
|
| 133 |
+
["Style", 28],
|
| 134 |
+
] as const;
|
| 135 |
+
return (
|
| 136 |
+
<div className="fx fx-voice">
|
| 137 |
+
<div className="fx-profile">
|
| 138 |
+
<span className="mono">Your voice</span>
|
| 139 |
+
<p>Warm, direct, short paragraphs. Opens with a number. Never uses jargon.</p>
|
| 140 |
+
</div>
|
| 141 |
+
{sliders.map(([label, v], i) => (
|
| 142 |
+
<div key={label} className="fx-slider" style={{ ["--v" as string]: `${v}%`, ["--d" as string]: `${0.2 + i * 0.15}s` }}>
|
| 143 |
+
<span>{label}</span>
|
| 144 |
+
<i />
|
| 145 |
+
</div>
|
| 146 |
+
))}
|
| 147 |
+
<div className="fx-clone mono">
|
| 148 |
+
<span className="fx-rec" /> Recording take 2 · 1:24
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function PadVisual() {
|
| 155 |
+
const lit = [2, 5, 9, 12, 16, 19];
|
| 156 |
+
return (
|
| 157 |
+
<div className="fx fx-pad">
|
| 158 |
+
<div className="fx-cal">
|
| 159 |
+
{Array.from({ length: 21 }).map((_, i) => (
|
| 160 |
+
<span key={i} className={lit.includes(i) ? "on" : ""} style={{ ["--d" as string]: `${lit.indexOf(i) * 0.12}s` }}>
|
| 161 |
+
{i + 1}
|
| 162 |
+
</span>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
<div className="fx-launch mono">
|
| 166 |
+
<span>X</span>
|
| 167 |
+
<span>LinkedIn</span>
|
| 168 |
+
<span>Bluesky</span>
|
| 169 |
+
<span>Notes reminder</span>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const FEATURES: Feature[] = [
|
| 176 |
+
{
|
| 177 |
+
id: "research",
|
| 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", "Summaries of whole notebooks, cited"],
|
| 182 |
+
visual: <ChatVisual />,
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
id: "map",
|
| 186 |
+
eyebrow: "Topic map",
|
| 187 |
+
title: "See the constellations in your writing.",
|
| 188 |
+
body: "Every post is tagged with what it is really about, then linked to the topics it travels with. Zoom through years of work and spin any topic into a notebook.",
|
| 189 |
+
points: ["Topics merge automatically as you publish", "Spot the themes you keep coming back to", "One click from a topic to a notebook"],
|
| 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 commercial voices, or clone your own with consent.",
|
| 197 |
+
points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Your cloned voice as host, if you want"],
|
| 198 |
+
visual: <AudioVisual />,
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
id: "video",
|
| 202 |
+
eyebrow: "Video studio",
|
| 203 |
+
title: "Shorts, explainers and audiograms, from one post.",
|
| 204 |
+
body: "Notestack storyboards the post, narrates it and renders it with word by word captions and your brand colors. Vertical for Reels and TikTok, wide for YouTube, square for feeds.",
|
| 205 |
+
points: ["9:16 shorts with a hook in two seconds", "16:9 explainers and 1:1 audiograms", "Your logo and accent color on every frame"],
|
| 206 |
+
visual: <VideoVisual />,
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
id: "kit",
|
| 210 |
+
eyebrow: "Launch Kit",
|
| 211 |
+
title: "One post becomes a launch on every platform.",
|
| 212 |
+
body: "Hooks, an X thread, a LinkedIn post, Substack Notes, a Bluesky thread, an SEO pack, a carousel and quote cards, all written in your voice from claims the post actually makes.",
|
| 213 |
+
points: ["Edit anything, copy in one click", "Character counts per platform", "Quote cards rendered and ready"],
|
| 214 |
+
visual: <KitVisual />,
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
id: "voice",
|
| 218 |
+
eyebrow: "Voice profile",
|
| 219 |
+
title: "Sounds like you, on the page and out loud.",
|
| 220 |
+
body: "Notestack learns your tone from your own posts, so drafts read like you wrote them. For audio, tune two hosts or record a couple of minutes to clone your voice.",
|
| 221 |
+
points: ["Editable writing voice", "Commercially licensed voice library", "Consented cloning you can revoke anytime"],
|
| 222 |
+
visual: <VoiceVisual />,
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
id: "pad",
|
| 226 |
+
eyebrow: "Launchpad",
|
| 227 |
+
title: "Schedule the countdown. We handle liftoff.",
|
| 228 |
+
body: "Plan every post on one calendar. Notestack publishes to X, LinkedIn and Bluesky on time, emails you Substack Notes to paste, and tracks the clicks that follow.",
|
| 229 |
+
points: ["Auto posting with connected accounts", "Tracked links and engagement", "Resurface evergreen posts when they matter"],
|
| 230 |
+
visual: <PadVisual />,
|
| 231 |
+
},
|
| 232 |
+
];
|
| 233 |
+
|
| 234 |
+
export default function FeatureShowcase() {
|
| 235 |
+
return (
|
| 236 |
+
<div className="showcase">
|
| 237 |
+
{FEATURES.map((f, i) => (
|
| 238 |
+
<section key={f.id} id={`feature-${f.id}`} className={`showcase-row arrive${i % 2 ? " flip" : ""}`}>
|
| 239 |
+
<div className="showcase-copy">
|
| 240 |
+
<p className="eyebrow">
|
| 241 |
+
<span className="mono showcase-n">{String(i + 1).padStart(2, "0")}</span> {f.eyebrow}
|
| 242 |
+
</p>
|
| 243 |
+
<h3>{f.title}</h3>
|
| 244 |
+
<p className="muted">{f.body}</p>
|
| 245 |
+
<ul>
|
| 246 |
+
{f.points.map((p) => (
|
| 247 |
+
<li key={p}>{p}</li>
|
| 248 |
+
))}
|
| 249 |
+
</ul>
|
| 250 |
+
{i === FEATURES.length - 1 && (
|
| 251 |
+
<Link to="/auth?mode=signup" className="btn btn-primary">
|
| 252 |
+
Launch my archive
|
| 253 |
+
</Link>
|
| 254 |
+
)}
|
| 255 |
+
</div>
|
| 256 |
+
<div className="showcase-visual card">{f.visual}</div>
|
| 257 |
+
</section>
|
| 258 |
+
))}
|
| 259 |
+
</div>
|
| 260 |
+
);
|
| 261 |
+
}
|
frontend/src/components/Markdown.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from "react";
|
| 2 |
+
import type { Citation } from "../api/types";
|
| 3 |
+
|
| 4 |
+
/** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns
|
| 5 |
+
* [n] citation markers into buttons that open the cited lines.
|
| 6 |
+
* Supports: headings, paragraphs, bullet and numbered lists (one nested level), blockquotes, fenced
|
| 7 |
+
* code, tables, horizontal rules, bold, italic, strikethrough, inline code and links. */
|
| 8 |
+
|
| 9 |
+
type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void };
|
| 10 |
+
|
| 11 |
+
const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(~~[^~]+~~)|(\[[^\]]+\]\((https?:\/\/[^)\s]+)\))|(\[\d+\](?:\[\d+\])*)|(https?:\/\/[^\s)]+)/g;
|
| 12 |
+
|
| 13 |
+
function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
|
| 14 |
+
const out: ReactNode[] = [];
|
| 15 |
+
const byMarker = new Map((cite.citations ?? []).map((c) => [c.marker, c]));
|
| 16 |
+
let last = 0;
|
| 17 |
+
let i = 0;
|
| 18 |
+
for (const m of text.matchAll(INLINE)) {
|
| 19 |
+
const idx = m.index ?? 0;
|
| 20 |
+
if (idx > last) out.push(text.slice(last, idx));
|
| 21 |
+
const key = `${keyBase}-${i++}`;
|
| 22 |
+
const [whole, code, bold, italic, strike, link, href, markers, bare] = m;
|
| 23 |
+
if (code) out.push(<code key={key}>{code.slice(1, -1)}</code>);
|
| 24 |
+
else if (bold) out.push(<strong key={key}>{inline(bold.slice(2, -2), cite, key)}</strong>);
|
| 25 |
+
else if (italic) out.push(<em key={key}>{inline(italic.slice(1, -1), cite, key)}</em>);
|
| 26 |
+
else if (strike) out.push(<del key={key}>{inline(strike.slice(2, -2), cite, key)}</del>);
|
| 27 |
+
else if (link && href) {
|
| 28 |
+
const label = link.slice(1, link.indexOf("]("));
|
| 29 |
+
out.push(
|
| 30 |
+
<a key={key} href={href} target="_blank" rel="noreferrer">
|
| 31 |
+
{inline(label, cite, key)}
|
| 32 |
+
</a>,
|
| 33 |
+
);
|
| 34 |
+
} else if (markers) {
|
| 35 |
+
for (const n of markers.matchAll(/\[(\d+)\]/g)) {
|
| 36 |
+
const c = byMarker.get(Number(n[1]));
|
| 37 |
+
out.push(
|
| 38 |
+
c ? (
|
| 39 |
+
<button key={`${key}-${n[1]}`} type="button" className="cite-marker mono" title={c.title} onClick={() => cite.onCite?.(c)}>
|
| 40 |
+
{c.marker}
|
| 41 |
+
</button>
|
| 42 |
+
) : (
|
| 43 |
+
<sup key={`${key}-${n[1]}`} className="mono muted">
|
| 44 |
+
{n[1]}
|
| 45 |
+
</sup>
|
| 46 |
+
),
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
} else if (bare) {
|
| 50 |
+
out.push(
|
| 51 |
+
<a key={key} href={bare} target="_blank" rel="noreferrer">
|
| 52 |
+
{bare}
|
| 53 |
+
</a>,
|
| 54 |
+
);
|
| 55 |
+
} else out.push(whole);
|
| 56 |
+
last = idx + whole.length;
|
| 57 |
+
}
|
| 58 |
+
if (last < text.length) out.push(text.slice(last));
|
| 59 |
+
return out;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
type ListItem = { text: string; children: ListItem[] };
|
| 63 |
+
type Block =
|
| 64 |
+
| { kind: "h"; level: number; text: string }
|
| 65 |
+
| { kind: "p"; text: string }
|
| 66 |
+
| { kind: "ul" | "ol"; items: ListItem[]; start: number }
|
| 67 |
+
| { kind: "quote"; text: string }
|
| 68 |
+
| { kind: "code"; text: string; lang: string }
|
| 69 |
+
| { kind: "table"; head: string[]; rows: string[][] }
|
| 70 |
+
| { kind: "hr" };
|
| 71 |
+
|
| 72 |
+
const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
| 73 |
+
const splitRow = (line: string) =>
|
| 74 |
+
line
|
| 75 |
+
.trim()
|
| 76 |
+
.replace(/^\|/, "")
|
| 77 |
+
.replace(/\|$/, "")
|
| 78 |
+
.split("|")
|
| 79 |
+
.map((c) => c.trim());
|
| 80 |
+
|
| 81 |
+
function parse(src: string): Block[] {
|
| 82 |
+
const lines = src.replace(/\r\n/g, "\n").split("\n");
|
| 83 |
+
const blocks: Block[] = [];
|
| 84 |
+
let i = 0;
|
| 85 |
+
while (i < lines.length) {
|
| 86 |
+
const line = lines[i];
|
| 87 |
+
const trimmed = line.trim();
|
| 88 |
+
if (!trimmed) {
|
| 89 |
+
i++;
|
| 90 |
+
continue;
|
| 91 |
+
}
|
| 92 |
+
const fence = trimmed.match(/^```(\w*)/);
|
| 93 |
+
if (fence) {
|
| 94 |
+
const body: string[] = [];
|
| 95 |
+
i++;
|
| 96 |
+
while (i < lines.length && !lines[i].trim().startsWith("```")) body.push(lines[i++]);
|
| 97 |
+
i++;
|
| 98 |
+
blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] });
|
| 99 |
+
continue;
|
| 100 |
+
}
|
| 101 |
+
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
| 102 |
+
if (heading) {
|
| 103 |
+
blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
|
| 104 |
+
i++;
|
| 105 |
+
continue;
|
| 106 |
+
}
|
| 107 |
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
| 108 |
+
blocks.push({ kind: "hr" });
|
| 109 |
+
i++;
|
| 110 |
+
continue;
|
| 111 |
+
}
|
| 112 |
+
if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
| 113 |
+
const head = splitRow(trimmed);
|
| 114 |
+
i += 2;
|
| 115 |
+
const rows: string[][] = [];
|
| 116 |
+
while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++]));
|
| 117 |
+
blocks.push({ kind: "table", head, rows });
|
| 118 |
+
continue;
|
| 119 |
+
}
|
| 120 |
+
if (trimmed.startsWith(">")) {
|
| 121 |
+
const body: string[] = [];
|
| 122 |
+
while (i < lines.length && lines[i].trim().startsWith(">")) body.push(lines[i++].trim().replace(/^>\s?/, ""));
|
| 123 |
+
blocks.push({ kind: "quote", text: body.join("\n") });
|
| 124 |
+
continue;
|
| 125 |
+
}
|
| 126 |
+
const first = line.match(LIST);
|
| 127 |
+
if (first) {
|
| 128 |
+
const ordered = /\d/.test(first[2]);
|
| 129 |
+
const baseIndent = first[1].length;
|
| 130 |
+
const items: ListItem[] = [];
|
| 131 |
+
while (i < lines.length) {
|
| 132 |
+
const m = lines[i].match(LIST);
|
| 133 |
+
if (m) {
|
| 134 |
+
const item = { text: m[3], children: [] as ListItem[] };
|
| 135 |
+
if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item);
|
| 136 |
+
else items.push(item);
|
| 137 |
+
i++;
|
| 138 |
+
} else if (lines[i].trim() && /^\s{2,}/.test(lines[i]) && items.length) {
|
| 139 |
+
// Continuation line of the previous item.
|
| 140 |
+
const target = items[items.length - 1];
|
| 141 |
+
const deepest = target.children.length ? target.children[target.children.length - 1] : target;
|
| 142 |
+
deepest.text += ` ${lines[i].trim()}`;
|
| 143 |
+
i++;
|
| 144 |
+
} else if (!lines[i].trim() && i + 1 < lines.length && LIST.test(lines[i + 1])) {
|
| 145 |
+
i++; // loose list: blank line between items
|
| 146 |
+
} else break;
|
| 147 |
+
}
|
| 148 |
+
blocks.push({ kind: ordered ? "ol" : "ul", items, start: ordered ? parseInt(first[2], 10) || 1 : 1 });
|
| 149 |
+
continue;
|
| 150 |
+
}
|
| 151 |
+
const para: string[] = [];
|
| 152 |
+
while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,4}\s|>|```|\|)/.test(lines[i].trim())) {
|
| 153 |
+
para.push(lines[i++].trim());
|
| 154 |
+
}
|
| 155 |
+
if (para.length === 0) {
|
| 156 |
+
para.push(trimmed);
|
| 157 |
+
i++;
|
| 158 |
+
}
|
| 159 |
+
blocks.push({ kind: "p", text: para.join("\n") });
|
| 160 |
+
}
|
| 161 |
+
return blocks;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function withBreaks(text: string, cite: Cite, key: string): ReactNode[] {
|
| 165 |
+
return text.split("\n").flatMap((part, j, all) => [
|
| 166 |
+
...inline(part, cite, `${key}-${j}`),
|
| 167 |
+
...(j < all.length - 1 ? [<br key={`${key}-br-${j}`} />] : []),
|
| 168 |
+
]);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode {
|
| 172 |
+
const children = items.map((it, j) => (
|
| 173 |
+
<li key={`${key}-${j}`}>
|
| 174 |
+
{inline(it.text, cite, `${key}-${j}`)}
|
| 175 |
+
{it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
|
| 176 |
+
</li>
|
| 177 |
+
));
|
| 178 |
+
return ordered ? (
|
| 179 |
+
<ol key={key} start={start}>
|
| 180 |
+
{children}
|
| 181 |
+
</ol>
|
| 182 |
+
) : (
|
| 183 |
+
<ul key={key}>{children}</ul>
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
export function Markdown({ text, citations, onCite, className = "" }: { text: string; className?: string } & Cite) {
|
| 188 |
+
const cite = { citations, onCite };
|
| 189 |
+
return (
|
| 190 |
+
<div className={`md ${className}`}>
|
| 191 |
+
{parse(text).map((b, i) => {
|
| 192 |
+
const key = `b${i}`;
|
| 193 |
+
switch (b.kind) {
|
| 194 |
+
case "h": {
|
| 195 |
+
const Tag = (["h3", "h3", "h4", "h5"] as const)[b.level - 1];
|
| 196 |
+
return <Tag key={key}>{inline(b.text, cite, key)}</Tag>;
|
| 197 |
+
}
|
| 198 |
+
case "p":
|
| 199 |
+
return <p key={key}>{withBreaks(b.text, cite, key)}</p>;
|
| 200 |
+
case "ul":
|
| 201 |
+
case "ol":
|
| 202 |
+
return renderItems(b.items, b.kind === "ol", cite, key, b.start);
|
| 203 |
+
case "quote":
|
| 204 |
+
return <blockquote key={key}>{withBreaks(b.text, cite, key)}</blockquote>;
|
| 205 |
+
case "code":
|
| 206 |
+
return (
|
| 207 |
+
<pre key={key} data-lang={b.lang || undefined}>
|
| 208 |
+
<code>{b.text}</code>
|
| 209 |
+
</pre>
|
| 210 |
+
);
|
| 211 |
+
case "table":
|
| 212 |
+
return (
|
| 213 |
+
<div key={key} className="md-table">
|
| 214 |
+
<table>
|
| 215 |
+
<thead>
|
| 216 |
+
<tr>
|
| 217 |
+
{b.head.map((h, j) => (
|
| 218 |
+
<th key={j}>{inline(h, cite, `${key}-h${j}`)}</th>
|
| 219 |
+
))}
|
| 220 |
+
</tr>
|
| 221 |
+
</thead>
|
| 222 |
+
<tbody>
|
| 223 |
+
{b.rows.map((r, j) => (
|
| 224 |
+
<tr key={j}>
|
| 225 |
+
{r.map((c, k) => (
|
| 226 |
+
<td key={k}>{inline(c, cite, `${key}-${j}-${k}`)}</td>
|
| 227 |
+
))}
|
| 228 |
+
</tr>
|
| 229 |
+
))}
|
| 230 |
+
</tbody>
|
| 231 |
+
</table>
|
| 232 |
+
</div>
|
| 233 |
+
);
|
| 234 |
+
case "hr":
|
| 235 |
+
return <hr key={key} />;
|
| 236 |
+
default:
|
| 237 |
+
return null;
|
| 238 |
+
}
|
| 239 |
+
})}
|
| 240 |
+
</div>
|
| 241 |
+
);
|
| 242 |
+
}
|
frontend/src/components/SkyCanvas.tsx
CHANGED
|
@@ -8,6 +8,13 @@ import { useEffect, useRef } from "react";
|
|
| 8 |
type Star = { x: number; y: number; r: number; a: number; layer: number; phase: number; speed: number };
|
| 9 |
type Sparkle = Star & { size: number; blue: boolean };
|
| 10 |
type Meteor = { x: number; y: number; vx: number; vy: number; life: number };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
const LAYERS = [
|
| 13 |
{ density: 0.00018, parallax: 6, drift: 0.004 },
|
|
@@ -21,7 +28,7 @@ function rand(min: number, max: number) {
|
|
| 21 |
return min + Math.random() * (max - min);
|
| 22 |
}
|
| 23 |
|
| 24 |
-
export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
|
| 25 |
const ref = useRef<HTMLCanvasElement>(null);
|
| 26 |
|
| 27 |
useEffect(() => {
|
|
@@ -40,6 +47,22 @@ export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
|
|
| 40 |
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
|
| 41 |
let raf = 0;
|
| 42 |
let scroll = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
const build = () => {
|
| 45 |
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
@@ -116,6 +139,72 @@ export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
|
|
| 116 |
pointer.y += (pointer.ty - pointer.y) * 0.04;
|
| 117 |
ctx.clearRect(0, 0, w, h);
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
for (const s of stars) {
|
| 120 |
const layer = LAYERS[s.layer];
|
| 121 |
const ox = reduced ? 0 : -pointer.x * layer.parallax;
|
|
@@ -198,7 +287,7 @@ export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
|
|
| 198 |
window.removeEventListener("scroll", onScroll);
|
| 199 |
document.removeEventListener("visibilitychange", onVisibility);
|
| 200 |
};
|
| 201 |
-
}, [intensity]);
|
| 202 |
|
| 203 |
return (
|
| 204 |
<div className="sky" aria-hidden="true">
|
|
|
|
| 8 |
type Star = { x: number; y: number; r: number; a: number; layer: number; phase: number; speed: number };
|
| 9 |
type Sparkle = Star & { size: number; blue: boolean };
|
| 10 |
type Meteor = { x: number; y: number; vx: number; vy: number; life: number };
|
| 11 |
+
type DeepStar = { x: number; y: number; z: number; blue: boolean };
|
| 12 |
+
type Nebula = { x: number; y: number; z: number; size: number; hue: number };
|
| 13 |
+
|
| 14 |
+
// Travel mode: scrolling the page flies the camera forward through a 3D field.
|
| 15 |
+
const DEEP_STARS = 300;
|
| 16 |
+
const NEBULAE = 5;
|
| 17 |
+
const TRAVEL_PER_PX = 0.00022; // depth units per scrolled pixel: a whole page is a gentle glide
|
| 18 |
|
| 19 |
const LAYERS = [
|
| 20 |
{ density: 0.00018, parallax: 6, drift: 0.004 },
|
|
|
|
| 28 |
return min + Math.random() * (max - min);
|
| 29 |
}
|
| 30 |
|
| 31 |
+
export default function SkyCanvas({ intensity = 1, travel = false }: { intensity?: number; travel?: boolean }) {
|
| 32 |
const ref = useRef<HTMLCanvasElement>(null);
|
| 33 |
|
| 34 |
useEffect(() => {
|
|
|
|
| 47 |
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
|
| 48 |
let raf = 0;
|
| 49 |
let scroll = 0;
|
| 50 |
+
let camZ = 0;
|
| 51 |
+
let lastScrollY = window.scrollY;
|
| 52 |
+
let velocity = 0;
|
| 53 |
+
const deep: DeepStar[] = Array.from({ length: travel ? DEEP_STARS : 0 }, () => ({
|
| 54 |
+
x: rand(-1, 1),
|
| 55 |
+
y: rand(-1, 1),
|
| 56 |
+
z: Math.random(),
|
| 57 |
+
blue: Math.random() < 0.28,
|
| 58 |
+
}));
|
| 59 |
+
const nebulae: Nebula[] = Array.from({ length: travel ? NEBULAE : 0 }, (_, i) => ({
|
| 60 |
+
x: rand(-0.9, 0.9),
|
| 61 |
+
y: rand(-0.7, 0.7),
|
| 62 |
+
z: i / NEBULAE,
|
| 63 |
+
size: rand(0.5, 1.1),
|
| 64 |
+
hue: Math.random(),
|
| 65 |
+
}));
|
| 66 |
|
| 67 |
const build = () => {
|
| 68 |
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
|
|
| 139 |
pointer.y += (pointer.ty - pointer.y) * 0.04;
|
| 140 |
ctx.clearRect(0, 0, w, h);
|
| 141 |
|
| 142 |
+
if (travel && !reduced) {
|
| 143 |
+
// Camera depth follows scroll position; velocity (smoothed) stretches stars into streaks.
|
| 144 |
+
const y = window.scrollY;
|
| 145 |
+
velocity += (y - lastScrollY - velocity) * 0.06;
|
| 146 |
+
lastScrollY = y;
|
| 147 |
+
const idle = t * 0.0035; // a slow cruise even when not scrolling
|
| 148 |
+
camZ = y * TRAVEL_PER_PX + idle;
|
| 149 |
+
const cx = w / 2 + pointer.x * -40;
|
| 150 |
+
const cy = h / 2 + pointer.y * -30;
|
| 151 |
+
const focal = Math.max(w, h) * 0.55;
|
| 152 |
+
const warp = Math.min(1, Math.max(0, Math.abs(velocity) - 6) / 60);
|
| 153 |
+
|
| 154 |
+
// Nebula clouds swell and slide past as you approach them.
|
| 155 |
+
for (const n of nebulae) {
|
| 156 |
+
const z = (((n.z - camZ * 0.25) % 1) + 1) % 1;
|
| 157 |
+
const depth = 0.08 + z * 1.6;
|
| 158 |
+
const px = cx + (n.x * focal) / depth;
|
| 159 |
+
const py = cy + (n.y * focal) / depth;
|
| 160 |
+
const r = (n.size * focal * 0.5) / depth;
|
| 161 |
+
const fade = Math.min(1, (1 - z) * 2) * Math.min(1, z * 6);
|
| 162 |
+
const alpha = 0.1 * fade;
|
| 163 |
+
if (alpha < 0.005 || r < 4) continue;
|
| 164 |
+
const g = ctx.createRadialGradient(px, py, 0, px, py, r);
|
| 165 |
+
const color = n.hue > 0.5 ? BLUE : "120, 170, 255";
|
| 166 |
+
g.addColorStop(0, `rgba(${color}, ${alpha})`);
|
| 167 |
+
g.addColorStop(0.5, `rgba(${BLUE}, ${alpha * 0.35})`);
|
| 168 |
+
g.addColorStop(1, `rgba(${BLUE}, 0)`);
|
| 169 |
+
ctx.fillStyle = g;
|
| 170 |
+
ctx.beginPath();
|
| 171 |
+
ctx.arc(px, py, r, 0, Math.PI * 2);
|
| 172 |
+
ctx.fill();
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Stars stream toward the camera.
|
| 176 |
+
for (const s of deep) {
|
| 177 |
+
const z = (((s.z - camZ) % 1) + 1) % 1; // 1 far, 0 at the lens
|
| 178 |
+
const depth = 0.03 + z;
|
| 179 |
+
const px = cx + (s.x * focal) / depth;
|
| 180 |
+
const py = cy + (s.y * focal) / depth;
|
| 181 |
+
if (px < -50 || px > w + 50 || py < -50 || py > h + 50) continue;
|
| 182 |
+
const near = 1 - z;
|
| 183 |
+
const alpha = Math.min(0.85, near * near * 1.3) * intensity;
|
| 184 |
+
const size = 0.3 + near * near * 1.6;
|
| 185 |
+
const color = s.blue ? BLUE : "255, 255, 255";
|
| 186 |
+
if (warp > 0.08) {
|
| 187 |
+
// Where the star was a moment ago, further away: the streak points back to the vanishing point.
|
| 188 |
+
const dz = Math.min(0.06, Math.abs(velocity) * TRAVEL_PER_PX * 3) * warp;
|
| 189 |
+
const back = 0.03 + Math.min(1, z + (velocity > 0 ? dz : -dz));
|
| 190 |
+
const bx = cx + (s.x * focal) / back;
|
| 191 |
+
const by = cy + (s.y * focal) / back;
|
| 192 |
+
ctx.strokeStyle = `rgba(${color}, ${alpha * 0.8})`;
|
| 193 |
+
ctx.lineWidth = size;
|
| 194 |
+
ctx.lineCap = "round";
|
| 195 |
+
ctx.beginPath();
|
| 196 |
+
ctx.moveTo(bx, by);
|
| 197 |
+
ctx.lineTo(px, py);
|
| 198 |
+
ctx.stroke();
|
| 199 |
+
} else {
|
| 200 |
+
ctx.fillStyle = `rgba(${color}, ${alpha})`;
|
| 201 |
+
ctx.beginPath();
|
| 202 |
+
ctx.arc(px, py, size, 0, Math.PI * 2);
|
| 203 |
+
ctx.fill();
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
for (const s of stars) {
|
| 209 |
const layer = LAYERS[s.layer];
|
| 210 |
const ox = reduced ? 0 : -pointer.x * layer.parallax;
|
|
|
|
| 287 |
window.removeEventListener("scroll", onScroll);
|
| 288 |
document.removeEventListener("visibilitychange", onVisibility);
|
| 289 |
};
|
| 290 |
+
}, [intensity, travel]);
|
| 291 |
|
| 292 |
return (
|
| 293 |
<div className="sky" aria-hidden="true">
|
frontend/src/lib/sound.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
-
/**
|
| 2 |
-
*
|
|
|
|
|
|
|
| 3 |
|
| 4 |
const KEY = "ns_click_sounds";
|
| 5 |
|
| 6 |
let ctx: AudioContext | null = null;
|
|
|
|
| 7 |
|
| 8 |
export function soundsEnabled(): boolean {
|
| 9 |
try {
|
|
@@ -21,6 +24,64 @@ export function setSoundsEnabled(on: boolean): void {
|
|
| 21 |
}
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export function playClick(kind: "tap" | "primary" = "tap"): void {
|
| 25 |
if (!soundsEnabled()) return;
|
| 26 |
try {
|
|
@@ -28,20 +89,19 @@ export function playClick(kind: "tap" | "primary" = "tap"): void {
|
|
| 28 |
if (!AC) return;
|
| 29 |
ctx ??= new AC();
|
| 30 |
if (ctx.state === "suspended") void ctx.resume();
|
| 31 |
-
const now = ctx.currentTime;
|
| 32 |
-
const
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
osc.stop(now + 0.12);
|
| 45 |
} catch {
|
| 46 |
/* audio is decoration only */
|
| 47 |
}
|
|
|
|
| 1 |
+
/** Space-flavored UI sounds, synthesized with Web Audio (no assets to load):
|
| 2 |
+
* - tap: a soft sonar ping (sine with a shimmering detuned partner) that fades into a faint echo
|
| 3 |
+
* - primary: a short rising "launch" chirp with a breath of filtered noise, also echoing
|
| 4 |
+
* Everything is very quiet. Viewers can turn sounds off in Settings (stored per browser). */
|
| 5 |
|
| 6 |
const KEY = "ns_click_sounds";
|
| 7 |
|
| 8 |
let ctx: AudioContext | null = null;
|
| 9 |
+
let bus: GainNode | null = null;
|
| 10 |
|
| 11 |
export function soundsEnabled(): boolean {
|
| 12 |
try {
|
|
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
|
| 27 |
+
/** Master bus with a dark feedback echo, built once. */
|
| 28 |
+
function output(ac: AudioContext): GainNode {
|
| 29 |
+
if (bus) return bus;
|
| 30 |
+
bus = ac.createGain();
|
| 31 |
+
bus.gain.value = 0.9;
|
| 32 |
+
const delay = ac.createDelay(1);
|
| 33 |
+
delay.delayTime.value = 0.19;
|
| 34 |
+
const feedback = ac.createGain();
|
| 35 |
+
feedback.gain.value = 0.32;
|
| 36 |
+
const tone = ac.createBiquadFilter();
|
| 37 |
+
tone.type = "lowpass";
|
| 38 |
+
tone.frequency.value = 1800;
|
| 39 |
+
const wet = ac.createGain();
|
| 40 |
+
wet.gain.value = 0.35;
|
| 41 |
+
bus.connect(ac.destination);
|
| 42 |
+
bus.connect(delay);
|
| 43 |
+
delay.connect(tone).connect(feedback).connect(delay);
|
| 44 |
+
tone.connect(wet).connect(ac.destination);
|
| 45 |
+
return bus;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function voice(ac: AudioContext, out: AudioNode, type: OscillatorType, from: number, to: number, start: number,
|
| 49 |
+
dur: number, peak: number, detune = 0): void {
|
| 50 |
+
const osc = ac.createOscillator();
|
| 51 |
+
const gain = ac.createGain();
|
| 52 |
+
osc.type = type;
|
| 53 |
+
osc.detune.value = detune;
|
| 54 |
+
osc.frequency.setValueAtTime(from, start);
|
| 55 |
+
osc.frequency.exponentialRampToValueAtTime(to, start + dur * 0.8);
|
| 56 |
+
gain.gain.setValueAtTime(0.0001, start);
|
| 57 |
+
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
|
| 58 |
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
| 59 |
+
osc.connect(gain).connect(out);
|
| 60 |
+
osc.start(start);
|
| 61 |
+
osc.stop(start + dur + 0.02);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function whoosh(ac: AudioContext, out: AudioNode, start: number, dur: number, peak: number): void {
|
| 65 |
+
const len = Math.floor(ac.sampleRate * dur);
|
| 66 |
+
const buffer = ac.createBuffer(1, len, ac.sampleRate);
|
| 67 |
+
const data = buffer.getChannelData(0);
|
| 68 |
+
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
| 69 |
+
const src = ac.createBufferSource();
|
| 70 |
+
src.buffer = buffer;
|
| 71 |
+
const band = ac.createBiquadFilter();
|
| 72 |
+
band.type = "bandpass";
|
| 73 |
+
band.Q.value = 6;
|
| 74 |
+
band.frequency.setValueAtTime(600, start);
|
| 75 |
+
band.frequency.exponentialRampToValueAtTime(3200, start + dur);
|
| 76 |
+
const gain = ac.createGain();
|
| 77 |
+
gain.gain.setValueAtTime(0.0001, start);
|
| 78 |
+
gain.gain.exponentialRampToValueAtTime(peak, start + dur * 0.3);
|
| 79 |
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
| 80 |
+
src.connect(band).connect(gain).connect(out);
|
| 81 |
+
src.start(start);
|
| 82 |
+
src.stop(start + dur);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
export function playClick(kind: "tap" | "primary" = "tap"): void {
|
| 86 |
if (!soundsEnabled()) return;
|
| 87 |
try {
|
|
|
|
| 89 |
if (!AC) return;
|
| 90 |
ctx ??= new AC();
|
| 91 |
if (ctx.state === "suspended") void ctx.resume();
|
| 92 |
+
const now = ctx.currentTime + 0.005;
|
| 93 |
+
const out = output(ctx);
|
| 94 |
+
if (kind === "primary") {
|
| 95 |
+
// Launch: two detuned voices sweep up an octave and a half, a thin noise trail rises with them.
|
| 96 |
+
voice(ctx, out, "sine", 330, 880, now, 0.28, 0.035);
|
| 97 |
+
voice(ctx, out, "triangle", 330, 880, now, 0.28, 0.012, 9);
|
| 98 |
+
voice(ctx, out, "sine", 1320, 1760, now + 0.09, 0.2, 0.01);
|
| 99 |
+
whoosh(ctx, out, now, 0.26, 0.012);
|
| 100 |
+
} else {
|
| 101 |
+
// Sonar ping: a clean tone with a slightly detuned shimmer, dropping a touch as it fades.
|
| 102 |
+
voice(ctx, out, "sine", 1046, 988, now, 0.22, 0.025);
|
| 103 |
+
voice(ctx, out, "sine", 1568, 1480, now, 0.16, 0.006, 7);
|
| 104 |
+
}
|
|
|
|
| 105 |
} catch {
|
| 106 |
/* audio is decoration only */
|
| 107 |
}
|
frontend/src/pages/Landing.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
-
import { useState, type FormEvent } from "react";
|
| 2 |
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
import SkyCanvas from "../components/SkyCanvas";
|
|
|
|
| 4 |
import PricingTiers from "../components/PricingTiers";
|
|
|
|
| 5 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 6 |
import { blogPosts } from "../content/blogPosts";
|
| 7 |
import { useAuth } from "../hooks/useAuth";
|
|
@@ -62,26 +64,105 @@ const STEPS = [
|
|
| 62 |
{ n: "03", title: "Launch", body: "Ask, listen, render and publish, all grounded in what you wrote." },
|
| 63 |
];
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
export default function Landing() {
|
| 66 |
const [url, setUrl] = useState("");
|
| 67 |
const navigate = useNavigate();
|
| 68 |
const { user } = useAuth();
|
|
|
|
| 69 |
|
| 70 |
const onSubmit = (e: FormEvent) => {
|
| 71 |
e.preventDefault();
|
|
|
|
|
|
|
| 72 |
const value = url.trim();
|
| 73 |
-
if (
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
| 78 |
}
|
| 79 |
navigate(user ? "/app" : "/auth?mode=signup");
|
| 80 |
};
|
| 81 |
|
| 82 |
return (
|
| 83 |
<div className="page">
|
| 84 |
-
<SkyCanvas />
|
| 85 |
<PublicNav />
|
| 86 |
|
| 87 |
<main>
|
|
@@ -91,7 +172,7 @@ export default function Landing() {
|
|
| 91 |
<h1 className="hero-title">
|
| 92 |
Your archive,
|
| 93 |
<br />
|
| 94 |
-
<span className="glow-text">in orbit.</span>
|
| 95 |
</h1>
|
| 96 |
<p className="hero-sub muted">
|
| 97 |
Notestack turns your posts into a research notebook you can question, podcasts you can publish and
|
|
@@ -117,23 +198,18 @@ export default function Landing() {
|
|
| 117 |
</form>
|
| 118 |
<p className="mono hero-meta">Free during early access. 20+ posts indexed in under 3 minutes.</p>
|
| 119 |
</div>
|
| 120 |
-
<div className="horizon" aria-hidden="true"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</section>
|
| 122 |
|
| 123 |
<section className="section demo">
|
| 124 |
<div className="container">
|
| 125 |
-
<
|
| 126 |
-
<div className="demo-bar mono">
|
| 127 |
-
<span>T-minus 00:42</span>
|
| 128 |
-
<span className="muted">Rendering: vertical short</span>
|
| 129 |
-
</div>
|
| 130 |
-
<div className="demo-body">
|
| 131 |
-
<div className="orbit-loader" aria-hidden="true">
|
| 132 |
-
<span />
|
| 133 |
-
</div>
|
| 134 |
-
<p className="muted">Product demo video lands here.</p>
|
| 135 |
-
</div>
|
| 136 |
-
</div>
|
| 137 |
</div>
|
| 138 |
</section>
|
| 139 |
|
|
@@ -142,8 +218,8 @@ export default function Landing() {
|
|
| 142 |
<p className="eyebrow">What you get</p>
|
| 143 |
<h2 className="section-title">Everything your writing can become</h2>
|
| 144 |
<div className="feature-grid">
|
| 145 |
-
{FEATURES.map(({ icon: Icon, label, title, body }) => (
|
| 146 |
-
<article key={label} className="card feature">
|
| 147 |
<div className="feature-label">
|
| 148 |
<Icon />
|
| 149 |
<span className="mono">{label}</span>
|
|
@@ -156,13 +232,21 @@ export default function Landing() {
|
|
| 156 |
</div>
|
| 157 |
</section>
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
<section className="section">
|
| 160 |
<div className="container">
|
| 161 |
<p className="eyebrow">How it works</p>
|
| 162 |
<h2 className="section-title">Three steps to liftoff</h2>
|
| 163 |
<ol className="steps">
|
| 164 |
-
{STEPS.map((s) => (
|
| 165 |
-
<li key={s.n} className="step">
|
| 166 |
<span className="step-n mono">{s.n}</span>
|
| 167 |
<h3>{s.title}</h3>
|
| 168 |
<p className="muted">{s.body}</p>
|
|
@@ -185,8 +269,8 @@ export default function Landing() {
|
|
| 185 |
<p className="eyebrow">From the blog</p>
|
| 186 |
<h2 className="section-title">Field notes</h2>
|
| 187 |
<div className="blog-grid">
|
| 188 |
-
{blogPosts.slice(0, 3).map((p) => (
|
| 189 |
-
<Link key={p.slug} to={`/blogs/${p.slug}`} className="card blog-card">
|
| 190 |
<span className="mono muted">
|
| 191 |
{p.category} · {p.readTime}
|
| 192 |
</span>
|
|
@@ -198,7 +282,7 @@ export default function Landing() {
|
|
| 198 |
</div>
|
| 199 |
</section>
|
| 200 |
|
| 201 |
-
<section className="section final-cta">
|
| 202 |
<div className="container">
|
| 203 |
<h2 className="section-title">Clear skies ahead.</h2>
|
| 204 |
<p className="muted">Paste your URL above or start with an empty notebook.</p>
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
import SkyCanvas from "../components/SkyCanvas";
|
| 4 |
+
import FeatureShowcase from "../components/FeatureShowcase";
|
| 5 |
import PricingTiers from "../components/PricingTiers";
|
| 6 |
+
import "../styles/showcase.css";
|
| 7 |
import { PublicFooter, PublicNav } from "../components/PublicChrome";
|
| 8 |
import { blogPosts } from "../content/blogPosts";
|
| 9 |
import { useAuth } from "../hooks/useAuth";
|
|
|
|
| 64 |
{ n: "03", title: "Launch", body: "Ask, listen, render and publish, all grounded in what you wrote." },
|
| 65 |
];
|
| 66 |
|
| 67 |
+
/** The narrated demo. Browsers only autoplay muted, so a button turns the captain's log on. */
|
| 68 |
+
function DemoVideo() {
|
| 69 |
+
const ref = useRef<HTMLVideoElement>(null);
|
| 70 |
+
const [muted, setMuted] = useState(true);
|
| 71 |
+
const toggle = () => {
|
| 72 |
+
const v = ref.current;
|
| 73 |
+
if (!v) return;
|
| 74 |
+
if (muted) {
|
| 75 |
+
v.currentTime = 0; // start the narration from the top
|
| 76 |
+
v.muted = false;
|
| 77 |
+
void v.play();
|
| 78 |
+
} else {
|
| 79 |
+
v.muted = true;
|
| 80 |
+
}
|
| 81 |
+
setMuted(!muted);
|
| 82 |
+
};
|
| 83 |
+
return (
|
| 84 |
+
<div className="demo-frame card arrive">
|
| 85 |
+
<div className="demo-bar mono">
|
| 86 |
+
<span>Mission log · 00:41</span>
|
| 87 |
+
<button type="button" className="demo-sound" onClick={toggle} aria-pressed={!muted}>
|
| 88 |
+
{muted ? "Play with sound" : "Mute"}
|
| 89 |
+
</button>
|
| 90 |
+
</div>
|
| 91 |
+
<div className="demo-stage">
|
| 92 |
+
<video
|
| 93 |
+
ref={ref}
|
| 94 |
+
className="demo-video"
|
| 95 |
+
src="/demo.mp4"
|
| 96 |
+
poster="/demo-poster.jpg"
|
| 97 |
+
autoPlay
|
| 98 |
+
muted
|
| 99 |
+
loop
|
| 100 |
+
playsInline
|
| 101 |
+
preload="metadata"
|
| 102 |
+
aria-label="Notestack product demo, narrated: paste your Substack, ask your archive, hear an audio overview and launch posts"
|
| 103 |
+
/>
|
| 104 |
+
{muted && (
|
| 105 |
+
<button type="button" className="demo-unmute" onClick={toggle} aria-label="Play the demo with sound">
|
| 106 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 107 |
+
<path d="M4 9v6h4l5 4V5L8 9H4z" />
|
| 108 |
+
<path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12" />
|
| 109 |
+
</svg>
|
| 110 |
+
Hear the captain's log
|
| 111 |
+
</button>
|
| 112 |
+
)}
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/** Sections drift in out of the depth as they reach the viewport, like planets on approach. */
|
| 119 |
+
function useArrivals() {
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
const els = Array.from(document.querySelectorAll<HTMLElement>(".arrive"));
|
| 122 |
+
if (!("IntersectionObserver" in window) || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
| 123 |
+
els.forEach((el) => el.classList.add("arrived"));
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
const io = new IntersectionObserver(
|
| 127 |
+
(entries) => {
|
| 128 |
+
for (const e of entries) {
|
| 129 |
+
if (e.isIntersecting) {
|
| 130 |
+
e.target.classList.add("arrived");
|
| 131 |
+
io.unobserve(e.target);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
},
|
| 135 |
+
{ threshold: 0.35, rootMargin: "0px 0px -12% 0px" },
|
| 136 |
+
);
|
| 137 |
+
els.forEach((el) => io.observe(el));
|
| 138 |
+
return () => io.disconnect();
|
| 139 |
+
}, []);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
export default function Landing() {
|
| 143 |
const [url, setUrl] = useState("");
|
| 144 |
const navigate = useNavigate();
|
| 145 |
const { user } = useAuth();
|
| 146 |
+
useArrivals();
|
| 147 |
|
| 148 |
const onSubmit = (e: FormEvent) => {
|
| 149 |
e.preventDefault();
|
| 150 |
+
// Always go to sign in / sign up. A pasted URL rides along and starts connecting after signup
|
| 151 |
+
// (Welcome picks it up); signed in writers go straight to Mission Control, which does the same.
|
| 152 |
const value = url.trim();
|
| 153 |
+
if (value) {
|
| 154 |
+
try {
|
| 155 |
+
sessionStorage.setItem(PENDING_SOURCE_KEY, value);
|
| 156 |
+
} catch {
|
| 157 |
+
/* ignore */
|
| 158 |
+
}
|
| 159 |
}
|
| 160 |
navigate(user ? "/app" : "/auth?mode=signup");
|
| 161 |
};
|
| 162 |
|
| 163 |
return (
|
| 164 |
<div className="page">
|
| 165 |
+
<SkyCanvas travel />
|
| 166 |
<PublicNav />
|
| 167 |
|
| 168 |
<main>
|
|
|
|
| 172 |
<h1 className="hero-title">
|
| 173 |
Your archive,
|
| 174 |
<br />
|
| 175 |
+
<span className="glow-text shimmer">in orbit.</span>
|
| 176 |
</h1>
|
| 177 |
<p className="hero-sub muted">
|
| 178 |
Notestack turns your posts into a research notebook you can question, podcasts you can publish and
|
|
|
|
| 198 |
</form>
|
| 199 |
<p className="mono hero-meta">Free during early access. 20+ posts indexed in under 3 minutes.</p>
|
| 200 |
</div>
|
| 201 |
+
<div className="horizon" aria-hidden="true">
|
| 202 |
+
<span className="horizon-flare" />
|
| 203 |
+
<span className="horizon-glint g1" />
|
| 204 |
+
<span className="horizon-glint g2" />
|
| 205 |
+
<span className="horizon-glint g3" />
|
| 206 |
+
</div>
|
| 207 |
+
<div className="hero-sweep" aria-hidden="true" />
|
| 208 |
</section>
|
| 209 |
|
| 210 |
<section className="section demo">
|
| 211 |
<div className="container">
|
| 212 |
+
<DemoVideo />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
</div>
|
| 214 |
</section>
|
| 215 |
|
|
|
|
| 218 |
<p className="eyebrow">What you get</p>
|
| 219 |
<h2 className="section-title">Everything your writing can become</h2>
|
| 220 |
<div className="feature-grid">
|
| 221 |
+
{FEATURES.map(({ icon: Icon, label, title, body }, i) => (
|
| 222 |
+
<article key={label} className="card feature arrive" style={{ ["--i" as string]: i % 3 }}>
|
| 223 |
<div className="feature-label">
|
| 224 |
<Icon />
|
| 225 |
<span className="mono">{label}</span>
|
|
|
|
| 232 |
</div>
|
| 233 |
</section>
|
| 234 |
|
| 235 |
+
<section id="tour" className="section tour">
|
| 236 |
+
<div className="container">
|
| 237 |
+
<p className="eyebrow">The tour</p>
|
| 238 |
+
<h2 className="section-title">Seven stops across your archive</h2>
|
| 239 |
+
<FeatureShowcase />
|
| 240 |
+
</div>
|
| 241 |
+
</section>
|
| 242 |
+
|
| 243 |
<section className="section">
|
| 244 |
<div className="container">
|
| 245 |
<p className="eyebrow">How it works</p>
|
| 246 |
<h2 className="section-title">Three steps to liftoff</h2>
|
| 247 |
<ol className="steps">
|
| 248 |
+
{STEPS.map((s, i) => (
|
| 249 |
+
<li key={s.n} className="step arrive" style={{ ["--i" as string]: i }}>
|
| 250 |
<span className="step-n mono">{s.n}</span>
|
| 251 |
<h3>{s.title}</h3>
|
| 252 |
<p className="muted">{s.body}</p>
|
|
|
|
| 269 |
<p className="eyebrow">From the blog</p>
|
| 270 |
<h2 className="section-title">Field notes</h2>
|
| 271 |
<div className="blog-grid">
|
| 272 |
+
{blogPosts.slice(0, 3).map((p, i) => (
|
| 273 |
+
<Link key={p.slug} to={`/blogs/${p.slug}`} className="card blog-card arrive" style={{ ["--i" as string]: i }}>
|
| 274 |
<span className="mono muted">
|
| 275 |
{p.category} · {p.readTime}
|
| 276 |
</span>
|
|
|
|
| 282 |
</div>
|
| 283 |
</section>
|
| 284 |
|
| 285 |
+
<section className="section final-cta arrive">
|
| 286 |
<div className="container">
|
| 287 |
<h2 className="section-title">Clear skies ahead.</h2>
|
| 288 |
<p className="muted">Paste your URL above or start with an empty notebook.</p>
|
frontend/src/pages/MissionControl.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
-
import { Link } from "react-router-dom";
|
| 3 |
import { api } from "../api/client";
|
| 4 |
import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi, voiceApi } from "../api/endpoints";
|
| 5 |
import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types";
|
|
@@ -9,6 +9,15 @@ import { useAuth } from "../hooks/useAuth";
|
|
| 9 |
import { useJobMap } from "../hooks/useJob";
|
| 10 |
import { AddSource } from "./Sources";
|
| 11 |
import { PENDING_SOURCE_KEY } from "./Landing";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
type Plan = { name: string };
|
| 14 |
|
|
@@ -39,6 +48,7 @@ export default function MissionControl() {
|
|
| 39 |
const [hasVoice, setHasVoice] = useState(false);
|
| 40 |
const [error, setError] = useState<string | null>(null);
|
| 41 |
const started = useRef(false);
|
|
|
|
| 42 |
|
| 43 |
const refresh = useCallback(async () => {
|
| 44 |
const [s, n, p, a, c, v] = await Promise.all([
|
|
@@ -50,12 +60,17 @@ export default function MissionControl() {
|
|
| 50 |
voiceApi.get(),
|
| 51 |
]);
|
| 52 |
setSources(s);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
setNotebooks(n);
|
| 54 |
setPlan(p.plan);
|
| 55 |
setRecent(a.items);
|
| 56 |
setUpcoming(c.slice(0, 5));
|
| 57 |
setHasVoice(Boolean(v.profile));
|
| 58 |
-
}, []);
|
| 59 |
|
| 60 |
const { jobs, watch } = useJobMap(() => refresh());
|
| 61 |
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Link, useNavigate } from "react-router-dom";
|
| 3 |
import { api } from "../api/client";
|
| 4 |
import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi, voiceApi } from "../api/endpoints";
|
| 5 |
import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types";
|
|
|
|
| 9 |
import { useJobMap } from "../hooks/useJob";
|
| 10 |
import { AddSource } from "./Sources";
|
| 11 |
import { PENDING_SOURCE_KEY } from "./Landing";
|
| 12 |
+
import { ONBOARDED_KEY } from "./Welcome";
|
| 13 |
+
|
| 14 |
+
function onboarded(): boolean {
|
| 15 |
+
try {
|
| 16 |
+
return localStorage.getItem(ONBOARDED_KEY) === "1";
|
| 17 |
+
} catch {
|
| 18 |
+
return true;
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
|
| 22 |
type Plan = { name: string };
|
| 23 |
|
|
|
|
| 48 |
const [hasVoice, setHasVoice] = useState(false);
|
| 49 |
const [error, setError] = useState<string | null>(null);
|
| 50 |
const started = useRef(false);
|
| 51 |
+
const navigate = useNavigate();
|
| 52 |
|
| 53 |
const refresh = useCallback(async () => {
|
| 54 |
const [s, n, p, a, c, v] = await Promise.all([
|
|
|
|
| 60 |
voiceApi.get(),
|
| 61 |
]);
|
| 62 |
setSources(s);
|
| 63 |
+
// First visit with nothing connected: run the guided setup once.
|
| 64 |
+
if (s.length === 0 && !onboarded()) {
|
| 65 |
+
navigate("/welcome", { replace: true });
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
setNotebooks(n);
|
| 69 |
setPlan(p.plan);
|
| 70 |
setRecent(a.items);
|
| 71 |
setUpcoming(c.slice(0, 5));
|
| 72 |
setHasVoice(Boolean(v.profile));
|
| 73 |
+
}, [navigate]);
|
| 74 |
|
| 75 |
const { jobs, watch } = useJobMap(() => refresh());
|
| 76 |
|
frontend/src/pages/NotebookView.tsx
CHANGED
|
@@ -3,7 +3,8 @@ import { Link, useParams } from "react-router-dom";
|
|
| 3 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
|
| 6 |
-
import { ArtifactCard, CitationList
|
|
|
|
| 7 |
import { DocPicker } from "../components/DocPicker";
|
| 8 |
import { TelescopeIcon } from "../components/icons/Icons";
|
| 9 |
import { Reader } from "../components/Reader";
|
|
@@ -295,11 +296,12 @@ export default function NotebookView() {
|
|
| 295 |
</details>
|
| 296 |
)}
|
| 297 |
{t.status && !t.steps?.length && <p className="mono muted">{t.status}...</p>}
|
| 298 |
-
{t.text &&
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
| 303 |
{t.citations && <CitationList citations={t.citations} onCite={cite} />}
|
| 304 |
</div>
|
| 305 |
))}
|
|
|
|
| 3 |
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
|
| 6 |
+
import { ArtifactCard, CitationList } from "../components/ArtifactCard";
|
| 7 |
+
import { Markdown } from "../components/Markdown";
|
| 8 |
import { DocPicker } from "../components/DocPicker";
|
| 9 |
import { TelescopeIcon } from "../components/icons/Icons";
|
| 10 |
import { Reader } from "../components/Reader";
|
|
|
|
| 296 |
</details>
|
| 297 |
)}
|
| 298 |
{t.status && !t.steps?.length && <p className="mono muted">{t.status}...</p>}
|
| 299 |
+
{t.text &&
|
| 300 |
+
(t.role === "assistant" ? (
|
| 301 |
+
<Markdown text={t.text} citations={t.citations ?? []} onCite={cite} className="turn-md" />
|
| 302 |
+
) : (
|
| 303 |
+
<p className="turn-text">{t.text}</p>
|
| 304 |
+
))}
|
| 305 |
{t.citations && <CitationList citations={t.citations} onCite={cite} />}
|
| 306 |
</div>
|
| 307 |
))}
|
frontend/src/pages/TopicMap.tsx
CHANGED
|
@@ -1,15 +1,103 @@
|
|
| 1 |
-
import { useEffect, useMemo, useState } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { notebooksApi, topicsApi } from "../api/endpoints";
|
| 4 |
import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
|
| 7 |
import { useJob } from "../hooks/useJob";
|
|
|
|
| 8 |
|
| 9 |
const W = 900;
|
| 10 |
const H = 620;
|
| 11 |
|
| 12 |
type P = { id: string; x: number; y: number; vx: number; vy: number; r: number };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
/** Small deterministic force layout: repulsion between all nodes, springs along edges, pull to center. */
|
| 15 |
function layout(data: TopicMapData): Map<string, P> {
|
|
@@ -80,6 +168,8 @@ export default function TopicMap() {
|
|
| 80 |
const [reading, setReading] = useState<string | null>(null);
|
| 81 |
const [error, setError] = useState<string | null>(null);
|
| 82 |
const navigate = useNavigate();
|
|
|
|
|
|
|
| 83 |
|
| 84 |
const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map."));
|
| 85 |
useEffect(() => {
|
|
@@ -113,7 +203,15 @@ export default function TopicMap() {
|
|
| 113 |
}
|
| 114 |
};
|
| 115 |
|
| 116 |
-
const open = async (id: string) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
return (
|
| 119 |
<div className="page-wrap">
|
|
@@ -142,7 +240,39 @@ export default function TopicMap() {
|
|
| 142 |
{data && data.nodes.length > 0 && (
|
| 143 |
<div className="map-layout">
|
| 144 |
<div className="card map-canvas">
|
| 145 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
<defs>
|
| 147 |
<radialGradient id="star" cx="50%" cy="50%" r="50%">
|
| 148 |
<stop offset="0%" stopColor="#ffffff" />
|
|
@@ -164,7 +294,7 @@ export default function TopicMap() {
|
|
| 164 |
y2={b.y}
|
| 165 |
stroke={lit ? "#217cff" : "#ffffff"}
|
| 166 |
strokeOpacity={lit ? 0.9 : focus ? 0.05 : 0.14}
|
| 167 |
-
strokeWidth={lit ? 1.6 : 1}
|
| 168 |
/>
|
| 169 |
);
|
| 170 |
})}
|
|
@@ -180,6 +310,7 @@ export default function TopicMap() {
|
|
| 180 |
onMouseEnter={() => setHover(n.id)}
|
| 181 |
onMouseLeave={() => setHover(null)}
|
| 182 |
onClick={() => open(n.id)}
|
|
|
|
| 183 |
tabIndex={0}
|
| 184 |
role="button"
|
| 185 |
aria-label={`${n.name}, ${n.post_count} posts`}
|
|
@@ -187,7 +318,7 @@ export default function TopicMap() {
|
|
| 187 |
>
|
| 188 |
<circle cx={p.x} cy={p.y} r={p.r * 1.9} fill="url(#star)" opacity={active ? 0.9 : 0.45} />
|
| 189 |
<circle cx={p.x} cy={p.y} r={Math.max(3, p.r * 0.45)} fill="#ffffff" />
|
| 190 |
-
<text x={p.x} y={p.y + p.r + 16} textAnchor="middle" className="map-label">
|
| 191 |
{n.name}
|
| 192 |
</text>
|
| 193 |
</g>
|
|
@@ -203,7 +334,7 @@ export default function TopicMap() {
|
|
| 203 |
<ol className="topic-rank">
|
| 204 |
{data.nodes.slice(0, 12).map((n) => (
|
| 205 |
<li key={n.id}>
|
| 206 |
-
<button className="link-btn" onClick={() => open(n.id)}>
|
| 207 |
{n.name}
|
| 208 |
</button>
|
| 209 |
<span className="mono muted"> {n.post_count}</span>
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
|
| 2 |
import { useNavigate } from "react-router-dom";
|
| 3 |
import { notebooksApi, topicsApi } from "../api/endpoints";
|
| 4 |
import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types";
|
| 5 |
import { Reader } from "../components/Reader";
|
| 6 |
import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
|
| 7 |
import { useJob } from "../hooks/useJob";
|
| 8 |
+
import { playClick } from "../lib/sound";
|
| 9 |
|
| 10 |
const W = 900;
|
| 11 |
const H = 620;
|
| 12 |
|
| 13 |
type P = { id: string; x: number; y: number; vx: number; vy: number; r: number };
|
| 14 |
+
type View = { x: number; y: number; w: number; h: number };
|
| 15 |
+
|
| 16 |
+
const HOME: View = { x: 0, y: 0, w: W, h: H };
|
| 17 |
+
const MIN_W = W / 8;
|
| 18 |
+
const MAX_W = W * 1.6;
|
| 19 |
+
|
| 20 |
+
/** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */
|
| 21 |
+
function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
| 22 |
+
const [view, setView] = useState<View>(HOME);
|
| 23 |
+
const drag = useRef<{ px: number; py: number; view: View; moved: boolean } | null>(null);
|
| 24 |
+
|
| 25 |
+
const toSvg = useCallback(
|
| 26 |
+
(clientX: number, clientY: number, v: View) => {
|
| 27 |
+
const rect = svgRef.current!.getBoundingClientRect();
|
| 28 |
+
return { x: v.x + ((clientX - rect.left) / rect.width) * v.w, y: v.y + ((clientY - rect.top) / rect.height) * v.h };
|
| 29 |
+
},
|
| 30 |
+
[svgRef],
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
const scaleView = (v: View, factor: number, fx: number, fy: number): View => {
|
| 34 |
+
const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor));
|
| 35 |
+
const h = (w / W) * H;
|
| 36 |
+
// Keep the focus point fixed on screen while scaling.
|
| 37 |
+
return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h };
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const zoomAt = useCallback((factor: number, cx?: number, cy?: number) => {
|
| 41 |
+
setView((v) => scaleView(v, factor, cx ?? v.x + v.w / 2, cy ?? v.y + v.h / 2));
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
const el = svgRef.current;
|
| 46 |
+
if (!el) return;
|
| 47 |
+
const onWheel = (e: WheelEvent) => {
|
| 48 |
+
e.preventDefault();
|
| 49 |
+
setView((v) => {
|
| 50 |
+
const p = toSvg(e.clientX, e.clientY, v);
|
| 51 |
+
return scaleView(v, Math.exp(Math.max(-0.4, Math.min(0.4, e.deltaY * 0.0015))), p.x, p.y);
|
| 52 |
+
});
|
| 53 |
+
};
|
| 54 |
+
el.addEventListener("wheel", onWheel, { passive: false });
|
| 55 |
+
return () => el.removeEventListener("wheel", onWheel);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
|
| 59 |
+
if ((e.target as Element).closest(".map-node")) return;
|
| 60 |
+
drag.current = { px: e.clientX, py: e.clientY, view, moved: false };
|
| 61 |
+
svgRef.current?.setPointerCapture(e.pointerId);
|
| 62 |
+
};
|
| 63 |
+
const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {
|
| 64 |
+
const d = drag.current;
|
| 65 |
+
if (!d || !svgRef.current) return;
|
| 66 |
+
const rect = svgRef.current.getBoundingClientRect();
|
| 67 |
+
const dx = ((e.clientX - d.px) / rect.width) * d.view.w;
|
| 68 |
+
const dy = ((e.clientY - d.py) / rect.height) * d.view.h;
|
| 69 |
+
if (Math.abs(dx) + Math.abs(dy) > 2) d.moved = true;
|
| 70 |
+
setView({ ...d.view, x: d.view.x - dx, y: d.view.y - dy });
|
| 71 |
+
};
|
| 72 |
+
const onPointerUp = (e: ReactPointerEvent<SVGSVGElement>) => {
|
| 73 |
+
drag.current = null;
|
| 74 |
+
if (svgRef.current?.hasPointerCapture(e.pointerId)) svgRef.current.releasePointerCapture(e.pointerId);
|
| 75 |
+
};
|
| 76 |
+
const onDoubleClick = (e: ReactMouseEvent<SVGSVGElement>) => {
|
| 77 |
+
if ((e.target as Element).closest(".map-node")) return;
|
| 78 |
+
const p = toSvg(e.clientX, e.clientY, view);
|
| 79 |
+
zoomAt(0.6, p.x, p.y);
|
| 80 |
+
};
|
| 81 |
+
const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h }));
|
| 82 |
+
const reset = () => {
|
| 83 |
+
setView(HOME);
|
| 84 |
+
};
|
| 85 |
+
const focusOn = (x: number, y: number) =>
|
| 86 |
+
setView((v) => {
|
| 87 |
+
const w = Math.min(v.w, W / 2);
|
| 88 |
+
const h = (w / W) * H;
|
| 89 |
+
return { x: x - w / 2, y: y - h / 2, w, h };
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
view,
|
| 94 |
+
zoomAt,
|
| 95 |
+
pan,
|
| 96 |
+
reset,
|
| 97 |
+
focusOn,
|
| 98 |
+
handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp, onDoubleClick },
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
|
| 102 |
/** Small deterministic force layout: repulsion between all nodes, springs along edges, pull to center. */
|
| 103 |
function layout(data: TopicMapData): Map<string, P> {
|
|
|
|
| 168 |
const [reading, setReading] = useState<string | null>(null);
|
| 169 |
const [error, setError] = useState<string | null>(null);
|
| 170 |
const navigate = useNavigate();
|
| 171 |
+
const svgRef = useRef<SVGSVGElement>(null);
|
| 172 |
+
const { view, zoomAt, pan, reset, focusOn, handlers } = useZoomPan(svgRef);
|
| 173 |
|
| 174 |
const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map."));
|
| 175 |
useEffect(() => {
|
|
|
|
| 203 |
}
|
| 204 |
};
|
| 205 |
|
| 206 |
+
const open = async (id: string, center = false) => {
|
| 207 |
+
playClick("tap");
|
| 208 |
+
if (center) {
|
| 209 |
+
const p = positions.get(id);
|
| 210 |
+
if (p) focusOn(p.x, p.y);
|
| 211 |
+
}
|
| 212 |
+
setSelected(await topicsApi.get(id));
|
| 213 |
+
};
|
| 214 |
+
const scale = view.w / W; // keeps labels and strokes a constant size on screen
|
| 215 |
|
| 216 |
return (
|
| 217 |
<div className="page-wrap">
|
|
|
|
| 240 |
{data && data.nodes.length > 0 && (
|
| 241 |
<div className="map-layout">
|
| 242 |
<div className="card map-canvas">
|
| 243 |
+
<div className="map-controls">
|
| 244 |
+
<button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in">
|
| 245 |
+
+
|
| 246 |
+
</button>
|
| 247 |
+
<button className="icon-btn" onClick={() => zoomAt(1.33)} aria-label="Zoom out" title="Zoom out">
|
| 248 |
+
-
|
| 249 |
+
</button>
|
| 250 |
+
<button className="icon-btn mono" onClick={reset} aria-label="Reset view" title="Reset view">
|
| 251 |
+
1:1
|
| 252 |
+
</button>
|
| 253 |
+
</div>
|
| 254 |
+
<p className="map-hint mono muted">Scroll to zoom · drag to pan · double click to dive in</p>
|
| 255 |
+
<svg
|
| 256 |
+
ref={svgRef}
|
| 257 |
+
viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
|
| 258 |
+
role="img"
|
| 259 |
+
aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
|
| 260 |
+
tabIndex={0}
|
| 261 |
+
className="map-svg"
|
| 262 |
+
{...handlers}
|
| 263 |
+
onKeyDown={(e) => {
|
| 264 |
+
const step = 0.12;
|
| 265 |
+
if (e.key === "+" || e.key === "=") zoomAt(0.8);
|
| 266 |
+
else if (e.key === "-") zoomAt(1.25);
|
| 267 |
+
else if (e.key === "0") reset();
|
| 268 |
+
else if (e.key === "ArrowLeft") pan(-step, 0);
|
| 269 |
+
else if (e.key === "ArrowRight") pan(step, 0);
|
| 270 |
+
else if (e.key === "ArrowUp") pan(0, -step);
|
| 271 |
+
else if (e.key === "ArrowDown") pan(0, step);
|
| 272 |
+
else return;
|
| 273 |
+
e.preventDefault();
|
| 274 |
+
}}
|
| 275 |
+
>
|
| 276 |
<defs>
|
| 277 |
<radialGradient id="star" cx="50%" cy="50%" r="50%">
|
| 278 |
<stop offset="0%" stopColor="#ffffff" />
|
|
|
|
| 294 |
y2={b.y}
|
| 295 |
stroke={lit ? "#217cff" : "#ffffff"}
|
| 296 |
strokeOpacity={lit ? 0.9 : focus ? 0.05 : 0.14}
|
| 297 |
+
strokeWidth={(lit ? 1.6 : 1) * scale}
|
| 298 |
/>
|
| 299 |
);
|
| 300 |
})}
|
|
|
|
| 310 |
onMouseEnter={() => setHover(n.id)}
|
| 311 |
onMouseLeave={() => setHover(null)}
|
| 312 |
onClick={() => open(n.id)}
|
| 313 |
+
onDoubleClick={() => open(n.id, true)}
|
| 314 |
tabIndex={0}
|
| 315 |
role="button"
|
| 316 |
aria-label={`${n.name}, ${n.post_count} posts`}
|
|
|
|
| 318 |
>
|
| 319 |
<circle cx={p.x} cy={p.y} r={p.r * 1.9} fill="url(#star)" opacity={active ? 0.9 : 0.45} />
|
| 320 |
<circle cx={p.x} cy={p.y} r={Math.max(3, p.r * 0.45)} fill="#ffffff" />
|
| 321 |
+
<text x={p.x} y={p.y + p.r + 16 * scale} textAnchor="middle" className="map-label" style={{ fontSize: 12 * scale, strokeWidth: 4 * scale }}>
|
| 322 |
{n.name}
|
| 323 |
</text>
|
| 324 |
</g>
|
|
|
|
| 334 |
<ol className="topic-rank">
|
| 335 |
{data.nodes.slice(0, 12).map((n) => (
|
| 336 |
<li key={n.id}>
|
| 337 |
+
<button className="link-btn" onClick={() => open(n.id, true)}>
|
| 338 |
{n.name}
|
| 339 |
</button>
|
| 340 |
<span className="mono muted"> {n.post_count}</span>
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
-
import { useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
-
import type { Job, Voice, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
|
|
|
|
|
|
|
| 8 |
const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
|
| 9 |
{ key: "tone", label: "Tone", hint: "warm, wry, direct" },
|
| 10 |
{ key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
|
|
@@ -13,24 +15,303 @@ const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[
|
|
| 13 |
{ key: "avoid", label: "Never does", hint: "jargon, hashtags" },
|
| 14 |
];
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const [recording, setRecording] = useState(false);
|
| 18 |
const [seconds, setSeconds] = useState(0);
|
|
|
|
| 19 |
const [error, setError] = useState<string | null>(null);
|
| 20 |
const rec = useRef<MediaRecorder | null>(null);
|
| 21 |
const timer = useRef<number>();
|
|
|
|
| 22 |
|
| 23 |
const start = async () => {
|
| 24 |
setError(null);
|
| 25 |
try {
|
| 26 |
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const chunks: Blob[] = [];
|
| 28 |
const mr = new MediaRecorder(stream);
|
|
|
|
| 29 |
mr.ondataavailable = (e) => chunks.push(e.data);
|
| 30 |
mr.onstop = () => {
|
| 31 |
stream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
|
|
| 32 |
const type = mr.mimeType.split(";")[0] || "audio/webm";
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
};
|
| 35 |
mr.start();
|
| 36 |
rec.current = mr;
|
|
@@ -38,7 +319,7 @@ function Recorder({ onFile }: { onFile: (f: File) => void }) {
|
|
| 38 |
setSeconds(0);
|
| 39 |
timer.current = window.setInterval(() => setSeconds((s) => s + 1), 1000);
|
| 40 |
} catch {
|
| 41 |
-
setError("Microphone access was blocked. You can upload
|
| 42 |
}
|
| 43 |
};
|
| 44 |
const stop = () => {
|
|
@@ -48,42 +329,106 @@ function Recorder({ onFile }: { onFile: (f: File) => void }) {
|
|
| 48 |
};
|
| 49 |
|
| 50 |
return (
|
| 51 |
-
<div className="
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
{error && <p className="error-text">{error}</p>}
|
| 62 |
</div>
|
| 63 |
);
|
| 64 |
}
|
| 65 |
|
| 66 |
function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: () => void; onDone: (job: Job) => void }) {
|
| 67 |
-
const [
|
|
|
|
| 68 |
const [agreed, setAgreed] = useState(false);
|
|
|
|
| 69 |
const [busy, setBusy] = useState(false);
|
| 70 |
const [error, setError] = useState<string | null>(null);
|
| 71 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
return (
|
| 73 |
-
<Modal title="Clone your voice" onClose={onClose}>
|
| 74 |
<div className="stack">
|
| 75 |
-
<
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
<label className="field">
|
| 80 |
-
<span>Or upload
|
| 81 |
-
<input className="input file-input" type="file" accept="audio/*" onChange={(e) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</label>
|
| 83 |
-
{preview && <audio controls src={preview} className="player" />}
|
| 84 |
<blockquote className="consent">{state.clone.consent_text}</blockquote>
|
| 85 |
<label className="check">
|
| 86 |
-
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />I agree to the statement above.
|
| 87 |
</label>
|
| 88 |
{error && <p className="error-text">{error}</p>}
|
| 89 |
<div className="row end">
|
|
@@ -92,12 +437,12 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
|
|
| 92 |
</button>
|
| 93 |
<button
|
| 94 |
className="btn btn-primary"
|
| 95 |
-
disabled={!
|
| 96 |
onClick={async () => {
|
| 97 |
setBusy(true);
|
| 98 |
setError(null);
|
| 99 |
try {
|
| 100 |
-
const res = await voiceApi.consent(file
|
| 101 |
onDone(res.job);
|
| 102 |
onClose();
|
| 103 |
} catch (e) {
|
|
@@ -119,13 +464,16 @@ export default function VoiceProfile() {
|
|
| 119 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 120 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 121 |
const [voices, setVoices] = useState<Voice[]>([]);
|
|
|
|
| 122 |
const [picking, setPicking] = useState(false);
|
| 123 |
const [samples, setSamples] = useState<string[]>([]);
|
| 124 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 125 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 126 |
const [cloning, setCloning] = useState(false);
|
|
|
|
| 127 |
const [saved, setSaved] = useState(false);
|
| 128 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 129 |
|
| 130 |
const load = () =>
|
| 131 |
voiceApi.get().then((s) => {
|
|
@@ -133,26 +481,28 @@ export default function VoiceProfile() {
|
|
| 133 |
setDraft(s.profile);
|
| 134 |
setSamples(s.sample_doc_ids);
|
| 135 |
});
|
|
|
|
| 136 |
|
| 137 |
useEffect(() => {
|
| 138 |
load();
|
| 139 |
-
|
|
|
|
| 140 |
}, []);
|
| 141 |
|
| 142 |
const build = useJob(buildJob, () => load());
|
| 143 |
const clone = useJob(cloneJob, () => {
|
| 144 |
load();
|
| 145 |
-
|
| 146 |
});
|
| 147 |
|
| 148 |
if (!state) return <Loading />;
|
| 149 |
const building = build && (build.status === "queued" || build.status === "running");
|
|
|
|
| 150 |
|
| 151 |
-
const
|
| 152 |
setError(null);
|
| 153 |
try {
|
| 154 |
-
|
| 155 |
-
setState(s);
|
| 156 |
setSaved(true);
|
| 157 |
setTimeout(() => setSaved(false), 1500);
|
| 158 |
} catch (e) {
|
|
@@ -160,22 +510,23 @@ export default function VoiceProfile() {
|
|
| 160 |
}
|
| 161 |
};
|
| 162 |
|
|
|
|
|
|
|
|
|
|
| 163 |
return (
|
| 164 |
<div className="page-wrap">
|
| 165 |
-
<PageHeader eyebrow="Voice profile" title="How you sound on the page">
|
| 166 |
<button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}>
|
| 167 |
-
{state.profile ? "Rebuild
|
| 168 |
</button>
|
| 169 |
</PageHeader>
|
| 170 |
-
<p className="muted lede">
|
| 171 |
-
Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.
|
| 172 |
-
</p>
|
| 173 |
{build && (building || build.status === "failed") && <JobProgress job={build} />}
|
| 174 |
{error && <p className="error-text">{error}</p>}
|
| 175 |
|
| 176 |
<div className="two-col">
|
| 177 |
<section className="card stack">
|
| 178 |
<h2>Writing voice</h2>
|
|
|
|
| 179 |
{!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>}
|
| 180 |
{draft && (
|
| 181 |
<>
|
|
@@ -203,8 +554,10 @@ export default function VoiceProfile() {
|
|
| 203 |
</label>
|
| 204 |
))}
|
| 205 |
<div className="row between">
|
| 206 |
-
<span className="mono muted">
|
| 207 |
-
|
|
|
|
|
|
|
| 208 |
{saved ? "Saved" : "Save changes"}
|
| 209 |
</button>
|
| 210 |
</div>
|
|
@@ -212,62 +565,70 @@ export default function VoiceProfile() {
|
|
| 212 |
)}
|
| 213 |
</section>
|
| 214 |
|
| 215 |
-
<
|
| 216 |
-
<
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
<
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
{!voices.some((v) => v.voice_id === state.host_voices[host]) && <option value={state.host_voices[host]}>Current voice</option>}
|
| 234 |
-
</select>
|
| 235 |
-
{voices.find((v) => v.voice_id === state.host_voices[host])?.preview_url && (
|
| 236 |
-
<button type="button" className="btn btn-small" onClick={() => new Audio(voices.find((v) => v.voice_id === state.host_voices[host])!.preview_url!).play()}>
|
| 237 |
-
Preview
|
| 238 |
-
</button>
|
| 239 |
-
)}
|
| 240 |
-
</div>
|
| 241 |
-
</label>
|
| 242 |
-
))}
|
| 243 |
|
| 244 |
-
<
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
<p className="muted">
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
<
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
<
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
</div>
|
| 272 |
|
| 273 |
{picking && (
|
|
@@ -292,6 +653,16 @@ export default function VoiceProfile() {
|
|
| 292 |
</Modal>
|
| 293 |
)}
|
| 294 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
</div>
|
| 296 |
);
|
| 297 |
}
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
+
import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
|
| 8 |
+
type Host = "host_a" | "host_b";
|
| 9 |
+
|
| 10 |
const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
|
| 11 |
{ key: "tone", label: "Tone", hint: "warm, wry, direct" },
|
| 12 |
{ key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
|
|
|
|
| 15 |
{ key: "avoid", label: "Never does", hint: "jargon, hashtags" },
|
| 16 |
];
|
| 17 |
|
| 18 |
+
const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; step: number; hint: string }[] = [
|
| 19 |
+
{ key: "stability", label: "Stability", min: 0, max: 1, step: 0.05, hint: "Lower is more expressive, higher is steadier" },
|
| 20 |
+
{ key: "similarity_boost", label: "Clarity and similarity", min: 0, max: 1, step: 0.05, hint: "How closely it sticks to the original voice" },
|
| 21 |
+
{ key: "style", label: "Style", min: 0, max: 1, step: 0.05, hint: "Exaggerates the speaker's style; keep low for news" },
|
| 22 |
+
{ key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" };
|
| 26 |
+
|
| 27 |
+
/** Plays one URL at a time across the page. */
|
| 28 |
+
function usePlayer() {
|
| 29 |
+
const audio = useRef<HTMLAudioElement | null>(null);
|
| 30 |
+
const [playing, setPlaying] = useState<string | null>(null);
|
| 31 |
+
const play = useCallback((url: string, id: string) => {
|
| 32 |
+
audio.current?.pause();
|
| 33 |
+
if (playing === id) {
|
| 34 |
+
setPlaying(null);
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
const a = new Audio(url);
|
| 38 |
+
audio.current = a;
|
| 39 |
+
a.onended = () => setPlaying(null);
|
| 40 |
+
void a.play();
|
| 41 |
+
setPlaying(id);
|
| 42 |
+
}, [playing]);
|
| 43 |
+
useEffect(() => () => audio.current?.pause(), []);
|
| 44 |
+
return { play, playing };
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function fmtSeconds(s: number) {
|
| 48 |
+
return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Speaking voices
|
| 52 |
+
|
| 53 |
+
function HostCard({
|
| 54 |
+
host,
|
| 55 |
+
state,
|
| 56 |
+
voices,
|
| 57 |
+
onSaved,
|
| 58 |
+
onBrowse,
|
| 59 |
+
}: {
|
| 60 |
+
host: Host;
|
| 61 |
+
state: VoiceState;
|
| 62 |
+
voices: Voice[];
|
| 63 |
+
onSaved: (s: VoiceState) => void;
|
| 64 |
+
onBrowse: (host: Host) => void;
|
| 65 |
+
}) {
|
| 66 |
+
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
| 67 |
+
const [busy, setBusy] = useState(false);
|
| 68 |
+
const [error, setError] = useState<string | null>(null);
|
| 69 |
+
const [dirty, setDirty] = useState(false);
|
| 70 |
+
const { play, playing } = usePlayer();
|
| 71 |
+
const current = state.host_voices[host];
|
| 72 |
+
const groups = voices.reduce<Record<string, Voice[]>>((acc, v) => {
|
| 73 |
+
const k = v.category ?? "other";
|
| 74 |
+
(acc[k] ??= []).push(v);
|
| 75 |
+
return acc;
|
| 76 |
+
}, {});
|
| 77 |
+
|
| 78 |
+
useEffect(() => {
|
| 79 |
+
setDelivery(state.delivery[host]);
|
| 80 |
+
setDirty(false);
|
| 81 |
+
}, [state.delivery, host]);
|
| 82 |
+
|
| 83 |
+
const preview = async () => {
|
| 84 |
+
setBusy(true);
|
| 85 |
+
setError(null);
|
| 86 |
+
try {
|
| 87 |
+
const { url } = await voiceApi.preview(current, { delivery });
|
| 88 |
+
play(url, "preview");
|
| 89 |
+
} catch (e) {
|
| 90 |
+
setError(errorMessage(e));
|
| 91 |
+
} finally {
|
| 92 |
+
setBusy(false);
|
| 93 |
+
}
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<div className="host-card">
|
| 98 |
+
<div className="row between">
|
| 99 |
+
<strong>{HOST_LABEL[host]}</strong>
|
| 100 |
+
<button className="link-btn small" onClick={() => onBrowse(host)}>
|
| 101 |
+
Browse voice library
|
| 102 |
+
</button>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="row">
|
| 105 |
+
<select
|
| 106 |
+
className="input input-sm"
|
| 107 |
+
value={current}
|
| 108 |
+
onChange={async (e) => onSaved(await voiceApi.update({ host_voices: { [host]: e.target.value } }))}
|
| 109 |
+
aria-label={`${HOST_LABEL[host]} voice`}
|
| 110 |
+
>
|
| 111 |
+
{Object.entries(groups).map(([cat, list]) => (
|
| 112 |
+
<optgroup key={cat} label={cat}>
|
| 113 |
+
{list.map((v) => (
|
| 114 |
+
<option key={v.voice_id} value={v.voice_id}>
|
| 115 |
+
{v.name}
|
| 116 |
+
{v.labels?.accent ? `, ${v.labels.accent}` : ""}
|
| 117 |
+
{v.labels?.gender ? `, ${v.labels.gender}` : ""}
|
| 118 |
+
</option>
|
| 119 |
+
))}
|
| 120 |
+
</optgroup>
|
| 121 |
+
))}
|
| 122 |
+
{!voices.some((v) => v.voice_id === current) && <option value={current}>Current voice</option>}
|
| 123 |
+
</select>
|
| 124 |
+
<button className="btn btn-small" onClick={preview} disabled={busy || !state.tts_configured}>
|
| 125 |
+
{busy ? "Rendering..." : playing ? "Stop" : "Preview"}
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
<details className="delivery">
|
| 129 |
+
<summary className="mono muted small">Delivery settings</summary>
|
| 130 |
+
{SLIDERS.map((s) => (
|
| 131 |
+
<label key={s.key} className="slider" title={s.hint}>
|
| 132 |
+
<span className="row between">
|
| 133 |
+
<span>{s.label}</span>
|
| 134 |
+
<span className="mono muted">{Number(delivery[s.key]).toFixed(2)}</span>
|
| 135 |
+
</span>
|
| 136 |
+
<input
|
| 137 |
+
type="range"
|
| 138 |
+
min={s.min}
|
| 139 |
+
max={s.max}
|
| 140 |
+
step={s.step}
|
| 141 |
+
value={Number(delivery[s.key])}
|
| 142 |
+
onChange={(e) => {
|
| 143 |
+
setDelivery({ ...delivery, [s.key]: Number(e.target.value) });
|
| 144 |
+
setDirty(true);
|
| 145 |
+
}}
|
| 146 |
+
/>
|
| 147 |
+
</label>
|
| 148 |
+
))}
|
| 149 |
+
{dirty && (
|
| 150 |
+
<div className="row end">
|
| 151 |
+
<button className="btn btn-small" onClick={() => (setDelivery(state.delivery[host]), setDirty(false))}>
|
| 152 |
+
Reset
|
| 153 |
+
</button>
|
| 154 |
+
<button
|
| 155 |
+
className="btn btn-small btn-primary"
|
| 156 |
+
onClick={async () => {
|
| 157 |
+
onSaved(await voiceApi.update({ delivery: { [host]: delivery } }));
|
| 158 |
+
setDirty(false);
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
Save delivery
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
</details>
|
| 166 |
+
{error && <p className="error-text">{error}</p>}
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// Voice library (commercially licensed voices from ElevenLabs)
|
| 172 |
+
|
| 173 |
+
function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
|
| 174 |
+
const [filters, setFilters] = useState({ search: "", gender: "", age: "", accent: "", use_case: "" });
|
| 175 |
+
const [voices, setVoices] = useState<LibraryVoice[] | null>(null);
|
| 176 |
+
const [page, setPage] = useState(0);
|
| 177 |
+
const [hasMore, setHasMore] = useState(false);
|
| 178 |
+
const [adding, setAdding] = useState<string | null>(null);
|
| 179 |
+
const [error, setError] = useState<string | null>(null);
|
| 180 |
+
const { play, playing } = usePlayer();
|
| 181 |
+
|
| 182 |
+
const load = useCallback(
|
| 183 |
+
async (p = 0) => {
|
| 184 |
+
setError(null);
|
| 185 |
+
try {
|
| 186 |
+
const res = await voiceApi.library({ ...filters, page: p });
|
| 187 |
+
setVoices((cur) => (p ? [...(cur ?? []), ...res.voices] : res.voices));
|
| 188 |
+
setHasMore(res.has_more);
|
| 189 |
+
setPage(p);
|
| 190 |
+
} catch (e) {
|
| 191 |
+
setError(errorMessage(e));
|
| 192 |
+
setVoices([]);
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
[filters],
|
| 196 |
+
);
|
| 197 |
+
|
| 198 |
+
useEffect(() => {
|
| 199 |
+
const t = setTimeout(() => load(0), 250);
|
| 200 |
+
return () => clearTimeout(t);
|
| 201 |
+
}, [load]);
|
| 202 |
+
|
| 203 |
+
const select = (key: keyof typeof filters, options: [string, string][]) => (
|
| 204 |
+
<select className="input input-sm" value={filters[key]} onChange={(e) => setFilters({ ...filters, [key]: e.target.value })} aria-label={key}>
|
| 205 |
+
{options.map(([v, l]) => (
|
| 206 |
+
<option key={v} value={v}>
|
| 207 |
+
{l}
|
| 208 |
+
</option>
|
| 209 |
+
))}
|
| 210 |
+
</select>
|
| 211 |
+
);
|
| 212 |
+
|
| 213 |
+
return (
|
| 214 |
+
<Modal title={`Voice library for ${host === "host_a" ? "Host A" : "Host B"}`} onClose={onClose} wide>
|
| 215 |
+
<div className="stack">
|
| 216 |
+
<p className="muted small">
|
| 217 |
+
Voices from the ElevenLabs Voice Library, licensed for commercial use on paid ElevenLabs plans. Adding one puts it in your ElevenLabs account.
|
| 218 |
+
</p>
|
| 219 |
+
<div className="library-filters">
|
| 220 |
+
<input className="input input-sm" placeholder="Search: calm narrator, british, podcast" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} aria-label="Search voices" />
|
| 221 |
+
{select("gender", [["", "Any gender"], ["female", "Female"], ["male", "Male"], ["neutral", "Neutral"]])}
|
| 222 |
+
{select("age", [["", "Any age"], ["young", "Young"], ["middle_aged", "Middle aged"], ["old", "Older"]])}
|
| 223 |
+
{select("accent", [["", "Any accent"], ["american", "American"], ["british", "British"], ["australian", "Australian"], ["irish", "Irish"], ["indian", "Indian"], ["african", "African"]])}
|
| 224 |
+
{select("use_case", [["", "Any use"], ["conversational", "Conversational"], ["narrative_story", "Narration"], ["informative_educational", "Educational"], ["social_media", "Social media"], ["entertainment_tv", "Entertainment"]])}
|
| 225 |
+
</div>
|
| 226 |
+
{error && <p className="error-text">{error}</p>}
|
| 227 |
+
{!voices && <Loading label="Searching voices" />}
|
| 228 |
+
<ul className="library-grid">
|
| 229 |
+
{voices?.map((v) => (
|
| 230 |
+
<li key={`${v.public_owner_id}-${v.voice_id}`} className="library-voice">
|
| 231 |
+
<div className="row between">
|
| 232 |
+
<strong>{v.name}</strong>
|
| 233 |
+
{v.preview_url && (
|
| 234 |
+
<button className="btn btn-small" onClick={() => play(v.preview_url!, v.voice_id)} aria-label={`Play ${v.name}`}>
|
| 235 |
+
{playing === v.voice_id ? "Stop" : "Play"}
|
| 236 |
+
</button>
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
<p className="mono muted small">{[v.gender, v.age?.replace("_", " "), v.accent, v.use_case?.replace(/_/g, " ")].filter(Boolean).join(" · ")}</p>
|
| 240 |
+
{v.description && <p className="muted small clamp-3">{v.description}</p>}
|
| 241 |
+
{v.notice_period ? <p className="mono muted small">Owner notice period: {v.notice_period} days</p> : null}
|
| 242 |
+
<button
|
| 243 |
+
className="btn btn-small btn-primary"
|
| 244 |
+
disabled={adding !== null}
|
| 245 |
+
onClick={async () => {
|
| 246 |
+
setAdding(v.voice_id);
|
| 247 |
+
setError(null);
|
| 248 |
+
try {
|
| 249 |
+
onAdded(await voiceApi.addFromLibrary(v, host));
|
| 250 |
+
onClose();
|
| 251 |
+
} catch (e) {
|
| 252 |
+
setError(errorMessage(e));
|
| 253 |
+
} finally {
|
| 254 |
+
setAdding(null);
|
| 255 |
+
}
|
| 256 |
+
}}
|
| 257 |
+
>
|
| 258 |
+
{adding === v.voice_id ? "Adding..." : `Use as ${host === "host_a" ? "Host A" : "Host B"}`}
|
| 259 |
+
</button>
|
| 260 |
+
</li>
|
| 261 |
+
))}
|
| 262 |
+
{voices?.length === 0 && !error && <li className="muted">No voices match those filters.</li>}
|
| 263 |
+
</ul>
|
| 264 |
+
{hasMore && (
|
| 265 |
+
<button className="btn" onClick={() => load(page + 1)}>
|
| 266 |
+
More voices
|
| 267 |
+
</button>
|
| 268 |
+
)}
|
| 269 |
+
</div>
|
| 270 |
+
</Modal>
|
| 271 |
+
);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// Cloning
|
| 275 |
+
|
| 276 |
+
type Take = { file: File; url: string; seconds: number };
|
| 277 |
+
|
| 278 |
+
function Recorder({ onTake }: { onTake: (t: Take) => void }) {
|
| 279 |
const [recording, setRecording] = useState(false);
|
| 280 |
const [seconds, setSeconds] = useState(0);
|
| 281 |
+
const [level, setLevel] = useState(0);
|
| 282 |
const [error, setError] = useState<string | null>(null);
|
| 283 |
const rec = useRef<MediaRecorder | null>(null);
|
| 284 |
const timer = useRef<number>();
|
| 285 |
+
const raf = useRef<number>();
|
| 286 |
|
| 287 |
const start = async () => {
|
| 288 |
setError(null);
|
| 289 |
try {
|
| 290 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: true } });
|
| 291 |
+
const ac = new AudioContext();
|
| 292 |
+
const analyser = ac.createAnalyser();
|
| 293 |
+
ac.createMediaStreamSource(stream).connect(analyser);
|
| 294 |
+
const buf = new Uint8Array(analyser.fftSize);
|
| 295 |
+
const meter = () => {
|
| 296 |
+
analyser.getByteTimeDomainData(buf);
|
| 297 |
+
let peak = 0;
|
| 298 |
+
for (const b of buf) peak = Math.max(peak, Math.abs(b - 128));
|
| 299 |
+
setLevel(peak / 128);
|
| 300 |
+
raf.current = requestAnimationFrame(meter);
|
| 301 |
+
};
|
| 302 |
+
meter();
|
| 303 |
const chunks: Blob[] = [];
|
| 304 |
const mr = new MediaRecorder(stream);
|
| 305 |
+
const started = Date.now();
|
| 306 |
mr.ondataavailable = (e) => chunks.push(e.data);
|
| 307 |
mr.onstop = () => {
|
| 308 |
stream.getTracks().forEach((t) => t.stop());
|
| 309 |
+
cancelAnimationFrame(raf.current!);
|
| 310 |
+
void ac.close();
|
| 311 |
const type = mr.mimeType.split(";")[0] || "audio/webm";
|
| 312 |
+
const file = new File(chunks, `take-${Date.now()}.${type.includes("mp4") ? "m4a" : "webm"}`, { type });
|
| 313 |
+
onTake({ file, url: URL.createObjectURL(file), seconds: (Date.now() - started) / 1000 });
|
| 314 |
+
setLevel(0);
|
| 315 |
};
|
| 316 |
mr.start();
|
| 317 |
rec.current = mr;
|
|
|
|
| 319 |
setSeconds(0);
|
| 320 |
timer.current = window.setInterval(() => setSeconds((s) => s + 1), 1000);
|
| 321 |
} catch {
|
| 322 |
+
setError("Microphone access was blocked. You can upload recordings instead.");
|
| 323 |
}
|
| 324 |
};
|
| 325 |
const stop = () => {
|
|
|
|
| 329 |
};
|
| 330 |
|
| 331 |
return (
|
| 332 |
+
<div className="stack">
|
| 333 |
+
<div className="row">
|
| 334 |
+
{recording ? (
|
| 335 |
+
<button type="button" className="btn btn-danger armed" onClick={stop}>
|
| 336 |
+
Stop take ({fmtSeconds(seconds)})
|
| 337 |
+
</button>
|
| 338 |
+
) : (
|
| 339 |
+
<button type="button" className="btn btn-primary" onClick={start}>
|
| 340 |
+
Record a take
|
| 341 |
+
</button>
|
| 342 |
+
)}
|
| 343 |
+
{recording && (
|
| 344 |
+
<div className="level" aria-label="Input level">
|
| 345 |
+
<div className="level-fill" style={{ width: `${Math.min(100, level * 160)}%` }} />
|
| 346 |
+
</div>
|
| 347 |
+
)}
|
| 348 |
+
</div>
|
| 349 |
+
{recording && level < 0.04 && seconds > 2 && <p className="muted small">We can barely hear you. Move closer to the microphone.</p>}
|
| 350 |
+
{level > 0.95 && <p className="muted small">Too loud: back off a little so it does not clip.</p>}
|
| 351 |
{error && <p className="error-text">{error}</p>}
|
| 352 |
</div>
|
| 353 |
);
|
| 354 |
}
|
| 355 |
|
| 356 |
function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: () => void; onDone: (job: Job) => void }) {
|
| 357 |
+
const [script, setScript] = useState<{ title: string; text: string } | null>(null);
|
| 358 |
+
const [takes, setTakes] = useState<Take[]>([]);
|
| 359 |
const [agreed, setAgreed] = useState(false);
|
| 360 |
+
const [denoise, setDenoise] = useState(true);
|
| 361 |
const [busy, setBusy] = useState(false);
|
| 362 |
const [error, setError] = useState<string | null>(null);
|
| 363 |
+
const total = takes.reduce((n, t) => n + t.seconds, 0);
|
| 364 |
+
|
| 365 |
+
useEffect(() => {
|
| 366 |
+
voiceApi.readingScript().then(setScript, () => setScript(null));
|
| 367 |
+
}, []);
|
| 368 |
+
|
| 369 |
+
const addFiles = (files: FileList | null) => {
|
| 370 |
+
for (const file of Array.from(files ?? [])) {
|
| 371 |
+
const url = URL.createObjectURL(file);
|
| 372 |
+
const probe = new Audio(url);
|
| 373 |
+
probe.onloadedmetadata = () =>
|
| 374 |
+
setTakes((t) => [...t, { file, url, seconds: Number.isFinite(probe.duration) ? probe.duration : 60 }]);
|
| 375 |
+
}
|
| 376 |
+
};
|
| 377 |
+
|
| 378 |
+
const quality = total < 30 ? "Too short: record at least 30 seconds" : total < 60 ? "Usable, but 1 to 3 minutes sounds much better" : total <= 300 ? "Great length" : "Plenty: more than 5 minutes adds little";
|
| 379 |
+
|
| 380 |
return (
|
| 381 |
+
<Modal title="Clone your voice" onClose={onClose} wide>
|
| 382 |
<div className="stack">
|
| 383 |
+
<ol className="clone-tips muted small">
|
| 384 |
+
<li>Quiet room, no music, phone on silent. Keep the same distance from the mic.</li>
|
| 385 |
+
<li>Read naturally, the way you would on a podcast. Pauses are fine.</li>
|
| 386 |
+
<li>Record in one or more takes: 1 to 3 minutes in total works best.</li>
|
| 387 |
+
</ol>
|
| 388 |
+
{script && (
|
| 389 |
+
<div className="reading-script">
|
| 390 |
+
<p className="eyebrow">Read this, from "{script.title}"</p>
|
| 391 |
+
{script.text.split(/\n\n+/).map((p, i) => (
|
| 392 |
+
<p key={i}>{p}</p>
|
| 393 |
+
))}
|
| 394 |
+
</div>
|
| 395 |
+
)}
|
| 396 |
+
<Recorder onTake={(t) => setTakes((list) => [...list, t])} />
|
| 397 |
<label className="field">
|
| 398 |
+
<span>Or upload recordings (mp3, m4a, wav, webm)</span>
|
| 399 |
+
<input className="input file-input" type="file" accept="audio/*" multiple onChange={(e) => addFiles(e.target.files)} />
|
| 400 |
+
</label>
|
| 401 |
+
{takes.length > 0 && (
|
| 402 |
+
<ul className="takes">
|
| 403 |
+
{takes.map((t, i) => (
|
| 404 |
+
<li key={t.url} className="row">
|
| 405 |
+
<span className="mono muted">Take {i + 1} · {fmtSeconds(t.seconds)}</span>
|
| 406 |
+
<audio controls src={t.url} className="player take-player" />
|
| 407 |
+
<button className="icon-btn" aria-label={`Remove take ${i + 1}`} onClick={() => setTakes(takes.filter((x) => x !== t))}>
|
| 408 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
| 409 |
+
<path d="M6 6l12 12M18 6L6 18" />
|
| 410 |
+
</svg>
|
| 411 |
+
</button>
|
| 412 |
+
</li>
|
| 413 |
+
))}
|
| 414 |
+
</ul>
|
| 415 |
+
)}
|
| 416 |
+
<div className="meter">
|
| 417 |
+
<div className="row between">
|
| 418 |
+
<span>Total {fmtSeconds(total)}</span>
|
| 419 |
+
<span className="mono muted small">{takes.length ? quality : "No takes yet"}</span>
|
| 420 |
+
</div>
|
| 421 |
+
<div className="ascent-track">
|
| 422 |
+
<div className="ascent-fill" style={{ width: `${Math.min(100, (total / 180) * 100)}%` }} />
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
<label className="check">
|
| 426 |
+
<input type="checkbox" checked={denoise} onChange={(e) => setDenoise(e.target.checked)} />
|
| 427 |
+
Remove background noise (turn off for studio quality recordings)
|
| 428 |
</label>
|
|
|
|
| 429 |
<blockquote className="consent">{state.clone.consent_text}</blockquote>
|
| 430 |
<label className="check">
|
| 431 |
+
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />I agree to the statement above, and this is my own voice.
|
| 432 |
</label>
|
| 433 |
{error && <p className="error-text">{error}</p>}
|
| 434 |
<div className="row end">
|
|
|
|
| 437 |
</button>
|
| 438 |
<button
|
| 439 |
className="btn btn-primary"
|
| 440 |
+
disabled={!takes.length || total < 10 || !agreed || busy}
|
| 441 |
onClick={async () => {
|
| 442 |
setBusy(true);
|
| 443 |
setError(null);
|
| 444 |
try {
|
| 445 |
+
const res = await voiceApi.consent(takes.map((t) => t.file), state.clone.consent_text, denoise);
|
| 446 |
onDone(res.job);
|
| 447 |
onClose();
|
| 448 |
} catch (e) {
|
|
|
|
| 464 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 465 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 466 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 467 |
+
const [quota, setQuota] = useState<Quota>({});
|
| 468 |
const [picking, setPicking] = useState(false);
|
| 469 |
const [samples, setSamples] = useState<string[]>([]);
|
| 470 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 471 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 472 |
const [cloning, setCloning] = useState(false);
|
| 473 |
+
const [browsing, setBrowsing] = useState<Host | null>(null);
|
| 474 |
const [saved, setSaved] = useState(false);
|
| 475 |
const [error, setError] = useState<string | null>(null);
|
| 476 |
+
const { play, playing } = usePlayer();
|
| 477 |
|
| 478 |
const load = () =>
|
| 479 |
voiceApi.get().then((s) => {
|
|
|
|
| 481 |
setDraft(s.profile);
|
| 482 |
setSamples(s.sample_doc_ids);
|
| 483 |
});
|
| 484 |
+
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 485 |
|
| 486 |
useEffect(() => {
|
| 487 |
load();
|
| 488 |
+
loadVoices();
|
| 489 |
+
voiceApi.quota().then(setQuota, () => setQuota({}));
|
| 490 |
}, []);
|
| 491 |
|
| 492 |
const build = useJob(buildJob, () => load());
|
| 493 |
const clone = useJob(cloneJob, () => {
|
| 494 |
load();
|
| 495 |
+
loadVoices();
|
| 496 |
});
|
| 497 |
|
| 498 |
if (!state) return <Loading />;
|
| 499 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 500 |
+
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 501 |
|
| 502 |
+
const saveProfile = async () => {
|
| 503 |
setError(null);
|
| 504 |
try {
|
| 505 |
+
setState(await voiceApi.update({ profile: draft! }));
|
|
|
|
| 506 |
setSaved(true);
|
| 507 |
setTimeout(() => setSaved(false), 1500);
|
| 508 |
} catch (e) {
|
|
|
|
| 510 |
}
|
| 511 |
};
|
| 512 |
|
| 513 |
+
const used = quota.character_count ?? 0;
|
| 514 |
+
const limit = quota.character_limit ?? 0;
|
| 515 |
+
|
| 516 |
return (
|
| 517 |
<div className="page-wrap">
|
| 518 |
+
<PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud">
|
| 519 |
<button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}>
|
| 520 |
+
{state.profile ? "Rebuild writing voice" : "Build my writing voice"}
|
| 521 |
</button>
|
| 522 |
</PageHeader>
|
|
|
|
|
|
|
|
|
|
| 523 |
{build && (building || build.status === "failed") && <JobProgress job={build} />}
|
| 524 |
{error && <p className="error-text">{error}</p>}
|
| 525 |
|
| 526 |
<div className="two-col">
|
| 527 |
<section className="card stack">
|
| 528 |
<h2>Writing voice</h2>
|
| 529 |
+
<p className="muted small">Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.</p>
|
| 530 |
{!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>}
|
| 531 |
{draft && (
|
| 532 |
<>
|
|
|
|
| 554 |
</label>
|
| 555 |
))}
|
| 556 |
<div className="row between">
|
| 557 |
+
<span className="mono muted small">
|
| 558 |
+
Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts
|
| 559 |
+
</span>
|
| 560 |
+
<button className="btn btn-primary btn-small" onClick={saveProfile}>
|
| 561 |
{saved ? "Saved" : "Save changes"}
|
| 562 |
</button>
|
| 563 |
</div>
|
|
|
|
| 565 |
)}
|
| 566 |
</section>
|
| 567 |
|
| 568 |
+
<div className="stack">
|
| 569 |
+
<section className="card stack">
|
| 570 |
+
<div className="row between">
|
| 571 |
+
<h2>Speaking voices</h2>
|
| 572 |
+
{limit > 0 && (
|
| 573 |
+
<span className="mono muted small" title="ElevenLabs characters this billing period">
|
| 574 |
+
{used.toLocaleString()} / {limit.toLocaleString()} chars
|
| 575 |
+
</span>
|
| 576 |
+
)}
|
| 577 |
+
</div>
|
| 578 |
+
{!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
|
| 579 |
+
<p className="muted small">
|
| 580 |
+
Audio overviews use both hosts; videos are narrated by Host A. Model: {state.models[state.model] ?? state.model}.
|
| 581 |
+
</p>
|
| 582 |
+
{(["host_a", "host_b"] as Host[]).map((h) => (
|
| 583 |
+
<HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
|
| 584 |
+
))}
|
| 585 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
|
| 587 |
+
<section className="card stack">
|
| 588 |
+
<h2>Your own voice</h2>
|
| 589 |
+
{!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>}
|
| 590 |
+
{quota.can_use_instant_voice_cloning === false && (
|
| 591 |
+
<p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
|
| 592 |
+
)}
|
| 593 |
+
{state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
|
| 594 |
+
<>
|
| 595 |
+
<p className="muted">
|
| 596 |
+
Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
|
| 597 |
+
</p>
|
| 598 |
+
{state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
|
| 599 |
+
<button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
|
| 600 |
+
Clone my voice
|
| 601 |
+
</button>
|
| 602 |
+
</>
|
| 603 |
+
)}
|
| 604 |
+
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
|
| 605 |
+
{state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
|
| 606 |
+
{state.clone.status === "ready" && (
|
| 607 |
+
<>
|
| 608 |
+
<p className="muted">Your voice is live and set as Host A. Adjust its delivery above.</p>
|
| 609 |
+
<div className="row">
|
| 610 |
+
{state.clone.preview_url && (
|
| 611 |
+
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 612 |
+
{playing === "clone" ? "Stop" : "Hear my voice"}
|
| 613 |
+
</button>
|
| 614 |
+
)}
|
| 615 |
+
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 616 |
+
Record again
|
| 617 |
+
</button>
|
| 618 |
+
<ConfirmButton
|
| 619 |
+
confirmLabel="Revoke and delete my voice?"
|
| 620 |
+
onConfirm={async () => {
|
| 621 |
+
setState(await voiceApi.revoke());
|
| 622 |
+
loadVoices();
|
| 623 |
+
}}
|
| 624 |
+
>
|
| 625 |
+
Revoke consent
|
| 626 |
+
</ConfirmButton>
|
| 627 |
+
</div>
|
| 628 |
+
</>
|
| 629 |
+
)}
|
| 630 |
+
</section>
|
| 631 |
+
</div>
|
| 632 |
</div>
|
| 633 |
|
| 634 |
{picking && (
|
|
|
|
| 653 |
</Modal>
|
| 654 |
)}
|
| 655 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 656 |
+
{browsing && (
|
| 657 |
+
<LibraryModal
|
| 658 |
+
host={browsing}
|
| 659 |
+
onClose={() => setBrowsing(null)}
|
| 660 |
+
onAdded={(s) => {
|
| 661 |
+
setState(s);
|
| 662 |
+
loadVoices();
|
| 663 |
+
}}
|
| 664 |
+
/>
|
| 665 |
+
)}
|
| 666 |
</div>
|
| 667 |
);
|
| 668 |
}
|
frontend/src/styles/app.css
CHANGED
|
@@ -1076,3 +1076,191 @@
|
|
| 1076 |
display: grid;
|
| 1077 |
gap: 2px;
|
| 1078 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
display: grid;
|
| 1077 |
gap: 2px;
|
| 1078 |
}
|
| 1079 |
+
|
| 1080 |
+
/* ---------- Landing: flare, shimmer, arrivals, demo video ---------- */
|
| 1081 |
+
|
| 1082 |
+
/* A soft light rides along the horizon; small glints twinkle where it passes. */
|
| 1083 |
+
.horizon {
|
| 1084 |
+
overflow: visible;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.horizon-flare {
|
| 1088 |
+
position: absolute;
|
| 1089 |
+
top: -3px;
|
| 1090 |
+
left: 50%;
|
| 1091 |
+
width: 16vw;
|
| 1092 |
+
height: 3px;
|
| 1093 |
+
margin-left: -8vw;
|
| 1094 |
+
border-radius: 999px;
|
| 1095 |
+
background: radial-gradient(50% 50% at 50% 50%, #ffffff 0%, var(--blue) 35%, transparent 70%);
|
| 1096 |
+
filter: blur(0.5px);
|
| 1097 |
+
box-shadow: 0 0 26px 4px var(--blue-60), 0 0 60px 8px var(--blue-20);
|
| 1098 |
+
transform-origin: 50% 75vw;
|
| 1099 |
+
animation: flare-ride 18s cubic-bezier(0.45, 0, 0.55, 1) infinite;
|
| 1100 |
+
opacity: 0;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.horizon-flare::after {
|
| 1104 |
+
content: "";
|
| 1105 |
+
position: absolute;
|
| 1106 |
+
left: 50%;
|
| 1107 |
+
top: 50%;
|
| 1108 |
+
width: 56px;
|
| 1109 |
+
height: 56px;
|
| 1110 |
+
margin: -28px 0 0 -28px;
|
| 1111 |
+
border-radius: 50%;
|
| 1112 |
+
background: radial-gradient(circle, #ffffff 0%, rgba(255, 255, 255, 0.6) 10%, var(--blue-60) 26%, transparent 64%);
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
@keyframes flare-ride {
|
| 1116 |
+
0% {
|
| 1117 |
+
transform: rotate(-26deg);
|
| 1118 |
+
opacity: 0;
|
| 1119 |
+
}
|
| 1120 |
+
15% {
|
| 1121 |
+
opacity: 0.9;
|
| 1122 |
+
}
|
| 1123 |
+
50% {
|
| 1124 |
+
opacity: 1;
|
| 1125 |
+
}
|
| 1126 |
+
85% {
|
| 1127 |
+
opacity: 0.8;
|
| 1128 |
+
}
|
| 1129 |
+
100% {
|
| 1130 |
+
transform: rotate(26deg);
|
| 1131 |
+
opacity: 0;
|
| 1132 |
+
}
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.horizon-glint {
|
| 1136 |
+
position: absolute;
|
| 1137 |
+
top: -10px;
|
| 1138 |
+
width: 12px;
|
| 1139 |
+
height: 12px;
|
| 1140 |
+
margin-left: -6px;
|
| 1141 |
+
background:
|
| 1142 |
+
linear-gradient(90deg, transparent 45%, #ffffff 50%, transparent 55%),
|
| 1143 |
+
linear-gradient(0deg, transparent 45%, #ffffff 50%, transparent 55%);
|
| 1144 |
+
filter: drop-shadow(0 0 6px var(--blue)) drop-shadow(0 0 2px #ffffff);
|
| 1145 |
+
opacity: 0;
|
| 1146 |
+
animation: glint 18s ease-in-out infinite;
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
+
.horizon-glint.g1 {
|
| 1150 |
+
left: 38%;
|
| 1151 |
+
animation-delay: 5.2s;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
.horizon-glint.g2 {
|
| 1155 |
+
left: 50%;
|
| 1156 |
+
top: -12px;
|
| 1157 |
+
animation-delay: 8.8s;
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
.horizon-glint.g3 {
|
| 1161 |
+
left: 62%;
|
| 1162 |
+
animation-delay: 12.4s;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
@keyframes glint {
|
| 1166 |
+
0%,
|
| 1167 |
+
100% {
|
| 1168 |
+
opacity: 0;
|
| 1169 |
+
transform: scale(0.2) rotate(0deg);
|
| 1170 |
+
}
|
| 1171 |
+
4% {
|
| 1172 |
+
opacity: 1;
|
| 1173 |
+
transform: scale(1.1) rotate(45deg);
|
| 1174 |
+
}
|
| 1175 |
+
10% {
|
| 1176 |
+
opacity: 0;
|
| 1177 |
+
transform: scale(0.4) rotate(90deg);
|
| 1178 |
+
}
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
/* A light sweep crosses the hero every few seconds, catching the headline. */
|
| 1182 |
+
.hero-sweep {
|
| 1183 |
+
position: absolute;
|
| 1184 |
+
inset: 0;
|
| 1185 |
+
z-index: 1;
|
| 1186 |
+
pointer-events: none;
|
| 1187 |
+
background: linear-gradient(105deg, transparent 42%, rgba(255, 255, 255, 0.035) 48%, var(--blue-05) 50%, transparent 55%);
|
| 1188 |
+
background-size: 250% 100%;
|
| 1189 |
+
animation: sweep 16s ease-in-out infinite;
|
| 1190 |
+
mix-blend-mode: screen;
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
@keyframes sweep {
|
| 1194 |
+
0% {
|
| 1195 |
+
background-position: 130% 0;
|
| 1196 |
+
}
|
| 1197 |
+
45%,
|
| 1198 |
+
100% {
|
| 1199 |
+
background-position: -40% 0;
|
| 1200 |
+
}
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
.glow-text.shimmer {
|
| 1204 |
+
background: linear-gradient(100deg, #ffffff 35%, #cfe2ff 45%, #ffffff 50%, var(--blue) 55%, #ffffff 65%);
|
| 1205 |
+
background-size: 300% 100%;
|
| 1206 |
+
-webkit-background-clip: text;
|
| 1207 |
+
background-clip: text;
|
| 1208 |
+
-webkit-text-fill-color: transparent;
|
| 1209 |
+
animation: shimmer 16s ease-in-out infinite;
|
| 1210 |
+
filter: drop-shadow(0 0 18px var(--blue-35)) drop-shadow(0 0 40px var(--blue-20));
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
@keyframes shimmer {
|
| 1214 |
+
0% {
|
| 1215 |
+
background-position: 100% 0;
|
| 1216 |
+
}
|
| 1217 |
+
45%,
|
| 1218 |
+
100% {
|
| 1219 |
+
background-position: 0% 0;
|
| 1220 |
+
}
|
| 1221 |
+
}
|
| 1222 |
+
|
| 1223 |
+
/* Sections arrive out of the depth. */
|
| 1224 |
+
.arrive {
|
| 1225 |
+
opacity: 0;
|
| 1226 |
+
transform: perspective(900px) translateZ(-90px) translateY(28px);
|
| 1227 |
+
filter: blur(4px);
|
| 1228 |
+
transition:
|
| 1229 |
+
opacity 1.4s ease,
|
| 1230 |
+
transform 1.8s cubic-bezier(0.22, 1, 0.36, 1),
|
| 1231 |
+
filter 1.4s ease;
|
| 1232 |
+
transition-delay: calc(var(--i, 0) * 260ms);
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
.arrive.arrived {
|
| 1236 |
+
opacity: 1;
|
| 1237 |
+
transform: none;
|
| 1238 |
+
filter: none;
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
.demo-video {
|
| 1242 |
+
display: block;
|
| 1243 |
+
width: 100%;
|
| 1244 |
+
aspect-ratio: 16 / 9;
|
| 1245 |
+
background: var(--black);
|
| 1246 |
+
}
|
| 1247 |
+
|
| 1248 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1249 |
+
.horizon-flare,
|
| 1250 |
+
.horizon-glint,
|
| 1251 |
+
.hero-sweep,
|
| 1252 |
+
.glow-text.shimmer {
|
| 1253 |
+
animation: none;
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
.horizon-flare {
|
| 1257 |
+
opacity: 0.8;
|
| 1258 |
+
transform: none;
|
| 1259 |
+
}
|
| 1260 |
+
|
| 1261 |
+
.arrive {
|
| 1262 |
+
opacity: 1;
|
| 1263 |
+
transform: none;
|
| 1264 |
+
filter: none;
|
| 1265 |
+
}
|
| 1266 |
+
}
|
frontend/src/styles/pages.css
CHANGED
|
@@ -1506,3 +1506,692 @@ select.input option {
|
|
| 1506 |
animation: none;
|
| 1507 |
}
|
| 1508 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1506 |
animation: none;
|
| 1507 |
}
|
| 1508 |
}
|
| 1509 |
+
|
| 1510 |
+
/* Markdown in answers and summaries */
|
| 1511 |
+
.md {
|
| 1512 |
+
display: grid;
|
| 1513 |
+
gap: 0.85em;
|
| 1514 |
+
line-height: 1.7;
|
| 1515 |
+
font-size: 0.975rem;
|
| 1516 |
+
color: var(--text);
|
| 1517 |
+
overflow-wrap: anywhere;
|
| 1518 |
+
}
|
| 1519 |
+
|
| 1520 |
+
.md > * {
|
| 1521 |
+
margin: 0;
|
| 1522 |
+
}
|
| 1523 |
+
|
| 1524 |
+
.md h3,
|
| 1525 |
+
.md h4,
|
| 1526 |
+
.md h5 {
|
| 1527 |
+
font-family: var(--font-display);
|
| 1528 |
+
line-height: 1.3;
|
| 1529 |
+
margin-top: 0.4em;
|
| 1530 |
+
}
|
| 1531 |
+
|
| 1532 |
+
.md h3 {
|
| 1533 |
+
font-size: 1.15rem;
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
.md h4 {
|
| 1537 |
+
font-size: 1.02rem;
|
| 1538 |
+
}
|
| 1539 |
+
|
| 1540 |
+
.md h5 {
|
| 1541 |
+
font-size: 0.95rem;
|
| 1542 |
+
color: var(--text-2);
|
| 1543 |
+
}
|
| 1544 |
+
|
| 1545 |
+
.md ul,
|
| 1546 |
+
.md ol {
|
| 1547 |
+
padding-left: 1.4em;
|
| 1548 |
+
display: grid;
|
| 1549 |
+
gap: 0.4em;
|
| 1550 |
+
}
|
| 1551 |
+
|
| 1552 |
+
.md li > ul,
|
| 1553 |
+
.md li > ol {
|
| 1554 |
+
margin-top: 0.4em;
|
| 1555 |
+
}
|
| 1556 |
+
|
| 1557 |
+
.md ul li::marker {
|
| 1558 |
+
color: var(--blue);
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
.md ol li::marker {
|
| 1562 |
+
color: var(--blue);
|
| 1563 |
+
font-family: var(--font-mono);
|
| 1564 |
+
font-size: 0.85em;
|
| 1565 |
+
}
|
| 1566 |
+
|
| 1567 |
+
.md strong {
|
| 1568 |
+
color: var(--white);
|
| 1569 |
+
font-weight: 650;
|
| 1570 |
+
}
|
| 1571 |
+
|
| 1572 |
+
.md em {
|
| 1573 |
+
color: var(--text-2);
|
| 1574 |
+
}
|
| 1575 |
+
|
| 1576 |
+
.md a {
|
| 1577 |
+
color: var(--white);
|
| 1578 |
+
text-decoration-color: var(--blue-60);
|
| 1579 |
+
text-underline-offset: 3px;
|
| 1580 |
+
}
|
| 1581 |
+
|
| 1582 |
+
.md code {
|
| 1583 |
+
font-family: var(--font-mono);
|
| 1584 |
+
font-size: 0.85em;
|
| 1585 |
+
padding: 1px 6px;
|
| 1586 |
+
border-radius: 6px;
|
| 1587 |
+
background: var(--surface-2);
|
| 1588 |
+
border: 1px solid var(--line);
|
| 1589 |
+
}
|
| 1590 |
+
|
| 1591 |
+
.md pre {
|
| 1592 |
+
padding: 14px 16px;
|
| 1593 |
+
border-radius: var(--radius-sm);
|
| 1594 |
+
background: rgba(255, 255, 255, 0.04);
|
| 1595 |
+
border: 1px solid var(--line);
|
| 1596 |
+
overflow-x: auto;
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
.md pre code {
|
| 1600 |
+
padding: 0;
|
| 1601 |
+
border: none;
|
| 1602 |
+
background: none;
|
| 1603 |
+
font-size: 0.8125rem;
|
| 1604 |
+
line-height: 1.6;
|
| 1605 |
+
}
|
| 1606 |
+
|
| 1607 |
+
.md blockquote {
|
| 1608 |
+
padding: 4px 0 4px 16px;
|
| 1609 |
+
border-left: 3px solid var(--blue);
|
| 1610 |
+
color: var(--text-2);
|
| 1611 |
+
}
|
| 1612 |
+
|
| 1613 |
+
.md hr {
|
| 1614 |
+
border: none;
|
| 1615 |
+
height: 1px;
|
| 1616 |
+
background: linear-gradient(90deg, transparent, var(--blue-35), transparent);
|
| 1617 |
+
}
|
| 1618 |
+
|
| 1619 |
+
.md-table {
|
| 1620 |
+
overflow-x: auto;
|
| 1621 |
+
}
|
| 1622 |
+
|
| 1623 |
+
.md table {
|
| 1624 |
+
width: 100%;
|
| 1625 |
+
border-collapse: collapse;
|
| 1626 |
+
font-size: 0.875rem;
|
| 1627 |
+
}
|
| 1628 |
+
|
| 1629 |
+
.md th,
|
| 1630 |
+
.md td {
|
| 1631 |
+
padding: 8px 10px;
|
| 1632 |
+
border-bottom: 1px solid var(--line);
|
| 1633 |
+
text-align: left;
|
| 1634 |
+
vertical-align: top;
|
| 1635 |
+
}
|
| 1636 |
+
|
| 1637 |
+
.md th {
|
| 1638 |
+
font-family: var(--font-mono);
|
| 1639 |
+
font-size: 0.75rem;
|
| 1640 |
+
letter-spacing: 0.06em;
|
| 1641 |
+
text-transform: uppercase;
|
| 1642 |
+
color: var(--blue);
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
.turn-md {
|
| 1646 |
+
padding: 4px 2px;
|
| 1647 |
+
}
|
| 1648 |
+
|
| 1649 |
+
.turn-assistant + .turn-user {
|
| 1650 |
+
margin-top: 8px;
|
| 1651 |
+
}
|
| 1652 |
+
|
| 1653 |
+
/* Topic map zoom and pan */
|
| 1654 |
+
.map-canvas {
|
| 1655 |
+
position: relative;
|
| 1656 |
+
overflow: hidden;
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
.map-svg {
|
| 1660 |
+
cursor: grab;
|
| 1661 |
+
touch-action: none;
|
| 1662 |
+
user-select: none;
|
| 1663 |
+
aspect-ratio: 900 / 620;
|
| 1664 |
+
}
|
| 1665 |
+
|
| 1666 |
+
.map-svg:active {
|
| 1667 |
+
cursor: grabbing;
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
.map-svg:focus-visible {
|
| 1671 |
+
outline: 1px solid var(--blue-60);
|
| 1672 |
+
outline-offset: -1px;
|
| 1673 |
+
}
|
| 1674 |
+
|
| 1675 |
+
.map-controls {
|
| 1676 |
+
position: absolute;
|
| 1677 |
+
top: 12px;
|
| 1678 |
+
right: 12px;
|
| 1679 |
+
z-index: 1;
|
| 1680 |
+
display: grid;
|
| 1681 |
+
gap: 4px;
|
| 1682 |
+
padding: 4px;
|
| 1683 |
+
border: 1px solid var(--line);
|
| 1684 |
+
border-radius: 12px;
|
| 1685 |
+
background: rgba(0, 0, 0, 0.7);
|
| 1686 |
+
}
|
| 1687 |
+
|
| 1688 |
+
.map-controls .icon-btn {
|
| 1689 |
+
font: 600 1rem var(--font-mono);
|
| 1690 |
+
color: var(--white);
|
| 1691 |
+
}
|
| 1692 |
+
|
| 1693 |
+
.map-controls .icon-btn.mono {
|
| 1694 |
+
font-size: 0.65rem;
|
| 1695 |
+
}
|
| 1696 |
+
|
| 1697 |
+
.map-hint {
|
| 1698 |
+
position: absolute;
|
| 1699 |
+
left: 16px;
|
| 1700 |
+
bottom: 10px;
|
| 1701 |
+
z-index: 1;
|
| 1702 |
+
font-size: 0.7rem;
|
| 1703 |
+
pointer-events: none;
|
| 1704 |
+
}
|
| 1705 |
+
|
| 1706 |
+
/* Voice page */
|
| 1707 |
+
.host-card {
|
| 1708 |
+
display: grid;
|
| 1709 |
+
gap: 10px;
|
| 1710 |
+
padding: 14px;
|
| 1711 |
+
border: 1px solid var(--line);
|
| 1712 |
+
border-radius: var(--radius-sm);
|
| 1713 |
+
}
|
| 1714 |
+
|
| 1715 |
+
.host-card select {
|
| 1716 |
+
flex: 1;
|
| 1717 |
+
min-width: 0;
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
.delivery summary {
|
| 1721 |
+
cursor: pointer;
|
| 1722 |
+
}
|
| 1723 |
+
|
| 1724 |
+
.delivery[open] {
|
| 1725 |
+
display: grid;
|
| 1726 |
+
gap: 10px;
|
| 1727 |
+
}
|
| 1728 |
+
|
| 1729 |
+
.slider {
|
| 1730 |
+
display: grid;
|
| 1731 |
+
gap: 4px;
|
| 1732 |
+
font-size: 0.85rem;
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
.slider input[type="range"] {
|
| 1736 |
+
width: 100%;
|
| 1737 |
+
accent-color: var(--blue);
|
| 1738 |
+
}
|
| 1739 |
+
|
| 1740 |
+
.library-filters {
|
| 1741 |
+
display: grid;
|
| 1742 |
+
gap: 8px;
|
| 1743 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 1744 |
+
}
|
| 1745 |
+
|
| 1746 |
+
.library-filters .input:first-child {
|
| 1747 |
+
grid-column: 1 / -1;
|
| 1748 |
+
}
|
| 1749 |
+
|
| 1750 |
+
.library-grid {
|
| 1751 |
+
list-style: none;
|
| 1752 |
+
margin: 0;
|
| 1753 |
+
padding: 0;
|
| 1754 |
+
display: grid;
|
| 1755 |
+
gap: 10px;
|
| 1756 |
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
| 1757 |
+
max-height: 52vh;
|
| 1758 |
+
overflow: auto;
|
| 1759 |
+
}
|
| 1760 |
+
|
| 1761 |
+
.library-voice {
|
| 1762 |
+
display: grid;
|
| 1763 |
+
gap: 6px;
|
| 1764 |
+
align-content: start;
|
| 1765 |
+
padding: 12px;
|
| 1766 |
+
border: 1px solid var(--line);
|
| 1767 |
+
border-radius: var(--radius-sm);
|
| 1768 |
+
}
|
| 1769 |
+
|
| 1770 |
+
.library-voice:hover {
|
| 1771 |
+
border-color: var(--blue-35);
|
| 1772 |
+
}
|
| 1773 |
+
|
| 1774 |
+
.clone-tips {
|
| 1775 |
+
margin: 0;
|
| 1776 |
+
padding-left: 18px;
|
| 1777 |
+
display: grid;
|
| 1778 |
+
gap: 4px;
|
| 1779 |
+
}
|
| 1780 |
+
|
| 1781 |
+
.reading-script {
|
| 1782 |
+
max-height: 220px;
|
| 1783 |
+
overflow: auto;
|
| 1784 |
+
padding: 14px 16px;
|
| 1785 |
+
border: 1px solid var(--blue-35);
|
| 1786 |
+
border-radius: var(--radius-sm);
|
| 1787 |
+
background: var(--blue-05);
|
| 1788 |
+
font-family: var(--font-display);
|
| 1789 |
+
font-size: 1.05rem;
|
| 1790 |
+
line-height: 1.6;
|
| 1791 |
+
display: grid;
|
| 1792 |
+
gap: 10px;
|
| 1793 |
+
}
|
| 1794 |
+
|
| 1795 |
+
.reading-script p {
|
| 1796 |
+
margin: 0;
|
| 1797 |
+
}
|
| 1798 |
+
|
| 1799 |
+
.takes {
|
| 1800 |
+
list-style: none;
|
| 1801 |
+
margin: 0;
|
| 1802 |
+
padding: 0;
|
| 1803 |
+
display: grid;
|
| 1804 |
+
gap: 6px;
|
| 1805 |
+
}
|
| 1806 |
+
|
| 1807 |
+
.take-player {
|
| 1808 |
+
flex: 1;
|
| 1809 |
+
height: 32px;
|
| 1810 |
+
min-width: 160px;
|
| 1811 |
+
}
|
| 1812 |
+
|
| 1813 |
+
.level {
|
| 1814 |
+
flex: 1;
|
| 1815 |
+
height: 6px;
|
| 1816 |
+
border-radius: 999px;
|
| 1817 |
+
background: var(--surface-2);
|
| 1818 |
+
overflow: hidden;
|
| 1819 |
+
}
|
| 1820 |
+
|
| 1821 |
+
.level-fill {
|
| 1822 |
+
height: 100%;
|
| 1823 |
+
background: var(--blue);
|
| 1824 |
+
box-shadow: 0 0 10px var(--blue);
|
| 1825 |
+
transition: width 0.08s linear;
|
| 1826 |
+
}
|
| 1827 |
+
|
| 1828 |
+
/* ---------- Density pass: tighter spacing, pages fit the window instead of forcing a scroll ---------- */
|
| 1829 |
+
:root {
|
| 1830 |
+
--main-pad-top: 20px;
|
| 1831 |
+
--main-pad-bottom: 20px;
|
| 1832 |
+
}
|
| 1833 |
+
|
| 1834 |
+
.shell-main {
|
| 1835 |
+
padding: var(--main-pad-top) var(--gutter) var(--main-pad-bottom);
|
| 1836 |
+
}
|
| 1837 |
+
|
| 1838 |
+
@media (min-width: 900px) {
|
| 1839 |
+
.shell-main {
|
| 1840 |
+
padding-left: 28px;
|
| 1841 |
+
padding-right: 28px;
|
| 1842 |
+
}
|
| 1843 |
+
|
| 1844 |
+
/* Beats `.shell > :not(.sky) { position: relative }`, so the nav stays put while content scrolls. */
|
| 1845 |
+
.shell > .sidebar {
|
| 1846 |
+
position: sticky;
|
| 1847 |
+
top: 0;
|
| 1848 |
+
height: 100vh;
|
| 1849 |
+
overflow-y: auto;
|
| 1850 |
+
padding: 14px 12px;
|
| 1851 |
+
gap: 14px;
|
| 1852 |
+
}
|
| 1853 |
+
|
| 1854 |
+
.side-link {
|
| 1855 |
+
padding: 7px 10px;
|
| 1856 |
+
font-size: 0.9rem;
|
| 1857 |
+
}
|
| 1858 |
+
}
|
| 1859 |
+
|
| 1860 |
+
.card {
|
| 1861 |
+
padding: 18px;
|
| 1862 |
+
}
|
| 1863 |
+
|
| 1864 |
+
.page-wrap,
|
| 1865 |
+
.mc {
|
| 1866 |
+
gap: 14px;
|
| 1867 |
+
}
|
| 1868 |
+
|
| 1869 |
+
.page-head,
|
| 1870 |
+
.mc-head {
|
| 1871 |
+
margin-bottom: 0;
|
| 1872 |
+
align-items: center;
|
| 1873 |
+
}
|
| 1874 |
+
|
| 1875 |
+
.page-head h1,
|
| 1876 |
+
.mc-head h1 {
|
| 1877 |
+
font-size: clamp(1.35rem, 2.6vw, 1.75rem);
|
| 1878 |
+
margin-top: 4px;
|
| 1879 |
+
}
|
| 1880 |
+
|
| 1881 |
+
.page-head .eyebrow,
|
| 1882 |
+
.mc-head .eyebrow {
|
| 1883 |
+
font-size: 0.7rem;
|
| 1884 |
+
}
|
| 1885 |
+
|
| 1886 |
+
.lede {
|
| 1887 |
+
font-size: 0.9rem;
|
| 1888 |
+
margin-top: -6px;
|
| 1889 |
+
}
|
| 1890 |
+
|
| 1891 |
+
.stack {
|
| 1892 |
+
gap: 10px;
|
| 1893 |
+
}
|
| 1894 |
+
|
| 1895 |
+
.two-col,
|
| 1896 |
+
.grid-cards,
|
| 1897 |
+
.gallery,
|
| 1898 |
+
.mc-grid {
|
| 1899 |
+
gap: 14px;
|
| 1900 |
+
align-items: start; /* no stretched cards with dead space below short content */
|
| 1901 |
+
}
|
| 1902 |
+
|
| 1903 |
+
.mc-card {
|
| 1904 |
+
gap: 10px;
|
| 1905 |
+
}
|
| 1906 |
+
|
| 1907 |
+
.empty-state {
|
| 1908 |
+
padding: 32px 16px;
|
| 1909 |
+
gap: 10px;
|
| 1910 |
+
}
|
| 1911 |
+
|
| 1912 |
+
.empty-state.small {
|
| 1913 |
+
padding: 20px 12px;
|
| 1914 |
+
}
|
| 1915 |
+
|
| 1916 |
+
.empty-state .empty-art {
|
| 1917 |
+
width: 72px;
|
| 1918 |
+
height: 54px;
|
| 1919 |
+
}
|
| 1920 |
+
|
| 1921 |
+
.artifact {
|
| 1922 |
+
padding: 14px;
|
| 1923 |
+
gap: 10px;
|
| 1924 |
+
}
|
| 1925 |
+
|
| 1926 |
+
.video-short {
|
| 1927 |
+
max-height: 360px;
|
| 1928 |
+
}
|
| 1929 |
+
|
| 1930 |
+
.picker-list {
|
| 1931 |
+
max-height: 260px;
|
| 1932 |
+
}
|
| 1933 |
+
|
| 1934 |
+
.doc-row {
|
| 1935 |
+
padding: 8px 0;
|
| 1936 |
+
}
|
| 1937 |
+
|
| 1938 |
+
.source-row {
|
| 1939 |
+
padding: 10px 12px;
|
| 1940 |
+
}
|
| 1941 |
+
|
| 1942 |
+
.loading {
|
| 1943 |
+
padding: 12px 0;
|
| 1944 |
+
}
|
| 1945 |
+
|
| 1946 |
+
/* Notebook: exactly the window height, panes scroll inside. */
|
| 1947 |
+
@media (min-width: 1100px) {
|
| 1948 |
+
.nbv {
|
| 1949 |
+
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
| 1950 |
+
min-height: 480px;
|
| 1951 |
+
}
|
| 1952 |
+
}
|
| 1953 |
+
|
| 1954 |
+
.nbv-chat {
|
| 1955 |
+
min-height: 360px;
|
| 1956 |
+
}
|
| 1957 |
+
|
| 1958 |
+
/* Topic map: canvas fills the space left in the window, never taller. */
|
| 1959 |
+
@media (min-width: 1100px) {
|
| 1960 |
+
.map-layout {
|
| 1961 |
+
height: calc(100vh - 150px);
|
| 1962 |
+
min-height: 420px;
|
| 1963 |
+
}
|
| 1964 |
+
|
| 1965 |
+
.map-canvas,
|
| 1966 |
+
.map-side {
|
| 1967 |
+
height: 100%;
|
| 1968 |
+
max-height: none;
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
.map-canvas {
|
| 1972 |
+
display: grid;
|
| 1973 |
+
place-items: center;
|
| 1974 |
+
}
|
| 1975 |
+
|
| 1976 |
+
/* Keep the 900:620 aspect so pointer math matches the viewBox; fit inside the available box. */
|
| 1977 |
+
.map-svg {
|
| 1978 |
+
height: 100%;
|
| 1979 |
+
width: auto;
|
| 1980 |
+
max-width: 100%;
|
| 1981 |
+
aspect-ratio: 900 / 620;
|
| 1982 |
+
}
|
| 1983 |
+
}
|
| 1984 |
+
|
| 1985 |
+
@media (max-width: 1099px) {
|
| 1986 |
+
.map-svg {
|
| 1987 |
+
max-height: 62vh;
|
| 1988 |
+
}
|
| 1989 |
+
}
|
| 1990 |
+
|
| 1991 |
+
/* Launch Kit: the post picker and kit list share the side column without pushing the page. */
|
| 1992 |
+
@media (min-width: 1100px) {
|
| 1993 |
+
.kit-layout > aside {
|
| 1994 |
+
position: sticky;
|
| 1995 |
+
top: 0;
|
| 1996 |
+
max-height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
| 1997 |
+
overflow-y: auto;
|
| 1998 |
+
}
|
| 1999 |
+
|
| 2000 |
+
.kit-layout .picker-list {
|
| 2001 |
+
max-height: 220px;
|
| 2002 |
+
}
|
| 2003 |
+
}
|
| 2004 |
+
|
| 2005 |
+
/* Launchpad: a month fits on one screen. */
|
| 2006 |
+
.cal-day {
|
| 2007 |
+
min-height: 72px;
|
| 2008 |
+
padding: 4px 5px;
|
| 2009 |
+
gap: 3px;
|
| 2010 |
+
}
|
| 2011 |
+
|
| 2012 |
+
.cal-head {
|
| 2013 |
+
margin-bottom: 8px;
|
| 2014 |
+
}
|
| 2015 |
+
|
| 2016 |
+
.accounts {
|
| 2017 |
+
gap: 8px;
|
| 2018 |
+
}
|
| 2019 |
+
|
| 2020 |
+
/* Modals: compact padding, body scrolls instead of the page. */
|
| 2021 |
+
.modal {
|
| 2022 |
+
gap: 12px;
|
| 2023 |
+
padding: 18px;
|
| 2024 |
+
}
|
| 2025 |
+
|
| 2026 |
+
/* ---------- Containment: text wraps inside its box instead of spilling past borders ---------- */
|
| 2027 |
+
.shell-main,
|
| 2028 |
+
.page-wrap > *,
|
| 2029 |
+
.mc > *,
|
| 2030 |
+
.two-col > *,
|
| 2031 |
+
.grid-cards > *,
|
| 2032 |
+
.gallery > *,
|
| 2033 |
+
.mc-grid > *,
|
| 2034 |
+
.kit-layout > *,
|
| 2035 |
+
.map-layout > *,
|
| 2036 |
+
.nbv > *,
|
| 2037 |
+
.stack > *,
|
| 2038 |
+
.row > *,
|
| 2039 |
+
.field,
|
| 2040 |
+
.artifact-head > div,
|
| 2041 |
+
.source-title,
|
| 2042 |
+
.nb-card,
|
| 2043 |
+
.library-voice,
|
| 2044 |
+
.host-card,
|
| 2045 |
+
.post-edit,
|
| 2046 |
+
.slide-edit,
|
| 2047 |
+
.resurface-row,
|
| 2048 |
+
.recent-list li,
|
| 2049 |
+
.recent-list a {
|
| 2050 |
+
min-width: 0;
|
| 2051 |
+
}
|
| 2052 |
+
|
| 2053 |
+
.card,
|
| 2054 |
+
.modal,
|
| 2055 |
+
.drawer,
|
| 2056 |
+
.md,
|
| 2057 |
+
.artifact-body,
|
| 2058 |
+
.cites,
|
| 2059 |
+
.transcript,
|
| 2060 |
+
.doc-title,
|
| 2061 |
+
.picker-title,
|
| 2062 |
+
.source-title,
|
| 2063 |
+
.nbv-doc,
|
| 2064 |
+
.kit-list,
|
| 2065 |
+
.topic-rank,
|
| 2066 |
+
.hooks,
|
| 2067 |
+
.claims,
|
| 2068 |
+
.seo dd,
|
| 2069 |
+
.account,
|
| 2070 |
+
.angle,
|
| 2071 |
+
.cite-span,
|
| 2072 |
+
.error-text,
|
| 2073 |
+
.notice {
|
| 2074 |
+
overflow-wrap: anywhere;
|
| 2075 |
+
word-break: normal;
|
| 2076 |
+
}
|
| 2077 |
+
|
| 2078 |
+
/* Small buttons may wrap to two lines rather than punch out of narrow panes. */
|
| 2079 |
+
.btn-small {
|
| 2080 |
+
white-space: normal;
|
| 2081 |
+
height: auto;
|
| 2082 |
+
min-height: 34px;
|
| 2083 |
+
padding: 6px 14px;
|
| 2084 |
+
line-height: 1.25;
|
| 2085 |
+
text-align: center;
|
| 2086 |
+
}
|
| 2087 |
+
|
| 2088 |
+
.btn {
|
| 2089 |
+
max-width: 100%;
|
| 2090 |
+
}
|
| 2091 |
+
|
| 2092 |
+
select.input,
|
| 2093 |
+
.input,
|
| 2094 |
+
.textarea {
|
| 2095 |
+
max-width: 100%;
|
| 2096 |
+
min-width: 0;
|
| 2097 |
+
text-overflow: ellipsis;
|
| 2098 |
+
}
|
| 2099 |
+
|
| 2100 |
+
.row > select.input {
|
| 2101 |
+
flex: 1 1 160px;
|
| 2102 |
+
}
|
| 2103 |
+
|
| 2104 |
+
.chip {
|
| 2105 |
+
max-width: 100%;
|
| 2106 |
+
overflow: hidden;
|
| 2107 |
+
text-overflow: ellipsis;
|
| 2108 |
+
white-space: nowrap;
|
| 2109 |
+
}
|
| 2110 |
+
|
| 2111 |
+
.small-link,
|
| 2112 |
+
.source-card a.mono {
|
| 2113 |
+
display: block;
|
| 2114 |
+
overflow: hidden;
|
| 2115 |
+
text-overflow: ellipsis;
|
| 2116 |
+
white-space: nowrap;
|
| 2117 |
+
}
|
| 2118 |
+
|
| 2119 |
+
.account {
|
| 2120 |
+
max-width: 100%;
|
| 2121 |
+
}
|
| 2122 |
+
|
| 2123 |
+
.account .mono {
|
| 2124 |
+
overflow: hidden;
|
| 2125 |
+
text-overflow: ellipsis;
|
| 2126 |
+
white-space: nowrap;
|
| 2127 |
+
max-width: 220px;
|
| 2128 |
+
display: inline-block;
|
| 2129 |
+
vertical-align: bottom;
|
| 2130 |
+
}
|
| 2131 |
+
|
| 2132 |
+
.pill {
|
| 2133 |
+
flex-shrink: 0;
|
| 2134 |
+
}
|
| 2135 |
+
|
| 2136 |
+
.artifact-head h3,
|
| 2137 |
+
.nb-card h3,
|
| 2138 |
+
.kit-view h2 {
|
| 2139 |
+
overflow-wrap: anywhere;
|
| 2140 |
+
}
|
| 2141 |
+
|
| 2142 |
+
.metrics,
|
| 2143 |
+
.picker-actions,
|
| 2144 |
+
.artifact-actions {
|
| 2145 |
+
flex-wrap: wrap;
|
| 2146 |
+
}
|
| 2147 |
+
|
| 2148 |
+
.map-side h2,
|
| 2149 |
+
.map-side .doc-title {
|
| 2150 |
+
overflow-wrap: anywhere;
|
| 2151 |
+
}
|
| 2152 |
+
|
| 2153 |
+
.sidebar-user .mono {
|
| 2154 |
+
overflow: hidden;
|
| 2155 |
+
text-overflow: ellipsis;
|
| 2156 |
+
white-space: nowrap;
|
| 2157 |
+
}
|
| 2158 |
+
|
| 2159 |
+
.reader-line span:last-child {
|
| 2160 |
+
min-width: 0;
|
| 2161 |
+
overflow-wrap: anywhere;
|
| 2162 |
+
}
|
| 2163 |
+
|
| 2164 |
+
/* Content uses the full width next to the sidebar (open or collapsed); centred only on very wide screens. */
|
| 2165 |
+
.page-wrap,
|
| 2166 |
+
.mc,
|
| 2167 |
+
.nbv {
|
| 2168 |
+
width: 100%;
|
| 2169 |
+
max-width: 1760px;
|
| 2170 |
+
margin-inline: auto;
|
| 2171 |
+
}
|
| 2172 |
+
|
| 2173 |
+
.shell-main {
|
| 2174 |
+
width: 100%;
|
| 2175 |
+
}
|
| 2176 |
+
|
| 2177 |
+
@media (min-width: 900px) {
|
| 2178 |
+
.shell.collapsed .shell-main {
|
| 2179 |
+
padding-left: 24px;
|
| 2180 |
+
padding-right: 24px;
|
| 2181 |
+
}
|
| 2182 |
+
}
|
| 2183 |
+
|
| 2184 |
+
@media (min-width: 1100px) {
|
| 2185 |
+
/* Wider screens give the side columns room instead of leaving it empty. */
|
| 2186 |
+
.nbv {
|
| 2187 |
+
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr) minmax(320px, 400px);
|
| 2188 |
+
}
|
| 2189 |
+
|
| 2190 |
+
.kit-layout {
|
| 2191 |
+
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
| 2192 |
+
}
|
| 2193 |
+
|
| 2194 |
+
.map-layout {
|
| 2195 |
+
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
| 2196 |
+
}
|
| 2197 |
+
}
|
frontend/src/styles/showcase.css
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ---------- Landing: feature tour ---------- */
|
| 2 |
+
.showcase {
|
| 3 |
+
display: grid;
|
| 4 |
+
gap: clamp(64px, 12vh, 140px);
|
| 5 |
+
margin-top: 24px;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.showcase-row {
|
| 9 |
+
display: grid;
|
| 10 |
+
gap: 28px;
|
| 11 |
+
align-items: center;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
@media (min-width: 900px) {
|
| 15 |
+
.showcase-row {
|
| 16 |
+
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
| 17 |
+
gap: 56px;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.showcase-row.flip .showcase-copy {
|
| 21 |
+
order: 2;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.showcase-copy {
|
| 26 |
+
display: grid;
|
| 27 |
+
gap: 14px;
|
| 28 |
+
align-content: start;
|
| 29 |
+
justify-items: start;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.showcase-copy h3 {
|
| 33 |
+
font-size: clamp(1.5rem, 3vw, 2.1rem);
|
| 34 |
+
line-height: 1.12;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.showcase-copy ul {
|
| 38 |
+
margin: 4px 0 0;
|
| 39 |
+
padding: 0;
|
| 40 |
+
list-style: none;
|
| 41 |
+
display: grid;
|
| 42 |
+
gap: 8px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.showcase-copy li {
|
| 46 |
+
position: relative;
|
| 47 |
+
padding-left: 22px;
|
| 48 |
+
color: var(--text-2);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.showcase-copy li::before {
|
| 52 |
+
content: "";
|
| 53 |
+
position: absolute;
|
| 54 |
+
left: 2px;
|
| 55 |
+
top: 0.55em;
|
| 56 |
+
width: 8px;
|
| 57 |
+
height: 8px;
|
| 58 |
+
border-radius: 50%;
|
| 59 |
+
background: var(--blue);
|
| 60 |
+
box-shadow: 0 0 10px var(--blue);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.showcase-n {
|
| 64 |
+
color: var(--white);
|
| 65 |
+
opacity: 0.6;
|
| 66 |
+
margin-right: 6px;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.showcase-visual {
|
| 70 |
+
padding: 0;
|
| 71 |
+
overflow: hidden;
|
| 72 |
+
border-color: var(--blue-20);
|
| 73 |
+
box-shadow: var(--glow-lg);
|
| 74 |
+
aspect-ratio: 16 / 11;
|
| 75 |
+
background: radial-gradient(70% 60% at 50% 40%, var(--blue-10), transparent 70%), rgba(0, 0, 0, 0.6);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Visual primitives. Everything starts hidden and plays once the row is .arrived. */
|
| 79 |
+
.fx {
|
| 80 |
+
height: 100%;
|
| 81 |
+
padding: clamp(16px, 3vw, 28px);
|
| 82 |
+
display: grid;
|
| 83 |
+
gap: 12px;
|
| 84 |
+
align-content: center;
|
| 85 |
+
font-size: 0.9rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.fx .mono {
|
| 89 |
+
font-size: 0.72rem;
|
| 90 |
+
color: var(--blue);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.fx > *,
|
| 94 |
+
.fx-card,
|
| 95 |
+
.fx-node,
|
| 96 |
+
.fx-edge,
|
| 97 |
+
.fx-cal span {
|
| 98 |
+
transition:
|
| 99 |
+
opacity 0.8s ease,
|
| 100 |
+
transform 0.9s cubic-bezier(0.22, 1, 0.36, 1),
|
| 101 |
+
stroke-dashoffset 1.2s ease,
|
| 102 |
+
background 0.6s ease,
|
| 103 |
+
border-color 0.6s ease,
|
| 104 |
+
box-shadow 0.6s ease;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.showcase-row:not(.arrived) .fx > * {
|
| 108 |
+
opacity: 0;
|
| 109 |
+
transform: translateY(14px);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.fx > *:nth-child(2) {
|
| 113 |
+
transition-delay: 0.35s;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.fx > *:nth-child(3) {
|
| 117 |
+
transition-delay: 0.7s;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.fx > *:nth-child(4) {
|
| 121 |
+
transition-delay: 1.05s;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.fx > *:nth-child(5) {
|
| 125 |
+
transition-delay: 1.3s;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Chat */
|
| 129 |
+
.fx-bubble {
|
| 130 |
+
justify-self: end;
|
| 131 |
+
padding: 8px 14px;
|
| 132 |
+
border-radius: 14px 14px 4px 14px;
|
| 133 |
+
background: var(--blue-10);
|
| 134 |
+
border: 1px solid var(--blue-35);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.fx-steps {
|
| 138 |
+
display: grid;
|
| 139 |
+
gap: 2px;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.fx-answer {
|
| 143 |
+
line-height: 1.6;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.fx-cite {
|
| 147 |
+
display: inline-grid;
|
| 148 |
+
place-items: center;
|
| 149 |
+
min-width: 16px;
|
| 150 |
+
height: 16px;
|
| 151 |
+
margin-left: 3px;
|
| 152 |
+
border-radius: 5px;
|
| 153 |
+
font: 600 0.6rem var(--font-mono);
|
| 154 |
+
vertical-align: super;
|
| 155 |
+
background: var(--blue);
|
| 156 |
+
box-shadow: 0 0 10px var(--blue);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.fx-source {
|
| 160 |
+
display: grid;
|
| 161 |
+
gap: 6px;
|
| 162 |
+
padding: 10px 12px;
|
| 163 |
+
border-radius: 10px;
|
| 164 |
+
border: 1px solid var(--line);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.fx-line {
|
| 168 |
+
padding: 4px 8px;
|
| 169 |
+
border-radius: 6px;
|
| 170 |
+
background: var(--blue-20);
|
| 171 |
+
box-shadow: inset 3px 0 0 var(--blue);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/* Topic map */
|
| 175 |
+
.fx-map {
|
| 176 |
+
padding: 8px;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.fx-map svg {
|
| 180 |
+
width: 100%;
|
| 181 |
+
height: 100%;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.fx-edge {
|
| 185 |
+
stroke: var(--white);
|
| 186 |
+
stroke-opacity: 0.3;
|
| 187 |
+
stroke-width: 0.4;
|
| 188 |
+
stroke-dasharray: 60;
|
| 189 |
+
stroke-dashoffset: 60;
|
| 190 |
+
transition-delay: var(--d);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.fx-halo {
|
| 194 |
+
fill: var(--blue);
|
| 195 |
+
opacity: 0.35;
|
| 196 |
+
filter: blur(2px);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.fx-node text {
|
| 200 |
+
fill: var(--white);
|
| 201 |
+
font: 500 3.6px var(--font-body);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.fx-node {
|
| 205 |
+
opacity: 0;
|
| 206 |
+
transition-delay: var(--d);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.showcase-row.arrived .fx-edge {
|
| 210 |
+
stroke-dashoffset: 0;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.showcase-row.arrived .fx-node {
|
| 214 |
+
opacity: 1;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.showcase-row.arrived .fx-halo {
|
| 218 |
+
animation: pulse-halo 4s ease-in-out infinite;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
@keyframes pulse-halo {
|
| 222 |
+
50% {
|
| 223 |
+
opacity: 0.6;
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/* Audio */
|
| 228 |
+
.fx-wave {
|
| 229 |
+
display: flex;
|
| 230 |
+
align-items: center;
|
| 231 |
+
gap: 4px;
|
| 232 |
+
height: 110px;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.fx-wave span {
|
| 236 |
+
flex: 1;
|
| 237 |
+
height: 12%;
|
| 238 |
+
border-radius: 99px;
|
| 239 |
+
background: var(--blue);
|
| 240 |
+
box-shadow: 0 0 8px var(--blue-60);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.fx-wave span:nth-child(2n) {
|
| 244 |
+
background: var(--white);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.showcase-row.arrived .fx-wave span {
|
| 248 |
+
animation: bar 1.4s ease-in-out infinite alternate;
|
| 249 |
+
animation-delay: var(--d);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
@keyframes bar {
|
| 253 |
+
from {
|
| 254 |
+
height: 12%;
|
| 255 |
+
}
|
| 256 |
+
to {
|
| 257 |
+
height: var(--h);
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.fx-lines {
|
| 262 |
+
display: grid;
|
| 263 |
+
gap: 8px;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.fx-lines p {
|
| 267 |
+
display: flex;
|
| 268 |
+
gap: 10px;
|
| 269 |
+
align-items: center;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.fx-host {
|
| 273 |
+
display: inline-grid;
|
| 274 |
+
place-items: center;
|
| 275 |
+
flex: none;
|
| 276 |
+
width: 22px;
|
| 277 |
+
height: 22px;
|
| 278 |
+
border-radius: 50%;
|
| 279 |
+
font: 600 0.65rem var(--font-mono);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.fx-host.a {
|
| 283 |
+
background: var(--blue);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.fx-host.b {
|
| 287 |
+
background: var(--white);
|
| 288 |
+
color: var(--black);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/* Video */
|
| 292 |
+
.fx-video {
|
| 293 |
+
grid-template-columns: 0.55fr 1.2fr 0.8fr;
|
| 294 |
+
align-items: center;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.fx-frame {
|
| 298 |
+
display: grid;
|
| 299 |
+
gap: 6px;
|
| 300 |
+
align-content: end;
|
| 301 |
+
padding: 10px;
|
| 302 |
+
border-radius: 10px;
|
| 303 |
+
border: 1px solid var(--blue-35);
|
| 304 |
+
background: radial-gradient(80% 60% at 50% 100%, var(--blue-20), transparent), var(--black);
|
| 305 |
+
box-shadow: 0 0 20px var(--blue-10);
|
| 306 |
+
font-size: 0.75rem;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.fx-frame b {
|
| 310 |
+
font-family: var(--font-display);
|
| 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 |
+
.v11 {
|
| 323 |
+
aspect-ratio: 1;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.fx-mini-wave {
|
| 327 |
+
display: block;
|
| 328 |
+
height: 18px;
|
| 329 |
+
background: repeating-linear-gradient(90deg, var(--blue) 0 3px, transparent 3px 6px);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/* Launch Kit */
|
| 333 |
+
.fx-kit {
|
| 334 |
+
grid-template-columns: repeat(2, 1fr);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.fx-card {
|
| 338 |
+
display: grid;
|
| 339 |
+
gap: 6px;
|
| 340 |
+
padding: 12px;
|
| 341 |
+
border-radius: 12px;
|
| 342 |
+
border: 1px solid var(--line-strong);
|
| 343 |
+
background: rgba(0, 0, 0, 0.7);
|
| 344 |
+
transition-delay: var(--d);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.fx-card p {
|
| 348 |
+
font-size: 0.82rem;
|
| 349 |
+
line-height: 1.45;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.showcase-row:not(.arrived) .fx-kit .fx-card {
|
| 353 |
+
opacity: 0;
|
| 354 |
+
transform: translateY(40px) rotate(var(--r));
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/* Voice */
|
| 358 |
+
.fx-profile {
|
| 359 |
+
display: grid;
|
| 360 |
+
gap: 4px;
|
| 361 |
+
padding: 12px;
|
| 362 |
+
border-radius: 12px;
|
| 363 |
+
border: 1px solid var(--blue-35);
|
| 364 |
+
background: var(--blue-05);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.fx-slider {
|
| 368 |
+
display: grid;
|
| 369 |
+
grid-template-columns: 90px 1fr;
|
| 370 |
+
align-items: center;
|
| 371 |
+
gap: 10px;
|
| 372 |
+
font-size: 0.8rem;
|
| 373 |
+
color: var(--text-2);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.fx-slider i {
|
| 377 |
+
position: relative;
|
| 378 |
+
height: 4px;
|
| 379 |
+
border-radius: 99px;
|
| 380 |
+
background: var(--surface-2);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.fx-slider i::after {
|
| 384 |
+
content: "";
|
| 385 |
+
position: absolute;
|
| 386 |
+
left: 0;
|
| 387 |
+
top: -3px;
|
| 388 |
+
width: 10px;
|
| 389 |
+
height: 10px;
|
| 390 |
+
margin-left: -5px;
|
| 391 |
+
border-radius: 50%;
|
| 392 |
+
background: var(--white);
|
| 393 |
+
box-shadow: 0 0 10px var(--blue);
|
| 394 |
+
transition: left 1.2s cubic-bezier(0.22, 1, 0.36, 1);
|
| 395 |
+
transition-delay: var(--d);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.showcase-row.arrived .fx-slider i::after {
|
| 399 |
+
left: var(--v);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.fx-clone {
|
| 403 |
+
display: flex;
|
| 404 |
+
align-items: center;
|
| 405 |
+
gap: 8px;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.fx-rec {
|
| 409 |
+
width: 8px;
|
| 410 |
+
height: 8px;
|
| 411 |
+
border-radius: 50%;
|
| 412 |
+
background: var(--white);
|
| 413 |
+
box-shadow: 0 0 8px var(--white);
|
| 414 |
+
animation: pill-pulse 1.4s ease-in-out infinite;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* Launchpad */
|
| 418 |
+
.fx-cal {
|
| 419 |
+
display: grid;
|
| 420 |
+
grid-template-columns: repeat(7, 1fr);
|
| 421 |
+
gap: 5px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.fx-cal span {
|
| 425 |
+
aspect-ratio: 1.3;
|
| 426 |
+
padding: 3px 5px;
|
| 427 |
+
border-radius: 6px;
|
| 428 |
+
border: 1px solid var(--line);
|
| 429 |
+
font: 0.6rem var(--font-mono);
|
| 430 |
+
color: var(--text-3);
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.fx-cal span.on {
|
| 434 |
+
transition-delay: var(--d);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.showcase-row.arrived .fx-cal span.on {
|
| 438 |
+
border-color: var(--blue);
|
| 439 |
+
background: var(--blue-20);
|
| 440 |
+
box-shadow: 0 0 12px var(--blue-35);
|
| 441 |
+
color: var(--white);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.fx-launch {
|
| 445 |
+
display: flex;
|
| 446 |
+
flex-wrap: wrap;
|
| 447 |
+
gap: 6px;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.fx-launch span {
|
| 451 |
+
padding: 4px 10px;
|
| 452 |
+
border-radius: 99px;
|
| 453 |
+
border: 1px solid var(--blue-35);
|
| 454 |
+
color: var(--white);
|
| 455 |
+
font-size: 0.72rem;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
@media (prefers-reduced-motion: reduce) {
|
| 459 |
+
.showcase-row .fx > *,
|
| 460 |
+
.showcase-row .fx-node,
|
| 461 |
+
.showcase-row .fx-card {
|
| 462 |
+
opacity: 1 !important;
|
| 463 |
+
transform: none !important;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.fx-edge {
|
| 467 |
+
stroke-dashoffset: 0;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.fx-wave span,
|
| 471 |
+
.fx-halo,
|
| 472 |
+
.fx-rec {
|
| 473 |
+
animation: none !important;
|
| 474 |
+
}
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/* Demo video sound controls */
|
| 478 |
+
.demo-bar {
|
| 479 |
+
align-items: center;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.demo-sound {
|
| 483 |
+
border: 1px solid var(--blue-35);
|
| 484 |
+
border-radius: 999px;
|
| 485 |
+
padding: 3px 12px;
|
| 486 |
+
background: transparent;
|
| 487 |
+
color: var(--white);
|
| 488 |
+
font: inherit;
|
| 489 |
+
cursor: pointer;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.demo-sound:hover {
|
| 493 |
+
border-color: var(--blue);
|
| 494 |
+
box-shadow: var(--glow-sm);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.demo-stage {
|
| 498 |
+
position: relative;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.demo-unmute {
|
| 502 |
+
position: absolute;
|
| 503 |
+
right: 16px;
|
| 504 |
+
bottom: 16px;
|
| 505 |
+
display: inline-flex;
|
| 506 |
+
align-items: center;
|
| 507 |
+
gap: 8px;
|
| 508 |
+
padding: 10px 16px;
|
| 509 |
+
border-radius: 999px;
|
| 510 |
+
border: 1px solid var(--blue-60);
|
| 511 |
+
background: rgba(0, 0, 0, 0.72);
|
| 512 |
+
backdrop-filter: blur(6px);
|
| 513 |
+
color: var(--white);
|
| 514 |
+
font: 600 0.875rem var(--font-body);
|
| 515 |
+
cursor: pointer;
|
| 516 |
+
box-shadow: var(--glow-md);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.demo-unmute:hover {
|
| 520 |
+
background: var(--blue);
|
| 521 |
+
}
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/logo.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx"],"version":"5.6.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
renderer/public/demo-vo/durations.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"warp": 3.79,
|
| 3 |
+
"paste": 3.27,
|
| 4 |
+
"orbit": 6.3,
|
| 5 |
+
"research": 4.29,
|
| 6 |
+
"audio": 3.74,
|
| 7 |
+
"launchkit": 6.53,
|
| 8 |
+
"launchpad": 3.22,
|
| 9 |
+
"outro": 2.48
|
| 10 |
+
}
|
renderer/src/Root.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Composition, Still } from "remotion";
|
|
| 2 |
import { AudiogramSquare, audiogramDuration } from "./compositions/AudiogramSquare";
|
| 3 |
import { CarouselSlide } from "./compositions/CarouselSlide";
|
| 4 |
import { ExplainerLong, explainerDuration } from "./compositions/ExplainerLong";
|
|
|
|
| 5 |
import { QuoteCard } from "./compositions/QuoteCard";
|
| 6 |
import { ShortVertical, shortVerticalDuration } from "./compositions/ShortVertical";
|
| 7 |
import {
|
|
@@ -42,6 +43,7 @@ const sampleAudiogram: AudiogramSquareProps = {
|
|
| 42 |
export function Root() {
|
| 43 |
return (
|
| 44 |
<>
|
|
|
|
| 45 |
<Composition
|
| 46 |
id="ShortVertical"
|
| 47 |
component={ShortVertical}
|
|
|
|
| 2 |
import { AudiogramSquare, audiogramDuration } from "./compositions/AudiogramSquare";
|
| 3 |
import { CarouselSlide } from "./compositions/CarouselSlide";
|
| 4 |
import { ExplainerLong, explainerDuration } from "./compositions/ExplainerLong";
|
| 5 |
+
import { DEMO_DURATION, DEMO_FPS, LandingDemo } from "./compositions/LandingDemo";
|
| 6 |
import { QuoteCard } from "./compositions/QuoteCard";
|
| 7 |
import { ShortVertical, shortVerticalDuration } from "./compositions/ShortVertical";
|
| 8 |
import {
|
|
|
|
| 43 |
export function Root() {
|
| 44 |
return (
|
| 45 |
<>
|
| 46 |
+
<Composition id="LandingDemo" component={LandingDemo} width={1920} height={1080} fps={DEMO_FPS} durationInFrames={DEMO_DURATION} />
|
| 47 |
<Composition
|
| 48 |
id="ShortVertical"
|
| 49 |
component={ShortVertical}
|
renderer/src/compositions/LandingDemo.tsx
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CSSProperties, ReactNode } from "react";
|
| 2 |
+
import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
| 3 |
+
import { theme } from "../theme";
|
| 4 |
+
|
| 5 |
+
/** Landing page product demo: a trip through the Notestack galaxy. 1920x1080, 30 fps, ~32 s.
|
| 6 |
+
* Scenes: warp in, paste URL, posts enter orbit, cited research, audio overview, Launch Kit,
|
| 7 |
+
* Launchpad, outro. Strict palette: black, #217cff, white. */
|
| 8 |
+
|
| 9 |
+
export const DEMO_FPS = 30;
|
| 10 |
+
const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
|
| 11 |
+
|
| 12 |
+
// Scene lengths fit the narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"):
|
| 13 |
+
// each line starts `vo` seconds into its scene and ends with room to breathe before the crossfade.
|
| 14 |
+
const SCENES = [
|
| 15 |
+
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 16 |
+
{ id: "paste", len: 4.6, vo: 0.4 },
|
| 17 |
+
{ id: "orbit", len: 7.6, vo: 0.4 },
|
| 18 |
+
{ id: "research", len: 5.6, vo: 0.4 },
|
| 19 |
+
{ id: "audio", len: 5.1, vo: 0.4 },
|
| 20 |
+
{ id: "launchkit", len: 7.8, vo: 0.4 },
|
| 21 |
+
{ id: "launchpad", len: 4.6, vo: 0.4 },
|
| 22 |
+
{ id: "outro", len: 4.2, vo: 0.5 },
|
| 23 |
+
] as const;
|
| 24 |
+
const XFADE = 0.5; // seconds of overlap between scenes
|
| 25 |
+
|
| 26 |
+
export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1));
|
| 27 |
+
|
| 28 |
+
const BLUE = theme.blue;
|
| 29 |
+
const ease = Easing.bezier(0.22, 1, 0.36, 1);
|
| 30 |
+
|
| 31 |
+
// Shared pieces
|
| 32 |
+
|
| 33 |
+
/** 3D starfield flying toward the camera. `speed` can ramp for warp streaks. */
|
| 34 |
+
function WarpField({ speed, count = 420, streak = 0 }: { speed: number; count?: number; streak?: number }) {
|
| 35 |
+
const frame = useCurrentFrame();
|
| 36 |
+
const { width, height } = useVideoConfig();
|
| 37 |
+
const cx = width / 2;
|
| 38 |
+
const cy = height / 2;
|
| 39 |
+
return (
|
| 40 |
+
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 41 |
+
<AbsoluteFill
|
| 42 |
+
style={{
|
| 43 |
+
background: `radial-gradient(55% 45% at 50% 50%, ${BLUE}22, transparent 70%), radial-gradient(40% 30% at 80% 20%, ${BLUE}14, transparent 70%)`,
|
| 44 |
+
}}
|
| 45 |
+
/>
|
| 46 |
+
<svg width={width} height={height}>
|
| 47 |
+
{Array.from({ length: count }).map((_, i) => {
|
| 48 |
+
const angle = random(`a${i}`) * Math.PI * 2;
|
| 49 |
+
const base = random(`z${i}`);
|
| 50 |
+
const z = (((base - frame * speed * 0.004) % 1) + 1) % 1; // 1 far, 0 at the camera
|
| 51 |
+
const depth = 0.04 + z;
|
| 52 |
+
const r = (random(`r${i}`) * 0.9 + 0.1) * 900;
|
| 53 |
+
const x = cx + (Math.cos(angle) * r) / depth / 6;
|
| 54 |
+
const y = cy + (Math.sin(angle) * r) / depth / 6;
|
| 55 |
+
const size = Math.max(0.4, (1 - z) * 2.6);
|
| 56 |
+
const alpha = Math.min(1, (1 - z) * 1.4);
|
| 57 |
+
const blue = random(`b${i}`) < 0.25;
|
| 58 |
+
if (streak > 0.02) {
|
| 59 |
+
const z2 = z + streak * 0.08;
|
| 60 |
+
const d2 = 0.04 + z2;
|
| 61 |
+
const x2 = cx + (Math.cos(angle) * r) / d2 / 6;
|
| 62 |
+
const y2 = cy + (Math.sin(angle) * r) / d2 / 6;
|
| 63 |
+
return (
|
| 64 |
+
<line key={i} x1={x} y1={y} x2={x2} y2={y2} stroke={blue ? BLUE : theme.white} strokeOpacity={alpha} strokeWidth={size * 0.8} strokeLinecap="round" />
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
return <circle key={i} cx={x} cy={y} r={size} fill={blue ? BLUE : theme.white} opacity={alpha} />;
|
| 68 |
+
})}
|
| 69 |
+
</svg>
|
| 70 |
+
</AbsoluteFill>
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/** A diagonal light flare that sweeps across, with a hot core and anamorphic streak. */
|
| 75 |
+
function Flare({ at, duration = 24, y = 50 }: { at: number; duration?: number; y?: number }) {
|
| 76 |
+
const frame = useCurrentFrame();
|
| 77 |
+
const p = interpolate(frame, [at, at + duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
|
| 78 |
+
if (p <= 0 || p >= 1) return null;
|
| 79 |
+
const x = interpolate(p, [0, 1], [-10, 110]);
|
| 80 |
+
const a = Math.sin(p * Math.PI);
|
| 81 |
+
return (
|
| 82 |
+
<AbsoluteFill style={{ pointerEvents: "none", mixBlendMode: "screen" }}>
|
| 83 |
+
<div
|
| 84 |
+
style={{
|
| 85 |
+
position: "absolute",
|
| 86 |
+
left: `${x}%`,
|
| 87 |
+
top: `${y}%`,
|
| 88 |
+
width: 520,
|
| 89 |
+
height: 520,
|
| 90 |
+
marginLeft: -260,
|
| 91 |
+
marginTop: -260,
|
| 92 |
+
borderRadius: "50%",
|
| 93 |
+
background: `radial-gradient(circle, rgba(255,255,255,${0.9 * a}) 0%, ${BLUE}${Math.round(a * 120).toString(16).padStart(2, "0")} 18%, transparent 60%)`,
|
| 94 |
+
}}
|
| 95 |
+
/>
|
| 96 |
+
<div
|
| 97 |
+
style={{
|
| 98 |
+
position: "absolute",
|
| 99 |
+
left: 0,
|
| 100 |
+
right: 0,
|
| 101 |
+
top: `${y}%`,
|
| 102 |
+
height: 3,
|
| 103 |
+
marginTop: -1.5,
|
| 104 |
+
opacity: a * 0.9,
|
| 105 |
+
background: `linear-gradient(90deg, transparent, ${BLUE} ${x - 20}%, #ffffff ${x}%, ${BLUE} ${x + 20}%, transparent)`,
|
| 106 |
+
boxShadow: `0 0 30px ${BLUE}`,
|
| 107 |
+
}}
|
| 108 |
+
/>
|
| 109 |
+
</AbsoluteFill>
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function FadeScene({ len, children }: { len: number; children: ReactNode }) {
|
| 114 |
+
const frame = useCurrentFrame();
|
| 115 |
+
const total = S(len);
|
| 116 |
+
const fade = S(XFADE);
|
| 117 |
+
const opacity = interpolate(frame, [0, fade, total - fade, total], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
| 118 |
+
const scale = interpolate(frame, [0, total], [1.04, 1], { easing: ease });
|
| 119 |
+
return <AbsoluteFill style={{ opacity, transform: `scale(${scale})` }}>{children}</AbsoluteFill>;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function Caption({ eyebrow, title, delay = 0 }: { eyebrow: string; title: string; delay?: number }) {
|
| 123 |
+
const frame = useCurrentFrame();
|
| 124 |
+
const { fps } = useVideoConfig();
|
| 125 |
+
const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
|
| 126 |
+
return (
|
| 127 |
+
<div style={{ position: "absolute", left: 140, top: 110, opacity: s, transform: `translateY(${(1 - s) * 24}px)` }}>
|
| 128 |
+
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
|
| 129 |
+
<h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth: 900 }}>{title}</h2>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const panel: CSSProperties = {
|
| 135 |
+
border: "1px solid rgba(255,255,255,0.14)",
|
| 136 |
+
borderRadius: 22,
|
| 137 |
+
background: "rgba(0,0,0,0.72)",
|
| 138 |
+
boxShadow: `0 0 60px ${BLUE}33, 0 0 2px ${BLUE}`,
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
function typed(text: string, frame: number, start: number, cps = 26): string {
|
| 142 |
+
const n = Math.max(0, Math.floor(((frame - start) / DEMO_FPS) * cps));
|
| 143 |
+
return text.slice(0, n);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Scenes
|
| 147 |
+
|
| 148 |
+
function Warp() {
|
| 149 |
+
const frame = useCurrentFrame();
|
| 150 |
+
const { fps } = useVideoConfig();
|
| 151 |
+
const speed = interpolate(frame, [0, 40, 70, 102], [14, 26, 6, 1.5], { extrapolateRight: "clamp" });
|
| 152 |
+
const streak = interpolate(frame, [0, 35, 70], [1, 1, 0], { extrapolateRight: "clamp" });
|
| 153 |
+
const title = spring({ frame: frame - 42, fps, config: { damping: 14 } });
|
| 154 |
+
const sub = spring({ frame: frame - 58, fps, config: { damping: 18 } });
|
| 155 |
+
return (
|
| 156 |
+
<AbsoluteFill>
|
| 157 |
+
<WarpField speed={speed} streak={streak} />
|
| 158 |
+
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
|
| 159 |
+
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 150, lineHeight: 1, color: theme.white, opacity: title, transform: `scale(${0.8 + title * 0.2})` }}>
|
| 160 |
+
Your archive,
|
| 161 |
+
<br />
|
| 162 |
+
<span style={{ color: theme.white, textShadow: `0 0 30px ${BLUE}, 0 0 80px ${BLUE}` }}>in orbit.</span>
|
| 163 |
+
</h1>
|
| 164 |
+
<p style={{ marginTop: 40, fontFamily: theme.mono, fontSize: 30, letterSpacing: 4, color: "rgba(255,255,255,0.75)", opacity: sub }}>
|
| 165 |
+
NOTESTACK
|
| 166 |
+
</p>
|
| 167 |
+
</AbsoluteFill>
|
| 168 |
+
<Flare at={50} duration={30} y={46} />
|
| 169 |
+
</AbsoluteFill>
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function Paste() {
|
| 174 |
+
const frame = useCurrentFrame();
|
| 175 |
+
const { fps } = useVideoConfig();
|
| 176 |
+
const url = typed("yourname.substack.com", frame, 18, 18);
|
| 177 |
+
const press = spring({ frame: frame - 62, fps, config: { damping: 10, mass: 0.5 } });
|
| 178 |
+
const ring = interpolate(frame, [64, 100], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
| 179 |
+
return (
|
| 180 |
+
<AbsoluteFill>
|
| 181 |
+
<WarpField speed={1.2} />
|
| 182 |
+
<Caption eyebrow="Step 01" title="Paste your Substack URL" />
|
| 183 |
+
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
| 184 |
+
<div style={{ ...panel, display: "flex", alignItems: "center", gap: 18, padding: 14, width: 1100, borderRadius: 999, marginTop: 120 }}>
|
| 185 |
+
<div style={{ flex: 1, fontFamily: theme.body, fontSize: 40, color: url ? theme.white : "rgba(255,255,255,0.4)", paddingLeft: 30 }}>
|
| 186 |
+
{url || "yourname.substack.com"}
|
| 187 |
+
<span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
|
| 188 |
+
</div>
|
| 189 |
+
<div
|
| 190 |
+
style={{
|
| 191 |
+
position: "relative",
|
| 192 |
+
padding: "24px 44px",
|
| 193 |
+
borderRadius: 999,
|
| 194 |
+
background: BLUE,
|
| 195 |
+
color: theme.white,
|
| 196 |
+
fontFamily: theme.body,
|
| 197 |
+
fontWeight: 600,
|
| 198 |
+
fontSize: 32,
|
| 199 |
+
transform: `scale(${1 - 0.06 * Math.sin(press * Math.PI)})`,
|
| 200 |
+
boxShadow: `0 0 ${30 + press * 40}px ${BLUE}`,
|
| 201 |
+
}}
|
| 202 |
+
>
|
| 203 |
+
Launch my archive
|
| 204 |
+
<div
|
| 205 |
+
style={{
|
| 206 |
+
position: "absolute",
|
| 207 |
+
inset: -ring * 60,
|
| 208 |
+
borderRadius: 999,
|
| 209 |
+
border: `2px solid ${BLUE}`,
|
| 210 |
+
opacity: 1 - ring,
|
| 211 |
+
}}
|
| 212 |
+
/>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</AbsoluteFill>
|
| 216 |
+
</AbsoluteFill>
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
const POSTS = [
|
| 221 |
+
"On Pricing", "Writing Every Day", "The Paid Tier", "Why I Quit Twitter", "Notes on Craft", "Letters to Readers",
|
| 222 |
+
"The Long Game", "Small Audiences", "Habits Beat Talent", "Editing Myself", "A Year of Essays", "What Readers Want",
|
| 223 |
+
];
|
| 224 |
+
|
| 225 |
+
function Orbit() {
|
| 226 |
+
const frame = useCurrentFrame();
|
| 227 |
+
const { width, height, fps } = useVideoConfig();
|
| 228 |
+
const cx = width / 2 + 180;
|
| 229 |
+
const cy = height / 2 + 60;
|
| 230 |
+
const count = Math.floor(interpolate(frame, [20, 110], [0, 142], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
|
| 231 |
+
const planet = spring({ frame, fps, config: { damping: 20 } });
|
| 232 |
+
return (
|
| 233 |
+
<AbsoluteFill>
|
| 234 |
+
<WarpField speed={0.8} />
|
| 235 |
+
<Caption eyebrow="Step 02" title="Watch your posts come into orbit" />
|
| 236 |
+
<svg width={width} height={height} style={{ position: "absolute" }}>
|
| 237 |
+
<defs>
|
| 238 |
+
<radialGradient id="planet" cx="40%" cy="35%" r="70%">
|
| 239 |
+
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
|
| 240 |
+
<stop offset="35%" stopColor={BLUE} />
|
| 241 |
+
<stop offset="100%" stopColor="#000000" />
|
| 242 |
+
</radialGradient>
|
| 243 |
+
</defs>
|
| 244 |
+
<circle cx={cx} cy={cy} r={150 * planet} fill="url(#planet)" style={{ filter: `drop-shadow(0 0 60px ${BLUE})` }} />
|
| 245 |
+
{[260, 360].map((r, i) => (
|
| 246 |
+
<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} />
|
| 247 |
+
))}
|
| 248 |
+
</svg>
|
| 249 |
+
{POSTS.map((title, i) => {
|
| 250 |
+
const start = 8 + i * 6;
|
| 251 |
+
const p = interpolate(frame, [start, start + 36], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
|
| 252 |
+
const ring = i % 2 ? 360 : 260;
|
| 253 |
+
const angle = (i / POSTS.length) * Math.PI * 2 + frame * 0.012 * (i % 2 ? 1 : -1);
|
| 254 |
+
const tx = cx + Math.cos(angle) * ring * 1.6;
|
| 255 |
+
const ty = cy + Math.sin(angle) * ring * 0.45;
|
| 256 |
+
const fromX = width + 200;
|
| 257 |
+
const fromY = random(`py${i}`) * height;
|
| 258 |
+
const x = interpolate(p, [0, 1], [fromX, tx]);
|
| 259 |
+
const y = interpolate(p, [0, 1], [fromY, ty]);
|
| 260 |
+
const front = Math.sin(angle) > 0;
|
| 261 |
+
return (
|
| 262 |
+
<div
|
| 263 |
+
key={title}
|
| 264 |
+
style={{
|
| 265 |
+
...panel,
|
| 266 |
+
position: "absolute",
|
| 267 |
+
left: x,
|
| 268 |
+
top: y,
|
| 269 |
+
transform: `translate(-50%, -50%) scale(${front ? 1 : 0.8})`,
|
| 270 |
+
padding: "12px 20px",
|
| 271 |
+
borderRadius: 12,
|
| 272 |
+
fontFamily: theme.body,
|
| 273 |
+
fontSize: 22,
|
| 274 |
+
color: theme.white,
|
| 275 |
+
opacity: p * (front ? 1 : 0.55),
|
| 276 |
+
zIndex: front ? 2 : 0,
|
| 277 |
+
whiteSpace: "nowrap",
|
| 278 |
+
}}
|
| 279 |
+
>
|
| 280 |
+
{title}
|
| 281 |
+
</div>
|
| 282 |
+
);
|
| 283 |
+
})}
|
| 284 |
+
<div style={{ position: "absolute", left: 140, bottom: 120, fontFamily: theme.mono, color: theme.white }}>
|
| 285 |
+
<span style={{ fontSize: 96, fontFamily: theme.display, textShadow: `0 0 30px ${BLUE}` }}>{count}</span>
|
| 286 |
+
<span style={{ fontSize: 28, marginLeft: 20, color: "rgba(255,255,255,0.7)", letterSpacing: 3 }}>POSTS IN ORBIT</span>
|
| 287 |
+
</div>
|
| 288 |
+
</AbsoluteFill>
|
| 289 |
+
);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function Research() {
|
| 293 |
+
const frame = useCurrentFrame();
|
| 294 |
+
const { fps } = useVideoConfig();
|
| 295 |
+
const q = typed("What have I written about pricing?", frame, 10, 30);
|
| 296 |
+
const answer = spring({ frame: frame - 52, fps, config: { damping: 18 } });
|
| 297 |
+
const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
|
| 298 |
+
const steps = ["Searching for pric(e|ing)|paid tier", "Reading On Pricing, lines 12 to 20", "Reading The Paid Tier, lines 4 to 9"];
|
| 299 |
+
return (
|
| 300 |
+
<AbsoluteFill>
|
| 301 |
+
<WarpField speed={0.6} count={260} />
|
| 302 |
+
<Caption eyebrow="Research" title="Ask your archive. Every answer cites you." />
|
| 303 |
+
<div style={{ position: "absolute", left: 140, right: 140, top: 360, display: "grid", gridTemplateColumns: "1.25fr 1fr", gap: 40 }}>
|
| 304 |
+
<div style={{ ...panel, padding: 36, display: "grid", gap: 22, alignContent: "start" }}>
|
| 305 |
+
<div style={{ justifySelf: "end", padding: "14px 22px", borderRadius: "18px 18px 4px 18px", background: `${BLUE}22`, border: `1px solid ${BLUE}66`, fontFamily: theme.body, fontSize: 30, color: theme.white }}>
|
| 306 |
+
{q || " "}
|
| 307 |
+
</div>
|
| 308 |
+
<div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
|
| 309 |
+
{steps.map((s, i) => (
|
| 310 |
+
<span key={s} style={{ opacity: interpolate(frame, [26 + i * 8, 34 + i * 8], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
|
| 311 |
+
{s}
|
| 312 |
+
</span>
|
| 313 |
+
))}
|
| 314 |
+
</div>
|
| 315 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
|
| 316 |
+
You raised prices twice and the <b>paid tier doubled</b>{" "}
|
| 317 |
+
<Marker n={1} glow={cite} /> because readers took the work more seriously{" "}
|
| 318 |
+
<Marker n={2} glow={cite} />.
|
| 319 |
+
</p>
|
| 320 |
+
</div>
|
| 321 |
+
<div style={{ ...panel, padding: 30, opacity: cite, transform: `translateX(${(1 - cite) * 60}px)`, alignSelf: "start" }}>
|
| 322 |
+
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
|
| 323 |
+
{["The paid tier doubled after I", "raised the price to ten dollars.", "Readers took the work seriously."].map((l, i) => (
|
| 324 |
+
<div key={l} style={{ display: "flex", gap: 18, marginTop: 14, fontFamily: theme.body, fontSize: 26, color: theme.white, background: `${BLUE}26`, boxShadow: `inset 4px 0 0 ${BLUE}`, padding: "6px 12px", borderRadius: 8 }}>
|
| 325 |
+
<span style={{ fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", paddingTop: 5 }}>{12 + i}</span>
|
| 326 |
+
{l}
|
| 327 |
+
</div>
|
| 328 |
+
))}
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
</AbsoluteFill>
|
| 332 |
+
);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function Marker({ n, glow }: { n: number; glow: number }) {
|
| 336 |
+
return (
|
| 337 |
+
<span style={{ display: "inline-block", minWidth: 34, padding: "0 8px", borderRadius: 8, fontFamily: theme.mono, fontSize: 20, verticalAlign: "super", color: theme.white, background: `${BLUE}${glow > 0.5 ? "" : "55"}`, border: `1px solid ${BLUE}`, boxShadow: `0 0 ${glow * 24}px ${BLUE}`, textAlign: "center" }}>
|
| 338 |
+
{n}
|
| 339 |
+
</span>
|
| 340 |
+
);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
function Audio() {
|
| 344 |
+
const frame = useCurrentFrame();
|
| 345 |
+
const { fps } = useVideoConfig();
|
| 346 |
+
const lines = [
|
| 347 |
+
{ who: "A", text: "So the big idea here is that price is a signal." },
|
| 348 |
+
{ who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
|
| 349 |
+
{ who: "A", text: "Which she wrote about back in March, in On Pricing." },
|
| 350 |
+
];
|
| 351 |
+
const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 20) / 34));
|
| 352 |
+
return (
|
| 353 |
+
<AbsoluteFill>
|
| 354 |
+
<WarpField speed={0.7} count={260} />
|
| 355 |
+
<Caption eyebrow="Audio overview" title="Two hosts. Your ideas. Grounded." />
|
| 356 |
+
<div style={{ position: "absolute", left: 140, right: 140, top: 380, display: "grid", gap: 40 }}>
|
| 357 |
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, height: 220 }}>
|
| 358 |
+
{Array.from({ length: 64 }).map((_, i) => {
|
| 359 |
+
const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
|
| 360 |
+
const env = spring({ frame: frame - 6, fps, config: { damping: 20 } });
|
| 361 |
+
return <div key={i} style={{ width: 14, height: 12 + v * 200 * env, borderRadius: 7, background: i % 2 ? theme.white : BLUE, boxShadow: `0 0 18px ${BLUE}` }} />;
|
| 362 |
+
})}
|
| 363 |
+
</div>
|
| 364 |
+
<div style={{ display: "grid", gap: 14 }}>
|
| 365 |
+
{lines.map((l, i) => {
|
| 366 |
+
const on = i === active;
|
| 367 |
+
const seen = i <= active;
|
| 368 |
+
return (
|
| 369 |
+
<div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity: seen ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 34, color: theme.white }}>
|
| 370 |
+
<span style={{ width: 52, height: 52, borderRadius: "50%", display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 22, background: l.who === "A" ? BLUE : theme.white, color: l.who === "A" ? theme.white : theme.black, boxShadow: on ? `0 0 26px ${BLUE}` : "none" }}>
|
| 371 |
+
{l.who}
|
| 372 |
+
</span>
|
| 373 |
+
{l.text}
|
| 374 |
+
</div>
|
| 375 |
+
);
|
| 376 |
+
})}
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
</AbsoluteFill>
|
| 380 |
+
);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
function LaunchKit() {
|
| 384 |
+
const frame = useCurrentFrame();
|
| 385 |
+
const { fps } = useVideoConfig();
|
| 386 |
+
const cards = [
|
| 387 |
+
{ label: "X THREAD", body: "I doubled my price. My paid tier doubled too. Here is what I learned." },
|
| 388 |
+
{ label: "LINKEDIN", body: "Most writers underprice. I did for two years. Then I ran an experiment." },
|
| 389 |
+
{ label: "SUBSTACK NOTES", body: "Price is a signal. Readers told me so, with their wallets." },
|
| 390 |
+
{ label: "BLUESKY", body: "Raised my price to $10. Paid readers went up, not down." },
|
| 391 |
+
];
|
| 392 |
+
return (
|
| 393 |
+
<AbsoluteFill>
|
| 394 |
+
<WarpField speed={0.9} count={260} />
|
| 395 |
+
<Caption eyebrow="Launch Kit" title="One post. Every platform. Your voice." />
|
| 396 |
+
<div style={{ position: "absolute", left: 140, right: 140, top: 380, display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 28 }}>
|
| 397 |
+
{cards.map((c, i) => {
|
| 398 |
+
const s = spring({ frame: frame - 10 - i * 7, fps, config: { damping: 14 } });
|
| 399 |
+
const rot = interpolate(s, [0, 1], [(i - 1.5) * 14, 0]);
|
| 400 |
+
return (
|
| 401 |
+
<div key={c.label} style={{ ...panel, padding: 28, minHeight: 300, opacity: s, transform: `translateY(${(1 - s) * 180}px) rotate(${rot}deg)`, display: "grid", alignContent: "start", gap: 18 }}>
|
| 402 |
+
<span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
|
| 403 |
+
<span style={{ fontFamily: theme.body, fontSize: 28, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
|
| 404 |
+
</div>
|
| 405 |
+
);
|
| 406 |
+
})}
|
| 407 |
+
</div>
|
| 408 |
+
<div style={{ position: "absolute", left: 140, bottom: 90, display: "flex", gap: 16, fontFamily: theme.mono, fontSize: 22, color: "rgba(255,255,255,0.7)", letterSpacing: 2 }}>
|
| 409 |
+
{["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
|
| 410 |
+
<span key={t} style={{ padding: "10px 18px", border: `1px solid ${BLUE}88`, borderRadius: 999, opacity: interpolate(frame, [50 + i * 6, 60 + i * 6], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
|
| 411 |
+
{t}
|
| 412 |
+
</span>
|
| 413 |
+
))}
|
| 414 |
+
</div>
|
| 415 |
+
</AbsoluteFill>
|
| 416 |
+
);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
function Launchpad() {
|
| 420 |
+
const frame = useCurrentFrame();
|
| 421 |
+
const { width, height } = useVideoConfig();
|
| 422 |
+
const lit = [2, 5, 9, 12, 16, 19, 23, 26];
|
| 423 |
+
const rocket = interpolate(frame, [70, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.in(Easing.cubic) });
|
| 424 |
+
return (
|
| 425 |
+
<AbsoluteFill>
|
| 426 |
+
<WarpField speed={0.8} count={260} />
|
| 427 |
+
<Caption eyebrow="Launchpad" title="Schedule it. Watch it lift off." />
|
| 428 |
+
<div style={{ position: "absolute", left: 140, top: 360, display: "grid", gridTemplateColumns: "repeat(7, 120px)", gap: 12 }}>
|
| 429 |
+
{Array.from({ length: 28 }).map((_, i) => {
|
| 430 |
+
const on = lit.includes(i) && frame > 8 + lit.indexOf(i) * 6;
|
| 431 |
+
return (
|
| 432 |
+
<div key={i} style={{ height: 88, borderRadius: 12, border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.14)"}`, background: on ? `${BLUE}33` : "rgba(0,0,0,0.5)", boxShadow: on ? `0 0 22px ${BLUE}88` : "none", padding: 10, fontFamily: theme.mono, fontSize: 16, color: "rgba(255,255,255,0.6)" }}>
|
| 433 |
+
{i + 1}
|
| 434 |
+
{on && <div style={{ marginTop: 18, height: 10, borderRadius: 5, background: theme.white, boxShadow: `0 0 12px ${BLUE}` }} />}
|
| 435 |
+
</div>
|
| 436 |
+
);
|
| 437 |
+
})}
|
| 438 |
+
</div>
|
| 439 |
+
<svg width={width} height={height} style={{ position: "absolute" }}>
|
| 440 |
+
<defs>
|
| 441 |
+
<linearGradient id="trail" x1="0" y1="1" x2="0" y2="0">
|
| 442 |
+
<stop offset="0%" stopColor={BLUE} stopOpacity="0" />
|
| 443 |
+
<stop offset="70%" stopColor={BLUE} stopOpacity="0.8" />
|
| 444 |
+
<stop offset="100%" stopColor="#ffffff" stopOpacity="1" />
|
| 445 |
+
</linearGradient>
|
| 446 |
+
</defs>
|
| 447 |
+
{(() => {
|
| 448 |
+
// A launch arc from the lower right: curved trail, hot head, exhaust sparks.
|
| 449 |
+
const pt = (u: number) => ({ x: 1360 + 300 * u + 120 * u * u, y: 1000 - 1050 * u + 120 * u * (1 - u) });
|
| 450 |
+
const head = pt(rocket);
|
| 451 |
+
const tail = pt(Math.max(0, rocket - 0.35));
|
| 452 |
+
const path = Array.from({ length: 24 }, (_, i) => pt(Math.max(0, rocket - 0.35) + ((rocket - Math.max(0, rocket - 0.35)) * i) / 23));
|
| 453 |
+
return (
|
| 454 |
+
<>
|
| 455 |
+
<circle cx={1360} cy={1000} r={140 * Math.min(1, rocket * 4)} fill={BLUE} opacity={0.25 * (1 - rocket)} style={{ filter: "blur(30px)" }} />
|
| 456 |
+
{rocket > 0 && (
|
| 457 |
+
<polyline points={path.map((q) => `${q.x},${q.y}`).join(" ")} fill="none" stroke="url(#trail)" strokeWidth={10} strokeLinecap="round" style={{ filter: `drop-shadow(0 0 18px ${BLUE})` }} />
|
| 458 |
+
)}
|
| 459 |
+
{rocket > 0 &&
|
| 460 |
+
Array.from({ length: 16 }).map((_, i) => {
|
| 461 |
+
const u = Math.max(0, rocket - random(`s${i}`) * 0.3);
|
| 462 |
+
const q = pt(u);
|
| 463 |
+
const jitter = (random(`j${i}`) - 0.5) * 60;
|
| 464 |
+
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}`)} />;
|
| 465 |
+
})}
|
| 466 |
+
{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)` }} />}
|
| 467 |
+
{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})` }} />}
|
| 468 |
+
</>
|
| 469 |
+
);
|
| 470 |
+
})()}
|
| 471 |
+
</svg>
|
| 472 |
+
<div style={{ position: "absolute", left: 1180, top: 400, fontFamily: theme.mono, color: theme.white, opacity: interpolate(frame, [20, 40], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
|
| 473 |
+
<p style={{ margin: 0, fontSize: 22, letterSpacing: 4, color: BLUE }}>NEXT LAUNCH</p>
|
| 474 |
+
<p style={{ margin: "10px 0 0", fontFamily: theme.display, fontSize: 88, textShadow: `0 0 30px ${BLUE}` }}>
|
| 475 |
+
T-{String(Math.max(0, 10 - Math.floor(frame / 7))).padStart(2, "0")}
|
| 476 |
+
</p>
|
| 477 |
+
<p style={{ margin: "6px 0 0", fontSize: 22, color: "rgba(255,255,255,0.7)", letterSpacing: 2 }}>X · LINKEDIN · BLUESKY</p>
|
| 478 |
+
</div>
|
| 479 |
+
</AbsoluteFill>
|
| 480 |
+
);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
function Outro() {
|
| 484 |
+
const frame = useCurrentFrame();
|
| 485 |
+
const { fps } = useVideoConfig();
|
| 486 |
+
const s = spring({ frame: frame - 8, fps, config: { damping: 14 } });
|
| 487 |
+
const cta = spring({ frame: frame - 30, fps, config: { damping: 18 } });
|
| 488 |
+
return (
|
| 489 |
+
<AbsoluteFill>
|
| 490 |
+
<WarpField speed={interpolate(frame, [0, 60, 114], [0.5, 2, 12])} streak={interpolate(frame, [70, 114], [0, 1], { extrapolateLeft: "clamp" })} />
|
| 491 |
+
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 30 }}>
|
| 492 |
+
<div style={{ width: 120, height: 120, borderRadius: 30, background: theme.white, display: "grid", placeItems: "center", opacity: s, transform: `scale(${s})`, boxShadow: `0 0 80px ${BLUE}` }}>
|
| 493 |
+
<div style={{ width: 44, height: 44, borderRadius: "50%", background: theme.black, boxShadow: `18px -18px 0 -10px ${theme.black}` }} />
|
| 494 |
+
</div>
|
| 495 |
+
<h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 120, color: theme.white, opacity: s, textShadow: `0 0 40px ${BLUE}` }}>Notestack</h1>
|
| 496 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.8)", opacity: cta }}>Your archive, in orbit.</p>
|
| 497 |
+
<p style={{ margin: 0, padding: "18px 40px", borderRadius: 999, background: BLUE, color: theme.white, fontFamily: theme.body, fontWeight: 600, fontSize: 32, opacity: cta, boxShadow: `0 0 40px ${BLUE}` }}>
|
| 498 |
+
Free during early access
|
| 499 |
+
</p>
|
| 500 |
+
</AbsoluteFill>
|
| 501 |
+
<Flare at={20} duration={34} y={42} />
|
| 502 |
+
</AbsoluteFill>
|
| 503 |
+
);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
const RENDER: Record<(typeof SCENES)[number]["id"], () => JSX.Element> = {
|
| 507 |
+
warp: Warp,
|
| 508 |
+
paste: Paste,
|
| 509 |
+
orbit: Orbit,
|
| 510 |
+
research: Research,
|
| 511 |
+
audio: Audio,
|
| 512 |
+
launchkit: LaunchKit,
|
| 513 |
+
launchpad: Launchpad,
|
| 514 |
+
outro: Outro,
|
| 515 |
+
};
|
| 516 |
+
|
| 517 |
+
export function LandingDemo() {
|
| 518 |
+
let cursor = 0;
|
| 519 |
+
return (
|
| 520 |
+
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 521 |
+
<SoundTrack src={staticFile("demo-vo/ambience.wav")} volume={0.32} />
|
| 522 |
+
{SCENES.map((scene, i) => {
|
| 523 |
+
const from = cursor;
|
| 524 |
+
cursor += S(scene.len - XFADE);
|
| 525 |
+
const Scene = RENDER[scene.id];
|
| 526 |
+
return (
|
| 527 |
+
<Sequence key={scene.id} from={from} durationInFrames={S(scene.len)} name={scene.id}>
|
| 528 |
+
<FadeScene len={scene.len}>
|
| 529 |
+
<Scene />
|
| 530 |
+
</FadeScene>
|
| 531 |
+
<Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
|
| 532 |
+
<SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
|
| 533 |
+
</Sequence>
|
| 534 |
+
{i === 0 && null}
|
| 535 |
+
</Sequence>
|
| 536 |
+
);
|
| 537 |
+
})}
|
| 538 |
+
</AbsoluteFill>
|
| 539 |
+
);
|
| 540 |
+
}
|