Spaces:
Runtime error
Runtime error
Merge pull request #3 from FireBird-Technologies/fix/utils
Browse files- backend/app/pipeline/media.py +2 -1
- backend/app/routers/artifacts.py +18 -2
- backend/app/routers/video_voices.py +10 -25
- backend/app/routers/voice.py +3 -1
- backend/app/services/notestack_voices.py +12 -0
- backend/app/worker.py +4 -1
- backend/tests/test_buildout.py +58 -2
- backend/tests/test_videos.py +10 -7
- frontend/src/App.tsx +11 -6
- frontend/src/api/endpoints.ts +3 -0
- frontend/src/api/types.ts +0 -2
- frontend/src/components/AppShell.tsx +2 -4
- frontend/src/components/icons/Icons.tsx +26 -0
- frontend/src/components/video/VideoVoicesSection.tsx +0 -208
- frontend/src/components/video/VoiceLibrary.tsx +0 -107
- frontend/src/components/voice/YourVoices.tsx +134 -0
- frontend/src/pages/Archive.tsx +30 -18
- frontend/src/pages/NotebookView.tsx +35 -4
- frontend/src/pages/VideoCreate.tsx +4 -10
- frontend/src/pages/VideoEditor.tsx +2 -2
- frontend/src/pages/VideoTemplates.tsx +1 -1
- frontend/src/pages/Videos.tsx +11 -11
- frontend/src/pages/VoiceProfile.tsx +55 -455
- frontend/src/styles/pages.css +86 -164
- frontend/src/styles/videos.css +68 -0
backend/app/pipeline/media.py
CHANGED
|
@@ -64,7 +64,8 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 64 |
if not lines:
|
| 65 |
raise RuntimeError("The script came back empty")
|
| 66 |
tools = tools_for(corpus, docs)
|
| 67 |
-
|
|
|
|
| 68 |
delivery = host_settings(db, artifact.workspace_id)
|
| 69 |
script = [
|
| 70 |
tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
|
|
|
|
| 64 |
if not lines:
|
| 65 |
raise RuntimeError("The script came back empty")
|
| 66 |
tools = tools_for(corpus, docs)
|
| 67 |
+
default_a, default_b = host_voices(db, artifact.workspace_id)
|
| 68 |
+
voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b # picked when made
|
| 69 |
delivery = host_settings(db, artifact.workspace_id)
|
| 70 |
script = [
|
| 71 |
tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -6,7 +6,8 @@ from pydantic import BaseModel, Field
|
|
| 6 |
from sqlalchemy import func, or_, select
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
-
from app.
|
|
|
|
| 10 |
from app.routers.notebooks import ensure_archive_notebook
|
| 11 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 12 |
from app.services.jobs import create_job, serialize_job
|
|
@@ -33,6 +34,9 @@ class GenerateIn(BaseModel):
|
|
| 33 |
# audio_overview
|
| 34 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 35 |
minutes: int = Field(6, ge=1, le=30)
|
|
|
|
|
|
|
|
|
|
| 36 |
# video: only audiograms here; other videos are made by blog2video (/api/videos)
|
| 37 |
style: str = "audiogram"
|
| 38 |
audio_artifact_id: uuid.UUID | None = None
|
|
@@ -44,6 +48,18 @@ class GenerateIn(BaseModel):
|
|
| 44 |
focus: str | None = Field(None, max_length=500)
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
class PatchIn(BaseModel):
|
| 48 |
title: str | None = Field(None, max_length=300)
|
| 49 |
content: dict | None = None
|
|
@@ -102,7 +118,7 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 102 |
target = _target_title(ctx, body.notebook_id, body.document_id)
|
| 103 |
if body.type == "audio_overview":
|
| 104 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 105 |
-
params = {"format": body.format, "minutes": body.minutes}
|
| 106 |
title = f"Audio overview: {target}"
|
| 107 |
elif body.type == "mind_map":
|
| 108 |
if not body.notebook_id:
|
|
|
|
| 6 |
from sqlalchemy import func, or_, select
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.models import Artifact, CalendarItem, Document, Job, Notebook, UserSavedVoice
|
| 11 |
from app.routers.notebooks import ensure_archive_notebook
|
| 12 |
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 13 |
from app.services.jobs import create_job, serialize_job
|
|
|
|
| 34 |
# audio_overview
|
| 35 |
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 36 |
minutes: int = Field(6, ge=1, le=30)
|
| 37 |
+
# the two hosts' voices, from the workspace's voices (none: the defaults)
|
| 38 |
+
host_a: str | None = Field(None, max_length=100)
|
| 39 |
+
host_b: str | None = Field(None, max_length=100)
|
| 40 |
# video: only audiograms here; other videos are made by blog2video (/api/videos)
|
| 41 |
style: str = "audiogram"
|
| 42 |
audio_artifact_id: uuid.UUID | None = None
|
|
|
|
| 48 |
focus: str | None = Field(None, max_length=500)
|
| 49 |
|
| 50 |
|
| 51 |
+
def _hosts(ctx: Ctx, body: GenerateIn) -> dict:
|
| 52 |
+
"""The voices picked for an audio overview's two hosts: each one from the workspace's voices (or a default)."""
|
| 53 |
+
picked = {k: v for k, v in (("host_a", body.host_a), ("host_b", body.host_b)) if v}
|
| 54 |
+
if picked:
|
| 55 |
+
ok = set(ctx.db.scalars(select(UserSavedVoice.voice_id).where(UserSavedVoice.workspace_id == ctx.workspace.id,
|
| 56 |
+
UserSavedVoice.voice_id.in_(picked.values()))))
|
| 57 |
+
ok |= {settings.elevenlabs_voice_a, settings.elevenlabs_voice_b}
|
| 58 |
+
if any(v not in ok for v in picked.values()):
|
| 59 |
+
raise HTTPException(400, "Pick the hosts' voices from your voices")
|
| 60 |
+
return picked
|
| 61 |
+
|
| 62 |
+
|
| 63 |
class PatchIn(BaseModel):
|
| 64 |
title: str | None = Field(None, max_length=300)
|
| 65 |
content: dict | None = None
|
|
|
|
| 118 |
target = _target_title(ctx, body.notebook_id, body.document_id)
|
| 119 |
if body.type == "audio_overview":
|
| 120 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 121 |
+
params = {"format": body.format, "minutes": body.minutes, **_hosts(ctx, body)}
|
| 122 |
title = f"Audio overview: {target}"
|
| 123 |
elif body.type == "mind_map":
|
| 124 |
if not body.notebook_id:
|
backend/app/routers/video_voices.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
|
| 2 |
Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
|
| 3 |
|
| 4 |
-
"
|
| 5 |
-
for the whole account.
|
| 6 |
Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
|
| 7 |
only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
|
| 8 |
of the workspace's own daily and total counters (video_limits).
|
|
@@ -20,7 +20,7 @@ from app.models import B2VCustomVoice, UserSavedVoice
|
|
| 20 |
from app.routers.videos import b2v_ready, upstream
|
| 21 |
from app.services import b2v_access, video_limits
|
| 22 |
from app.services import blog2video as b2v
|
| 23 |
-
from app.services.notestack_voices import notestack_voices
|
| 24 |
|
| 25 |
log = logging.getLogger(__name__)
|
| 26 |
|
|
@@ -28,7 +28,6 @@ router = APIRouter(prefix="/api/video-voices", tags=["videos"])
|
|
| 28 |
|
| 29 |
CLONE_MAX_BYTES = 50 * 1024 * 1024
|
| 30 |
STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
|
| 31 |
-
MAX_SAVED_VOICES = 5 # "My voices": what step 3 of a new video offers
|
| 32 |
|
| 33 |
|
| 34 |
def _labels(v: dict) -> dict:
|
|
@@ -41,14 +40,6 @@ def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
|
|
| 41 |
"source": "notestack" if v.voice_id in ours else "blog2video"}
|
| 42 |
|
| 43 |
|
| 44 |
-
def _room(ctx: Ctx) -> bool:
|
| 45 |
-
return len(_saved(ctx)) < MAX_SAVED_VOICES
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _full() -> HTTPException:
|
| 49 |
-
return HTTPException(409, f"You can keep {MAX_SAVED_VOICES} voices. Remove one first.")
|
| 50 |
-
|
| 51 |
-
|
| 52 |
def _library_out(v: dict, saved: set[str]) -> dict:
|
| 53 |
labels = _labels(v)
|
| 54 |
return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
|
|
@@ -74,7 +65,7 @@ def _saved(ctx: Ctx) -> list[UserSavedVoice]:
|
|
| 74 |
|
| 75 |
@router.get("")
|
| 76 |
def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 77 |
-
"""
|
| 78 |
with upstream():
|
| 79 |
library = b2v.prebuilt_voices()
|
| 80 |
saved = _saved(ctx)
|
|
@@ -88,7 +79,7 @@ def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
|
| 88 |
.order_by(B2VCustomVoice.created_at.desc()))
|
| 89 |
mine = notestack_voices(ctx.db, ctx.workspace.id)
|
| 90 |
ours = {v["voice_id"] for v in mine}
|
| 91 |
-
return {"saved": [_voice_out(v, ours) for v in saved],
|
| 92 |
"notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
|
| 93 |
"library": [_library_out(v, ids) for v in library if v.get("voice_id")],
|
| 94 |
"custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
|
|
@@ -103,16 +94,12 @@ class SaveIn(BaseModel):
|
|
| 103 |
def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 104 |
if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
|
| 105 |
return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
|
| 106 |
-
if not _room(ctx):
|
| 107 |
-
raise _full()
|
| 108 |
custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
|
| 109 |
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
| 110 |
mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
|
| 111 |
if mine:
|
| 112 |
-
# A Notestack voice:
|
| 113 |
-
|
| 114 |
-
row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=mine["voice_id"],
|
| 115 |
-
name=mine["name"][:255], premium=True, is_custom=True))
|
| 116 |
ctx.db.commit()
|
| 117 |
return _voice_out(row, {row.voice_id})
|
| 118 |
if custom:
|
|
@@ -191,13 +178,11 @@ def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
|
|
| 191 |
voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
|
| 192 |
preview_url=made.get("preview_url"))
|
| 193 |
ctx.db.add(row)
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
|
| 197 |
-
preview_url=row.preview_url, premium=True, is_custom=True))
|
| 198 |
ctx.db.commit()
|
| 199 |
return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
|
| 200 |
-
"preview_url": row.preview_url, "saved":
|
| 201 |
|
| 202 |
|
| 203 |
def _make_custom(ctx: Ctx, source: str, send) -> dict:
|
|
|
|
| 1 |
"""Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
|
| 2 |
Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
|
| 3 |
|
| 4 |
+
"Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
|
| 5 |
+
here: blog2video's saved-voice list is one list for the whole account.
|
| 6 |
Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
|
| 7 |
only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
|
| 8 |
of the workspace's own daily and total counters (video_limits).
|
|
|
|
| 20 |
from app.routers.videos import b2v_ready, upstream
|
| 21 |
from app.services import b2v_access, video_limits
|
| 22 |
from app.services import blog2video as b2v
|
| 23 |
+
from app.services.notestack_voices import notestack_voices, save_notestack_voice
|
| 24 |
|
| 25 |
log = logging.getLogger(__name__)
|
| 26 |
|
|
|
|
| 28 |
|
| 29 |
CLONE_MAX_BYTES = 50 * 1024 * 1024
|
| 30 |
STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def _labels(v: dict) -> dict:
|
|
|
|
| 40 |
"source": "notestack" if v.voice_id in ours else "blog2video"}
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def _library_out(v: dict, saved: set[str]) -> dict:
|
| 44 |
labels = _labels(v)
|
| 45 |
return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
|
|
|
|
| 65 |
|
| 66 |
@router.get("")
|
| 67 |
def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 68 |
+
"""Your voices (audio overviews and videos), the built-in library (to save from) and my custom voices."""
|
| 69 |
with upstream():
|
| 70 |
library = b2v.prebuilt_voices()
|
| 71 |
saved = _saved(ctx)
|
|
|
|
| 79 |
.order_by(B2VCustomVoice.created_at.desc()))
|
| 80 |
mine = notestack_voices(ctx.db, ctx.workspace.id)
|
| 81 |
ours = {v["voice_id"] for v in mine}
|
| 82 |
+
return {"saved": [_voice_out(v, ours) for v in saved],
|
| 83 |
"notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
|
| 84 |
"library": [_library_out(v, ids) for v in library if v.get("voice_id")],
|
| 85 |
"custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
|
|
|
|
| 94 |
def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
| 95 |
if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
|
| 96 |
return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
|
|
|
|
|
|
|
| 97 |
custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
|
| 98 |
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
| 99 |
mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
|
| 100 |
if mine:
|
| 101 |
+
# A Notestack voice: saved on any plan, premium to use in a video. No preview link (the Voice page plays it).
|
| 102 |
+
row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
|
|
|
|
|
|
|
| 103 |
ctx.db.commit()
|
| 104 |
return _voice_out(row, {row.voice_id})
|
| 105 |
if custom:
|
|
|
|
| 178 |
voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
|
| 179 |
preview_url=made.get("preview_url"))
|
| 180 |
ctx.db.add(row)
|
| 181 |
+
ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
|
| 182 |
+
preview_url=row.preview_url, premium=True, is_custom=True)) # a new voice is saved
|
|
|
|
|
|
|
| 183 |
ctx.db.commit()
|
| 184 |
return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
|
| 185 |
+
"preview_url": row.preview_url, "saved": True}
|
| 186 |
|
| 187 |
|
| 188 |
def _make_custom(ctx: Ctx, source: str, send) -> dict:
|
backend/app/routers/voice.py
CHANGED
|
@@ -12,7 +12,7 @@ from app.config import settings
|
|
| 12 |
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
|
| 13 |
from app.services import tts
|
| 14 |
from app.services.jobs import create_job, record_usage, serialize_job
|
| 15 |
-
from app.services.notestack_voices import forget_video_voice
|
| 16 |
from app.services.plans import effective_plan, plan_limit_error
|
| 17 |
from app.services.storage import storage
|
| 18 |
|
|
@@ -215,6 +215,7 @@ async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 215 |
if body.use_as in ("host_a", "host_b"):
|
| 216 |
voices[body.use_as] = voice_id
|
| 217 |
vp.host_voices = voices
|
|
|
|
| 218 |
ctx.db.commit()
|
| 219 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 220 |
|
|
@@ -293,6 +294,7 @@ async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 293 |
if body.use_as in ("host_a", "host_b"):
|
| 294 |
voices[body.use_as] = voice_id
|
| 295 |
vp.host_voices = voices
|
|
|
|
| 296 |
ctx.db.commit()
|
| 297 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 298 |
|
|
|
|
| 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.notestack_voices import forget_video_voice, save_notestack_voice
|
| 16 |
from app.services.plans import effective_plan, plan_limit_error
|
| 17 |
from app.services.storage import storage
|
| 18 |
|
|
|
|
| 215 |
if body.use_as in ("host_a", "host_b"):
|
| 216 |
voices[body.use_as] = voice_id
|
| 217 |
vp.host_voices = voices
|
| 218 |
+
save_notestack_voice(ctx.db, ctx.workspace.id, voice_id, body.name) # a voice added is a voice saved
|
| 219 |
ctx.db.commit()
|
| 220 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 221 |
|
|
|
|
| 294 |
if body.use_as in ("host_a", "host_b"):
|
| 295 |
voices[body.use_as] = voice_id
|
| 296 |
vp.host_voices = voices
|
| 297 |
+
save_notestack_voice(ctx.db, ctx.workspace.id, voice_id, body.name) # a voice made is a voice saved
|
| 298 |
ctx.db.commit()
|
| 299 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 300 |
|
backend/app/services/notestack_voices.py
CHANGED
|
@@ -31,6 +31,18 @@ def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> b
|
|
| 31 |
return any(v["voice_id"] == voice_id for v in notestack_voices(db, workspace_id))
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def forget_video_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> None:
|
| 35 |
"""A Notestack voice that is gone (clone revoked) leaves the workspace's saved video voices too."""
|
| 36 |
db.execute(delete(UserSavedVoice).where(UserSavedVoice.workspace_id == workspace_id,
|
|
|
|
| 31 |
return any(v["voice_id"] == voice_id for v in notestack_voices(db, workspace_id))
|
| 32 |
|
| 33 |
|
| 34 |
+
def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice:
|
| 35 |
+
"""Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan:
|
| 36 |
+
using it in a video is premium (b2v_access.check_voice). No preview link: it is played through /api/voice/preview.
|
| 37 |
+
The caller commits."""
|
| 38 |
+
row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id})
|
| 39 |
+
if row is None:
|
| 40 |
+
row = UserSavedVoice(workspace_id=workspace_id, voice_id=voice_id, name=(name or "Voice")[:255], premium=True,
|
| 41 |
+
is_custom=True)
|
| 42 |
+
db.add(row)
|
| 43 |
+
return row
|
| 44 |
+
|
| 45 |
+
|
| 46 |
def forget_video_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> None:
|
| 47 |
"""A Notestack voice that is gone (clone revoked) leaves the workspace's saved video voices too."""
|
| 48 |
db.execute(delete(UserSavedVoice).where(UserSavedVoice.workspace_id == workspace_id,
|
backend/app/worker.py
CHANGED
|
@@ -61,6 +61,7 @@ from app.services.jobs import (
|
|
| 61 |
stale_jobs,
|
| 62 |
update_job,
|
| 63 |
)
|
|
|
|
| 64 |
from app.services.plans import effective_plan
|
| 65 |
from app.services.renderer import PermanentJobError, request_render
|
| 66 |
from app.services.storage import keys, storage
|
|
@@ -212,13 +213,15 @@ def handle_voice_clone(db: Session, job: Job):
|
|
| 212 |
consent.elevenlabs_voice_id = voice_id
|
| 213 |
db.commit()
|
| 214 |
|
| 215 |
-
#
|
|
|
|
| 216 |
update_job(db, job, progress=0.7, message="Recording a preview in your voice")
|
| 217 |
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id))
|
| 218 |
if not vp:
|
| 219 |
vp = VoiceProfile(workspace_id=consent.workspace_id)
|
| 220 |
db.add(vp)
|
| 221 |
vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id}
|
|
|
|
| 222 |
db.commit()
|
| 223 |
preview_text = job.params.get("preview_text") or (
|
| 224 |
"Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.")
|
|
|
|
| 61 |
stale_jobs,
|
| 62 |
update_job,
|
| 63 |
)
|
| 64 |
+
from app.services.notestack_voices import save_notestack_voice
|
| 65 |
from app.services.plans import effective_plan
|
| 66 |
from app.services.renderer import PermanentJobError, request_render
|
| 67 |
from app.services.storage import keys, storage
|
|
|
|
| 213 |
consent.elevenlabs_voice_id = voice_id
|
| 214 |
db.commit()
|
| 215 |
|
| 216 |
+
# Save the new voice to the workspace's voices (and keep it as the default host A), then record a short preview
|
| 217 |
+
# so the writer can judge it straight away.
|
| 218 |
update_job(db, job, progress=0.7, message="Recording a preview in your voice")
|
| 219 |
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id))
|
| 220 |
if not vp:
|
| 221 |
vp = VoiceProfile(workspace_id=consent.workspace_id)
|
| 222 |
db.add(vp)
|
| 223 |
vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id}
|
| 224 |
+
save_notestack_voice(db, consent.workspace_id, voice_id, "My voice")
|
| 225 |
db.commit()
|
| 226 |
preview_text = job.params.get("preview_text") or (
|
| 227 |
"Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.")
|
backend/tests/test_buildout.py
CHANGED
|
@@ -18,6 +18,7 @@ from app.models import (
|
|
| 18 |
Source,
|
| 19 |
TrackedLink,
|
| 20 |
UsageEvent,
|
|
|
|
| 21 |
Workspace,
|
| 22 |
)
|
| 23 |
from app.services.email import ConsoleEmailProvider
|
|
@@ -766,7 +767,13 @@ def test_artifact_ownership(client, auth, db_session):
|
|
| 766 |
# Voice pipeline (ElevenLabs stubbed)
|
| 767 |
|
| 768 |
|
| 769 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
from app.config import settings
|
| 771 |
from app.services import tts
|
| 772 |
|
|
@@ -783,6 +790,7 @@ def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_j
|
|
| 783 |
"public_owner_id": "owner-1", "voice_id": "lib-voice-1", "name": "Narrator",
|
| 784 |
"use_as": "host_b"}, headers=auth).json()
|
| 785 |
assert r["voice_id"] == "added-lib-voice-1" and r["host_voices"]["host_b"] == "added-lib-voice-1"
|
|
|
|
| 786 |
|
| 787 |
prev = client.post("/api/voice/preview", json={"voice_id": "added-lib-voice-1", "text": "Hello there",
|
| 788 |
"delivery": {"stability": 0.3, "similarity_boost": 0.8,
|
|
@@ -794,7 +802,7 @@ def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_j
|
|
| 794 |
assert script["words"] > 5 and script["title"]
|
| 795 |
|
| 796 |
|
| 797 |
-
def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatch, run_jobs):
|
| 798 |
from app.config import settings
|
| 799 |
from app.services import tts
|
| 800 |
|
|
@@ -823,6 +831,54 @@ def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatc
|
|
| 823 |
voice = client.get("/api/voice", headers=auth).json()
|
| 824 |
assert voice["clone"]["status"] == "ready" and voice["host_voices"]["host_a"] == "clone-1"
|
| 825 |
assert voice["clone"]["preview_url"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
|
| 827 |
|
| 828 |
def test_tts_retries_rate_limits(monkeypatch):
|
|
|
|
| 18 |
Source,
|
| 19 |
TrackedLink,
|
| 20 |
UsageEvent,
|
| 21 |
+
UserSavedVoice,
|
| 22 |
Workspace,
|
| 23 |
)
|
| 24 |
from app.services.email import ConsoleEmailProvider
|
|
|
|
| 767 |
# Voice pipeline (ElevenLabs stubbed)
|
| 768 |
|
| 769 |
|
| 770 |
+
def saved_voices(db_session) -> dict[str, str]:
|
| 771 |
+
"""The workspace's voices (the one list for audio and video): voice id -> name."""
|
| 772 |
+
db_session.expire_all()
|
| 773 |
+
return {v.voice_id: v.name for v in db_session.query(UserSavedVoice)}
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_jobs, db_session):
|
| 777 |
from app.config import settings
|
| 778 |
from app.services import tts
|
| 779 |
|
|
|
|
| 790 |
"public_owner_id": "owner-1", "voice_id": "lib-voice-1", "name": "Narrator",
|
| 791 |
"use_as": "host_b"}, headers=auth).json()
|
| 792 |
assert r["voice_id"] == "added-lib-voice-1" and r["host_voices"]["host_b"] == "added-lib-voice-1"
|
| 793 |
+
assert saved_voices(db_session) == {"added-lib-voice-1": "Narrator"} # added, so saved
|
| 794 |
|
| 795 |
prev = client.post("/api/voice/preview", json={"voice_id": "added-lib-voice-1", "text": "Hello there",
|
| 796 |
"delivery": {"stability": 0.3, "similarity_boost": 0.8,
|
|
|
|
| 802 |
assert script["words"] > 5 and script["title"]
|
| 803 |
|
| 804 |
|
| 805 |
+
def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatch, run_jobs, db_session):
|
| 806 |
from app.config import settings
|
| 807 |
from app.services import tts
|
| 808 |
|
|
|
|
| 831 |
voice = client.get("/api/voice", headers=auth).json()
|
| 832 |
assert voice["clone"]["status"] == "ready" and voice["host_voices"]["host_a"] == "clone-1"
|
| 833 |
assert voice["clone"]["preview_url"]
|
| 834 |
+
assert saved_voices(db_session) == {"clone-1": "My voice"}
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
def test_a_designed_voice_is_saved_to_the_voices(client, auth, monkeypatch, db_session):
|
| 838 |
+
from app.config import settings
|
| 839 |
+
from app.services import tts
|
| 840 |
+
|
| 841 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 842 |
+
monkeypatch.setattr(tts, "create_designed_voice", lambda name, description, generated: "designed-1")
|
| 843 |
+
r = client.post("/api/voice/design/save", json={"generated_voice_id": "gen-12345", "name": "Narrator",
|
| 844 |
+
"description": "A calm narrator"}, headers=auth)
|
| 845 |
+
assert r.status_code == 200 and r.json()["voice_id"] == "designed-1"
|
| 846 |
+
assert saved_voices(db_session) == {"designed-1": "Narrator"}
|
| 847 |
+
client.post("/api/voice/design/save", json={"generated_voice_id": "gen-12345", "name": "Narrator"}, headers=auth)
|
| 848 |
+
assert len(saved_voices(db_session)) == 1 # saved once
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
def test_audio_overview_hosts_are_picked_from_the_voices(client, auth, run_jobs, feed, llm, monkeypatch,
|
| 852 |
+
db_session):
|
| 853 |
+
from app.config import settings
|
| 854 |
+
from app.services import tts
|
| 855 |
+
|
| 856 |
+
connect(client, auth, run_jobs)
|
| 857 |
+
doc = docs(client, auth)[0]
|
| 858 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 859 |
+
used = []
|
| 860 |
+
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
|
| 861 |
+
b"\x00" * 16000)
|
| 862 |
+
llm["PodcastScript"] = {"lines": [{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 863 |
+
{"speaker": "host_b", "text": "Thanks.", "sources": []}]}
|
| 864 |
+
ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
|
| 865 |
+
db_session.add_all([UserSavedVoice(workspace_id=ws_id, voice_id=v, name=v) for v in ("mine-1", "mine-2")])
|
| 866 |
+
db_session.commit()
|
| 867 |
+
body = {"type": "audio_overview", "document_id": doc["id"], "minutes": 3}
|
| 868 |
+
art = client.post("/api/artifacts/generate", json={**body, "host_a": "mine-2", "host_b": "mine-1"},
|
| 869 |
+
headers=auth).json()
|
| 870 |
+
run_jobs()
|
| 871 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 872 |
+
assert done["content"]["voices"] == {"host_a": "mine-2", "host_b": "mine-1"}
|
| 873 |
+
assert dict(used) == {"Welcome in.": "mine-2", "Thanks.": "mine-1"}
|
| 874 |
+
# Only the workspace's own voices (or the defaults).
|
| 875 |
+
r = client.post("/api/artifacts/generate", json={**body, "host_a": "someone-elses"}, headers=auth)
|
| 876 |
+
assert r.status_code == 400
|
| 877 |
+
# None picked: the defaults, as before.
|
| 878 |
+
art = client.post("/api/artifacts/generate", json=body, headers=auth).json()
|
| 879 |
+
run_jobs()
|
| 880 |
+
voices = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["voices"]
|
| 881 |
+
assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
|
| 882 |
|
| 883 |
|
| 884 |
def test_tts_retries_rate_limits(monkeypatch):
|
backend/tests/test_videos.py
CHANGED
|
@@ -1157,15 +1157,15 @@ def notestack_voice(db, ws, voice_id="nv_narrator", name="Narrator") -> None:
|
|
| 1157 |
db.commit()
|
| 1158 |
|
| 1159 |
|
| 1160 |
-
def
|
| 1161 |
headers, ws = owner
|
| 1162 |
client.get("/api/video-voices", headers=headers) # the starter voice
|
| 1163 |
-
for i in range(
|
| 1164 |
db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
|
| 1165 |
db_session.commit()
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
assert
|
| 1169 |
|
| 1170 |
|
| 1171 |
def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session):
|
|
@@ -1185,11 +1185,14 @@ def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, o
|
|
| 1185 |
assert r.status_code == 404
|
| 1186 |
|
| 1187 |
|
| 1188 |
-
def
|
|
|
|
| 1189 |
headers, ws = owner
|
| 1190 |
notestack_voice(db_session, ws)
|
| 1191 |
assert client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"},
|
| 1192 |
-
headers=headers).status_code ==
|
|
|
|
|
|
|
| 1193 |
|
| 1194 |
|
| 1195 |
def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
|
|
|
|
| 1157 |
db.commit()
|
| 1158 |
|
| 1159 |
|
| 1160 |
+
def test_any_number_of_voices_can_be_saved(client, b2v, owner, db_session):
|
| 1161 |
headers, ws = owner
|
| 1162 |
client.get("/api/video-voices", headers=headers) # the starter voice
|
| 1163 |
+
for i in range(7):
|
| 1164 |
db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
|
| 1165 |
db_session.commit()
|
| 1166 |
+
assert client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers).status_code == 201
|
| 1167 |
+
got = client.get("/api/video-voices", headers=headers).json()
|
| 1168 |
+
assert len(got["saved"]) == 9 and "max_saved" not in got
|
| 1169 |
|
| 1170 |
|
| 1171 |
def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session):
|
|
|
|
| 1185 |
assert r.status_code == 404
|
| 1186 |
|
| 1187 |
|
| 1188 |
+
def test_notestack_voices_are_saved_on_any_plan_but_need_premium_in_videos(client, b2v, owner, db_session,
|
| 1189 |
+
free_plan):
|
| 1190 |
headers, ws = owner
|
| 1191 |
notestack_voice(db_session, ws)
|
| 1192 |
assert client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"},
|
| 1193 |
+
headers=headers).status_code == 201 # in the list, for audio overviews
|
| 1194 |
+
r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"}, headers=headers)
|
| 1195 |
+
assert r.status_code == 402 and not creates(b2v)
|
| 1196 |
|
| 1197 |
|
| 1198 |
def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
|
frontend/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { Navigate, Route, Routes } from "react-router-dom";
|
| 2 |
import AppShell from "./components/AppShell";
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
|
@@ -20,10 +20,8 @@ import Sources from "./pages/Sources";
|
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
import VideoCreate from "./pages/VideoCreate";
|
| 22 |
import VideoEditor from "./pages/VideoEditor";
|
| 23 |
-
import Videos from "./pages/Videos";
|
| 24 |
import TopicMap from "./pages/TopicMap";
|
| 25 |
import { ToolPage, ToolsHub } from "./pages/Tools";
|
| 26 |
-
import VoiceProfile from "./pages/VoiceProfile";
|
| 27 |
import Welcome from "./pages/Welcome";
|
| 28 |
import { SupportWidget } from "./components/support/SupportWidget";
|
| 29 |
|
|
@@ -60,13 +58,13 @@ export default function App() {
|
|
| 60 |
<Route index element={<MissionControl />} />
|
| 61 |
<Route path="notebooks" element={<Notebooks />} />
|
| 62 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 63 |
-
<Route path="videos" element={<
|
| 64 |
<Route path="videos/new" element={<VideoCreate />} />
|
| 65 |
-
<Route path="videos/voices" element={<Navigate to="/app/
|
| 66 |
<Route path="videos/:id" element={<VideoEditor />} />
|
| 67 |
<Route path="sources" element={<Sources />} />
|
| 68 |
<Route path="map" element={<TopicMap />} />
|
| 69 |
-
<Route path="voice" element={<
|
| 70 |
<Route path="studio" element={<Studio />} />
|
| 71 |
<Route path="launch-kit" element={<LaunchKit />} />
|
| 72 |
<Route path="launchpad" element={<Launchpad />} />
|
|
@@ -81,3 +79,10 @@ export default function App() {
|
|
| 81 |
</>
|
| 82 |
);
|
| 83 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
|
| 2 |
import AppShell from "./components/AppShell";
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
|
|
|
| 20 |
import Studio from "./pages/Studio";
|
| 21 |
import VideoCreate from "./pages/VideoCreate";
|
| 22 |
import VideoEditor from "./pages/VideoEditor";
|
|
|
|
| 23 |
import TopicMap from "./pages/TopicMap";
|
| 24 |
import { ToolPage, ToolsHub } from "./pages/Tools";
|
|
|
|
| 25 |
import Welcome from "./pages/Welcome";
|
| 26 |
import { SupportWidget } from "./components/support/SupportWidget";
|
| 27 |
|
|
|
|
| 58 |
<Route index element={<MissionControl />} />
|
| 59 |
<Route path="notebooks" element={<Notebooks />} />
|
| 60 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 61 |
+
<Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
|
| 62 |
<Route path="videos/new" element={<VideoCreate />} />
|
| 63 |
+
<Route path="videos/voices" element={<Navigate to="/app/archive?tab=voices" replace />} />
|
| 64 |
<Route path="videos/:id" element={<VideoEditor />} />
|
| 65 |
<Route path="sources" element={<Sources />} />
|
| 66 |
<Route path="map" element={<TopicMap />} />
|
| 67 |
+
<Route path="voice" element={<VoiceRedirect />} />
|
| 68 |
<Route path="studio" element={<Studio />} />
|
| 69 |
<Route path="launch-kit" element={<LaunchKit />} />
|
| 70 |
<Route path="launchpad" element={<Launchpad />} />
|
|
|
|
| 79 |
</>
|
| 80 |
);
|
| 81 |
}
|
| 82 |
+
|
| 83 |
+
/** The old Voice page (/app/voice, ?step=writing for the writing voice) is the Library's Voices tab now. */
|
| 84 |
+
function VoiceRedirect() {
|
| 85 |
+
const [params] = useSearchParams();
|
| 86 |
+
const writing = params.get("step") === "writing";
|
| 87 |
+
return <Navigate to={`/app/archive?tab=voices${writing ? "&step=writing" : ""}`} replace />;
|
| 88 |
+
}
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -113,6 +113,9 @@ export type GenerateBody = {
|
|
| 113 |
minutes?: number;
|
| 114 |
style?: "audiogram";
|
| 115 |
audio_artifact_id?: string;
|
|
|
|
|
|
|
|
|
|
| 116 |
slides?: { heading: string; body: string }[];
|
| 117 |
parent_id?: string;
|
| 118 |
/** mind_map: the posts to chart (none: all in the notebook) and what to centre on. */
|
|
|
|
| 113 |
minutes?: number;
|
| 114 |
style?: "audiogram";
|
| 115 |
audio_artifact_id?: string;
|
| 116 |
+
/** audio_overview: the two hosts' voices, from the workspace's voices (none: the defaults). */
|
| 117 |
+
host_a?: string;
|
| 118 |
+
host_b?: string;
|
| 119 |
slides?: { heading: string; body: string }[];
|
| 120 |
parent_id?: string;
|
| 121 |
/** mind_map: the posts to chart (none: all in the notebook) and what to centre on. */
|
frontend/src/api/types.ts
CHANGED
|
@@ -485,8 +485,6 @@ export type VideoVoicesResponse = {
|
|
| 485 |
library: VideoLibraryVoice[];
|
| 486 |
custom: VideoCustomVoice[];
|
| 487 |
notestack: VideoNotestackVoice[];
|
| 488 |
-
/** How many voices "My voices" can hold. */
|
| 489 |
-
max_saved: number;
|
| 490 |
};
|
| 491 |
|
| 492 |
export type VideoDesignedVoice = {
|
|
|
|
| 485 |
library: VideoLibraryVoice[];
|
| 486 |
custom: VideoCustomVoice[];
|
| 487 |
notestack: VideoNotestackVoice[];
|
|
|
|
|
|
|
| 488 |
};
|
| 489 |
|
| 490 |
export type VideoDesignedVoice = {
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -7,9 +7,8 @@ import { FuelGauge, NudgeDock } from "./Nudges";
|
|
| 7 |
import SkyCanvas from "./SkyCanvas";
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
-
CometIcon,
|
| 11 |
-
HelmetIcon,
|
| 12 |
LaunchpadIcon,
|
|
|
|
| 13 |
OrbitIcon,
|
| 14 |
PlanetIcon,
|
| 15 |
SatelliteDishIcon,
|
|
@@ -19,8 +18,7 @@ import {
|
|
| 19 |
const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean; also?: string[] }[] = [
|
| 20 |
{ to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
|
| 21 |
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 22 |
-
{ to: "/app/
|
| 23 |
-
{ to: "/app/voice", label: "Voices", icon: HelmetIcon },
|
| 24 |
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
|
| 25 |
];
|
| 26 |
|
|
|
|
| 7 |
import SkyCanvas from "./SkyCanvas";
|
| 8 |
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
|
|
|
|
|
|
| 10 |
LaunchpadIcon,
|
| 11 |
+
LibraryIcon,
|
| 12 |
OrbitIcon,
|
| 13 |
PlanetIcon,
|
| 14 |
SatelliteDishIcon,
|
|
|
|
| 18 |
const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean; also?: string[] }[] = [
|
| 19 |
{ to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
|
| 20 |
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 21 |
+
{ to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
|
|
|
|
| 22 |
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
|
| 23 |
];
|
| 24 |
|
frontend/src/components/icons/Icons.tsx
CHANGED
|
@@ -161,6 +161,32 @@ export const DownloadIcon = (p: P) => (
|
|
| 161 |
</Icon>
|
| 162 |
);
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
export const FilmIcon = (p: P) => (
|
| 165 |
<Icon {...p}>
|
| 166 |
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
|
|
| 161 |
</Icon>
|
| 162 |
);
|
| 163 |
|
| 164 |
+
/** A stack of media with a play mark on the front: the Library (what you made, your videos and your voices). */
|
| 165 |
+
export const LibraryIcon = (p: P) => (
|
| 166 |
+
<Icon {...p}>
|
| 167 |
+
<rect x="3" y="8" width="18" height="12.5" rx="2.5" />
|
| 168 |
+
<path d="M5.5 5h13M8 2.5h8" />
|
| 169 |
+
<path d="M10.25 11.6v5.3l4.4-2.65z" />
|
| 170 |
+
</Icon>
|
| 171 |
+
);
|
| 172 |
+
|
| 173 |
+
/** A video camera: the Videos section. */
|
| 174 |
+
export const VideoIcon = (p: P) => (
|
| 175 |
+
<Icon {...p}>
|
| 176 |
+
<rect x="3" y="6.5" width="12.5" height="11" rx="2.5" />
|
| 177 |
+
<path d="M15.5 10.5l4.6-2.7a.6.6 0 0 1 .9.5v7.4a.6.6 0 0 1-.9.5l-4.6-2.7" />
|
| 178 |
+
</Icon>
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
/** A microphone with sound waves: the Voices section. */
|
| 182 |
+
export const MicIcon = (p: P) => (
|
| 183 |
+
<Icon {...p}>
|
| 184 |
+
<rect x="9" y="3" width="6" height="11" rx="3" />
|
| 185 |
+
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6" />
|
| 186 |
+
<path d="M2.5 9v3M21.5 9v3" />
|
| 187 |
+
</Icon>
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
export const FilmIcon = (p: P) => (
|
| 191 |
<Icon {...p}>
|
| 192 |
<rect x="3" y="5" width="18" height="14" rx="2" />
|
frontend/src/components/video/VideoVoicesSection.tsx
DELETED
|
@@ -1,208 +0,0 @@
|
|
| 1 |
-
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
| 2 |
-
import { videosApi, videoVoicesApi, voiceApi } from "../../api/endpoints";
|
| 3 |
-
import type { VideoSavedVoice, VideoVoicesResponse } from "../../api/types";
|
| 4 |
-
import { errorMessage, Loading, Modal, Tabs } from "../ui";
|
| 5 |
-
import { useUpgrade } from "../../hooks/useUpgrade";
|
| 6 |
-
import { VoiceLibrary } from "./VoiceLibrary";
|
| 7 |
-
import { Premium, PlayButton, useAudio } from "./parts";
|
| 8 |
-
|
| 9 |
-
const KIND = { clone: "Your cloned voice", designed: "Generated", library: "From the voice library" } as const;
|
| 10 |
-
|
| 11 |
-
/** Play a voice: blog2video voices have a sample link; Notestack voices have none, so the Voice page speaks a line. */
|
| 12 |
-
function useVoicePlayer(onError: (msg: string) => void) {
|
| 13 |
-
const { playing, play, stop } = useAudio();
|
| 14 |
-
const playNotestack = async (voiceId: string) => {
|
| 15 |
-
if (playing === voiceId) return stop();
|
| 16 |
-
const res = await voiceApi.preview(voiceId).catch((e) => {
|
| 17 |
-
onError(errorMessage(e));
|
| 18 |
-
return null;
|
| 19 |
-
});
|
| 20 |
-
if (res?.url) play(voiceId, res.url);
|
| 21 |
-
};
|
| 22 |
-
const playSaved = (v: VideoSavedVoice) =>
|
| 23 |
-
v.source === "notestack" ? playNotestack(v.voice_id) : play(v.voice_id, v.preview_url);
|
| 24 |
-
return { playing, playNotestack, playSaved };
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
/** The Voice page's "Video voiceovers" tab: My video voices (what step 3 of a new video offers, up to max_saved) and
|
| 28 |
-
* "Manage your voiceovers", which adds from the video voice library or the workspace's own Notestack voices. Notestack
|
| 29 |
-
* voices work in videos because blog2video speaks with the same ElevenLabs account. */
|
| 30 |
-
export function VideoVoicesSection({ renderAddVoice, reloadKey = 0 }: {
|
| 31 |
-
/** The modal's Add your voice tab: the Voice page's generator. onDone = saved (back to the library); close = close the
|
| 32 |
-
* modal (cloning opens its own). The new voice is added to the video voices by the Voice page. */
|
| 33 |
-
renderAddVoice: (onDone: () => void, close: () => void) => ReactNode;
|
| 34 |
-
/** Bumped by the Voice page after it made a voice, so the list here reloads. */
|
| 35 |
-
reloadKey?: number;
|
| 36 |
-
}) {
|
| 37 |
-
const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
|
| 38 |
-
const [premium, setPremium] = useState(false);
|
| 39 |
-
const [managing, setManaging] = useState(false);
|
| 40 |
-
const [busy, setBusy] = useState<string | null>(null);
|
| 41 |
-
const [error, setError] = useState<string | null>(null);
|
| 42 |
-
const { playing, playSaved } = useVoicePlayer(setError);
|
| 43 |
-
|
| 44 |
-
const load = useCallback(() => videoVoicesApi.list().then(setVoices).catch((e) => setError(errorMessage(e))), []);
|
| 45 |
-
useEffect(() => {
|
| 46 |
-
load();
|
| 47 |
-
videosApi.config().then((c) => setPremium(!!c.premium)).catch(() => undefined);
|
| 48 |
-
}, [load, reloadKey]);
|
| 49 |
-
|
| 50 |
-
const remove = async (voiceId: string) => {
|
| 51 |
-
setBusy(voiceId);
|
| 52 |
-
setError(null);
|
| 53 |
-
try {
|
| 54 |
-
await videoVoicesApi.unsave(voiceId);
|
| 55 |
-
await load();
|
| 56 |
-
} catch (e) {
|
| 57 |
-
setError(errorMessage(e));
|
| 58 |
-
} finally {
|
| 59 |
-
setBusy(null);
|
| 60 |
-
}
|
| 61 |
-
};
|
| 62 |
-
|
| 63 |
-
return (
|
| 64 |
-
<div id="video-voices" className="stack">
|
| 65 |
-
<div className="row between wrap manage-top">
|
| 66 |
-
<p className="muted">
|
| 67 |
-
Up to {voices?.max_saved ?? 5} voices, offered in step 3 of a new video. Mix your own voices with the video
|
| 68 |
-
voice library.
|
| 69 |
-
</p>
|
| 70 |
-
<button className="btn btn-primary" disabled={!voices} onClick={() => setManaging(true)}>
|
| 71 |
-
Manage your voiceovers
|
| 72 |
-
</button>
|
| 73 |
-
</div>
|
| 74 |
-
{error && <p className="error-text">{error}</p>}
|
| 75 |
-
{!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
|
| 76 |
-
|
| 77 |
-
{voices && (
|
| 78 |
-
<div className="stack">
|
| 79 |
-
<h3>My video voices <span className="mono muted small">{voices.saved.length} of {voices.max_saved}</span></h3>
|
| 80 |
-
<div className="vw-voice-list">
|
| 81 |
-
{voices.saved.map((v) => (
|
| 82 |
-
<div key={v.voice_id} className="vw-vrow">
|
| 83 |
-
<PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
|
| 84 |
-
label={v.name} onClick={() => playSaved(v)} />
|
| 85 |
-
<div className="vw-vpick static">
|
| 86 |
-
<strong>{v.name}</strong>
|
| 87 |
-
<span className="muted">
|
| 88 |
-
{v.source === "notestack" || v.is_custom ? "Your voice" : [v.gender, v.accent].filter(Boolean).join(" • ")}
|
| 89 |
-
</span>
|
| 90 |
-
</div>
|
| 91 |
-
{v.premium && <Premium small />}
|
| 92 |
-
<button className="btn btn-small" disabled={busy === v.voice_id} onClick={() => remove(v.voice_id)}>
|
| 93 |
-
Remove
|
| 94 |
-
</button>
|
| 95 |
-
</div>
|
| 96 |
-
))}
|
| 97 |
-
{voices.saved.length === 0 && <p className="muted">No voices yet. Add some with Manage your voiceovers.</p>}
|
| 98 |
-
</div>
|
| 99 |
-
</div>
|
| 100 |
-
)}
|
| 101 |
-
|
| 102 |
-
{managing && voices && (
|
| 103 |
-
<ManageVideoVoicesModal voices={voices} premium={premium} onChange={load} onClose={() => setManaging(false)}
|
| 104 |
-
renderAddVoice={renderAddVoice} />
|
| 105 |
-
)}
|
| 106 |
-
</div>
|
| 107 |
-
);
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
/** Add or remove video voices: blog2video's library (and custom voices) plus the workspace's own Notestack voices, or
|
| 111 |
-
* make a new voice (Add your voice). */
|
| 112 |
-
function ManageVideoVoicesModal({ voices, premium, onChange, onClose, renderAddVoice }: {
|
| 113 |
-
voices: VideoVoicesResponse;
|
| 114 |
-
premium: boolean;
|
| 115 |
-
onChange: () => Promise<unknown>;
|
| 116 |
-
onClose: () => void;
|
| 117 |
-
renderAddVoice: (onDone: () => void, close: () => void) => ReactNode;
|
| 118 |
-
}) {
|
| 119 |
-
const { openUpgrade } = useUpgrade();
|
| 120 |
-
const [tab, setTab] = useState<"library" | "add">("library");
|
| 121 |
-
const [busy, setBusy] = useState<string | null>(null);
|
| 122 |
-
const [error, setError] = useState<string | null>(null);
|
| 123 |
-
const { playing, playNotestack } = useVoicePlayer(setError);
|
| 124 |
-
const full = voices.saved.length >= voices.max_saved;
|
| 125 |
-
// Sections: saved (from a voice library) and cloned voices, voices created (designed), and the built-in library.
|
| 126 |
-
const ownSaved = voices.notestack.filter((v) => v.kind !== "designed");
|
| 127 |
-
const ownCreated = voices.notestack.filter((v) => v.kind === "designed");
|
| 128 |
-
const clonedCustom = voices.custom.some((c) => c.source === "clone");
|
| 129 |
-
const createdCustom = voices.custom.some((c) => c.source !== "clone");
|
| 130 |
-
const libProps = { voices, premium, full, onChange, onLocked: () => openUpgrade() };
|
| 131 |
-
|
| 132 |
-
const ownRow = (v: VideoVoicesResponse["notestack"][number]) => (
|
| 133 |
-
<div key={v.voice_id} className="vw-vrow">
|
| 134 |
-
<PlayButton on={playing === v.voice_id} label={v.name} onClick={() => playNotestack(v.voice_id)} />
|
| 135 |
-
<div className="vw-vpick static">
|
| 136 |
-
<strong>{v.name}</strong>
|
| 137 |
-
<span className="muted">{KIND[v.kind]}</span>
|
| 138 |
-
</div>
|
| 139 |
-
<Premium small />
|
| 140 |
-
{v.saved ? (
|
| 141 |
-
<button className="btn btn-small" disabled={busy === v.voice_id}
|
| 142 |
-
onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>Remove</button>
|
| 143 |
-
) : (
|
| 144 |
-
<button className="btn btn-small btn-primary" disabled={busy === v.voice_id || full}
|
| 145 |
-
onClick={() => (premium ? act(v.voice_id, () => videoVoicesApi.save(v.voice_id)) : openUpgrade())}>
|
| 146 |
-
Add
|
| 147 |
-
</button>
|
| 148 |
-
)}
|
| 149 |
-
</div>
|
| 150 |
-
);
|
| 151 |
-
|
| 152 |
-
async function act(key: string, fn: () => Promise<unknown>) {
|
| 153 |
-
setBusy(key);
|
| 154 |
-
setError(null);
|
| 155 |
-
try {
|
| 156 |
-
await fn();
|
| 157 |
-
await onChange();
|
| 158 |
-
} catch (e) {
|
| 159 |
-
setError(errorMessage(e));
|
| 160 |
-
} finally {
|
| 161 |
-
setBusy(null);
|
| 162 |
-
}
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
return (
|
| 166 |
-
<Modal title="Manage your voiceovers" onClose={onClose} wide>
|
| 167 |
-
<div className="stack">
|
| 168 |
-
<p className="mono muted small">
|
| 169 |
-
{voices.saved.length} of {voices.max_saved} added
|
| 170 |
-
{full ? ". Remove one to add another." : ""}
|
| 171 |
-
</p>
|
| 172 |
-
<Tabs
|
| 173 |
-
tabs={[
|
| 174 |
-
{ id: "library", label: "Voice library" },
|
| 175 |
-
{ id: "add", label: "Add your voice" },
|
| 176 |
-
]}
|
| 177 |
-
value={tab}
|
| 178 |
-
onChange={setTab}
|
| 179 |
-
/>
|
| 180 |
-
{error && <p className="error-text">{error}</p>}
|
| 181 |
-
{tab === "add" ? (
|
| 182 |
-
renderAddVoice(() => setTab("library"), onClose)
|
| 183 |
-
) : (
|
| 184 |
-
<div className="stack">
|
| 185 |
-
{(ownSaved.length > 0 || clonedCustom) && (
|
| 186 |
-
<section className="stack">
|
| 187 |
-
<h3>Saved and cloned</h3>
|
| 188 |
-
{ownSaved.length > 0 && <div className="vw-voice-list">{ownSaved.map(ownRow)}</div>}
|
| 189 |
-
<VoiceLibrary {...libProps} part="custom" customKind="clone" />
|
| 190 |
-
</section>
|
| 191 |
-
)}
|
| 192 |
-
{(ownCreated.length > 0 || createdCustom) && (
|
| 193 |
-
<section className="stack">
|
| 194 |
-
<h3>Created by you</h3>
|
| 195 |
-
{ownCreated.length > 0 && <div className="vw-voice-list">{ownCreated.map(ownRow)}</div>}
|
| 196 |
-
<VoiceLibrary {...libProps} part="custom" customKind="designed" />
|
| 197 |
-
</section>
|
| 198 |
-
)}
|
| 199 |
-
<section className="stack">
|
| 200 |
-
<h3>Built-in voices</h3>
|
| 201 |
-
<VoiceLibrary {...libProps} part="library" />
|
| 202 |
-
</section>
|
| 203 |
-
</div>
|
| 204 |
-
)}
|
| 205 |
-
</div>
|
| 206 |
-
</Modal>
|
| 207 |
-
);
|
| 208 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/video/VoiceLibrary.tsx
DELETED
|
@@ -1,107 +0,0 @@
|
|
| 1 |
-
import { useState } from "react";
|
| 2 |
-
import { videoVoicesApi } from "../../api/endpoints";
|
| 3 |
-
import type { VideoVoicesResponse } from "../../api/types";
|
| 4 |
-
import { ConfirmButton, errorMessage } from "../ui";
|
| 5 |
-
import { Premium, PlayButton, useAudio } from "./parts";
|
| 6 |
-
|
| 7 |
-
const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");
|
| 8 |
-
|
| 9 |
-
/** The built-in voices to add to "My voices", and this workspace's custom voices. */
|
| 10 |
-
export function VoiceLibrary({ voices, premium, onChange, onLocked, full = false, part, customKind }: {
|
| 11 |
-
voices: VideoVoicesResponse; premium: boolean; onChange: () => void; onLocked: () => void;
|
| 12 |
-
/** My voices is at its cap: nothing more can be added until one is removed. */
|
| 13 |
-
full?: boolean;
|
| 14 |
-
/** Just one part, with no heading of its own (the caller titles it): the custom voices or the built-in library. */
|
| 15 |
-
part?: "custom" | "library";
|
| 16 |
-
/** With part "custom": only cloned or only designed custom voices. */
|
| 17 |
-
customKind?: "clone" | "designed";
|
| 18 |
-
}) {
|
| 19 |
-
const [busy, setBusy] = useState<string | null>(null);
|
| 20 |
-
const [error, setError] = useState<string | null>(null);
|
| 21 |
-
const { playing, play } = useAudio();
|
| 22 |
-
|
| 23 |
-
const shown = voices.library;
|
| 24 |
-
const custom = voices.custom.filter((c) => !customKind || (customKind === "clone") === (c.source === "clone"));
|
| 25 |
-
|
| 26 |
-
async function act(key: string, fn: () => Promise<unknown>) {
|
| 27 |
-
setBusy(key);
|
| 28 |
-
setError(null);
|
| 29 |
-
try {
|
| 30 |
-
await fn();
|
| 31 |
-
onChange();
|
| 32 |
-
} catch (e) {
|
| 33 |
-
setError(errorMessage(e));
|
| 34 |
-
} finally {
|
| 35 |
-
setBusy(null);
|
| 36 |
-
}
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
async function playCustom(id: number, url: string | null) {
|
| 40 |
-
if (url) return play(`c${id}`, url);
|
| 41 |
-
const res = await videoVoicesApi.customPreview(id).catch(() => null);
|
| 42 |
-
if (res?.preview_url) play(`c${id}`, res.preview_url);
|
| 43 |
-
else setError("The preview is not ready yet. Try again in a minute.");
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
return (
|
| 47 |
-
<div className="stack">
|
| 48 |
-
{part !== "library" && custom.length > 0 && (
|
| 49 |
-
<section className="stack">
|
| 50 |
-
{!part && <h3>Your custom voices</h3>}
|
| 51 |
-
<div className="vw-voice-list">
|
| 52 |
-
{custom.map((c) => (
|
| 53 |
-
<div key={c.id} className="vw-vrow">
|
| 54 |
-
<PlayButton on={playing === `c${c.id}`} label={c.name} onClick={() => playCustom(c.id, c.preview_url)} />
|
| 55 |
-
<div className="vw-vpick static">
|
| 56 |
-
<strong>{c.name}</strong>
|
| 57 |
-
<span className="muted">{c.source === "clone" ? "Cloned" : "Designed"}</span>
|
| 58 |
-
</div>
|
| 59 |
-
{c.saved ? (
|
| 60 |
-
<button className="btn btn-small" disabled={busy === c.voice_id}
|
| 61 |
-
onClick={() => act(c.voice_id, () => videoVoicesApi.unsave(c.voice_id))}>Remove</button>
|
| 62 |
-
) : (
|
| 63 |
-
<button className="btn btn-small" disabled={busy === c.voice_id || full}
|
| 64 |
-
onClick={() => act(c.voice_id, () => videoVoicesApi.save(c.voice_id))}>Add</button>
|
| 65 |
-
)}
|
| 66 |
-
<ConfirmButton onConfirm={() => act(`d${c.id}`, () => videoVoicesApi.removeCustom(c.id))}>Delete</ConfirmButton>
|
| 67 |
-
</div>
|
| 68 |
-
))}
|
| 69 |
-
</div>
|
| 70 |
-
</section>
|
| 71 |
-
)}
|
| 72 |
-
|
| 73 |
-
{part === "custom" && error && <p className="error-text">{error}</p>}
|
| 74 |
-
{part !== "custom" && (
|
| 75 |
-
<section className="stack">
|
| 76 |
-
{error && <p className="error-text">{error}</p>}
|
| 77 |
-
<div className="vw-voice-list">
|
| 78 |
-
{shown.map((v) => (
|
| 79 |
-
<div key={v.voice_id} className="vw-vrow">
|
| 80 |
-
<PlayButton on={playing === v.voice_id} disabled={!v.preview_url} label={v.name}
|
| 81 |
-
onClick={() => play(v.voice_id, v.preview_url)} />
|
| 82 |
-
<div className="vw-vpick static">
|
| 83 |
-
<strong>{v.name}</strong>
|
| 84 |
-
<span className="muted">
|
| 85 |
-
{[cap(v.gender), cap(v.accent), cap(v.age)].filter(Boolean).join(" • ")}
|
| 86 |
-
{v.description ? ` - ${v.description}` : ""}
|
| 87 |
-
</span>
|
| 88 |
-
</div>
|
| 89 |
-
{v.premium && <Premium small />}
|
| 90 |
-
{v.saved ? (
|
| 91 |
-
<button className="btn btn-small" disabled={busy === v.voice_id}
|
| 92 |
-
onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>Remove</button>
|
| 93 |
-
) : (
|
| 94 |
-
<button className="btn btn-small btn-primary" disabled={busy === v.voice_id || full}
|
| 95 |
-
onClick={() => (v.premium && !premium ? onLocked() : act(v.voice_id, () => videoVoicesApi.save(v.voice_id)))}>
|
| 96 |
-
Add
|
| 97 |
-
</button>
|
| 98 |
-
)}
|
| 99 |
-
</div>
|
| 100 |
-
))}
|
| 101 |
-
{shown.length === 0 && <p className="muted">No voices in the library right now.</p>}
|
| 102 |
-
</div>
|
| 103 |
-
</section>
|
| 104 |
-
)}
|
| 105 |
-
</div>
|
| 106 |
-
);
|
| 107 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/voice/YourVoices.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { videosApi, videoVoicesApi, voiceApi } from "../../api/endpoints";
|
| 3 |
+
import type { VideoLibraryVoice, VideoSavedVoice, VideoVoicesResponse } from "../../api/types";
|
| 4 |
+
import { useUpgrade } from "../../hooks/useUpgrade";
|
| 5 |
+
import { errorMessage, Loading } from "../ui";
|
| 6 |
+
import { Premium, PlayButton, useAudio } from "../video/parts";
|
| 7 |
+
|
| 8 |
+
const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");
|
| 9 |
+
|
| 10 |
+
/** Play a voice: library voices have a sample link; the workspace's own (Notestack) voices have none, so the Voice
|
| 11 |
+
* page speaks a line in them. */
|
| 12 |
+
export function useVoicePlayer(onError: (msg: string) => void) {
|
| 13 |
+
const { playing, play, stop } = useAudio();
|
| 14 |
+
const playNotestack = async (voiceId: string) => {
|
| 15 |
+
if (playing === voiceId) return stop();
|
| 16 |
+
const res = await voiceApi.preview(voiceId).catch((e) => {
|
| 17 |
+
onError(errorMessage(e));
|
| 18 |
+
return null;
|
| 19 |
+
});
|
| 20 |
+
if (res?.url) play(voiceId, res.url);
|
| 21 |
+
};
|
| 22 |
+
const playSaved = (v: VideoSavedVoice) =>
|
| 23 |
+
v.source === "notestack" ? playNotestack(v.voice_id) : play(v.voice_id, v.preview_url);
|
| 24 |
+
return { playing, play, playNotestack, playSaved };
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/** The Voice page's Speaking voices: the workspace's voices (one list for audio overviews and videos, as many as
|
| 28 |
+
* wanted) and, below, the built-in library to add from. New voices (Add voices) are saved here by the server. */
|
| 29 |
+
export function YourVoices({ reloadKey = 0, onAdd }: {
|
| 30 |
+
/** Bumped by the Voice page after a voice was made or cloned, so the list reloads. */
|
| 31 |
+
reloadKey?: number;
|
| 32 |
+
/** Opens the Add voices modal (build, describe or clone). */
|
| 33 |
+
onAdd: () => void;
|
| 34 |
+
}) {
|
| 35 |
+
const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
|
| 36 |
+
const [premium, setPremium] = useState(false);
|
| 37 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 38 |
+
const [error, setError] = useState<string | null>(null);
|
| 39 |
+
const { playing, play, playSaved } = useVoicePlayer(setError);
|
| 40 |
+
const { openUpgrade } = useUpgrade();
|
| 41 |
+
|
| 42 |
+
const load = useCallback(() => videoVoicesApi.list().then(setVoices).catch((e) => setError(errorMessage(e))), []);
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
load();
|
| 45 |
+
videosApi.config().then((c) => setPremium(!!c.premium)).catch(() => undefined);
|
| 46 |
+
}, [load, reloadKey]);
|
| 47 |
+
|
| 48 |
+
async function act(voiceId: string, fn: () => Promise<unknown>) {
|
| 49 |
+
setBusy(voiceId);
|
| 50 |
+
setError(null);
|
| 51 |
+
try {
|
| 52 |
+
await fn();
|
| 53 |
+
await load();
|
| 54 |
+
} catch (e) {
|
| 55 |
+
setError(errorMessage(e));
|
| 56 |
+
} finally {
|
| 57 |
+
setBusy(null);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const add = (v: VideoLibraryVoice) =>
|
| 62 |
+
v.premium && !premium ? openUpgrade() : act(v.voice_id, () => videoVoicesApi.save(v.voice_id));
|
| 63 |
+
const library = voices?.library.filter((v) => !v.saved) ?? [];
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="stack">
|
| 67 |
+
<div className="row between wrap manage-top">
|
| 68 |
+
<p className="muted">Your voices read your audio overviews and videos. Add your own, or pick from the library.</p>
|
| 69 |
+
<button className="btn btn-primary" onClick={onAdd}>Add voices</button>
|
| 70 |
+
</div>
|
| 71 |
+
{error && <p className="error-text">{error}</p>}
|
| 72 |
+
{!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
|
| 73 |
+
|
| 74 |
+
{voices && (
|
| 75 |
+
<>
|
| 76 |
+
<section className="stack yv-panel">
|
| 77 |
+
<h3 className="yv-head">Your voices <span className="yv-count mono">{voices.saved.length}</span></h3>
|
| 78 |
+
{voices.saved.length === 0 ? (
|
| 79 |
+
<p className="muted">No voices yet. Add one, or pick from the library below.</p>
|
| 80 |
+
) : (
|
| 81 |
+
<div className="vw-voice-list vw-voice-list-tight vw-voice-list-four">
|
| 82 |
+
{voices.saved.map((v) => (
|
| 83 |
+
<div key={v.voice_id} className="vw-vrow">
|
| 84 |
+
<PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
|
| 85 |
+
label={v.name} onClick={() => playSaved(v)} />
|
| 86 |
+
<div className="vw-vpick static">
|
| 87 |
+
<strong>{v.name}</strong>
|
| 88 |
+
<span className="muted">
|
| 89 |
+
{v.source === "notestack" || v.is_custom ? "Your voice"
|
| 90 |
+
: [cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}
|
| 91 |
+
</span>
|
| 92 |
+
</div>
|
| 93 |
+
{v.premium && <Premium small />}
|
| 94 |
+
<button className="btn btn-small" disabled={busy === v.voice_id}
|
| 95 |
+
onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>
|
| 96 |
+
Remove
|
| 97 |
+
</button>
|
| 98 |
+
</div>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
)}
|
| 102 |
+
</section>
|
| 103 |
+
|
| 104 |
+
<section className="stack yv-panel">
|
| 105 |
+
<h3 className="yv-head">Voice library</h3>
|
| 106 |
+
{library.length === 0 ? (
|
| 107 |
+
<p className="muted">Every library voice is in your voices.</p>
|
| 108 |
+
) : (
|
| 109 |
+
<div className="vw-voice-list vw-voice-list-tight">
|
| 110 |
+
{library.map((v) => (
|
| 111 |
+
<div key={v.voice_id} className="vw-vrow">
|
| 112 |
+
<PlayButton on={playing === v.voice_id} disabled={!v.preview_url} label={v.name}
|
| 113 |
+
onClick={() => play(v.voice_id, v.preview_url)} />
|
| 114 |
+
<div className="vw-vpick static">
|
| 115 |
+
<strong>{v.name}</strong>
|
| 116 |
+
<span className="muted">
|
| 117 |
+
{[cap(v.gender), cap(v.accent), cap(v.age)].filter(Boolean).join(" • ")}
|
| 118 |
+
{v.description ? ` - ${v.description}` : ""}
|
| 119 |
+
</span>
|
| 120 |
+
</div>
|
| 121 |
+
{v.premium && <Premium small />}
|
| 122 |
+
<button className="btn btn-small btn-primary" disabled={busy === v.voice_id} onClick={() => add(v)}>
|
| 123 |
+
Add
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
))}
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</section>
|
| 130 |
+
</>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
frontend/src/pages/Archive.tsx
CHANGED
|
@@ -1,16 +1,41 @@
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
-
import { Link } from "react-router-dom";
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact } from "../api/types";
|
| 5 |
import { ArtifactRow } from "../components/ArtifactRow";
|
| 6 |
import { Reader } from "../components/Reader";
|
| 7 |
import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
type Filter = "all" | "summary" | "audio_overview" | "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const PAGE = 30;
|
| 11 |
|
| 12 |
export default function Archive() {
|
| 13 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const [q, setQ] = useState("");
|
| 15 |
const [items, setItems] = useState<Artifact[] | null>(null);
|
| 16 |
const [total, setTotal] = useState(0);
|
|
@@ -33,21 +58,8 @@ export default function Archive() {
|
|
| 33 |
}, [load]);
|
| 34 |
|
| 35 |
return (
|
| 36 |
-
<div className="
|
| 37 |
-
<
|
| 38 |
-
<div className="row between wrap">
|
| 39 |
-
<Tabs<Filter>
|
| 40 |
-
tabs={[
|
| 41 |
-
{ id: "all", label: "All" },
|
| 42 |
-
{ id: "summary", label: "Summaries" },
|
| 43 |
-
{ id: "audio_overview", label: "Audio" },
|
| 44 |
-
{ id: "video", label: "Video" },
|
| 45 |
-
{ id: "launch_kit", label: "Launch Kits" },
|
| 46 |
-
{ id: "quote_card,carousel", label: "Stills" },
|
| 47 |
-
]}
|
| 48 |
-
value={filter}
|
| 49 |
-
onChange={setFilter}
|
| 50 |
-
/>
|
| 51 |
<input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
|
| 52 |
</div>
|
| 53 |
{!items && <Loading />}
|
|
|
|
| 1 |
import { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { Link, useSearchParams } from "react-router-dom";
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact } from "../api/types";
|
| 5 |
import { ArtifactRow } from "../components/ArtifactRow";
|
| 6 |
import { Reader } from "../components/Reader";
|
| 7 |
import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
|
| 8 |
+
import Videos from "./Videos";
|
| 9 |
+
import VoiceProfile from "./VoiceProfile";
|
| 10 |
|
| 11 |
+
type Filter = "all" | "summary" | "audio_overview" | "launch_kit" | "quote_card,carousel";
|
| 12 |
+
/** The Library's tabs: kinds of things made (a filter on one list), plus your videos and your voices (their own pages). */
|
| 13 |
+
type Tab = Filter | "videos" | "voices";
|
| 14 |
+
// For now just these three; the per-kind filters (Summaries, Audio, Launch Kits, Stills) can come back as tabs.
|
| 15 |
+
const TABS: { id: Tab; label: string }[] = [
|
| 16 |
+
{ id: "all", label: "All" },
|
| 17 |
+
{ id: "videos", label: "Videos" },
|
| 18 |
+
{ id: "voices", label: "Voices" },
|
| 19 |
+
];
|
| 20 |
const PAGE = 30;
|
| 21 |
|
| 22 |
export default function Archive() {
|
| 23 |
+
const [params, setParams] = useSearchParams();
|
| 24 |
+
const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
|
| 25 |
+
// The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
|
| 26 |
+
const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div className="page-wrap">
|
| 30 |
+
<PageHeader eyebrow="Library" title="Everything you have made" />
|
| 31 |
+
<Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
|
| 32 |
+
{tab === "videos" ? <Videos /> : tab === "voices" ? <VoiceProfile /> : <MadeList filter={tab} />}
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** What was made, of one kind (or all), searchable. */
|
| 38 |
+
function MadeList({ filter }: { filter: Filter }) {
|
| 39 |
const [q, setQ] = useState("");
|
| 40 |
const [items, setItems] = useState<Artifact[] | null>(null);
|
| 41 |
const [total, setTotal] = useState(0);
|
|
|
|
| 58 |
}, [load]);
|
| 59 |
|
| 60 |
return (
|
| 61 |
+
<div className="stack">
|
| 62 |
+
<div className="row end wrap">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
|
| 64 |
</div>
|
| 65 |
{!items && <Loading />}
|
frontend/src/pages/NotebookView.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useParams, useSearchParams } from "react-router-dom";
|
| 3 |
-
import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
-
import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook } from "../api/types";
|
| 6 |
import { AnswerFeedback } from "../components/AnswerFeedback";
|
| 7 |
import { ArtifactCard, CitationList } from "../components/ArtifactCard";
|
| 8 |
import { Markdown } from "../components/Markdown";
|
|
@@ -52,6 +52,9 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
|
|
| 52 |
const [busy, setBusy] = useState(false);
|
| 53 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 54 |
const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
|
|
|
|
|
|
|
|
|
|
| 55 |
const [mapDialog, setMapDialog] = useState(false);
|
| 56 |
const [mapBusy, setMapBusy] = useState(false);
|
| 57 |
const [mapError, setMapError] = useState<string | null>(null);
|
|
@@ -59,6 +62,25 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
|
|
| 59 |
const [exploring, setExploring] = useState<Artifact | null>(null);
|
| 60 |
|
| 61 |
const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
useEffect(() => {
|
| 63 |
load();
|
| 64 |
}, [load]);
|
|
@@ -134,9 +156,18 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
|
|
| 134 |
<Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
|
| 135 |
options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
|
| 136 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
| 138 |
-
<button className="btn btn-primary btn-small" disabled={off}
|
| 139 |
-
onClick={() => make({ type: "audio_overview", format, minutes
|
|
|
|
|
|
|
| 140 |
{busy ? "Starting..." : "Create audio overview"}
|
| 141 |
</button>
|
| 142 |
</div>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useParams, useSearchParams } from "react-router-dom";
|
| 3 |
+
import { artifactsApi, notebooksApi, videoVoicesApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
+
import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook, VideoSavedVoice } from "../api/types";
|
| 6 |
import { AnswerFeedback } from "../components/AnswerFeedback";
|
| 7 |
import { ArtifactCard, CitationList } from "../components/ArtifactCard";
|
| 8 |
import { Markdown } from "../components/Markdown";
|
|
|
|
| 52 |
const [busy, setBusy] = useState(false);
|
| 53 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 54 |
const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
|
| 55 |
+
// The two hosts' voices, picked from the workspace's voices (loaded when the options first open).
|
| 56 |
+
const [hostVoices, setHostVoices] = useState<VideoSavedVoice[] | null>(null);
|
| 57 |
+
const [hosts, setHosts] = useState<[string, string]>(["", ""]);
|
| 58 |
const [mapDialog, setMapDialog] = useState(false);
|
| 59 |
const [mapBusy, setMapBusy] = useState(false);
|
| 60 |
const [mapError, setMapError] = useState<string | null>(null);
|
|
|
|
| 62 |
const [exploring, setExploring] = useState<Artifact | null>(null);
|
| 63 |
|
| 64 |
const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (!audioOpen || hostVoices) return;
|
| 68 |
+
videoVoicesApi.list().then((r) => {
|
| 69 |
+
setHostVoices(r.saved);
|
| 70 |
+
// The first two voices by default; with just one, it reads both parts.
|
| 71 |
+
setHosts([r.saved[0]?.voice_id ?? "", (r.saved[1] ?? r.saved[0])?.voice_id ?? ""]);
|
| 72 |
+
}).catch(() => setHostVoices([])); // none to pick: the server's default voices
|
| 73 |
+
}, [audioOpen, hostVoices]);
|
| 74 |
+
|
| 75 |
+
/** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
|
| 76 |
+
const pickHost = (i: 0 | 1, voiceId: string) =>
|
| 77 |
+
setHosts((cur) => {
|
| 78 |
+
const next: [string, string] = [...cur];
|
| 79 |
+
next[i] = voiceId;
|
| 80 |
+
const other = i === 0 ? 1 : 0;
|
| 81 |
+
if (next[other] === voiceId) next[other] = hostVoices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
|
| 82 |
+
return next;
|
| 83 |
+
});
|
| 84 |
useEffect(() => {
|
| 85 |
load();
|
| 86 |
}, [load]);
|
|
|
|
| 156 |
<Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
|
| 157 |
options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
|
| 158 |
</div>
|
| 159 |
+
{hostVoices && hostVoices.length > 0 && ([0, 1] as const).map((i) => (
|
| 160 |
+
<div key={i} className="field">
|
| 161 |
+
<span className="small muted">Host {i + 1}</span>
|
| 162 |
+
<Dropdown<string> label={`Host ${i + 1} voice`} value={hosts[i]} onChange={(v) => pickHost(i, v)}
|
| 163 |
+
options={hostVoices.map((v) => ({ value: v.voice_id, label: v.name }))} />
|
| 164 |
+
</div>
|
| 165 |
+
))}
|
| 166 |
</div>
|
| 167 |
+
<button className="btn btn-primary btn-small" disabled={off || (audioOpen && hostVoices === null)}
|
| 168 |
+
onClick={() => make({ type: "audio_overview", format, minutes,
|
| 169 |
+
...(hosts[0] ? { host_a: hosts[0], host_b: hosts[1] || hosts[0] } : {}) })
|
| 170 |
+
.then(() => setAudioOpen(false))}>
|
| 171 |
{busy ? "Starting..." : "Create audio overview"}
|
| 172 |
</button>
|
| 173 |
</div>
|
frontend/src/pages/VideoCreate.tsx
CHANGED
|
@@ -145,7 +145,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 145 |
}).catch((e) => setLoadError(errorMessage(e)));
|
| 146 |
loadVoices()
|
| 147 |
.then((v) => v.saved[0] && pickVoice(v.saved[0]))
|
| 148 |
-
.catch(() => setVoices({ saved: [], library: [], custom: [], notestack: []
|
| 149 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 150 |
}, [config?.configured]);
|
| 151 |
|
|
@@ -279,7 +279,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 279 |
{step === 1 && !inModal && (
|
| 280 |
// Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
|
| 281 |
<button type="button" className="vw-back"
|
| 282 |
-
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/videos"))}>
|
| 283 |
Back
|
| 284 |
</button>
|
| 285 |
)}
|
|
@@ -367,13 +367,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 367 |
<small className="muted mono">{custom.length}/500</small>
|
| 368 |
</span>
|
| 369 |
</label>
|
| 370 |
-
|
| 371 |
-
<small className="muted vw-hint">
|
| 372 |
-
{focus ? "The video is mainly about this topic."
|
| 373 |
-
: custom.trim() ? "The video follows your topic, as far as the posts cover it."
|
| 374 |
-
: "None picked: the video covers the material as a whole."}
|
| 375 |
-
</small>
|
| 376 |
-
)}
|
| 377 |
</div>
|
| 378 |
)}
|
| 379 |
|
|
@@ -471,7 +465,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 471 |
{voices === null ? (
|
| 472 |
<Loading label="Loading voices" />
|
| 473 |
) : saved.length === 0 ? (
|
| 474 |
-
<p className="muted">No voices
|
| 475 |
) : (
|
| 476 |
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
|
| 477 |
{saved.map((v) => {
|
|
|
|
| 145 |
}).catch((e) => setLoadError(errorMessage(e)));
|
| 146 |
loadVoices()
|
| 147 |
.then((v) => v.saved[0] && pickVoice(v.saved[0]))
|
| 148 |
+
.catch(() => setVoices({ saved: [], library: [], custom: [], notestack: [] }));
|
| 149 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 150 |
}, [config?.configured]);
|
| 151 |
|
|
|
|
| 279 |
{step === 1 && !inModal && (
|
| 280 |
// Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
|
| 281 |
<button type="button" className="vw-back"
|
| 282 |
+
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/archive?tab=videos"))}>
|
| 283 |
Back
|
| 284 |
</button>
|
| 285 |
)}
|
|
|
|
| 367 |
<small className="muted mono">{custom.length}/500</small>
|
| 368 |
</span>
|
| 369 |
</label>
|
| 370 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
</div>
|
| 372 |
)}
|
| 373 |
|
|
|
|
| 465 |
{voices === null ? (
|
| 466 |
<Loading label="Loading voices" />
|
| 467 |
) : saved.length === 0 ? (
|
| 468 |
+
<p className="muted">No voices yet. Add some on the <Link to="/app/archive?tab=voices">Voices</Link> tab of your Library.</p>
|
| 469 |
) : (
|
| 470 |
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
|
| 471 |
{saved.map((v) => {
|
frontend/src/pages/VideoEditor.tsx
CHANGED
|
@@ -282,14 +282,14 @@ export default function VideoEditor() {
|
|
| 282 |
|
| 283 |
const remove = async () => {
|
| 284 |
await videosApi.remove(id);
|
| 285 |
-
navigate("/app/videos");
|
| 286 |
};
|
| 287 |
|
| 288 |
if (notFound) {
|
| 289 |
return (
|
| 290 |
<div className="page-wrap">
|
| 291 |
<PageHeader eyebrow="Videos" title="Video not found" />
|
| 292 |
-
<Link to="/app/videos" className="btn">Back to videos</Link>
|
| 293 |
</div>
|
| 294 |
);
|
| 295 |
}
|
|
|
|
| 282 |
|
| 283 |
const remove = async () => {
|
| 284 |
await videosApi.remove(id);
|
| 285 |
+
navigate("/app/archive?tab=videos");
|
| 286 |
};
|
| 287 |
|
| 288 |
if (notFound) {
|
| 289 |
return (
|
| 290 |
<div className="page-wrap">
|
| 291 |
<PageHeader eyebrow="Videos" title="Video not found" />
|
| 292 |
+
<Link to="/app/archive?tab=videos" className="btn">Back to videos</Link>
|
| 293 |
</div>
|
| 294 |
);
|
| 295 |
}
|
frontend/src/pages/VideoTemplates.tsx
CHANGED
|
@@ -16,7 +16,7 @@ export default function VideoTemplates() {
|
|
| 16 |
return (
|
| 17 |
<div className="page-wrap">
|
| 18 |
<PageHeader eyebrow="Videos" title="Templates">
|
| 19 |
-
<Link to="/app/videos" className="btn">Back to videos</Link>
|
| 20 |
<Link to="/app/videos/templates/new" className="btn btn-primary">Create a template</Link>
|
| 21 |
</PageHeader>
|
| 22 |
{slots && (
|
|
|
|
| 16 |
return (
|
| 17 |
<div className="page-wrap">
|
| 18 |
<PageHeader eyebrow="Videos" title="Templates">
|
| 19 |
+
<Link to="/app/archive?tab=videos" className="btn">Back to videos</Link>
|
| 20 |
<Link to="/app/videos/templates/new" className="btn btn-primary">Create a template</Link>
|
| 21 |
</PageHeader>
|
| 22 |
{slots && (
|
frontend/src/pages/Videos.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { Link, useLocation } from "react-router-dom";
|
|
| 3 |
import { videosApi } from "../api/endpoints";
|
| 4 |
import type { VideoListItem, VideoQuota } from "../api/types";
|
| 5 |
import { TrashIcon } from "../components/icons/Icons";
|
| 6 |
-
import { EmptyState, errorMessage, formatDate, Loading, Modal
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
| 9 |
const JOB_LABELS: Record<string, string> = {
|
|
@@ -76,21 +76,21 @@ export default function Videos() {
|
|
| 76 |
const out = !!quota && quota.used >= quota.limit;
|
| 77 |
|
| 78 |
return (
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
{out && status?.can_upgrade ? (
|
| 82 |
<button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
|
| 83 |
) : (
|
| 84 |
<Link to="/app/videos/new" className="btn btn-primary">New video</Link>
|
| 85 |
)}
|
| 86 |
-
</
|
| 87 |
-
|
| 88 |
-
{quota && (
|
| 89 |
-
<p className="mono muted">
|
| 90 |
-
{quota.used} of {quota.limit} video{quota.limit === 1 ? "" : "s"} used
|
| 91 |
-
{quota.resets_at ? ` this period, refills ${formatDate(quota.resets_at)}` : " on your plan"}
|
| 92 |
-
</p>
|
| 93 |
-
)}
|
| 94 |
{notice && <p className="notice">{notice}</p>}
|
| 95 |
{error && <p className="error-text">{error}</p>}
|
| 96 |
{!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
|
|
|
|
| 3 |
import { videosApi } from "../api/endpoints";
|
| 4 |
import type { VideoListItem, VideoQuota } from "../api/types";
|
| 5 |
import { TrashIcon } from "../components/icons/Icons";
|
| 6 |
+
import { EmptyState, errorMessage, formatDate, Loading, Modal } from "../components/ui";
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
| 9 |
const JOB_LABELS: Record<string, string> = {
|
|
|
|
| 76 |
const out = !!quota && quota.used >= quota.limit;
|
| 77 |
|
| 78 |
return (
|
| 79 |
+
// The Library's Videos tab: the allowance on the left, New video on the right, then the list.
|
| 80 |
+
<div className="stack">
|
| 81 |
+
<div className="row between wrap lib-tab-head">
|
| 82 |
+
<p className="mono muted">
|
| 83 |
+
{quota && <>
|
| 84 |
+
{quota.used} of {quota.limit} video{quota.limit === 1 ? "" : "s"} used
|
| 85 |
+
{quota.resets_at ? ` this period, refills ${formatDate(quota.resets_at)}` : " on your plan"}
|
| 86 |
+
</>}
|
| 87 |
+
</p>
|
| 88 |
{out && status?.can_upgrade ? (
|
| 89 |
<button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
|
| 90 |
) : (
|
| 91 |
<Link to="/app/videos/new" className="btn btn-primary">New video</Link>
|
| 92 |
)}
|
| 93 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
{notice && <p className="notice">{notice}</p>}
|
| 95 |
{error && <p className="error-text">{error}</p>}
|
| 96 |
{!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -1,16 +1,13 @@
|
|
| 1 |
-
import {
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
-
import {
|
| 4 |
-
import type {
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 8 |
-
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
|
| 9 |
import { useJob } from "../hooks/useJob";
|
| 10 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 11 |
|
| 12 |
-
type Host = "host_a" | "host_b";
|
| 13 |
-
|
| 14 |
const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
|
| 15 |
{ key: "tone", label: "Tone", hint: "warm, wry, direct" },
|
| 16 |
{ key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
|
|
@@ -19,13 +16,6 @@ const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[
|
|
| 19 |
{ key: "avoid", label: "Never does", hint: "jargon, hashtags" },
|
| 20 |
];
|
| 21 |
|
| 22 |
-
const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; step: number; hint: string }[] = [
|
| 23 |
-
{ key: "stability", label: "Stability", min: 0, max: 1, step: 0.05, hint: "Lower is more expressive, higher is steadier" },
|
| 24 |
-
{ key: "similarity_boost", label: "Clarity and similarity", min: 0, max: 1, step: 0.05, hint: "How closely it sticks to the original voice" },
|
| 25 |
-
{ key: "style", label: "Style", min: 0, max: 1, step: 0.05, hint: "Exaggerates the speaker's style; keep low for news" },
|
| 26 |
-
{ key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
|
| 27 |
-
];
|
| 28 |
-
|
| 29 |
/** Plays one URL at a time across the page. */
|
| 30 |
function usePlayer() {
|
| 31 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
@@ -50,188 +40,6 @@ function fmtSeconds(s: number) {
|
|
| 50 |
return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
| 51 |
}
|
| 52 |
|
| 53 |
-
// Speaking voices
|
| 54 |
-
|
| 55 |
-
type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person";
|
| 56 |
-
|
| 57 |
-
/** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */
|
| 58 |
-
const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [
|
| 59 |
-
{ voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" },
|
| 60 |
-
{ voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" },
|
| 61 |
-
{ voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" },
|
| 62 |
-
{ voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
|
| 63 |
-
];
|
| 64 |
-
|
| 65 |
-
type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
|
| 66 |
-
|
| 67 |
-
/** The voices a host can use: the premade four, then the writer's own (clone, generated), then whatever else a host is
|
| 68 |
-
* set to (e.g. a library voice). Shared by the tiles and the Manage voices modal. */
|
| 69 |
-
function hostTiles(state: VoiceState, voices: Voice[]): Tile[] {
|
| 70 |
-
const byId = new Map(voices.map((v) => [v.voice_id, v]));
|
| 71 |
-
const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
|
| 72 |
-
if (state.clone.status === "ready" && state.clone.voice_id) {
|
| 73 |
-
tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
|
| 74 |
-
}
|
| 75 |
-
for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" });
|
| 76 |
-
for (const id of [state.host_voices.host_a, state.host_voices.host_b]) {
|
| 77 |
-
if (!tiles.some((t) => t.voice_id === id)) {
|
| 78 |
-
const v = byId.get(id);
|
| 79 |
-
tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
|
| 80 |
-
}
|
| 81 |
-
}
|
| 82 |
-
return tiles;
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
/** Listen to a voice: its sample link when it has one, otherwise a line spoken with the host's delivery. */
|
| 86 |
-
function useListen(host: Host, onError: (msg: string | null) => void) {
|
| 87 |
-
const [loading, setLoading] = useState<string | null>(null);
|
| 88 |
-
const { play, playing } = usePlayer();
|
| 89 |
-
const listen = async (t: Tile) => {
|
| 90 |
-
if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
|
| 91 |
-
setLoading(t.voice_id);
|
| 92 |
-
onError(null);
|
| 93 |
-
try {
|
| 94 |
-
const { url } = await voiceApi.preview(t.voice_id, { host });
|
| 95 |
-
play(url, t.voice_id);
|
| 96 |
-
} catch (e) {
|
| 97 |
-
onError(errorMessage(e));
|
| 98 |
-
} finally {
|
| 99 |
-
setLoading(null);
|
| 100 |
-
}
|
| 101 |
-
};
|
| 102 |
-
return { listen, loading, playing };
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
function VoicePicker({
|
| 106 |
-
state,
|
| 107 |
-
voices,
|
| 108 |
-
onSaved,
|
| 109 |
-
onManage,
|
| 110 |
-
}: {
|
| 111 |
-
state: VoiceState;
|
| 112 |
-
voices: Voice[];
|
| 113 |
-
onSaved: (s: VoiceState) => void;
|
| 114 |
-
onManage: (host: Host) => void;
|
| 115 |
-
}) {
|
| 116 |
-
const [host, setHost] = useState<Host>("host_a");
|
| 117 |
-
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
| 118 |
-
const [dirty, setDirty] = useState(false);
|
| 119 |
-
const [error, setError] = useState<string | null>(null);
|
| 120 |
-
const { listen, loading, playing } = useListen(host, setError);
|
| 121 |
-
const current = state.host_voices[host];
|
| 122 |
-
const other: Host = host === "host_a" ? "host_b" : "host_a";
|
| 123 |
-
|
| 124 |
-
useEffect(() => {
|
| 125 |
-
setDelivery(state.delivery[host]);
|
| 126 |
-
setDirty(false);
|
| 127 |
-
}, [state.delivery, host]);
|
| 128 |
-
|
| 129 |
-
const tiles = hostTiles(state, voices);
|
| 130 |
-
|
| 131 |
-
const choose = async (voice_id: string) => {
|
| 132 |
-
if (voice_id === current) return;
|
| 133 |
-
setError(null);
|
| 134 |
-
try {
|
| 135 |
-
onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } }));
|
| 136 |
-
} catch (e) {
|
| 137 |
-
setError(errorMessage(e));
|
| 138 |
-
}
|
| 139 |
-
};
|
| 140 |
-
|
| 141 |
-
return (
|
| 142 |
-
<div className="stack">
|
| 143 |
-
<div className="row between wrap manage-top">
|
| 144 |
-
<p className="muted">Audio overviews are a conversation between two hosts. Tap a voice to use it, or add your own.</p>
|
| 145 |
-
<button className="btn btn-primary" onClick={() => onManage(host)}>
|
| 146 |
-
Manage voices
|
| 147 |
-
</button>
|
| 148 |
-
</div>
|
| 149 |
-
<Tabs<Host>
|
| 150 |
-
tabs={[
|
| 151 |
-
{ id: "host_a", label: "Host A · leads" },
|
| 152 |
-
{ id: "host_b", label: "Host B · co-host" },
|
| 153 |
-
]}
|
| 154 |
-
value={host}
|
| 155 |
-
onChange={setHost}
|
| 156 |
-
/>
|
| 157 |
-
{/* One row per voice, as in Video voiceovers: tap a row to give it to this host */}
|
| 158 |
-
<div className="vw-voice-list" role="radiogroup" aria-label={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`}>
|
| 159 |
-
{tiles.map((t) => {
|
| 160 |
-
const selected = t.voice_id === current;
|
| 161 |
-
const usedByOther = t.voice_id === state.host_voices[other];
|
| 162 |
-
return (
|
| 163 |
-
<div key={t.voice_id} className={`vw-vrow${selected ? " on" : ""}`}>
|
| 164 |
-
<PlayButton on={playing === t.voice_id} label={t.name} onClick={() => listen(t)}
|
| 165 |
-
disabled={loading !== null || (!t.preview_url && !state.tts_configured)} />
|
| 166 |
-
<button type="button" role="radio" aria-checked={selected} className="vw-vpick" onClick={() => choose(t.voice_id)}>
|
| 167 |
-
<strong>{t.name}</strong>
|
| 168 |
-
<span className="muted">{t.tag}</span>
|
| 169 |
-
</button>
|
| 170 |
-
{selected && <span className="voice-badge-inline mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
|
| 171 |
-
{!selected && usedByOther && <span className="voice-badge-inline dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
|
| 172 |
-
</div>
|
| 173 |
-
);
|
| 174 |
-
})}
|
| 175 |
-
</div>
|
| 176 |
-
{error && <p className="error-text">{error}</p>}
|
| 177 |
-
<details className="delivery-card">
|
| 178 |
-
<summary>
|
| 179 |
-
Delivery settings for {host === "host_a" ? "Host A" : "Host B"}
|
| 180 |
-
<svg className="delivery-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 181 |
-
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 182 |
-
<path d="m6 9 6 6 6-6" />
|
| 183 |
-
</svg>
|
| 184 |
-
</summary>
|
| 185 |
-
<div className="delivery-grid">
|
| 186 |
-
{SLIDERS.map((s) => {
|
| 187 |
-
const value = Number(delivery[s.key]);
|
| 188 |
-
const pct = ((value - s.min) / (s.max - s.min)) * 100;
|
| 189 |
-
return (
|
| 190 |
-
<label key={s.key} className="delivery-slider">
|
| 191 |
-
<span className="row between">
|
| 192 |
-
<span>{s.label}</span>
|
| 193 |
-
<span className="delivery-value mono">{value.toFixed(2)}</span>
|
| 194 |
-
</span>
|
| 195 |
-
<input
|
| 196 |
-
type="range"
|
| 197 |
-
min={s.min}
|
| 198 |
-
max={s.max}
|
| 199 |
-
step={s.step}
|
| 200 |
-
value={value}
|
| 201 |
-
style={{ "--pct": `${pct}%` } as CSSProperties}
|
| 202 |
-
onChange={(e) => {
|
| 203 |
-
setDelivery({ ...delivery, [s.key]: Number(e.target.value) });
|
| 204 |
-
setDirty(true);
|
| 205 |
-
}}
|
| 206 |
-
/>
|
| 207 |
-
<span className="muted small">{s.hint}</span>
|
| 208 |
-
</label>
|
| 209 |
-
);
|
| 210 |
-
})}
|
| 211 |
-
</div>
|
| 212 |
-
{dirty && (
|
| 213 |
-
<div className="row end delivery-actions">
|
| 214 |
-
<button className="btn btn-small" onClick={() => (setDelivery(state.delivery[host]), setDirty(false))}>
|
| 215 |
-
Reset
|
| 216 |
-
</button>
|
| 217 |
-
<button
|
| 218 |
-
className="btn btn-small btn-primary"
|
| 219 |
-
onClick={async () => {
|
| 220 |
-
onSaved(await voiceApi.update({ delivery: { [host]: delivery } }));
|
| 221 |
-
setDirty(false);
|
| 222 |
-
}}
|
| 223 |
-
>
|
| 224 |
-
Save delivery
|
| 225 |
-
</button>
|
| 226 |
-
</div>
|
| 227 |
-
)}
|
| 228 |
-
</details>
|
| 229 |
-
</div>
|
| 230 |
-
);
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
// Generating a new voice: build from options, describe it, or clone your own
|
| 234 |
-
|
| 235 |
type GenMode = "options" | "prompt" | "clone";
|
| 236 |
|
| 237 |
const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
|
|
@@ -250,21 +58,18 @@ const PROMPT_IDEAS = [
|
|
| 250 |
|
| 251 |
/** Make a new voice: build it from options, describe it, or clone your own. Inside the Manage voices modals. */
|
| 252 |
function GenerateVoicePanel({
|
| 253 |
-
host,
|
| 254 |
state,
|
| 255 |
onDone,
|
| 256 |
onSaved,
|
| 257 |
onClone,
|
| 258 |
}: {
|
| 259 |
-
/** The host the new voice is for; null when made for video voiceovers (no host changes). */
|
| 260 |
-
host: Host | null;
|
| 261 |
state: VoiceState;
|
| 262 |
/** Saved: the caller closes or switches away. */
|
| 263 |
onDone: () => void;
|
| 264 |
onSaved: (s: VoiceState, voiceId: string) => void;
|
| 265 |
onClone: () => void;
|
| 266 |
}) {
|
| 267 |
-
const [mode, setMode] = useState<GenMode>("
|
| 268 |
const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
|
| 269 |
const [prompt, setPrompt] = useState("");
|
| 270 |
const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
|
|
@@ -274,7 +79,6 @@ function GenerateVoicePanel({
|
|
| 274 |
const [error, setError] = useState<string | null>(null);
|
| 275 |
const { play, playing } = usePlayer();
|
| 276 |
const { openUpgrade } = useUpgrade();
|
| 277 |
-
const hostLabel = host === "host_a" ? "Host A" : host === "host_b" ? "Host B" : null;
|
| 278 |
|
| 279 |
const generate = async () => {
|
| 280 |
setBusy("generate");
|
|
@@ -297,8 +101,7 @@ function GenerateVoicePanel({
|
|
| 297 |
setBusy("save");
|
| 298 |
setError(null);
|
| 299 |
try {
|
| 300 |
-
const saved = await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description
|
| 301 |
-
...(host ? { use_as: host } : {}) });
|
| 302 |
onSaved(saved, saved.voice_id);
|
| 303 |
onDone();
|
| 304 |
} catch (e) {
|
|
@@ -313,9 +116,9 @@ function GenerateVoicePanel({
|
|
| 313 |
<div className="stack">
|
| 314 |
<Tabs<GenMode>
|
| 315 |
tabs={[
|
| 316 |
-
{ id: "options", label: "Build from options" },
|
| 317 |
{ id: "prompt", label: "Describe it" },
|
| 318 |
{ id: "clone", label: "Clone my voice" },
|
|
|
|
| 319 |
]}
|
| 320 |
value={mode}
|
| 321 |
onChange={(m) => {
|
|
@@ -329,8 +132,8 @@ function GenerateVoicePanel({
|
|
| 329 |
<div className="gen-options">
|
| 330 |
{GEN_OPTIONS.map((g) => (
|
| 331 |
<div key={g.key} className="gen-row">
|
| 332 |
-
<span className="
|
| 333 |
-
<div className="gen-chips">
|
| 334 |
{g.values.map((v) => (
|
| 335 |
<button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
|
| 336 |
{v}
|
|
@@ -364,7 +167,7 @@ function GenerateVoicePanel({
|
|
| 364 |
{mode === "clone" &&
|
| 365 |
(state.clone.allowed ? (
|
| 366 |
<>
|
| 367 |
-
<p className="muted">Read a passage from your own writing for one to three minutes. Your voice
|
| 368 |
<div className="row">
|
| 369 |
<button
|
| 370 |
className="btn btn-primary"
|
|
@@ -414,7 +217,7 @@ function GenerateVoicePanel({
|
|
| 414 |
<div className="row">
|
| 415 |
<input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" />
|
| 416 |
<button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
|
| 417 |
-
{busy === "save" ? "Saving..." :
|
| 418 |
</button>
|
| 419 |
</div>
|
| 420 |
</div>
|
|
@@ -426,152 +229,6 @@ function GenerateVoicePanel({
|
|
| 426 |
);
|
| 427 |
}
|
| 428 |
|
| 429 |
-
// Voice library (commercially licensed voices from ElevenLabs)
|
| 430 |
-
|
| 431 |
-
function LibraryBrowser({ host, onAdded }: { host: Host; onAdded: (s: VoiceState) => void }) {
|
| 432 |
-
const [filters, setFilters] = useState({ search: "", gender: "", age: "", accent: "", use_case: "" });
|
| 433 |
-
const [voices, setVoices] = useState<LibraryVoice[] | null>(null);
|
| 434 |
-
const [page, setPage] = useState(0);
|
| 435 |
-
const [hasMore, setHasMore] = useState(false);
|
| 436 |
-
const [adding, setAdding] = useState<string | null>(null);
|
| 437 |
-
const [error, setError] = useState<string | null>(null);
|
| 438 |
-
const { play, playing } = usePlayer();
|
| 439 |
-
|
| 440 |
-
const load = useCallback(
|
| 441 |
-
async (p = 0) => {
|
| 442 |
-
setError(null);
|
| 443 |
-
try {
|
| 444 |
-
const res = await voiceApi.library({ ...filters, page: p });
|
| 445 |
-
setVoices((cur) => (p ? [...(cur ?? []), ...res.voices] : res.voices));
|
| 446 |
-
setHasMore(res.has_more);
|
| 447 |
-
setPage(p);
|
| 448 |
-
} catch (e) {
|
| 449 |
-
setError(errorMessage(e));
|
| 450 |
-
setVoices([]);
|
| 451 |
-
}
|
| 452 |
-
},
|
| 453 |
-
[filters],
|
| 454 |
-
);
|
| 455 |
-
|
| 456 |
-
useEffect(() => {
|
| 457 |
-
const t = setTimeout(() => load(0), 250);
|
| 458 |
-
return () => clearTimeout(t);
|
| 459 |
-
}, [load]);
|
| 460 |
-
|
| 461 |
-
const select = (key: keyof typeof filters, options: [string, string][]) => (
|
| 462 |
-
<select className="input input-sm" value={filters[key]} onChange={(e) => setFilters({ ...filters, [key]: e.target.value })} aria-label={key}>
|
| 463 |
-
{options.map(([v, l]) => (
|
| 464 |
-
<option key={v} value={v}>
|
| 465 |
-
{l}
|
| 466 |
-
</option>
|
| 467 |
-
))}
|
| 468 |
-
</select>
|
| 469 |
-
);
|
| 470 |
-
|
| 471 |
-
return (
|
| 472 |
-
<>
|
| 473 |
-
<div className="stack">
|
| 474 |
-
<p className="muted small">
|
| 475 |
-
Voices from the ElevenLabs Voice Library, licensed for commercial use on paid ElevenLabs plans. Adding one puts it in your ElevenLabs account.
|
| 476 |
-
</p>
|
| 477 |
-
<div className="library-filters">
|
| 478 |
-
<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" />
|
| 479 |
-
{select("gender", [["", "Any gender"], ["female", "Female"], ["male", "Male"], ["neutral", "Neutral"]])}
|
| 480 |
-
{select("age", [["", "Any age"], ["young", "Young"], ["middle_aged", "Middle aged"], ["old", "Older"]])}
|
| 481 |
-
{select("accent", [["", "Any accent"], ["american", "American"], ["british", "British"], ["australian", "Australian"], ["irish", "Irish"], ["indian", "Indian"], ["african", "African"]])}
|
| 482 |
-
{select("use_case", [["", "Any use"], ["conversational", "Conversational"], ["narrative_story", "Narration"], ["informative_educational", "Educational"], ["social_media", "Social media"], ["entertainment_tv", "Entertainment"]])}
|
| 483 |
-
</div>
|
| 484 |
-
{error && <p className="error-text">{error}</p>}
|
| 485 |
-
{!voices && <Loading label="Searching voices" />}
|
| 486 |
-
<ul className="library-grid">
|
| 487 |
-
{voices?.map((v) => (
|
| 488 |
-
<li key={`${v.public_owner_id}-${v.voice_id}`} className="library-voice">
|
| 489 |
-
<div className="row between">
|
| 490 |
-
<strong>{v.name}</strong>
|
| 491 |
-
{v.preview_url && (
|
| 492 |
-
<button className="btn btn-small" onClick={() => play(v.preview_url!, v.voice_id)} aria-label={`Play ${v.name}`}>
|
| 493 |
-
{playing === v.voice_id ? "Stop" : "Play"}
|
| 494 |
-
</button>
|
| 495 |
-
)}
|
| 496 |
-
</div>
|
| 497 |
-
<p className="mono muted small">{[v.gender, v.age?.replace("_", " "), v.accent, v.use_case?.replace(/_/g, " ")].filter(Boolean).join(" · ")}</p>
|
| 498 |
-
{v.description && <p className="muted small clamp-3">{v.description}</p>}
|
| 499 |
-
{v.notice_period ? <p className="mono muted small">Owner notice period: {v.notice_period} days</p> : null}
|
| 500 |
-
<button
|
| 501 |
-
className="btn btn-small btn-primary"
|
| 502 |
-
disabled={adding !== null}
|
| 503 |
-
onClick={async () => {
|
| 504 |
-
setAdding(v.voice_id);
|
| 505 |
-
setError(null);
|
| 506 |
-
try {
|
| 507 |
-
onAdded(await voiceApi.addFromLibrary(v, host));
|
| 508 |
-
} catch (e) {
|
| 509 |
-
setError(errorMessage(e));
|
| 510 |
-
} finally {
|
| 511 |
-
setAdding(null);
|
| 512 |
-
}
|
| 513 |
-
}}
|
| 514 |
-
>
|
| 515 |
-
{adding === v.voice_id ? "Adding..." : `Use as ${host === "host_a" ? "Host A" : "Host B"}`}
|
| 516 |
-
</button>
|
| 517 |
-
</li>
|
| 518 |
-
))}
|
| 519 |
-
{voices?.length === 0 && !error && <li className="muted">No voices match those filters.</li>}
|
| 520 |
-
</ul>
|
| 521 |
-
{hasMore && (
|
| 522 |
-
<button className="btn" onClick={() => load(page + 1)}>
|
| 523 |
-
More voices
|
| 524 |
-
</button>
|
| 525 |
-
)}
|
| 526 |
-
</div>
|
| 527 |
-
</>
|
| 528 |
-
);
|
| 529 |
-
}
|
| 530 |
-
|
| 531 |
-
// Managing a host's voices: add one from the library, or make your own
|
| 532 |
-
|
| 533 |
-
type ManageTab = "library" | "add";
|
| 534 |
-
|
| 535 |
-
function ManageHostVoicesModal({
|
| 536 |
-
host,
|
| 537 |
-
state,
|
| 538 |
-
onClose,
|
| 539 |
-
onSaved,
|
| 540 |
-
onClone,
|
| 541 |
-
}: {
|
| 542 |
-
host: Host;
|
| 543 |
-
state: VoiceState;
|
| 544 |
-
onClose: () => void;
|
| 545 |
-
onSaved: (s: VoiceState) => void;
|
| 546 |
-
onClone: () => void;
|
| 547 |
-
}) {
|
| 548 |
-
const [tab, setTab] = useState<ManageTab>("library");
|
| 549 |
-
const { openUpgrade } = useUpgrade();
|
| 550 |
-
|
| 551 |
-
return (
|
| 552 |
-
<Modal title={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`} onClose={onClose} wide>
|
| 553 |
-
<div className="stack">
|
| 554 |
-
<Tabs<ManageTab>
|
| 555 |
-
tabs={[
|
| 556 |
-
{ id: "library", label: "Voice library" },
|
| 557 |
-
{ id: "add", label: "Add your voice" },
|
| 558 |
-
]}
|
| 559 |
-
value={tab}
|
| 560 |
-
onChange={setTab}
|
| 561 |
-
/>
|
| 562 |
-
{/* Either way the voice is set for this host and shows in the rows, so the modal closes */}
|
| 563 |
-
{tab === "library" && <LibraryBrowser host={host} onAdded={(s) => { onSaved(s); onClose(); }} />}
|
| 564 |
-
{tab === "add" && (
|
| 565 |
-
<GenerateVoicePanel host={host} state={state} onSaved={(s) => onSaved(s)} onDone={onClose}
|
| 566 |
-
onClone={() => (state.clone.allowed ? onClone() : openUpgrade("writer"))} />
|
| 567 |
-
)}
|
| 568 |
-
</div>
|
| 569 |
-
</Modal>
|
| 570 |
-
);
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
-
// Cloning
|
| 574 |
-
|
| 575 |
type Take = { file: File; url: string; seconds: number };
|
| 576 |
|
| 577 |
function Recorder({ onTake }: { onTake: (t: Take) => void }) {
|
|
@@ -764,29 +421,31 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
|
|
| 764 |
|
| 765 |
type Step = "writing" | "hosts";
|
| 766 |
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
|
| 772 |
export default function VoiceProfile() {
|
| 773 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 774 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 775 |
-
const [voices, setVoices] = useState<Voice[]>([]);
|
| 776 |
const [params] = useSearchParams();
|
| 777 |
-
//
|
| 778 |
-
const [step, setStep] = useState<Step
|
| 779 |
const [picking, setPicking] = useState(false);
|
| 780 |
const [samples, setSamples] = useState<string[]>([]);
|
| 781 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 782 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 783 |
const [cloning, setCloning] = useState(false);
|
| 784 |
-
const [
|
| 785 |
-
|
| 786 |
-
const [speakTab, setSpeakTab] = useState<"hosts" | "video">(
|
| 787 |
-
typeof window !== "undefined" && window.location.hash === "#video-voices" ? "video" : "hosts",
|
| 788 |
-
);
|
| 789 |
-
const [videoKey, setVideoKey] = useState(0);
|
| 790 |
const { openUpgrade } = useUpgrade();
|
| 791 |
const [saved, setSaved] = useState(false);
|
| 792 |
const [error, setError] = useState<string | null>(null);
|
|
@@ -798,10 +457,9 @@ export default function VoiceProfile() {
|
|
| 798 |
setState(s);
|
| 799 |
setDraft(s.profile);
|
| 800 |
setSamples(s.sample_doc_ids);
|
| 801 |
-
setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
|
| 802 |
return s;
|
| 803 |
});
|
| 804 |
-
const loadVoices = () =>
|
| 805 |
|
| 806 |
const startBuild = async (ids: string[]) => {
|
| 807 |
setPicking(false);
|
|
@@ -821,21 +479,18 @@ export default function VoiceProfile() {
|
|
| 821 |
void startBuild([]);
|
| 822 |
}
|
| 823 |
});
|
| 824 |
-
loadVoices();
|
| 825 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 826 |
}, []);
|
| 827 |
|
| 828 |
const build = useJob(buildJob, () => load());
|
| 829 |
const clone = useJob(cloneJob, () => {
|
| 830 |
load();
|
| 831 |
-
loadVoices();
|
| 832 |
-
setVideoKey((k) => k + 1); // a new clone shows up under video voiceovers' Your voices
|
| 833 |
});
|
| 834 |
|
| 835 |
-
if (!state
|
| 836 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 837 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 838 |
-
const done: Record<Step, boolean> = { writing: Boolean(state.profile), hosts: Boolean(state.profile) && step === "hosts" };
|
| 839 |
const showClone = state.clone.status !== "none" || Boolean(clone);
|
| 840 |
|
| 841 |
const saveProfile = async () => {
|
|
@@ -850,22 +505,14 @@ export default function VoiceProfile() {
|
|
| 850 |
};
|
| 851 |
|
| 852 |
return (
|
| 853 |
-
|
| 854 |
-
|
| 855 |
|
| 856 |
-
<
|
| 857 |
-
{
|
| 858 |
-
<
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
<span>
|
| 862 |
-
{i + 1}. {s.label}
|
| 863 |
-
{done[s.id] && s.id !== step ? " ✓" : ""}
|
| 864 |
-
</span>
|
| 865 |
-
</button>
|
| 866 |
-
</li>
|
| 867 |
-
))}
|
| 868 |
-
</ol>
|
| 869 |
{error && <p className="error-text">{error}</p>}
|
| 870 |
|
| 871 |
{step === "writing" && (
|
|
@@ -922,9 +569,6 @@ export default function VoiceProfile() {
|
|
| 922 |
<button className="btn btn-small" onClick={saveProfile}>
|
| 923 |
{saved ? "Saved" : "Save changes"}
|
| 924 |
</button>
|
| 925 |
-
<button className="btn btn-primary" onClick={() => setStep("hosts")}>
|
| 926 |
-
Next: pick your hosts
|
| 927 |
-
</button>
|
| 928 |
</div>
|
| 929 |
</>
|
| 930 |
)}
|
|
@@ -934,47 +578,14 @@ export default function VoiceProfile() {
|
|
| 934 |
{step === "hosts" && (
|
| 935 |
<section className="card stack vp-card">
|
| 936 |
<h2>Who reads your work aloud</h2>
|
| 937 |
-
<Tabs<"hosts" | "video">
|
| 938 |
-
tabs={[
|
| 939 |
-
{ id: "hosts", label: "Hosts Audios" },
|
| 940 |
-
{ id: "video", label: "Video voiceovers" },
|
| 941 |
-
]}
|
| 942 |
-
value={speakTab}
|
| 943 |
-
onChange={setSpeakTab}
|
| 944 |
-
/>
|
| 945 |
-
{speakTab === "video" && (
|
| 946 |
-
<VideoVoicesSection reloadKey={videoKey}
|
| 947 |
-
renderAddVoice={(onDone, close) => (
|
| 948 |
-
<GenerateVoicePanel
|
| 949 |
-
host={null}
|
| 950 |
-
state={state}
|
| 951 |
-
onDone={onDone}
|
| 952 |
-
onSaved={(s, voiceId) => {
|
| 953 |
-
setState(s);
|
| 954 |
-
loadVoices();
|
| 955 |
-
// Made for video voiceovers: added there straight away (room and plan permitting).
|
| 956 |
-
videoVoicesApi.save(voiceId)
|
| 957 |
-
.catch((e) => setError(`Saved to your voices, not added to video voiceovers: ${errorMessage(e)}`))
|
| 958 |
-
.finally(() => setVideoKey((k) => k + 1));
|
| 959 |
-
}}
|
| 960 |
-
onClone={() => {
|
| 961 |
-
close(); // the recorder opens in its own modal
|
| 962 |
-
if (state.clone.allowed) setCloning(true);
|
| 963 |
-
else openUpgrade("writer");
|
| 964 |
-
}}
|
| 965 |
-
/>
|
| 966 |
-
)} />
|
| 967 |
-
)}
|
| 968 |
-
{speakTab === "hosts" && (
|
| 969 |
-
<>
|
| 970 |
{!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
|
| 971 |
-
<
|
| 972 |
|
| 973 |
{showClone && (
|
| 974 |
<div className="vp-clone">
|
| 975 |
<strong>Your cloned voice</strong>
|
| 976 |
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
|
| 977 |
-
{state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It
|
| 978 |
{state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
|
| 979 |
<div className="row">
|
| 980 |
{state.clone.status === "ready" && state.clone.preview_url && (
|
|
@@ -1001,19 +612,6 @@ export default function VoiceProfile() {
|
|
| 1001 |
</div>
|
| 1002 |
</div>
|
| 1003 |
)}
|
| 1004 |
-
|
| 1005 |
-
</>
|
| 1006 |
-
)}
|
| 1007 |
-
|
| 1008 |
-
<div className="row">
|
| 1009 |
-
<button className="back-link" onClick={() => setStep("writing")}>
|
| 1010 |
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"
|
| 1011 |
-
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 1012 |
-
<path d="M19 12H5m6-6-6 6 6 6" />
|
| 1013 |
-
</svg>
|
| 1014 |
-
Back to writing voice
|
| 1015 |
-
</button>
|
| 1016 |
-
</div>
|
| 1017 |
</section>
|
| 1018 |
)}
|
| 1019 |
|
|
@@ -1029,20 +627,22 @@ export default function VoiceProfile() {
|
|
| 1029 |
</Modal>
|
| 1030 |
)}
|
| 1031 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 1032 |
-
{
|
| 1033 |
-
<
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
|
|
|
|
|
|
| 1046 |
)}
|
| 1047 |
</div>
|
| 1048 |
);
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
+
import { voiceApi } from "../api/endpoints";
|
| 4 |
+
import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
| 6 |
+
import { YourVoices } from "../components/voice/YourVoices";
|
| 7 |
+
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, Tabs } from "../components/ui";
|
|
|
|
| 8 |
import { useJob } from "../hooks/useJob";
|
| 9 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 10 |
|
|
|
|
|
|
|
| 11 |
const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
|
| 12 |
{ key: "tone", label: "Tone", hint: "warm, wry, direct" },
|
| 13 |
{ key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
|
|
|
|
| 16 |
{ key: "avoid", label: "Never does", hint: "jargon, hashtags" },
|
| 17 |
];
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
/** Plays one URL at a time across the page. */
|
| 20 |
function usePlayer() {
|
| 21 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
|
|
| 40 |
return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
type GenMode = "options" | "prompt" | "clone";
|
| 44 |
|
| 45 |
const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
|
|
|
|
| 58 |
|
| 59 |
/** Make a new voice: build it from options, describe it, or clone your own. Inside the Manage voices modals. */
|
| 60 |
function GenerateVoicePanel({
|
|
|
|
| 61 |
state,
|
| 62 |
onDone,
|
| 63 |
onSaved,
|
| 64 |
onClone,
|
| 65 |
}: {
|
|
|
|
|
|
|
| 66 |
state: VoiceState;
|
| 67 |
/** Saved: the caller closes or switches away. */
|
| 68 |
onDone: () => void;
|
| 69 |
onSaved: (s: VoiceState, voiceId: string) => void;
|
| 70 |
onClone: () => void;
|
| 71 |
}) {
|
| 72 |
+
const [mode, setMode] = useState<GenMode>("prompt"); // Describe it first
|
| 73 |
const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
|
| 74 |
const [prompt, setPrompt] = useState("");
|
| 75 |
const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
|
|
|
|
| 79 |
const [error, setError] = useState<string | null>(null);
|
| 80 |
const { play, playing } = usePlayer();
|
| 81 |
const { openUpgrade } = useUpgrade();
|
|
|
|
| 82 |
|
| 83 |
const generate = async () => {
|
| 84 |
setBusy("generate");
|
|
|
|
| 101 |
setBusy("save");
|
| 102 |
setError(null);
|
| 103 |
try {
|
| 104 |
+
const saved = await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description });
|
|
|
|
| 105 |
onSaved(saved, saved.voice_id);
|
| 106 |
onDone();
|
| 107 |
} catch (e) {
|
|
|
|
| 116 |
<div className="stack">
|
| 117 |
<Tabs<GenMode>
|
| 118 |
tabs={[
|
|
|
|
| 119 |
{ id: "prompt", label: "Describe it" },
|
| 120 |
{ id: "clone", label: "Clone my voice" },
|
| 121 |
+
{ id: "options", label: "Build from options" },
|
| 122 |
]}
|
| 123 |
value={mode}
|
| 124 |
onChange={(m) => {
|
|
|
|
| 132 |
<div className="gen-options">
|
| 133 |
{GEN_OPTIONS.map((g) => (
|
| 134 |
<div key={g.key} className="gen-row">
|
| 135 |
+
<span className="gen-label">{g.label}</span>
|
| 136 |
+
<div className="gen-chips" role="group" aria-label={g.label}>
|
| 137 |
{g.values.map((v) => (
|
| 138 |
<button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
|
| 139 |
{v}
|
|
|
|
| 167 |
{mode === "clone" &&
|
| 168 |
(state.clone.allowed ? (
|
| 169 |
<>
|
| 170 |
+
<p className="muted">Read a passage from your own writing for one to three minutes. Your voice joins your voices, ready for audio overviews and videos.</p>
|
| 171 |
<div className="row">
|
| 172 |
<button
|
| 173 |
className="btn btn-primary"
|
|
|
|
| 217 |
<div className="row">
|
| 218 |
<input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" />
|
| 219 |
<button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
|
| 220 |
+
{busy === "save" ? "Saving..." : "Save voice"}
|
| 221 |
</button>
|
| 222 |
</div>
|
| 223 |
</div>
|
|
|
|
| 229 |
);
|
| 230 |
}
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
type Take = { file: File; url: string; seconds: number };
|
| 233 |
|
| 234 |
function Recorder({ onTake }: { onTake: (t: Take) => void }) {
|
|
|
|
| 421 |
|
| 422 |
type Step = "writing" | "hosts";
|
| 423 |
|
| 424 |
+
/** The underlined link between the two views (Speaking voices <-> Writing voice), its arrow pointing the way. */
|
| 425 |
+
function ViewLink({ to, label, onGo }: { to: Step; label: string; onGo: (s: Step) => void }) {
|
| 426 |
+
const back = to === "hosts";
|
| 427 |
+
return (
|
| 428 |
+
<button type="button" className={`vp-switch${back ? " back" : ""}`} onClick={() => onGo(to)}>
|
| 429 |
+
{back && <span aria-hidden="true">←</span>}
|
| 430 |
+
<span className="vp-switch-text">{label}</span>
|
| 431 |
+
{!back && <span aria-hidden="true">→</span>}
|
| 432 |
+
</button>
|
| 433 |
+
);
|
| 434 |
+
}
|
| 435 |
|
| 436 |
export default function VoiceProfile() {
|
| 437 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 438 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
|
|
|
| 439 |
const [params] = useSearchParams();
|
| 440 |
+
// Opens on Speaking voices; ?step=writing opens the writing voice (links between the two switch views).
|
| 441 |
+
const [step, setStep] = useState<Step>(params.get("step") === "writing" ? "writing" : "hosts");
|
| 442 |
const [picking, setPicking] = useState(false);
|
| 443 |
const [samples, setSamples] = useState<string[]>([]);
|
| 444 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 445 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 446 |
const [cloning, setCloning] = useState(false);
|
| 447 |
+
const [adding, setAdding] = useState(false); // the Add voices modal
|
| 448 |
+
const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
const { openUpgrade } = useUpgrade();
|
| 450 |
const [saved, setSaved] = useState(false);
|
| 451 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 457 |
setState(s);
|
| 458 |
setDraft(s.profile);
|
| 459 |
setSamples(s.sample_doc_ids);
|
|
|
|
| 460 |
return s;
|
| 461 |
});
|
| 462 |
+
const loadVoices = () => setVoicesKey((k) => k + 1);
|
| 463 |
|
| 464 |
const startBuild = async (ids: string[]) => {
|
| 465 |
setPicking(false);
|
|
|
|
| 479 |
void startBuild([]);
|
| 480 |
}
|
| 481 |
});
|
|
|
|
| 482 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 483 |
}, []);
|
| 484 |
|
| 485 |
const build = useJob(buildJob, () => load());
|
| 486 |
const clone = useJob(cloneJob, () => {
|
| 487 |
load();
|
| 488 |
+
loadVoices(); // a finished clone is saved to Your voices as "My voice"
|
|
|
|
| 489 |
});
|
| 490 |
|
| 491 |
+
if (!state) return <Loading />;
|
| 492 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 493 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
|
|
|
| 494 |
const showClone = state.clone.status !== "none" || Boolean(clone);
|
| 495 |
|
| 496 |
const saveProfile = async () => {
|
|
|
|
| 505 |
};
|
| 506 |
|
| 507 |
return (
|
| 508 |
+
// The Library's Voices tab: speaking voices, and the writing voice behind a link.
|
| 509 |
+
<div className="stack vp">
|
| 510 |
|
| 511 |
+
<div className="vp-switch-row">
|
| 512 |
+
{step === "hosts"
|
| 513 |
+
? <ViewLink to="writing" label="Writing voice" onGo={setStep} />
|
| 514 |
+
: <ViewLink to="hosts" label="Speaking voices" onGo={setStep} />}
|
| 515 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
{error && <p className="error-text">{error}</p>}
|
| 517 |
|
| 518 |
{step === "writing" && (
|
|
|
|
| 569 |
<button className="btn btn-small" onClick={saveProfile}>
|
| 570 |
{saved ? "Saved" : "Save changes"}
|
| 571 |
</button>
|
|
|
|
|
|
|
|
|
|
| 572 |
</div>
|
| 573 |
</>
|
| 574 |
)}
|
|
|
|
| 578 |
{step === "hosts" && (
|
| 579 |
<section className="card stack vp-card">
|
| 580 |
<h2>Who reads your work aloud</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
{!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
|
| 582 |
+
<YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
|
| 583 |
|
| 584 |
{showClone && (
|
| 585 |
<div className="vp-clone">
|
| 586 |
<strong>Your cloned voice</strong>
|
| 587 |
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
|
| 588 |
+
{state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
|
| 589 |
{state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
|
| 590 |
<div className="row">
|
| 591 |
{state.clone.status === "ready" && state.clone.preview_url && (
|
|
|
|
| 612 |
</div>
|
| 613 |
</div>
|
| 614 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
</section>
|
| 616 |
)}
|
| 617 |
|
|
|
|
| 627 |
</Modal>
|
| 628 |
)}
|
| 629 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 630 |
+
{adding && (
|
| 631 |
+
<Modal title="Add voices" onClose={() => setAdding(false)} wide>
|
| 632 |
+
<GenerateVoicePanel
|
| 633 |
+
state={state}
|
| 634 |
+
onDone={() => setAdding(false)}
|
| 635 |
+
onSaved={(s) => {
|
| 636 |
+
setState(s);
|
| 637 |
+
loadVoices(); // the server saved it to Your voices
|
| 638 |
+
}}
|
| 639 |
+
onClone={() => {
|
| 640 |
+
setAdding(false); // the recorder opens in its own modal
|
| 641 |
+
if (state.clone.allowed) setCloning(true);
|
| 642 |
+
else openUpgrade("writer");
|
| 643 |
+
}}
|
| 644 |
+
/>
|
| 645 |
+
</Modal>
|
| 646 |
)}
|
| 647 |
</div>
|
| 648 |
);
|
frontend/src/styles/pages.css
CHANGED
|
@@ -662,7 +662,6 @@ select.input option {
|
|
| 662 |
accent-color: var(--blue);
|
| 663 |
}
|
| 664 |
|
| 665 |
-
|
| 666 |
.picker-list {
|
| 667 |
list-style: none;
|
| 668 |
margin: 0;
|
|
@@ -2180,51 +2179,52 @@ select.input option {
|
|
| 2180 |
animation: welcome-in 0.35s ease both;
|
| 2181 |
}
|
| 2182 |
|
| 2183 |
-
|
| 2184 |
-
|
| 2185 |
-
|
| 2186 |
-
|
| 2187 |
-
|
| 2188 |
-
grid-template-columns: repeat(2, 1fr);
|
| 2189 |
-
gap: 8px;
|
| 2190 |
-
font-size: 0.75rem;
|
| 2191 |
}
|
| 2192 |
|
| 2193 |
-
.vp-
|
| 2194 |
-
display:
|
|
|
|
| 2195 |
gap: 8px;
|
| 2196 |
-
|
| 2197 |
-
padding: 0;
|
| 2198 |
border: 0;
|
| 2199 |
background: none;
|
| 2200 |
-
color: var(--text-
|
| 2201 |
font: inherit;
|
| 2202 |
-
|
| 2203 |
cursor: pointer;
|
|
|
|
| 2204 |
}
|
| 2205 |
|
| 2206 |
-
.vp-
|
| 2207 |
-
|
|
|
|
|
|
|
|
|
|
| 2208 |
}
|
| 2209 |
|
| 2210 |
-
.vp-
|
| 2211 |
-
color: var(--
|
| 2212 |
}
|
| 2213 |
|
| 2214 |
-
.vp-
|
| 2215 |
-
|
| 2216 |
-
border-radius: 999px;
|
| 2217 |
-
background: var(--line-strong);
|
| 2218 |
-
transition: background 0.3s, box-shadow 0.3s;
|
| 2219 |
}
|
| 2220 |
|
| 2221 |
-
.vp-
|
| 2222 |
-
|
| 2223 |
}
|
| 2224 |
|
| 2225 |
-
.vp-
|
| 2226 |
-
|
| 2227 |
-
|
|
|
|
|
|
|
|
|
|
| 2228 |
}
|
| 2229 |
|
| 2230 |
.voice-grid {
|
|
@@ -2352,14 +2352,69 @@ select.input option {
|
|
| 2352 |
border-color: var(--blue);
|
| 2353 |
}
|
| 2354 |
|
|
|
|
| 2355 |
.gen-options {
|
| 2356 |
display: grid;
|
| 2357 |
-
|
|
|
|
|
|
|
|
|
|
| 2358 |
}
|
| 2359 |
|
| 2360 |
.gen-row {
|
| 2361 |
display: grid;
|
| 2362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2363 |
}
|
| 2364 |
|
| 2365 |
.gen-chips {
|
|
@@ -2478,30 +2533,6 @@ select.input option {
|
|
| 2478 |
grid-column: 1 / -1;
|
| 2479 |
}
|
| 2480 |
|
| 2481 |
-
.library-grid {
|
| 2482 |
-
list-style: none;
|
| 2483 |
-
margin: 0;
|
| 2484 |
-
padding: 0;
|
| 2485 |
-
display: grid;
|
| 2486 |
-
gap: 10px;
|
| 2487 |
-
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
| 2488 |
-
max-height: 52vh;
|
| 2489 |
-
overflow: auto;
|
| 2490 |
-
}
|
| 2491 |
-
|
| 2492 |
-
.library-voice {
|
| 2493 |
-
display: grid;
|
| 2494 |
-
gap: 6px;
|
| 2495 |
-
align-content: start;
|
| 2496 |
-
padding: 12px;
|
| 2497 |
-
border: 1px solid var(--line);
|
| 2498 |
-
border-radius: var(--radius-sm);
|
| 2499 |
-
}
|
| 2500 |
-
|
| 2501 |
-
.library-voice:hover {
|
| 2502 |
-
border-color: var(--blue-35);
|
| 2503 |
-
}
|
| 2504 |
-
|
| 2505 |
.clone-tips {
|
| 2506 |
margin: 0;
|
| 2507 |
padding-left: 18px;
|
|
@@ -2777,7 +2808,6 @@ select.input option {
|
|
| 2777 |
.artifact-head > div,
|
| 2778 |
.source-title,
|
| 2779 |
.nb-card,
|
| 2780 |
-
.library-voice,
|
| 2781 |
.host-card,
|
| 2782 |
.post-edit,
|
| 2783 |
.slide-edit,
|
|
@@ -4082,27 +4112,11 @@ details.map-legend[open] summary {
|
|
| 4082 |
white-space: nowrap;
|
| 4083 |
}
|
| 4084 |
|
| 4085 |
-
|
| 4086 |
.voice-new .voice-pick:disabled {
|
| 4087 |
cursor: not-allowed;
|
| 4088 |
opacity: 0.6;
|
| 4089 |
}
|
| 4090 |
|
| 4091 |
-
/* The host a voice row belongs to (Audio hosts list) */
|
| 4092 |
-
.voice-badge-inline {
|
| 4093 |
-
padding: 3px 10px;
|
| 4094 |
-
border-radius: 999px;
|
| 4095 |
-
background: var(--blue);
|
| 4096 |
-
color: var(--white);
|
| 4097 |
-
font-size: 0.72rem;
|
| 4098 |
-
letter-spacing: 0.06em;
|
| 4099 |
-
}
|
| 4100 |
-
|
| 4101 |
-
.voice-badge-inline.dim {
|
| 4102 |
-
background: var(--surface-2, rgba(255, 255, 255, 0.08));
|
| 4103 |
-
color: var(--text-2);
|
| 4104 |
-
}
|
| 4105 |
-
|
| 4106 |
/* Audio hosts / Video voiceovers: the explainer takes the room, the Manage button sits at the right end */
|
| 4107 |
.manage-top > p {
|
| 4108 |
flex: 1;
|
|
@@ -4141,98 +4155,6 @@ details.map-legend[open] summary {
|
|
| 4141 |
transition: transform 0.15s;
|
| 4142 |
}
|
| 4143 |
|
| 4144 |
-
/* Delivery settings for a host: a text link with a chevron that opens a two-column grid of sliders */
|
| 4145 |
-
.delivery-card summary {
|
| 4146 |
-
display: inline-flex;
|
| 4147 |
-
align-items: center;
|
| 4148 |
-
gap: 6px;
|
| 4149 |
-
color: var(--text-2);
|
| 4150 |
-
font-size: 0.9rem;
|
| 4151 |
-
cursor: pointer;
|
| 4152 |
-
list-style: none;
|
| 4153 |
-
}
|
| 4154 |
-
|
| 4155 |
-
.delivery-card summary::-webkit-details-marker {
|
| 4156 |
-
display: none;
|
| 4157 |
-
}
|
| 4158 |
-
|
| 4159 |
-
.delivery-card summary:hover {
|
| 4160 |
-
color: var(--text);
|
| 4161 |
-
}
|
| 4162 |
-
|
| 4163 |
-
.delivery-chevron {
|
| 4164 |
-
flex-shrink: 0;
|
| 4165 |
-
transition: transform 0.2s;
|
| 4166 |
-
}
|
| 4167 |
-
|
| 4168 |
-
.delivery-card[open] .delivery-chevron {
|
| 4169 |
-
transform: rotate(180deg);
|
| 4170 |
-
}
|
| 4171 |
-
|
| 4172 |
-
.delivery-grid {
|
| 4173 |
-
display: grid;
|
| 4174 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 4175 |
-
gap: 22px 32px;
|
| 4176 |
-
padding-top: 18px;
|
| 4177 |
-
}
|
| 4178 |
-
|
| 4179 |
-
.delivery-slider {
|
| 4180 |
-
display: grid;
|
| 4181 |
-
gap: 8px;
|
| 4182 |
-
font-size: 0.9rem;
|
| 4183 |
-
}
|
| 4184 |
-
|
| 4185 |
-
.delivery-value {
|
| 4186 |
-
padding: 2px 8px;
|
| 4187 |
-
border-radius: 999px;
|
| 4188 |
-
background: rgba(33, 124, 255, 0.14);
|
| 4189 |
-
color: var(--blue);
|
| 4190 |
-
font-size: 0.78rem;
|
| 4191 |
-
}
|
| 4192 |
-
|
| 4193 |
-
/* A slim track, filled up to the thumb (--pct) */
|
| 4194 |
-
.delivery-slider input[type="range"] {
|
| 4195 |
-
width: 100%;
|
| 4196 |
-
height: 4px;
|
| 4197 |
-
border-radius: 999px;
|
| 4198 |
-
background: linear-gradient(to right, var(--blue) var(--pct, 50%), var(--line-strong) var(--pct, 50%));
|
| 4199 |
-
appearance: none;
|
| 4200 |
-
outline: none;
|
| 4201 |
-
cursor: pointer;
|
| 4202 |
-
}
|
| 4203 |
-
|
| 4204 |
-
.delivery-slider input[type="range"]::-webkit-slider-thumb {
|
| 4205 |
-
width: 16px;
|
| 4206 |
-
height: 16px;
|
| 4207 |
-
border: 2px solid var(--white);
|
| 4208 |
-
border-radius: 50%;
|
| 4209 |
-
background: var(--blue);
|
| 4210 |
-
box-shadow: 0 0 0 4px rgba(33, 124, 255, 0.2);
|
| 4211 |
-
appearance: none;
|
| 4212 |
-
}
|
| 4213 |
-
|
| 4214 |
-
.delivery-slider input[type="range"]::-moz-range-thumb {
|
| 4215 |
-
width: 14px;
|
| 4216 |
-
height: 14px;
|
| 4217 |
-
border: 2px solid var(--white);
|
| 4218 |
-
border-radius: 50%;
|
| 4219 |
-
background: var(--blue);
|
| 4220 |
-
}
|
| 4221 |
-
|
| 4222 |
-
.delivery-slider input[type="range"]:focus-visible::-webkit-slider-thumb {
|
| 4223 |
-
box-shadow: 0 0 0 6px rgba(33, 124, 255, 0.35);
|
| 4224 |
-
}
|
| 4225 |
-
|
| 4226 |
-
.delivery-actions {
|
| 4227 |
-
padding-top: 14px;
|
| 4228 |
-
}
|
| 4229 |
-
|
| 4230 |
-
@media (max-width: 640px) {
|
| 4231 |
-
.delivery-grid {
|
| 4232 |
-
grid-template-columns: 1fr;
|
| 4233 |
-
}
|
| 4234 |
-
}
|
| 4235 |
-
|
| 4236 |
.mm-row-progress {
|
| 4237 |
flex-basis: 100%;
|
| 4238 |
padding: 0 6px 6px;
|
|
|
|
| 662 |
accent-color: var(--blue);
|
| 663 |
}
|
| 664 |
|
|
|
|
| 665 |
.picker-list {
|
| 666 |
list-style: none;
|
| 667 |
margin: 0;
|
|
|
|
| 2179 |
animation: welcome-in 0.35s ease both;
|
| 2180 |
}
|
| 2181 |
|
| 2182 |
+
/* The link between the two views (Speaking voices / Writing voice): underlined text and an arrow */
|
| 2183 |
+
.vp-switch-row {
|
| 2184 |
+
display: flex;
|
| 2185 |
+
justify-content: flex-end; /* at the right end */
|
| 2186 |
+
margin: -6px 0 4px;
|
|
|
|
|
|
|
|
|
|
| 2187 |
}
|
| 2188 |
|
| 2189 |
+
.vp-switch {
|
| 2190 |
+
display: inline-flex;
|
| 2191 |
+
align-items: center;
|
| 2192 |
gap: 8px;
|
| 2193 |
+
padding: 4px 0;
|
|
|
|
| 2194 |
border: 0;
|
| 2195 |
background: none;
|
| 2196 |
+
color: var(--text-2);
|
| 2197 |
font: inherit;
|
| 2198 |
+
font-size: 0.95rem;
|
| 2199 |
cursor: pointer;
|
| 2200 |
+
transition: color 0.15s;
|
| 2201 |
}
|
| 2202 |
|
| 2203 |
+
.vp-switch-text {
|
| 2204 |
+
text-decoration: underline;
|
| 2205 |
+
text-decoration-color: var(--line-strong);
|
| 2206 |
+
text-underline-offset: 4px;
|
| 2207 |
+
transition: text-decoration-color 0.15s;
|
| 2208 |
}
|
| 2209 |
|
| 2210 |
+
.vp-switch:hover {
|
| 2211 |
+
color: var(--text);
|
| 2212 |
}
|
| 2213 |
|
| 2214 |
+
.vp-switch:hover .vp-switch-text {
|
| 2215 |
+
text-decoration-color: var(--blue);
|
|
|
|
|
|
|
|
|
|
| 2216 |
}
|
| 2217 |
|
| 2218 |
+
.vp-switch > span[aria-hidden] {
|
| 2219 |
+
transition: transform 0.15s;
|
| 2220 |
}
|
| 2221 |
|
| 2222 |
+
.vp-switch:hover > span[aria-hidden] {
|
| 2223 |
+
transform: translateX(3px);
|
| 2224 |
+
}
|
| 2225 |
+
|
| 2226 |
+
.vp-switch.back:hover > span[aria-hidden] {
|
| 2227 |
+
transform: translateX(-3px);
|
| 2228 |
}
|
| 2229 |
|
| 2230 |
.voice-grid {
|
|
|
|
| 2352 |
border-color: var(--blue);
|
| 2353 |
}
|
| 2354 |
|
| 2355 |
+
/* Build from options: one grey card, a label column on the left and its choices on the right, a line between rows */
|
| 2356 |
.gen-options {
|
| 2357 |
display: grid;
|
| 2358 |
+
padding: 6px 18px;
|
| 2359 |
+
border: 1px solid var(--line);
|
| 2360 |
+
border-radius: var(--radius-lg);
|
| 2361 |
+
background: rgba(255, 255, 255, 0.05);
|
| 2362 |
}
|
| 2363 |
|
| 2364 |
.gen-row {
|
| 2365 |
display: grid;
|
| 2366 |
+
grid-template-columns: 110px minmax(0, 1fr);
|
| 2367 |
+
align-items: start; /* the label lines up with the first line of chips, also when they wrap */
|
| 2368 |
+
gap: 8px 16px;
|
| 2369 |
+
padding: 18px 0;
|
| 2370 |
+
}
|
| 2371 |
+
|
| 2372 |
+
.gen-row + .gen-row {
|
| 2373 |
+
border-top: 1px solid var(--line);
|
| 2374 |
+
}
|
| 2375 |
+
|
| 2376 |
+
.gen-label {
|
| 2377 |
+
color: var(--text-2);
|
| 2378 |
+
font-size: 0.85rem;
|
| 2379 |
+
font-weight: 500;
|
| 2380 |
+
line-height: 32px; /* a chip's height */
|
| 2381 |
+
}
|
| 2382 |
+
|
| 2383 |
+
.gen-options .gen-chips {
|
| 2384 |
+
gap: 8px;
|
| 2385 |
+
}
|
| 2386 |
+
|
| 2387 |
+
/* Its chips: smaller than the app's default chip (the later .chip rule), only in this card */
|
| 2388 |
+
/* Soft pills: a faint fill and border; the picked one is tinted blue rather than solid */
|
| 2389 |
+
.gen-options .gen-chips .chip {
|
| 2390 |
+
height: 32px;
|
| 2391 |
+
padding: 0 14px;
|
| 2392 |
+
border: 1px solid var(--line);
|
| 2393 |
+
border-radius: 999px;
|
| 2394 |
+
background: rgba(255, 255, 255, 0.04);
|
| 2395 |
+
color: var(--text-2);
|
| 2396 |
+
font-size: 0.85rem;
|
| 2397 |
+
font-weight: 400;
|
| 2398 |
+
line-height: 30px;
|
| 2399 |
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
| 2400 |
+
}
|
| 2401 |
+
|
| 2402 |
+
.gen-options .gen-chips .chip:hover {
|
| 2403 |
+
border-color: var(--line-strong);
|
| 2404 |
+
background: rgba(255, 255, 255, 0.08);
|
| 2405 |
+
color: var(--text);
|
| 2406 |
+
}
|
| 2407 |
+
|
| 2408 |
+
.gen-options .gen-chips .chip.on {
|
| 2409 |
+
border-color: rgba(33, 124, 255, 0.55);
|
| 2410 |
+
background: rgba(33, 124, 255, 0.2);
|
| 2411 |
+
color: var(--white);
|
| 2412 |
+
}
|
| 2413 |
+
|
| 2414 |
+
@media (max-width: 560px) {
|
| 2415 |
+
.gen-row {
|
| 2416 |
+
grid-template-columns: minmax(0, 1fr);
|
| 2417 |
+
}
|
| 2418 |
}
|
| 2419 |
|
| 2420 |
.gen-chips {
|
|
|
|
| 2533 |
grid-column: 1 / -1;
|
| 2534 |
}
|
| 2535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2536 |
.clone-tips {
|
| 2537 |
margin: 0;
|
| 2538 |
padding-left: 18px;
|
|
|
|
| 2808 |
.artifact-head > div,
|
| 2809 |
.source-title,
|
| 2810 |
.nb-card,
|
|
|
|
| 2811 |
.host-card,
|
| 2812 |
.post-edit,
|
| 2813 |
.slide-edit,
|
|
|
|
| 4112 |
white-space: nowrap;
|
| 4113 |
}
|
| 4114 |
|
|
|
|
| 4115 |
.voice-new .voice-pick:disabled {
|
| 4116 |
cursor: not-allowed;
|
| 4117 |
opacity: 0.6;
|
| 4118 |
}
|
| 4119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4120 |
/* Audio hosts / Video voiceovers: the explainer takes the room, the Manage button sits at the right end */
|
| 4121 |
.manage-top > p {
|
| 4122 |
flex: 1;
|
|
|
|
| 4155 |
transition: transform 0.15s;
|
| 4156 |
}
|
| 4157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4158 |
.mm-row-progress {
|
| 4159 |
flex-basis: 100%;
|
| 4160 |
padding: 0 6px 6px;
|
frontend/src/styles/videos.css
CHANGED
|
@@ -477,6 +477,74 @@
|
|
| 477 |
gap: 2px;
|
| 478 |
}
|
| 479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
.vw-vrow:hover {
|
| 481 |
border-color: var(--line-strong);
|
| 482 |
}
|
|
|
|
| 477 |
gap: 2px;
|
| 478 |
}
|
| 479 |
|
| 480 |
+
/* The Voice page's two lists (Your voices, Voice library): each in its own grey panel so they read as separate */
|
| 481 |
+
.yv-panel {
|
| 482 |
+
gap: 12px;
|
| 483 |
+
padding: 16px;
|
| 484 |
+
border: 1px solid var(--line);
|
| 485 |
+
border-radius: var(--radius-lg);
|
| 486 |
+
background: rgba(255, 255, 255, 0.05);
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.yv-panel + .yv-panel {
|
| 490 |
+
margin-top: 8px;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
/* Section headings: a step up from the rows, with the count in a small pill */
|
| 494 |
+
.yv-head {
|
| 495 |
+
display: flex;
|
| 496 |
+
align-items: center;
|
| 497 |
+
gap: 10px;
|
| 498 |
+
margin: 0;
|
| 499 |
+
font-size: 1.2rem;
|
| 500 |
+
font-weight: 600;
|
| 501 |
+
letter-spacing: -0.01em;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.yv-count {
|
| 505 |
+
padding: 2px 9px;
|
| 506 |
+
border-radius: 999px;
|
| 507 |
+
background: var(--surface-2);
|
| 508 |
+
color: var(--text-2);
|
| 509 |
+
font-size: 0.78rem;
|
| 510 |
+
font-weight: 500;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
/* Their rows: darker than the panel, four and a half in view, the rest scrolls inside */
|
| 514 |
+
.vw-voice-list-tight {
|
| 515 |
+
gap: 6px;
|
| 516 |
+
max-height: calc(4.5 * 60px + 4 * 6px);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.vw-voice-list-tight .vw-vrow {
|
| 520 |
+
gap: 12px;
|
| 521 |
+
height: 60px;
|
| 522 |
+
padding: 0 12px 0 10px;
|
| 523 |
+
background: rgba(0, 0, 0, 0.35);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
/* Your voices: four rows in view and a sliver of the fifth (so it reads as scrollable), plus the list's 2px padding */
|
| 527 |
+
.vw-voice-list-tight.vw-voice-list-four {
|
| 528 |
+
max-height: calc(4.3 * 60px + 4 * 6px + 2px);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.vw-voice-list-tight .vw-vplay {
|
| 532 |
+
width: 38px;
|
| 533 |
+
height: 38px;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.vw-voice-list-tight .vw-vpick {
|
| 537 |
+
gap: 1px;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
.vw-voice-list-tight .vw-vpick strong {
|
| 541 |
+
font-size: 0.95rem;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.vw-voice-list-tight .vw-vpick span {
|
| 545 |
+
font-size: 0.8rem;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
.vw-vrow:hover {
|
| 549 |
border-color: var(--line-strong);
|
| 550 |
}
|