Spaces:
Runtime error
Runtime error
Commit ·
1aa4a16
1
Parent(s): db8dd62
video style phase 2
Browse files- backend/app/llm/provider.py +2 -2
- backend/app/llm/run.py +47 -6
- backend/app/llm/signatures.py +15 -5
- backend/app/pipeline/generate.py +55 -12
- backend/app/routers/topics.py +2 -2
- backend/app/routers/video_voices.py +38 -8
- backend/app/routers/videos.py +50 -20
- backend/app/routers/voice.py +11 -3
- backend/app/services/b2v_access.py +7 -1
- backend/app/services/jobs.py +6 -0
- backend/app/services/notestack_voices.py +37 -0
- backend/app/services/video_focus.py +156 -0
- backend/app/worker.py +26 -8
- backend/tests/test_buildout.py +69 -0
- backend/tests/test_videos.py +275 -2
- frontend/src/App.tsx +2 -3
- frontend/src/api/types.ts +15 -1
- frontend/src/components/video/SourcePicker.tsx +9 -100
- frontend/src/components/video/VideoVoicesSection.tsx +208 -0
- frontend/src/components/video/VoiceLibrary.tsx +23 -30
- frontend/src/components/video/sourceCache.ts +1 -9
- frontend/src/pages/VideoCreate.tsx +113 -102
- frontend/src/pages/VideoVoices.tsx +0 -76
- frontend/src/pages/Videos.tsx +1 -1
- frontend/src/pages/VoiceProfile.tsx +218 -153
- frontend/src/styles/pages.css +158 -1
- frontend/src/styles/videos.css +67 -73
backend/app/llm/provider.py
CHANGED
|
@@ -66,8 +66,8 @@ FOCUS_MAX_TOKENS = 2000 # three titles and two-line descriptions plus GLM's low
|
|
| 66 |
@lru_cache
|
| 67 |
def focus_lm(retry: bool = False) -> dspy.LM:
|
| 68 |
"""The video wizard's focus suggestions: a little material in, three short topics out, so a small cap keeps it
|
| 69 |
-
quick. retry=True is the second try after an unusable answer: a
|
| 70 |
-
not that answer again from the cache."""
|
| 71 |
return _build(settings.llm_fast_model, effort="low", temperature=0.8 if retry else 0.4,
|
| 72 |
max_tokens=FOCUS_MAX_TOKENS)
|
| 73 |
|
|
|
|
| 66 |
@lru_cache
|
| 67 |
def focus_lm(retry: bool = False) -> dspy.LM:
|
| 68 |
"""The video wizard's focus suggestions: a little material in, three short topics out, so a small cap keeps it
|
| 69 |
+
quick. Only for a source with no topics to show. retry=True is the second try after an unusable answer: a
|
| 70 |
+
different temperature, so it is a fresh call and not that answer again from the cache."""
|
| 71 |
return _build(settings.llm_fast_model, effort="low", temperature=0.8 if retry else 0.4,
|
| 72 |
max_tokens=FOCUS_MAX_TOKENS)
|
| 73 |
|
backend/app/llm/run.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
"""One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
|
| 2 |
em dashes from the outputs. Tests replace `predict` to return canned outputs."""
|
| 3 |
|
|
|
|
| 4 |
import uuid
|
|
|
|
| 5 |
|
| 6 |
import dspy
|
| 7 |
from sqlalchemy.orm import Session
|
|
@@ -12,6 +14,8 @@ from app.llm.provider import main_lm, provider_name, track_usage
|
|
| 12 |
from app.models import DspyTrace, Job, Workspace
|
| 13 |
from app.services.jobs import record_usage
|
| 14 |
|
|
|
|
|
|
|
| 15 |
|
| 16 |
def _dump(value):
|
| 17 |
if hasattr(value, "model_dump"):
|
|
@@ -35,14 +39,51 @@ def predict(
|
|
| 35 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 36 |
pred = dspy.Predict(signature)(**inputs)
|
| 37 |
outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
if usage.total:
|
| 39 |
record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
|
| 40 |
quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
|
| 41 |
workspace = db.get(Workspace, workspace_id)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
db.commit()
|
| 48 |
-
return outputs
|
|
|
|
| 1 |
"""One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
|
| 2 |
em dashes from the outputs. Tests replace `predict` to return canned outputs."""
|
| 3 |
|
| 4 |
+
import logging
|
| 5 |
import uuid
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
|
| 8 |
import dspy
|
| 9 |
from sqlalchemy.orm import Session
|
|
|
|
| 14 |
from app.models import DspyTrace, Job, Workspace
|
| 15 |
from app.services.jobs import record_usage
|
| 16 |
|
| 17 |
+
log = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
|
| 20 |
def _dump(value):
|
| 21 |
if hasattr(value, "model_dump"):
|
|
|
|
| 39 |
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 40 |
pred = dspy.Predict(signature)(**inputs)
|
| 41 |
outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
| 42 |
+
_record(db, workspace_id, job, signature, lm, usage, [(inputs, outputs)])
|
| 43 |
+
return outputs
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def predict_many(
|
| 47 |
+
signature: type[dspy.Signature],
|
| 48 |
+
inputs_list: list[dict],
|
| 49 |
+
*,
|
| 50 |
+
db: Session,
|
| 51 |
+
workspace_id: uuid.UUID,
|
| 52 |
+
lm: dspy.LM | None = None,
|
| 53 |
+
workers: int = 5,
|
| 54 |
+
) -> list[dict | None]:
|
| 55 |
+
"""`predict` once per inputs, in parallel. Only the model calls run in threads: usage, traces and the commit
|
| 56 |
+
happen here, on the caller's session. A call that fails is None in its place."""
|
| 57 |
+
if not inputs_list:
|
| 58 |
+
return []
|
| 59 |
+
lm = lm or main_lm()
|
| 60 |
+
|
| 61 |
+
def call(inputs: dict) -> dict | None:
|
| 62 |
+
try:
|
| 63 |
+
with dspy.context(lm=lm):
|
| 64 |
+
pred = dspy.Predict(signature)(**inputs)
|
| 65 |
+
return {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
| 66 |
+
except Exception:
|
| 67 |
+
log.warning("%s failed", signature.__name__, exc_info=True)
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
with track_usage(lm) as usage, ThreadPoolExecutor(max_workers=min(workers, len(inputs_list))) as pool:
|
| 71 |
+
results = list(pool.map(call, inputs_list))
|
| 72 |
+
_record(db, workspace_id, None, signature, lm, usage,
|
| 73 |
+
[(i, o) for i, o in zip(inputs_list, results, strict=True) if o is not None])
|
| 74 |
+
return results
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
|
| 78 |
+
usage, calls: list[tuple[dict, dict]]) -> None:
|
| 79 |
if usage.total:
|
| 80 |
record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
|
| 81 |
quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
|
| 82 |
workspace = db.get(Workspace, workspace_id)
|
| 83 |
+
for inputs, outputs in calls:
|
| 84 |
+
db.add(DspyTrace(
|
| 85 |
+
workspace_id=workspace_id, module=signature.__name__, model=lm.model,
|
| 86 |
+
inputs={k: (v if len(str(v)) < 4000 else str(v)[:4000]) for k, v in inputs.items()},
|
| 87 |
+
outputs=outputs, training_allowed=bool(workspace and workspace.training_opt_in),
|
| 88 |
+
))
|
| 89 |
db.commit()
|
|
|
backend/app/llm/signatures.py
CHANGED
|
@@ -275,10 +275,20 @@ class FocusTopic(BaseModel):
|
|
| 275 |
"angle it takes, using only this material")
|
| 276 |
|
| 277 |
|
| 278 |
-
class
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
items: list[str] = dspy.InputField(desc="Each item: a title (when there is one), then the start of its text")
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
"angle it takes, using only this material")
|
| 276 |
|
| 277 |
|
| 278 |
+
class FocusAngle(FocusTopic):
|
| 279 |
+
tag: str = Field(description="The candidate tag this angle is built on, copied exactly (its name only), or an "
|
| 280 |
+
"empty string when it comes from the material alone")
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
class VideoFocusAngles(dspy.Signature):
|
| 284 |
+
"""Suggest three distinct angles a short explainer video could focus on, drawn only from this material. Build
|
| 285 |
+
each angle on a different candidate tag when tags are given (a tag shared by several items makes a good angle that
|
| 286 |
+
connects them). Each has a short, specific topic (different for each of the three) and a two sentence summary of
|
| 287 |
+
what the video covers about that angle. Never reuse or closely reword an avoided title: find a new angle instead.
|
| 288 |
+
Never use em dashes."""
|
| 289 |
|
| 290 |
items: list[str] = dspy.InputField(desc="Each item: a title (when there is one), then the start of its text")
|
| 291 |
+
candidate_tags: list[str] = dspy.InputField(
|
| 292 |
+
desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
|
| 293 |
+
avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
|
| 294 |
+
topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
|
backend/app/pipeline/generate.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
"""Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import uuid
|
| 4 |
from collections import Counter, defaultdict
|
| 5 |
from datetime import UTC, datetime
|
|
@@ -11,11 +13,13 @@ from app.corpus import Corpus, slugify
|
|
| 11 |
from app.llm import run
|
| 12 |
from app.llm.provider import fast_lm
|
| 13 |
from app.llm.signatures import BuildVoiceProfile, ConsolidateTopics, EvergreenScore, ExtractTopics, SummarizeNotebook
|
| 14 |
-
from app.models import Artifact, DocumentTopic, Job, Notebook, Topic, VoiceProfile
|
| 15 |
from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, workspace_docs
|
| 16 |
from app.pipeline.research import verify_citations
|
| 17 |
from app.services.jobs import update_job
|
| 18 |
|
|
|
|
|
|
|
| 19 |
|
| 20 |
class NothingToDo(ValueError):
|
| 21 |
pass
|
|
@@ -59,26 +63,65 @@ def summarize_notebook(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 59 |
# Topic map
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
|
| 63 |
"""Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
|
| 64 |
docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
|
| 65 |
-
todo = [d for d in docs if doc_ids or
|
| 66 |
known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
|
| 67 |
-
|
| 68 |
for i, d in enumerate(todo):
|
| 69 |
update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
except Exception:
|
| 74 |
-
continue
|
| 75 |
-
tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
|
| 76 |
-
d.metadata_json = {**(d.metadata_json or {}), "topics": tags}
|
| 77 |
-
known = sorted(set(known) | {t["name"] for t in tags})[:200]
|
| 78 |
db.commit()
|
| 79 |
update_job(db, job, progress=0.85, message="Drawing constellations")
|
| 80 |
count = rebuild_topics(db, job, workspace_id)
|
| 81 |
-
return {"tagged": len(todo), "topics": count}
|
| 82 |
|
| 83 |
|
| 84 |
def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
|
|
|
|
| 1 |
"""Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
import uuid
|
| 6 |
from collections import Counter, defaultdict
|
| 7 |
from datetime import UTC, datetime
|
|
|
|
| 13 |
from app.llm import run
|
| 14 |
from app.llm.provider import fast_lm
|
| 15 |
from app.llm.signatures import BuildVoiceProfile, ConsolidateTopics, EvergreenScore, ExtractTopics, SummarizeNotebook
|
| 16 |
+
from app.models import Artifact, Document, DocumentTopic, Job, Notebook, Topic, VoiceProfile
|
| 17 |
from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, workspace_docs
|
| 18 |
from app.pipeline.research import verify_citations
|
| 19 |
from app.services.jobs import update_job
|
| 20 |
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
|
| 24 |
class NothingToDo(ValueError):
|
| 25 |
pass
|
|
|
|
| 63 |
# Topic map
|
| 64 |
|
| 65 |
|
| 66 |
+
TAG_TRIES = 3 # per post per job: the model times out or rate limits now and then
|
| 67 |
+
TAG_BACKOFF = (2.0, 6.0) # seconds before the second and third try
|
| 68 |
+
MAX_TAG_FAILURES = 5 # jobs in a row that could not tag a post: the sweep stops asking for it (and logs it)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def needs_topics(meta: dict | None) -> bool:
|
| 72 |
+
"""A post without topics yet that is still worth another try (see worker.topics_backfill)."""
|
| 73 |
+
meta = meta or {}
|
| 74 |
+
return not meta.get("topics") and int(meta.get("topics_failures") or 0) < MAX_TAG_FAILURES
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def save_topic_tags(doc: Document, out: dict) -> list[dict]:
|
| 78 |
+
"""ExtractTopics' answer for a post (at most 8 named topics), kept in metadata_json["topics"]. An answer with no
|
| 79 |
+
topics is not kept, so the post still counts as untagged. Also used by the video wizard, which tags a post on
|
| 80 |
+
demand when it has none yet."""
|
| 81 |
+
tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
|
| 82 |
+
if tags:
|
| 83 |
+
meta = {k: v for k, v in (doc.metadata_json or {}).items() if k != "topics_failures"}
|
| 84 |
+
doc.metadata_json = {**meta, "topics": tags}
|
| 85 |
+
return tags
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def tag_post(db: Session, job: Job | None, workspace_id: uuid.UUID, doc: Document, known: list[str]) -> list[dict]:
|
| 89 |
+
"""One post's topics, tried a few times. Out of tries, the post is counted as failed once more (the sweep asks
|
| 90 |
+
again later) and [] returned."""
|
| 91 |
+
error: object = None
|
| 92 |
+
for attempt in range(TAG_TRIES):
|
| 93 |
+
if attempt:
|
| 94 |
+
time.sleep(TAG_BACKOFF[attempt - 1])
|
| 95 |
+
try:
|
| 96 |
+
out = run.predict(ExtractTopics, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(),
|
| 97 |
+
title=doc.title, text=(doc.clean_text or "")[:12000], known_topics=known)
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
error = exc
|
| 100 |
+
continue
|
| 101 |
+
if tags := save_topic_tags(doc, out):
|
| 102 |
+
return tags
|
| 103 |
+
error = "no topics in the answer"
|
| 104 |
+
meta = doc.metadata_json or {}
|
| 105 |
+
doc.metadata_json = {**meta, "topics_failures": int(meta.get("topics_failures") or 0) + 1}
|
| 106 |
+
log.warning("could not tag post %s (%s): %s", doc.id, doc.title[:60], error)
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
|
| 111 |
"""Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
|
| 112 |
docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
|
| 113 |
+
todo = [d for d in docs if doc_ids or needs_topics(d.metadata_json)]
|
| 114 |
known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
|
| 115 |
+
tagged = 0
|
| 116 |
for i, d in enumerate(todo):
|
| 117 |
update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
|
| 118 |
+
if tags := tag_post(db, job, workspace_id, d, known):
|
| 119 |
+
tagged += 1
|
| 120 |
+
known = sorted(set(known) | {t["name"] for t in tags})[:200]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
db.commit()
|
| 122 |
update_job(db, job, progress=0.85, message="Drawing constellations")
|
| 123 |
count = rebuild_topics(db, job, workspace_id)
|
| 124 |
+
return {"tagged": tagged, "failed": len(todo) - tagged, "topics": count}
|
| 125 |
|
| 126 |
|
| 127 |
def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
|
backend/app/routers/topics.py
CHANGED
|
@@ -234,8 +234,8 @@ def rebuild(ctx: Ctx = Depends(get_ctx), full: bool = False):
|
|
| 234 |
if full:
|
| 235 |
# Clear per post tags so every post is mapped again.
|
| 236 |
for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
|
| 237 |
-
if d.metadata_json and "topics"
|
| 238 |
-
d.metadata_json = {k: v for k, v in d.metadata_json.items() if k
|
| 239 |
ctx.db.commit()
|
| 240 |
job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
|
| 241 |
return serialize_job(job)
|
|
|
|
| 234 |
if full:
|
| 235 |
# Clear per post tags so every post is mapped again.
|
| 236 |
for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
|
| 237 |
+
if d.metadata_json and ({"topics", "topics_failures"} & d.metadata_json.keys()):
|
| 238 |
+
d.metadata_json = {k: v for k, v in d.metadata_json.items() if k not in ("topics", "topics_failures")}
|
| 239 |
ctx.db.commit()
|
| 240 |
job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
|
| 241 |
return serialize_job(job)
|
backend/app/routers/video_voices.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
-
"""Voices for videos: the workspace's saved voices, the built-in library,
|
|
|
|
| 2 |
|
| 3 |
-
"My voices" (user_saved_voices) lives only here: blog2video's saved-voice list is one list
|
|
|
|
| 4 |
Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
|
| 5 |
only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
|
| 6 |
of the workspace's own daily and total counters (video_limits).
|
|
@@ -18,6 +20,7 @@ from app.models import B2VCustomVoice, UserSavedVoice
|
|
| 18 |
from app.routers.videos import b2v_ready, upstream
|
| 19 |
from app.services import b2v_access, video_limits
|
| 20 |
from app.services import blog2video as b2v
|
|
|
|
| 21 |
|
| 22 |
log = logging.getLogger(__name__)
|
| 23 |
|
|
@@ -25,15 +28,25 @@ router = APIRouter(prefix="/api/video-voices", tags=["videos"])
|
|
| 25 |
|
| 26 |
CLONE_MAX_BYTES = 50 * 1024 * 1024
|
| 27 |
STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def _labels(v: dict) -> dict:
|
| 31 |
return v.get("labels") or {}
|
| 32 |
|
| 33 |
|
| 34 |
-
def _voice_out(v: UserSavedVoice) -> dict:
|
| 35 |
return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
|
| 36 |
-
"accent": v.accent, "premium": v.premium, "is_custom": v.is_custom
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def _library_out(v: dict, saved: set[str]) -> dict:
|
|
@@ -73,7 +86,10 @@ def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
|
| 73 |
ids = {v.voice_id for v in saved}
|
| 74 |
custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
|
| 75 |
.order_by(B2VCustomVoice.created_at.desc()))
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
"library": [_library_out(v, ids) for v in library if v.get("voice_id")],
|
| 78 |
"custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
|
| 79 |
"preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
|
|
@@ -85,8 +101,20 @@ class SaveIn(BaseModel):
|
|
| 85 |
|
| 86 |
@router.post("/saved", status_code=201)
|
| 87 |
def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
|
| 89 |
B2VCustomVoice.workspace_id == ctx.workspace.id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
if custom:
|
| 91 |
row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
|
| 92 |
preview_url=custom.preview_url, premium=True, is_custom=True))
|
|
@@ -163,11 +191,13 @@ def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
|
|
| 163 |
voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
|
| 164 |
preview_url=made.get("preview_url"))
|
| 165 |
ctx.db.add(row)
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
ctx.db.commit()
|
| 169 |
return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
|
| 170 |
-
"preview_url": row.preview_url, "saved":
|
| 171 |
|
| 172 |
|
| 173 |
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 |
+
"My voices" (user_saved_voices, at most MAX_SAVED_VOICES) lives only here: blog2video's saved-voice list is one list
|
| 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 |
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 |
|
| 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:
|
| 35 |
return v.get("labels") or {}
|
| 36 |
|
| 37 |
|
| 38 |
+
def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
|
| 39 |
return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
|
| 40 |
+
"accent": v.accent, "premium": v.premium, "is_custom": v.is_custom,
|
| 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:
|
|
|
|
| 86 |
ids = {v.voice_id for v in saved}
|
| 87 |
custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
|
| 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], "max_saved": MAX_SAVED_VOICES,
|
| 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,
|
| 95 |
"preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
|
|
|
|
| 101 |
|
| 102 |
@router.post("/saved", status_code=201)
|
| 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: premium, like a custom voice. No preview link (the Voice page plays it).
|
| 113 |
+
video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
|
| 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:
|
| 119 |
row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
|
| 120 |
preview_url=custom.preview_url, premium=True, is_custom=True))
|
|
|
|
| 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 |
+
saved = _room(ctx) # kept in My voices only while there is room; otherwise added from the list later
|
| 195 |
+
if saved:
|
| 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": saved}
|
| 201 |
|
| 202 |
|
| 203 |
def _make_custom(ctx: Ctx, source: str, send) -> dict:
|
backend/app/routers/videos.py
CHANGED
|
@@ -28,10 +28,10 @@ from app.auth import Ctx, get_ctx
|
|
| 28 |
from app.config import settings
|
| 29 |
from app.llm import run
|
| 30 |
from app.llm.provider import focus_lm
|
| 31 |
-
from app.llm.signatures import
|
| 32 |
from app.models import Artifact, B2VVideo, Chat, Document, Message, Notebook, Upload
|
| 33 |
from app.routers.sources import is_locked
|
| 34 |
-
from app.services import b2v_access, video_limits, video_quota
|
| 35 |
from app.services import blog2video as b2v
|
| 36 |
from app.services.artifacts import serialize_artifact
|
| 37 |
from app.services.plans import Plan, plan_limit_error
|
|
@@ -200,6 +200,8 @@ class VideoCreateIn(VideoOptions):
|
|
| 200 |
# One of the suggested focus topics (/focus-topics): the video is mainly about it. None = the material as a whole.
|
| 201 |
focus: str | None = Field(None, max_length=200)
|
| 202 |
focus_detail: str | None = Field(None, max_length=400) # the topic's description: what the video should cover
|
|
|
|
|
|
|
| 203 |
|
| 204 |
@model_validator(mode="after")
|
| 205 |
def one_source(self):
|
|
@@ -209,6 +211,13 @@ class VideoCreateIn(VideoOptions):
|
|
| 209 |
self.document_id, self.document_ids = self.document_ids[0], None
|
| 210 |
return self
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
class VideoBatchIn(VideoOptions):
|
| 214 |
urls: list[str] = Field(min_length=1, max_length=MAX_LINKS)
|
|
@@ -298,10 +307,16 @@ def _source_text(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int = MAX_CONTE
|
|
| 298 |
|
| 299 |
|
| 300 |
def _instructions(body: VideoCreateIn) -> str:
|
| 301 |
-
"""What blog2video's script writer is told ahead of the material: what a chat is, and the focus picked
|
| 302 |
-
for the house style's depth). Empty when neither applies."""
|
| 303 |
parts = [CHAT_CONTEXT] if body.chat_ids else []
|
| 304 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
detail = re.sub(r"\s+", " ", body.focus_detail or "").strip().rstrip(".")
|
| 306 |
cover = f" What to cover: {detail}." if detail else ""
|
| 307 |
length = b2v_access.house_length(body.video_style)
|
|
@@ -324,7 +339,7 @@ def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
|
|
| 324 |
if not re.match(r"https?://", body.url):
|
| 325 |
raise HTTPException(400, "Enter a link that starts with http:// or https://")
|
| 326 |
return {"url": body.url}, body.title or body.url
|
| 327 |
-
if body.document_id and not body.focus:
|
| 328 |
doc = _doc(ctx, body.document_id)
|
| 329 |
if re.match(r"https?://", doc.url or ""):
|
| 330 |
return {"url": doc.url, "title": doc.title[:255]}, doc.title
|
|
@@ -449,21 +464,22 @@ def _excerpt(text: str | None, limit: int) -> str:
|
|
| 449 |
return text[:limit].rsplit(" ", 1)[0] + "..."
|
| 450 |
|
| 451 |
|
| 452 |
-
def _focus_material(ctx: Ctx, body: FocusIn) ->
|
| 453 |
-
"""What the focus suggestions read: each post's title and the start of its text, or a
|
| 454 |
-
|
| 455 |
-
(another workspace's source 404s, locked posts
|
|
|
|
| 456 |
if body.chat_ids:
|
| 457 |
loaded = _load_chats(ctx, body.chat_ids)
|
| 458 |
answer_len = 600 if len(loaded) == 1 else 300
|
| 459 |
-
|
| 460 |
for chat, msgs in loaded:
|
| 461 |
question = next((m.content for m in msgs if m.role == "user"), "")
|
| 462 |
answer = next((m.content for m in msgs if m.role == "assistant"), "")
|
| 463 |
parts = [chat.title or "", _excerpt(question, 300), _excerpt(_cite_free(answer), answer_len)]
|
| 464 |
if item := "\n".join(p for p in parts if p):
|
| 465 |
-
|
| 466 |
-
return
|
| 467 |
ids = set(body.document_ids or [body.document_id])
|
| 468 |
docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
|
| 469 |
Document.workspace_id == ctx.workspace.id)))
|
|
@@ -471,28 +487,42 @@ def _focus_material(ctx: Ctx, body: FocusIn) -> list[str]:
|
|
| 471 |
raise HTTPException(404, "Post not found")
|
| 472 |
if locked := sum(is_locked(d) for d in docs):
|
| 473 |
raise _not_indexed(locked)
|
|
|
|
| 474 |
each = 800 if len(docs) == 1 else 400
|
| 475 |
-
items =
|
| 476 |
-
return
|
| 477 |
|
| 478 |
|
| 479 |
@router.post("/focus-topics")
|
| 480 |
def focus_topics(body: FocusIn, ctx: Ctx = Depends(get_ctx)):
|
| 481 |
-
"""
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
|
|
|
| 485 |
return {"topics": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
topics: list[dict] = []
|
| 487 |
# One retry when the answer has fewer than 3 usable topics (a placeholder for every title, repeats).
|
| 488 |
for retry in (False, True):
|
| 489 |
-
out = run.predict(
|
|
|
|
| 490 |
_add_topics(topics, out.get("topics"))
|
| 491 |
if len(topics) >= 3:
|
| 492 |
break
|
| 493 |
return {"topics": topics[:3]}
|
| 494 |
|
| 495 |
|
|
|
|
| 496 |
FOCUS_PLACEHOLDERS = {"title", "topic", "description", "summary", "name", "angle", "focus"}
|
| 497 |
|
| 498 |
|
|
|
|
| 28 |
from app.config import settings
|
| 29 |
from app.llm import run
|
| 30 |
from app.llm.provider import focus_lm
|
| 31 |
+
from app.llm.signatures import VideoFocusAngles
|
| 32 |
from app.models import Artifact, B2VVideo, Chat, Document, Message, Notebook, Upload
|
| 33 |
from app.routers.sources import is_locked
|
| 34 |
+
from app.services import b2v_access, video_focus, video_limits, video_quota
|
| 35 |
from app.services import blog2video as b2v
|
| 36 |
from app.services.artifacts import serialize_artifact
|
| 37 |
from app.services.plans import Plan, plan_limit_error
|
|
|
|
| 200 |
# One of the suggested focus topics (/focus-topics): the video is mainly about it. None = the material as a whole.
|
| 201 |
focus: str | None = Field(None, max_length=200)
|
| 202 |
focus_detail: str | None = Field(None, max_length=400) # the topic's description: what the video should cover
|
| 203 |
+
# Or the user's own topic, in their words: followed as far as the material supports it (instead of `focus`).
|
| 204 |
+
focus_prompt: str | None = Field(None, max_length=500)
|
| 205 |
|
| 206 |
@model_validator(mode="after")
|
| 207 |
def one_source(self):
|
|
|
|
| 211 |
self.document_id, self.document_ids = self.document_ids[0], None
|
| 212 |
return self
|
| 213 |
|
| 214 |
+
@model_validator(mode="after")
|
| 215 |
+
def one_focus(self):
|
| 216 |
+
self.focus_prompt = re.sub(r"\s+", " ", self.focus_prompt or "").strip() or None
|
| 217 |
+
if self.focus_prompt and (self.focus or "").strip():
|
| 218 |
+
raise ValueError("Pick a suggested topic or write your own, not both")
|
| 219 |
+
return self
|
| 220 |
+
|
| 221 |
|
| 222 |
class VideoBatchIn(VideoOptions):
|
| 223 |
urls: list[str] = Field(min_length=1, max_length=MAX_LINKS)
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
def _instructions(body: VideoCreateIn) -> str:
|
| 310 |
+
"""What blog2video's script writer is told ahead of the material: what a chat is, and the focus picked or written
|
| 311 |
+
by the user (worded for the house style's depth). Empty when neither applies."""
|
| 312 |
parts = [CHAT_CONTEXT] if body.chat_ids else []
|
| 313 |
+
if prompt := (body.focus_prompt or "").replace('"', "'"):
|
| 314 |
+
depth = {"short": " Make it a brief overview.", "medium": " Make it a deep dive."}
|
| 315 |
+
parts.append(f'The user wants this video to focus on: "{prompt}". Follow it only as far as the material below '
|
| 316 |
+
"supports it: use what the material says about it and leave the rest out. If the material does "
|
| 317 |
+
"not cover it, ignore this request and make a general video about the material."
|
| 318 |
+
+ depth.get(b2v_access.house_length(body.video_style), ""))
|
| 319 |
+
elif focus := (body.focus or "").strip():
|
| 320 |
detail = re.sub(r"\s+", " ", body.focus_detail or "").strip().rstrip(".")
|
| 321 |
cover = f" What to cover: {detail}." if detail else ""
|
| 322 |
length = b2v_access.house_length(body.video_style)
|
|
|
|
| 339 |
if not re.match(r"https?://", body.url):
|
| 340 |
raise HTTPException(400, "Enter a link that starts with http:// or https://")
|
| 341 |
return {"url": body.url}, body.title or body.url
|
| 342 |
+
if body.document_id and not (body.focus or body.focus_prompt):
|
| 343 |
doc = _doc(ctx, body.document_id)
|
| 344 |
if re.match(r"https?://", doc.url or ""):
|
| 345 |
return {"url": doc.url, "title": doc.title[:255]}, doc.title
|
|
|
|
| 464 |
return text[:limit].rsplit(" ", 1)[0] + "..."
|
| 465 |
|
| 466 |
|
| 467 |
+
def _focus_material(ctx: Ctx, body: FocusIn) -> tuple[dict[str, str], list[Document]]:
|
| 468 |
+
"""What the focus suggestions read, per post or chat (by id): each post's title and the start of its text, or a
|
| 469 |
+
chat's title and first exchange. A little text, so a vague title or an untitled chat still says what it is about.
|
| 470 |
+
Also the posts themselves (none for chats). Checked like a create (another workspace's source 404s, locked posts
|
| 471 |
+
400)."""
|
| 472 |
if body.chat_ids:
|
| 473 |
loaded = _load_chats(ctx, body.chat_ids)
|
| 474 |
answer_len = 600 if len(loaded) == 1 else 300
|
| 475 |
+
material = {}
|
| 476 |
for chat, msgs in loaded:
|
| 477 |
question = next((m.content for m in msgs if m.role == "user"), "")
|
| 478 |
answer = next((m.content for m in msgs if m.role == "assistant"), "")
|
| 479 |
parts = [chat.title or "", _excerpt(question, 300), _excerpt(_cite_free(answer), answer_len)]
|
| 480 |
if item := "\n".join(p for p in parts if p):
|
| 481 |
+
material[str(chat.id)] = item
|
| 482 |
+
return material, []
|
| 483 |
ids = set(body.document_ids or [body.document_id])
|
| 484 |
docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
|
| 485 |
Document.workspace_id == ctx.workspace.id)))
|
|
|
|
| 487 |
raise HTTPException(404, "Post not found")
|
| 488 |
if locked := sum(is_locked(d) for d in docs):
|
| 489 |
raise _not_indexed(locked)
|
| 490 |
+
docs.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
|
| 491 |
each = 800 if len(docs) == 1 else 400
|
| 492 |
+
items = {str(d.id): "\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs}
|
| 493 |
+
return {k: v for k, v in items.items() if v}, docs
|
| 494 |
|
| 495 |
|
| 496 |
@router.post("/focus-topics")
|
| 497 |
def focus_topics(body: FocusIn, ctx: Ctx = Depends(get_ctx)):
|
| 498 |
+
"""Up to three topics a video from this source could focus on: the topics the workspace already has for it,
|
| 499 |
+
shown as they are (untagged posts are tagged on demand; a chat's are the cited posts' topics). The AI writes them
|
| 500 |
+
only when there are none. Asked for and shown on the wizard's step 1, once the source is chosen."""
|
| 501 |
+
material, docs = _focus_material(ctx, body)
|
| 502 |
+
if not material:
|
| 503 |
return {"topics": []}
|
| 504 |
+
ws = ctx.workspace.id
|
| 505 |
+
if body.chat_ids:
|
| 506 |
+
cited, docs = video_focus.chat_citations(ctx.db, ws, body.chat_ids)
|
| 507 |
+
sources = {chat: cited.get(chat, {}) for chat in material}
|
| 508 |
+
else:
|
| 509 |
+
sources = {str(d.id): {d.id: 1.0} for d in docs}
|
| 510 |
+
video_focus.ensure_tags(ctx.db, ws, docs)
|
| 511 |
+
if cards := video_focus.pick_cards(video_focus.build_pool(ctx.db, ws, sources, docs)):
|
| 512 |
+
return {"topics": [{"title": c.name, "description": c.summary} for c in cards]}
|
| 513 |
+
items = list(material.values())[:FOCUS_ITEMS]
|
| 514 |
topics: list[dict] = []
|
| 515 |
# One retry when the answer has fewer than 3 usable topics (a placeholder for every title, repeats).
|
| 516 |
for retry in (False, True):
|
| 517 |
+
out = run.predict(VideoFocusAngles, db=ctx.db, workspace_id=ws, lm=focus_lm(retry), items=items,
|
| 518 |
+
candidate_tags=[], avoid_titles=[])
|
| 519 |
_add_topics(topics, out.get("topics"))
|
| 520 |
if len(topics) >= 3:
|
| 521 |
break
|
| 522 |
return {"topics": topics[:3]}
|
| 523 |
|
| 524 |
|
| 525 |
+
FOCUS_ITEMS = 5 # posts or chats the AI reads when there are no topics to show
|
| 526 |
FOCUS_PLACEHOLDERS = {"title", "topic", "description", "summary", "name", "angle", "focus"}
|
| 527 |
|
| 528 |
|
backend/app/routers/voice.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.auth import Ctx, get_ctx
|
|
| 11 |
from app.config import settings
|
| 12 |
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
|
| 13 |
from app.services import tts
|
|
|
|
| 14 |
from app.services.jobs import create_job, record_usage, serialize_job
|
| 15 |
from app.services.plans import effective_plan, plan_limit_error
|
| 16 |
from app.services.storage import storage
|
|
@@ -206,10 +207,15 @@ async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 206 |
voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
|
| 207 |
except tts.TTSError as exc:
|
| 208 |
raise HTTPException(502, str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if body.use_as in ("host_a", "host_b"):
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 214 |
|
| 215 |
|
|
@@ -369,5 +375,7 @@ async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
|
|
| 369 |
vp = _profile(ctx)
|
| 370 |
if vp and vp.host_voices:
|
| 371 |
vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
|
|
|
|
|
|
|
| 372 |
ctx.db.commit()
|
| 373 |
return _serialize(ctx)
|
|
|
|
| 11 |
from app.config import settings
|
| 12 |
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
|
| 13 |
from app.services import tts
|
| 14 |
+
from app.services.notestack_voices import forget_video_voice
|
| 15 |
from app.services.jobs import create_job, record_usage, serialize_job
|
| 16 |
from app.services.plans import effective_plan, plan_limit_error
|
| 17 |
from app.services.storage import storage
|
|
|
|
| 207 |
voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
|
| 208 |
except tts.TTSError as exc:
|
| 209 |
raise HTTPException(502, str(exc)) from exc
|
| 210 |
+
vp = _ensure_profile(ctx)
|
| 211 |
+
voices = dict(vp.host_voices or {})
|
| 212 |
+
# Listed, so it can be added to the workspace's video voices too.
|
| 213 |
+
added = [v for v in voices.get("added") or [] if v.get("voice_id") != voice_id]
|
| 214 |
+
voices["added"] = [*added, {"voice_id": voice_id, "name": body.name[:100]}]
|
| 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 |
|
| 221 |
|
|
|
|
| 375 |
vp = _profile(ctx)
|
| 376 |
if vp and vp.host_voices:
|
| 377 |
vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
|
| 378 |
+
if consent.elevenlabs_voice_id:
|
| 379 |
+
forget_video_voice(ctx.db, ctx.workspace.id, consent.elevenlabs_voice_id)
|
| 380 |
ctx.db.commit()
|
| 381 |
return _serialize(ctx)
|
backend/app/services/b2v_access.py
CHANGED
|
@@ -14,6 +14,7 @@ from sqlalchemy import select
|
|
| 14 |
from app.auth import Ctx
|
| 15 |
from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
|
| 16 |
from app.services import blog2video as b2v
|
|
|
|
| 17 |
from app.services.plans import Plan, effective_plan
|
| 18 |
from app.services.video_limits import require_premium
|
| 19 |
|
|
@@ -157,7 +158,8 @@ def check_style(ctx: Ctx, style: str) -> None:
|
|
| 157 |
|
| 158 |
|
| 159 |
def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
|
| 160 |
-
"""A built-in voice (premium if blog2video marks it paid) or one of this workspace's custom
|
|
|
|
| 161 |
prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
|
| 162 |
if voice_id in prebuilt:
|
| 163 |
if prebuilt[voice_id].get("plan") == "paid":
|
|
@@ -167,6 +169,10 @@ def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
|
|
| 167 |
B2VCustomVoice.workspace_id == ctx.workspace.id)):
|
| 168 |
require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
|
| 169 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
raise not_found("Voice not found")
|
| 171 |
|
| 172 |
|
|
|
|
| 14 |
from app.auth import Ctx
|
| 15 |
from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
|
| 16 |
from app.services import blog2video as b2v
|
| 17 |
+
from app.services.notestack_voices import is_notestack_voice
|
| 18 |
from app.services.plans import Plan, effective_plan
|
| 19 |
from app.services.video_limits import require_premium
|
| 20 |
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
|
| 161 |
+
"""A built-in voice (premium if blog2video marks it paid), or one of this workspace's custom or Notestack
|
| 162 |
+
voices."""
|
| 163 |
prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
|
| 164 |
if voice_id in prebuilt:
|
| 165 |
if prebuilt[voice_id].get("plan") == "paid":
|
|
|
|
| 169 |
B2VCustomVoice.workspace_id == ctx.workspace.id)):
|
| 170 |
require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
|
| 171 |
return
|
| 172 |
+
# One of this workspace's Notestack voices (clone, designed, added): same ElevenLabs account, premium like custom.
|
| 173 |
+
if is_notestack_voice(ctx.db, ctx.workspace.id, voice_id):
|
| 174 |
+
require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
|
| 175 |
+
return
|
| 176 |
raise not_found("Voice not found")
|
| 177 |
|
| 178 |
|
backend/app/services/jobs.py
CHANGED
|
@@ -46,6 +46,12 @@ def serialize_job(job: Job) -> dict:
|
|
| 46 |
# Producer side
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def create_job(
|
| 50 |
db: Session,
|
| 51 |
workspace_id: uuid.UUID,
|
|
|
|
| 46 |
# Producer side
|
| 47 |
|
| 48 |
|
| 49 |
+
def pending_job(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
|
| 50 |
+
"""A job of this kind is already queued or running for the workspace."""
|
| 51 |
+
return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
|
| 52 |
+
Job.status.in_(("queued", "running"))).limit(1)) is not None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def create_job(
|
| 56 |
db: Session,
|
| 57 |
workspace_id: uuid.UUID,
|
backend/app/services/notestack_voices.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
|
| 2 |
+
from the ElevenLabs library. blog2video speaks with the same ElevenLabs account, so these can be used in videos too
|
| 3 |
+
(as `custom_voice_id`); video_voices lists them and b2v_access accepts them."""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import delete, select
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from app.models import UserSavedVoice, VoiceConsent, VoiceProfile
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def notestack_voices(db: Session, workspace_id: uuid.UUID) -> list[dict]:
|
| 14 |
+
"""[{voice_id, name, kind}], kind: clone | designed | library. The clone first, no repeats."""
|
| 15 |
+
out: list[dict] = []
|
| 16 |
+
consent = db.scalar(select(VoiceConsent).where(VoiceConsent.workspace_id == workspace_id,
|
| 17 |
+
VoiceConsent.revoked_at.is_(None))
|
| 18 |
+
.order_by(VoiceConsent.created_at.desc()))
|
| 19 |
+
if consent and consent.elevenlabs_voice_id:
|
| 20 |
+
out.append({"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "kind": "clone"})
|
| 21 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 22 |
+
voices = (vp.host_voices if vp else None) or {}
|
| 23 |
+
for kind, key in (("designed", "custom"), ("library", "added")):
|
| 24 |
+
for v in voices.get(key) or []:
|
| 25 |
+
if v.get("voice_id") and all(o["voice_id"] != v["voice_id"] for o in out):
|
| 26 |
+
out.append({"voice_id": v["voice_id"], "name": v.get("name") or "Voice", "kind": kind})
|
| 27 |
+
return out
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> bool:
|
| 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,
|
| 37 |
+
UserSavedVoice.voice_id == voice_id))
|
backend/app/services/video_focus.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The video wizard's focus cards: the topics the workspace already has for the source, shown as they are.
|
| 2 |
+
|
| 3 |
+
Each post is tagged by the background `topics` job (generate.extract_topics); a post still without tags is tagged
|
| 4 |
+
here on demand. A source's tags make its pool, each scored by weight and by how much of the source shares it (a
|
| 5 |
+
chat's tags are those of the posts its answers cite). The cards are the best merged topics (the Topic Map's, with a
|
| 6 |
+
summary), spread over the source's posts, then the posts' own tags (a name only) when there are fewer than three.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
from collections import Counter, defaultdict
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from sqlalchemy import func, select
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
|
| 16 |
+
from app.config import settings
|
| 17 |
+
from app.corpus import slugify
|
| 18 |
+
from app.llm import run
|
| 19 |
+
from app.llm.provider import fast_lm
|
| 20 |
+
from app.llm.signatures import ExtractTopics
|
| 21 |
+
from app.models import Citation, Document, DocumentTopic, Message, Topic
|
| 22 |
+
from app.pipeline.generate import save_topic_tags
|
| 23 |
+
from app.routers.sources import is_locked
|
| 24 |
+
from app.services.jobs import create_job, pending_job
|
| 25 |
+
|
| 26 |
+
INLINE_TAG_CAP = 5 # untagged posts tagged while the wizard waits; the rest go to the background job
|
| 27 |
+
TAG_TEXT = 6000 # characters of a post read when tagging it on demand (the background job reads 12000)
|
| 28 |
+
CARDS = 3
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class Candidate:
|
| 33 |
+
name: str
|
| 34 |
+
summary: str
|
| 35 |
+
score: float
|
| 36 |
+
sources: set[str] # the posts (or chats) that carry it
|
| 37 |
+
merged: bool # a topic of the topic table (has a summary), not only a post's own tag
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# Tags
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def ensure_tags(db: Session, workspace_id: uuid.UUID, docs: list[Document]) -> None:
|
| 44 |
+
"""Tag the newest few untagged posts now, in parallel, and leave the rest to the background job, which also
|
| 45 |
+
merges these new tags into the topic table. A failed call leaves that post untagged: never an error here."""
|
| 46 |
+
untagged = [d for d in docs if not (d.metadata_json or {}).get("topics") and not is_locked(d)]
|
| 47 |
+
if not untagged or not settings.llm_api_key:
|
| 48 |
+
return
|
| 49 |
+
untagged.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
|
| 50 |
+
now = untagged[:INLINE_TAG_CAP]
|
| 51 |
+
known = sorted(db.scalars(select(Topic.name).where(Topic.workspace_id == workspace_id)))[:200]
|
| 52 |
+
outs = run.predict_many(ExtractTopics, [{"title": d.title, "text": (d.clean_text or "")[:TAG_TEXT],
|
| 53 |
+
"known_topics": known} for d in now],
|
| 54 |
+
db=db, workspace_id=workspace_id, lm=fast_lm())
|
| 55 |
+
for doc, out in zip(now, outs, strict=True):
|
| 56 |
+
if out is not None:
|
| 57 |
+
save_topic_tags(doc, out)
|
| 58 |
+
db.commit()
|
| 59 |
+
# With no ids the job tags every post still untagged (the rest of these too) and rebuilds the topic table.
|
| 60 |
+
if not pending_job(db, workspace_id, "topics"):
|
| 61 |
+
create_job(db, workspace_id, "topics", {}, max_attempts=2)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def chat_citations(db: Session, workspace_id: uuid.UUID,
|
| 65 |
+
chat_ids: list[uuid.UUID]) -> tuple[dict[str, dict[uuid.UUID, float]], list[Document]]:
|
| 66 |
+
"""For each chat, the posts its answers cite (weighted by how often, the most cited 1.0), and those posts.
|
| 67 |
+
Locked posts are left out: the chat itself is fine to use."""
|
| 68 |
+
rows = db.execute(
|
| 69 |
+
select(Message.chat_id, Citation.document_id, func.count())
|
| 70 |
+
.join(Citation, Citation.message_id == Message.id)
|
| 71 |
+
.where(Message.chat_id.in_(chat_ids))
|
| 72 |
+
.group_by(Message.chat_id, Citation.document_id)
|
| 73 |
+
).all()
|
| 74 |
+
docs = {d.id: d for d in db.scalars(select(Document).where(
|
| 75 |
+
Document.id.in_({doc_id for _, doc_id, _ in rows}), Document.workspace_id == workspace_id)) if not is_locked(d)}
|
| 76 |
+
counts: dict[str, dict[uuid.UUID, int]] = defaultdict(dict)
|
| 77 |
+
for chat_id, doc_id, n in rows:
|
| 78 |
+
if doc_id in docs:
|
| 79 |
+
counts[str(chat_id)][doc_id] = n
|
| 80 |
+
cited = {chat: {d: n / max(per.values()) for d, n in per.items()} for chat, per in counts.items()}
|
| 81 |
+
return cited, list(docs.values())
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_pool(db: Session, workspace_id: uuid.UUID, sources: dict[str, dict[uuid.UUID, float]],
|
| 85 |
+
docs: list[Document]) -> list[Candidate]:
|
| 86 |
+
"""Every tag of the source's posts, best first. A source is a post (itself, weight 1) or a chat (the posts it
|
| 87 |
+
cites). Score: the tag's weight summed over the sources that carry it, times the share of sources that do, so a
|
| 88 |
+
theme running through the selection beats one post's strongest tag."""
|
| 89 |
+
tags = _doc_tags(db, workspace_id, docs)
|
| 90 |
+
score: dict[str, float] = defaultdict(float)
|
| 91 |
+
carried: dict[str, set[str]] = defaultdict(set)
|
| 92 |
+
summaries: dict[str, str] = {}
|
| 93 |
+
merged: dict[str, bool] = defaultdict(bool)
|
| 94 |
+
for source, weights in sources.items():
|
| 95 |
+
best: dict[str, float] = {}
|
| 96 |
+
for doc_id, w in weights.items():
|
| 97 |
+
for name, (tag_weight, summary, is_merged) in tags.get(doc_id, {}).items():
|
| 98 |
+
best[name] = max(best.get(name, 0.0), tag_weight * w)
|
| 99 |
+
summaries.setdefault(name, summary)
|
| 100 |
+
merged[name] |= is_merged
|
| 101 |
+
for name, v in best.items():
|
| 102 |
+
score[name] += v
|
| 103 |
+
carried[name].add(source)
|
| 104 |
+
total = max(len(sources), 1)
|
| 105 |
+
pool = [Candidate(name, summaries[name], score[name] * len(carried[name]) / total, carried[name], merged[name])
|
| 106 |
+
for name in score]
|
| 107 |
+
return sorted(pool, key=lambda c: (-c.score, c.name))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _doc_tags(db: Session, workspace_id: uuid.UUID,
|
| 111 |
+
docs: list[Document]) -> dict[uuid.UUID, dict[str, tuple[float, str, bool]]]:
|
| 112 |
+
"""Post -> {topic name: (weight, summary, merged)}: the topic table's (merged) topics and the post's own tags. The
|
| 113 |
+
merge folds a post's specific tags into a few broad workspace topics ("Content Repurposing" into "Finance
|
| 114 |
+
Substacks & Audience Growth"), which alone leaves a single post one or two angles; the merged names are what lets
|
| 115 |
+
posts share a tag. An own tag is named as the topic table's matching topic if any, and skipped if already there."""
|
| 116 |
+
out: dict[uuid.UUID, dict[str, tuple[float, str, bool]]] = defaultdict(dict)
|
| 117 |
+
rows = db.execute(
|
| 118 |
+
select(DocumentTopic.document_id, DocumentTopic.weight, Topic.name, Topic.summary)
|
| 119 |
+
.join(Topic, Topic.id == DocumentTopic.topic_id)
|
| 120 |
+
.where(DocumentTopic.document_id.in_([d.id for d in docs]), Topic.workspace_id == workspace_id)
|
| 121 |
+
).all()
|
| 122 |
+
for doc_id, weight, name, summary in rows:
|
| 123 |
+
out[doc_id][name] = (float(weight or 0.5), summary or "", True)
|
| 124 |
+
own = [d for d in docs if (d.metadata_json or {}).get("topics")]
|
| 125 |
+
if own:
|
| 126 |
+
known = {t.slug: t for t in db.scalars(select(Topic).where(Topic.workspace_id == workspace_id))}
|
| 127 |
+
for d in own:
|
| 128 |
+
have = {slugify(n, 120) for n in out[d.id]}
|
| 129 |
+
for t in d.metadata_json["topics"]:
|
| 130 |
+
topic = known.get(slugify(t["name"], 120))
|
| 131 |
+
name = topic.name if topic else t["name"]
|
| 132 |
+
if slugify(name, 120) in have:
|
| 133 |
+
continue
|
| 134 |
+
have.add(slugify(name, 120))
|
| 135 |
+
summary = (topic.summary or "") if topic else ""
|
| 136 |
+
out[d.id][name] = (float(t.get("weight") or 0.5), summary, topic is not None)
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def pick_cards(pool: list[Candidate], n: int = CARDS) -> list[Candidate]:
|
| 141 |
+
"""The cards: merged topics first, then own tags. Within each, best score first, halved for each card already on
|
| 142 |
+
every post the topic has, so several posts' cards do not all come from one post."""
|
| 143 |
+
chosen: list[Candidate] = []
|
| 144 |
+
covered: Counter[str] = Counter() # source -> cards on it so far
|
| 145 |
+
|
| 146 |
+
def worth(c: Candidate) -> float:
|
| 147 |
+
return c.score * 0.5 ** min(covered[s] for s in c.sources)
|
| 148 |
+
|
| 149 |
+
for group in ([c for c in pool if c.merged], [c for c in pool if not c.merged]):
|
| 150 |
+
rest = list(enumerate(group)) # the pool's order breaks ties
|
| 151 |
+
while rest and len(chosen) < n:
|
| 152 |
+
i, best = max(rest, key=lambda ic: (worth(ic[1]), -ic[0]))
|
| 153 |
+
chosen.append(best)
|
| 154 |
+
covered.update(best.sources)
|
| 155 |
+
rest.remove((i, best))
|
| 156 |
+
return chosen
|
backend/app/worker.py
CHANGED
|
@@ -29,6 +29,7 @@ from app.config import settings
|
|
| 29 |
from app.db import SessionLocal, engine
|
| 30 |
from app.models import (
|
| 31 |
Artifact,
|
|
|
|
| 32 |
Job,
|
| 33 |
Source,
|
| 34 |
Subscription,
|
|
@@ -47,7 +48,15 @@ from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article
|
|
| 47 |
from app.services import launchpad, tts, video_quota
|
| 48 |
from app.services.email import email_service
|
| 49 |
from app.services.email_verification import purge_old_codes
|
| 50 |
-
from app.services.jobs import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
from app.services.plans import effective_plan
|
| 52 |
from app.services.renderer import PermanentJobError, request_render
|
| 53 |
from app.services.storage import keys, storage
|
|
@@ -67,21 +76,16 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
|
|
| 67 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 68 |
|
| 69 |
|
| 70 |
-
def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool:
|
| 71 |
-
return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind,
|
| 72 |
-
Job.status.in_(("queued", "running"))).limit(1)) is not None
|
| 73 |
-
|
| 74 |
-
|
| 75 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 76 |
"""New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
|
| 77 |
first import builds the writing voice, so none of these need a button press."""
|
| 78 |
if not changed or not settings.llm_api_key:
|
| 79 |
return
|
| 80 |
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 81 |
-
if not
|
| 82 |
create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
|
| 83 |
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 84 |
-
if not (vp and vp.profile_json) and not
|
| 85 |
create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
|
| 86 |
|
| 87 |
|
|
@@ -307,6 +311,19 @@ def single_flight(name: str):
|
|
| 307 |
conn.commit()
|
| 308 |
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
|
| 311 |
"""Sends one daily batch per scheduled campaign at its send hour (UTC)."""
|
| 312 |
now = datetime.now(UTC)
|
|
@@ -363,6 +380,7 @@ PERIODIC = [
|
|
| 363 |
Periodic("publish_due", 30, launchpad.publish_due),
|
| 364 |
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 365 |
Periodic("update_email_batch", 300, update_email_batch),
|
|
|
|
| 366 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
| 367 |
Periodic("video_period_reset", 3600, video_quota.reset_due_video_periods),
|
| 368 |
Periodic("video_refund_sweep", 600, video_quota.sweep_failed_videos),
|
|
|
|
| 29 |
from app.db import SessionLocal, engine
|
| 30 |
from app.models import (
|
| 31 |
Artifact,
|
| 32 |
+
Document,
|
| 33 |
Job,
|
| 34 |
Source,
|
| 35 |
Subscription,
|
|
|
|
| 48 |
from app.services import launchpad, tts, video_quota
|
| 49 |
from app.services.email import email_service
|
| 50 |
from app.services.email_verification import purge_old_codes
|
| 51 |
+
from app.services.jobs import (
|
| 52 |
+
claim_next,
|
| 53 |
+
create_job,
|
| 54 |
+
pending_job,
|
| 55 |
+
record_usage,
|
| 56 |
+
retry_or_fail,
|
| 57 |
+
stale_jobs,
|
| 58 |
+
update_job,
|
| 59 |
+
)
|
| 60 |
from app.services.plans import effective_plan
|
| 61 |
from app.services.renderer import PermanentJobError, request_render
|
| 62 |
from app.services.storage import keys, storage
|
|
|
|
| 76 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 80 |
"""New or changed posts are mapped into the topic constellation and scored for resurfacing, and the
|
| 81 |
first import builds the writing voice, so none of these need a button press."""
|
| 82 |
if not changed or not settings.llm_api_key:
|
| 83 |
return
|
| 84 |
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 85 |
+
if not pending_job(db, workspace_id, "resurface_scan"):
|
| 86 |
create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2)
|
| 87 |
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 88 |
+
if not (vp and vp.profile_json) and not pending_job(db, workspace_id, "voice_profile"):
|
| 89 |
create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2)
|
| 90 |
|
| 91 |
|
|
|
|
| 311 |
conn.commit()
|
| 312 |
|
| 313 |
|
| 314 |
+
def topics_backfill(session_factory: Callable[[], Session] = SessionLocal) -> None:
|
| 315 |
+
"""Every indexed post gets topics: one whose tagging failed (the model timed out, rate limited, answered nothing)
|
| 316 |
+
or that came before posts were tagged on import is asked for again here, one topics job per workspace that has
|
| 317 |
+
some, unless one is already queued. A post that keeps failing stops being asked (generate.MAX_TAG_FAILURES)."""
|
| 318 |
+
if not settings.llm_api_key:
|
| 319 |
+
return
|
| 320 |
+
with session_factory() as db:
|
| 321 |
+
rows = db.execute(select(Document.workspace_id, Document.metadata_json).where(Document.path.is_not(None)))
|
| 322 |
+
for workspace_id in {ws for ws, meta in rows if generate.needs_topics(meta)}:
|
| 323 |
+
if not pending_job(db, workspace_id, "topics"):
|
| 324 |
+
create_job(db, workspace_id, "topics", {}, max_attempts=2)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
|
| 328 |
"""Sends one daily batch per scheduled campaign at its send hour (UTC)."""
|
| 329 |
now = datetime.now(UTC)
|
|
|
|
| 380 |
Periodic("publish_due", 30, launchpad.publish_due),
|
| 381 |
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 382 |
Periodic("update_email_batch", 300, update_email_batch),
|
| 383 |
+
Periodic("topics_backfill", 900, topics_backfill),
|
| 384 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
| 385 |
Periodic("video_period_reset", 3600, video_quota.reset_due_video_periods),
|
| 386 |
Periodic("video_refund_sweep", 600, video_quota.sweep_failed_videos),
|
backend/tests/test_buildout.py
CHANGED
|
@@ -329,6 +329,75 @@ def test_topic_map_and_notebook_from_topic(client, auth, run_jobs, feed, llm):
|
|
| 329 |
assert nb["added"] == 1
|
| 330 |
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
def test_voice_profile_build_and_edit(client, auth, run_jobs, feed, llm):
|
| 333 |
connect(client, auth, run_jobs)
|
| 334 |
profile = {"tone": ["warm"], "sentence_length": "short", "vocabulary": ["readers"], "structure_habits": [],
|
|
|
|
| 329 |
assert nb["added"] == 1
|
| 330 |
|
| 331 |
|
| 332 |
+
def topic_metas(session_factory) -> dict[str, dict]:
|
| 333 |
+
with session_factory() as db:
|
| 334 |
+
return {d.title: d.metadata_json or {} for d in db.query(Document).filter(Document.path.is_not(None))}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def test_a_post_the_model_fails_on_is_tried_again(client, auth, run_jobs, feed, llm, monkeypatch, session_factory):
|
| 338 |
+
from app.pipeline import generate
|
| 339 |
+
monkeypatch.setattr(generate, "TAG_BACKOFF", (0.0, 0.0))
|
| 340 |
+
connect(client, auth, run_jobs)
|
| 341 |
+
tries: dict[str, int] = {}
|
| 342 |
+
|
| 343 |
+
def extract(title, **_):
|
| 344 |
+
tries[title] = tries.get(title, 0) + 1
|
| 345 |
+
if tries[title] == 1:
|
| 346 |
+
raise TimeoutError("the model timed out") # once per post: the second try works
|
| 347 |
+
if tries[title] == 2 and "Pricing" in title:
|
| 348 |
+
return {"topics": []} # an empty answer is not a result: tried a third time
|
| 349 |
+
return {"topics": [{"name": "Writing", "weight": 0.8}]}
|
| 350 |
+
|
| 351 |
+
llm["ExtractTopics"] = extract
|
| 352 |
+
llm["ConsolidateTopics"] = {"groups": []}
|
| 353 |
+
client.post("/api/topics/rebuild", headers=auth)
|
| 354 |
+
job = run_jobs()[0]
|
| 355 |
+
metas = topic_metas(session_factory)
|
| 356 |
+
assert job.status == "done" and job.result["tagged"] == len(metas) and job.result["failed"] == 0
|
| 357 |
+
assert all(m["topics"] == [{"name": "Writing", "weight": 0.8}] for m in metas.values())
|
| 358 |
+
assert max(tries.values()) == 3
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_every_post_gets_topics_through_the_sweep(client, auth, run_jobs, feed, llm, monkeypatch, session_factory):
|
| 362 |
+
from app import worker
|
| 363 |
+
from app.config import settings
|
| 364 |
+
from app.pipeline import generate
|
| 365 |
+
monkeypatch.setattr(generate, "TAG_BACKOFF", (0.0, 0.0))
|
| 366 |
+
monkeypatch.setattr(settings, "llm_api_key", "test-key")
|
| 367 |
+
llm["ConsolidateTopics"] = {"groups": []}
|
| 368 |
+
llm["ExtractTopics"] = lambda **_: (_ for _ in ()).throw(TimeoutError("the model is down"))
|
| 369 |
+
connect(client, auth, run_jobs) # the import's topics job runs too, and tags nothing
|
| 370 |
+
metas = topic_metas(session_factory)
|
| 371 |
+
assert metas and all("topics" not in m and m["topics_failures"] == 1 for m in metas.values())
|
| 372 |
+
|
| 373 |
+
worker.topics_backfill(session_factory)
|
| 374 |
+
worker.topics_backfill(session_factory) # one job per workspace, not one per sweep
|
| 375 |
+
llm["ExtractTopics"] = {"topics": [{"name": "Pricing", "weight": 0.9}]} # the model is back
|
| 376 |
+
jobs = run_jobs()
|
| 377 |
+
assert len(jobs) == 1 and jobs[0].result["tagged"] == len(metas)
|
| 378 |
+
assert all(m["topics"] and "topics_failures" not in m for m in topic_metas(session_factory).values())
|
| 379 |
+
worker.topics_backfill(session_factory)
|
| 380 |
+
assert not run_jobs() # all tagged: nothing to do
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def test_the_sweep_gives_up_on_a_post_that_keeps_failing(client, auth, run_jobs, feed, llm, monkeypatch,
|
| 384 |
+
session_factory):
|
| 385 |
+
from app import worker
|
| 386 |
+
from app.config import settings
|
| 387 |
+
from app.pipeline import generate
|
| 388 |
+
monkeypatch.setattr(generate, "TAG_BACKOFF", (0.0, 0.0))
|
| 389 |
+
monkeypatch.setattr(settings, "llm_api_key", "test-key")
|
| 390 |
+
llm["ConsolidateTopics"] = {"groups": []}
|
| 391 |
+
llm["ExtractTopics"] = lambda **_: (_ for _ in ()).throw(TimeoutError("always"))
|
| 392 |
+
connect(client, auth, run_jobs)
|
| 393 |
+
for _ in range(generate.MAX_TAG_FAILURES):
|
| 394 |
+
worker.topics_backfill(session_factory)
|
| 395 |
+
run_jobs()
|
| 396 |
+
assert all(m["topics_failures"] == generate.MAX_TAG_FAILURES for m in topic_metas(session_factory).values())
|
| 397 |
+
worker.topics_backfill(session_factory)
|
| 398 |
+
assert not run_jobs()
|
| 399 |
+
|
| 400 |
+
|
| 401 |
def test_voice_profile_build_and_edit(client, auth, run_jobs, feed, llm):
|
| 402 |
connect(client, auth, run_jobs)
|
| 403 |
profile = {"tone": ["warm"], "sentence_length": "short", "vocabulary": ["readers"], "structure_habits": [],
|
backend/tests/test_videos.py
CHANGED
|
@@ -20,15 +20,22 @@ from app.models import (
|
|
| 20 |
B2VTemplate,
|
| 21 |
B2VVideo,
|
| 22 |
Chat,
|
|
|
|
| 23 |
Document,
|
|
|
|
|
|
|
| 24 |
Message,
|
| 25 |
Notebook,
|
| 26 |
Subscription,
|
|
|
|
| 27 |
UsageCounter,
|
|
|
|
| 28 |
UserSavedVoice,
|
|
|
|
|
|
|
| 29 |
Workspace,
|
| 30 |
)
|
| 31 |
-
from app.services import blog2video, video_limits, video_quota
|
| 32 |
from tests.conftest import last_code
|
| 33 |
|
| 34 |
KEY = "b2v_live_test_key"
|
|
@@ -599,7 +606,7 @@ def test_focus_topics_for_posts_a_chat_and_one_post(client, owner, db_session, s
|
|
| 599 |
{"title": "Why prices go up", "description": "Why raises happen. Once a year, with notice."},
|
| 600 |
{"title": "Grandfathering early readers", "description": "Keep early prices."},
|
| 601 |
{"title": "Annual raises", "description": "One raise a year."}]
|
| 602 |
-
assert [s["signature"] for s in suggest] == ["
|
| 603 |
# Each item is a title and the start of its text: a little, never the whole post or transcript.
|
| 604 |
posts, chat_item, one = (s["items"] for s in suggest)
|
| 605 |
assert len(posts) == 2 and all(p.startswith("On Pricing\n") for p in posts)
|
|
@@ -660,6 +667,189 @@ def test_focus_topics_check_the_source(client, owner, other, db_session, suggest
|
|
| 660 |
assert r.status_code == 400 and not suggest
|
| 661 |
|
| 662 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
@pytest.mark.parametrize("style,length,words", [("custom:20", "short", "overview of that topic"),
|
| 664 |
("custom:21", "medium", "deep dive into that topic")])
|
| 665 |
def test_focus_goes_ahead_of_the_material_worded_for_the_style(client, b2v, owner, house, db_session,
|
|
@@ -680,6 +870,36 @@ def test_focus_goes_ahead_of_the_material_worded_for_the_style(client, b2v, owne
|
|
| 680 |
assert '"Annual raises"' in head and "What to cover" not in head
|
| 681 |
|
| 682 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
def test_chat_with_a_focus_keeps_the_chat_context_first(client, b2v, owner, house, db_session):
|
| 684 |
from app.routers.videos import CHAT_CONTEXT
|
| 685 |
headers, ws = owner
|
|
@@ -931,6 +1151,59 @@ def test_saved_voices_never_touch_blog2video_saved_list(client, b2v, owner, db_s
|
|
| 931 |
assert not [p for p in b2v.paths() if "/voices/saved" in p]
|
| 932 |
|
| 933 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
def test_design_keep_and_delete_a_custom_voice(client, b2v, owner, other, db_session):
|
| 935 |
headers, ws = owner
|
| 936 |
previews = client.post("/api/video-voices/design/prompt", json={"prompt": "A calm, warm narrator voice please"},
|
|
|
|
| 20 |
B2VTemplate,
|
| 21 |
B2VVideo,
|
| 22 |
Chat,
|
| 23 |
+
Citation,
|
| 24 |
Document,
|
| 25 |
+
DocumentTopic,
|
| 26 |
+
Job,
|
| 27 |
Message,
|
| 28 |
Notebook,
|
| 29 |
Subscription,
|
| 30 |
+
Topic,
|
| 31 |
UsageCounter,
|
| 32 |
+
User,
|
| 33 |
UserSavedVoice,
|
| 34 |
+
VoiceConsent,
|
| 35 |
+
VoiceProfile,
|
| 36 |
Workspace,
|
| 37 |
)
|
| 38 |
+
from app.services import blog2video, video_focus, video_limits, video_quota
|
| 39 |
from tests.conftest import last_code
|
| 40 |
|
| 41 |
KEY = "b2v_live_test_key"
|
|
|
|
| 606 |
{"title": "Why prices go up", "description": "Why raises happen. Once a year, with notice."},
|
| 607 |
{"title": "Grandfathering early readers", "description": "Keep early prices."},
|
| 608 |
{"title": "Annual raises", "description": "One raise a year."}]
|
| 609 |
+
assert [s["signature"] for s in suggest] == ["VideoFocusAngles"] * 3 # no topics to show: the AI writes them
|
| 610 |
# Each item is a title and the start of its text: a little, never the whole post or transcript.
|
| 611 |
posts, chat_item, one = (s["items"] for s in suggest)
|
| 612 |
assert len(posts) == 2 and all(p.startswith("On Pricing\n") for p in posts)
|
|
|
|
| 667 |
assert r.status_code == 400 and not suggest
|
| 668 |
|
| 669 |
|
| 670 |
+
# Focus topics: the workspace's own, shown as they are
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
@pytest.fixture()
|
| 674 |
+
def angles(monkeypatch):
|
| 675 |
+
"""The AI, canned: VideoFocusAngles (only when there are no topics to show) answers three fixed angles.
|
| 676 |
+
ExtractTopics (tagging on demand) answers per post from `tag_answers` (title -> tags; None = the call failed)."""
|
| 677 |
+
from app.llm import run
|
| 678 |
+
calls: dict = {"focus": [], "tagged": [], "tag_answers": {}}
|
| 679 |
+
|
| 680 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 681 |
+
calls["focus"].append(inputs)
|
| 682 |
+
return {"topics": [{"topic": f"AI angle {i}", "summary": "Written by the AI.", "tag": ""} for i in range(3)]}
|
| 683 |
+
|
| 684 |
+
def predict_many(signature, inputs_list, *, db, workspace_id, lm=None, workers=5):
|
| 685 |
+
calls["tagged"].append([i["title"] for i in inputs_list])
|
| 686 |
+
answers = [calls["tag_answers"].get(i["title"], []) for i in inputs_list]
|
| 687 |
+
return [None if a is None else {"topics": [{"name": n, "weight": 0.8} for n in a]} for a in answers]
|
| 688 |
+
|
| 689 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 690 |
+
monkeypatch.setattr(run, "predict_many", predict_many)
|
| 691 |
+
monkeypatch.setattr(settings, "llm_api_key", "test-key")
|
| 692 |
+
return calls
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def tagged_doc(db, ws, slug: str, tags: list[str], title: str = "On Pricing",
|
| 696 |
+
weights: list[float] | None = None) -> Document:
|
| 697 |
+
doc = add_doc(db, ws, f"https://ada.example.com/p/{slug}", f"All about {slug}. " * 20)
|
| 698 |
+
weights = weights or [0.8] * len(tags)
|
| 699 |
+
doc.title = title
|
| 700 |
+
doc.metadata_json = {"topics": [{"name": t, "weight": w} for t, w in zip(tags, weights, strict=True)]}
|
| 701 |
+
db.commit()
|
| 702 |
+
return doc
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def merge(db, ws, name: str, summary: str, *links: tuple[Document, float]) -> Topic:
|
| 706 |
+
"""A topic of the topic table (what rebuild_topics makes), linked to posts with a weight."""
|
| 707 |
+
topic = Topic(workspace_id=ws.id, name=name, slug=name.lower().replace(" ", "-"), summary=summary,
|
| 708 |
+
post_count=len(links))
|
| 709 |
+
db.add(topic)
|
| 710 |
+
db.flush()
|
| 711 |
+
db.add_all([DocumentTopic(document_id=d.id, topic_id=topic.id, weight=w) for d, w in links])
|
| 712 |
+
db.commit()
|
| 713 |
+
return topic
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def focus(client, headers, **body) -> list[dict]:
|
| 717 |
+
r = client.post("/api/videos/focus-topics", json=body, headers=headers)
|
| 718 |
+
assert r.status_code == 200, r.text
|
| 719 |
+
return r.json()["topics"]
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def test_a_tag_shared_by_the_selection_outranks_one_posts_strongest(owner, db_session):
|
| 723 |
+
_, ws = owner
|
| 724 |
+
docs = [add_doc(db_session, ws, f"https://ada.example.com/p/{i}", "Text.") for i in range(3)]
|
| 725 |
+
merge(db_session, ws, "Pricing", "How to price.", *[(d, 0.5) for d in docs])
|
| 726 |
+
merge(db_session, ws, "Churn", "", (docs[0], 1.0))
|
| 727 |
+
pool = video_focus.build_pool(db_session, ws.id, {str(d.id): {d.id: 1.0} for d in docs}, docs)
|
| 728 |
+
assert [c.name for c in pool] == ["Pricing", "Churn"]
|
| 729 |
+
assert pool[0].summary == "How to price." and len(pool[0].sources) == 3 and pool[0].merged
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def test_a_posts_own_tags_join_the_merged_topics(owner, db_session):
|
| 733 |
+
"""The merge folds a post's specific tags into a few broad topics; the specific ones still count."""
|
| 734 |
+
_, ws = owner
|
| 735 |
+
doc = tagged_doc(db_session, ws, "a", ["Content Repurposing", "Video Marketing", "Finance Writing"])
|
| 736 |
+
merge(db_session, ws, "Finance Substacks", "Growing a finance newsletter.", (doc, 0.9))
|
| 737 |
+
merge(db_session, ws, "Finance Writing", "", (doc, 0.4))
|
| 738 |
+
pool = video_focus.build_pool(db_session, ws.id, {str(doc.id): {doc.id: 1.0}}, [doc])
|
| 739 |
+
assert sorted((c.name, c.merged) for c in pool) == [
|
| 740 |
+
("Content Repurposing", False), ("Finance Substacks", True), ("Finance Writing", True), # once, merged
|
| 741 |
+
("Video Marketing", False)]
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def test_existing_topics_are_shown_as_they_are_without_the_ai(client, owner, db_session, angles):
|
| 745 |
+
headers, ws = owner
|
| 746 |
+
doc = tagged_doc(db_session, ws, "a", ["Chinese LLMs", "Token Pricing", "Software Margins"],
|
| 747 |
+
weights=[0.9, 0.7, 0.5])
|
| 748 |
+
merge(db_session, ws, "AI & China LLM Competition", "Why Chinese LLMs are cheaper.", (doc, 0.9))
|
| 749 |
+
cards = focus(client, headers, document_id=str(doc.id))
|
| 750 |
+
# Merged topics first (with their summary), then the post's own tags (a name only), best first.
|
| 751 |
+
assert cards == [{"title": "AI & China LLM Competition", "description": "Why Chinese LLMs are cheaper."},
|
| 752 |
+
{"title": "Chinese LLMs", "description": ""},
|
| 753 |
+
{"title": "Token Pricing", "description": ""}]
|
| 754 |
+
assert not angles["focus"] and not angles["tagged"] # no AI at all
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def test_a_fresh_tag_that_names_a_merged_topic_is_shown_with_its_summary(client, owner, db_session, angles):
|
| 758 |
+
headers, ws = owner
|
| 759 |
+
merge(db_session, ws, "Annual Raises", "Once a year, with notice.")
|
| 760 |
+
doc = tagged_doc(db_session, ws, "a", ["annual raises"])
|
| 761 |
+
assert focus(client, headers, document_id=str(doc.id)) == [
|
| 762 |
+
{"title": "Annual Raises", "description": "Once a year, with notice."}] # one topic: one card, no AI
|
| 763 |
+
assert not angles["focus"]
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
def test_several_posts_show_their_shared_topics_first_spread_over_the_posts(client, owner, db_session, angles):
|
| 767 |
+
headers, ws = owner
|
| 768 |
+
a, b, c = (tagged_doc(db_session, ws, s, [f"{s} own"], title=f"Post {s}") for s in "abc")
|
| 769 |
+
merge(db_session, ws, "Pricing", "Shared by all.", (a, 0.5), (b, 0.5), (c, 0.5))
|
| 770 |
+
merge(db_session, ws, "A Big", "Post a's main topic.", (a, 1.0))
|
| 771 |
+
merge(db_session, ws, "A Small", "Post a again.", (a, 0.9))
|
| 772 |
+
merge(db_session, ws, "B Side", "Post b's.", (b, 0.8))
|
| 773 |
+
cards = focus(client, headers, document_ids=[str(d.id) for d in (a, b, c)])
|
| 774 |
+
# Pricing (in all three) first; then post a's topics are already covered, so b's comes before a's second one.
|
| 775 |
+
assert [x["title"] for x in cards] == ["Pricing", "A Big", "B Side"]
|
| 776 |
+
assert not angles["focus"]
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def test_untagged_posts_are_tagged_on_demand_then_shown(client, owner, db_session, angles):
|
| 780 |
+
headers, ws = owner
|
| 781 |
+
docs = [add_doc(db_session, ws, f"https://ada.example.com/p/{i}", "Some text. " * 20) for i in range(7)]
|
| 782 |
+
for i, d in enumerate(docs):
|
| 783 |
+
d.title, d.published_at = f"Post {i}", datetime(2026, 1, 1 + i, tzinfo=UTC)
|
| 784 |
+
db_session.commit()
|
| 785 |
+
angles["tag_answers"] = {f"Post {i}": [f"Theme {i}", "Pricing"] for i in range(7)}
|
| 786 |
+
angles["tag_answers"]["Post 6"] = None # that call failed
|
| 787 |
+
body = {"document_ids": [str(d.id) for d in docs]}
|
| 788 |
+
cards = focus(client, headers, **body)
|
| 789 |
+
assert angles["tagged"] == [["Post 6", "Post 5", "Post 4", "Post 3", "Post 2"]] # the newest five
|
| 790 |
+
assert cards[0] == {"title": "Pricing", "description": ""} and len(cards) == 3 # in 4 posts: first
|
| 791 |
+
assert not angles["focus"]
|
| 792 |
+
db_session.expire_all()
|
| 793 |
+
assert [d.metadata_json.get("topics", [{}])[0].get("name") for d in docs] == [
|
| 794 |
+
None, None, "Theme 2", "Theme 3", "Theme 4", "Theme 5", None]
|
| 795 |
+
jobs = db_session.scalars(select(Job).where(Job.workspace_id == ws.id, Job.kind == "topics")).all()
|
| 796 |
+
assert len(jobs) == 1 and jobs[0].params == {}
|
| 797 |
+
focus(client, headers, **body) # the job is still queued: not again
|
| 798 |
+
assert len(db_session.scalars(select(Job).where(Job.workspace_id == ws.id, Job.kind == "topics")).all()) == 1
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
def test_with_no_topics_to_show_the_ai_writes_them(client, owner, db_session, angles, monkeypatch):
|
| 802 |
+
headers, ws = owner
|
| 803 |
+
monkeypatch.setattr(settings, "llm_api_key", "") # nothing is tagged
|
| 804 |
+
doc = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year. " * 5)
|
| 805 |
+
cards = focus(client, headers, document_id=str(doc.id))
|
| 806 |
+
assert [c["title"] for c in cards] == ["AI angle 0", "AI angle 1", "AI angle 2"] and not angles["tagged"]
|
| 807 |
+
assert angles["focus"][0]["candidate_tags"] == [] and angles["focus"][0]["avoid_titles"] == []
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def cite(db, chat: Chat, *docs: Document) -> None:
|
| 811 |
+
answer = db.scalars(select(Message).where(Message.chat_id == chat.id, Message.role == "assistant")).first()
|
| 812 |
+
db.add_all([Citation(message_id=answer.id, document_id=d.id, marker=i + 1, path="sources/x/p.md", line_start=1,
|
| 813 |
+
line_end=2) for i, d in enumerate(docs)])
|
| 814 |
+
db.commit()
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def test_a_chat_shows_the_topics_of_the_posts_it_cites(client, owner, db_session, angles):
|
| 818 |
+
headers, ws = owner
|
| 819 |
+
a = tagged_doc(db_session, ws, "a", ["Annual Raises"])
|
| 820 |
+
b = tagged_doc(db_session, ws, "b", ["Grandfathering"])
|
| 821 |
+
tagged_doc(db_session, ws, "c", ["Never Cited"])
|
| 822 |
+
merge(db_session, ws, "Pricing", "How to price.", (a, 0.9), (b, 0.9))
|
| 823 |
+
chat = add_chat(db_session, ws, [("user", "How should I price?"),
|
| 824 |
+
("assistant", "Once a year [1], keep early [2].")])
|
| 825 |
+
cite(db_session, chat, a, b)
|
| 826 |
+
cards = focus(client, headers, chat_ids=[str(chat.id)])
|
| 827 |
+
assert cards[0] == {"title": "Pricing", "description": "How to price."}
|
| 828 |
+
assert {c["title"] for c in cards[1:]} == {"Annual Raises", "Grandfathering"} and not angles["focus"]
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
def test_a_chats_cited_posts_get_tagged_and_locked_ones_are_skipped(client, owner, db_session, angles):
|
| 832 |
+
headers, ws = owner
|
| 833 |
+
fresh = add_doc(db_session, ws, "https://ada.example.com/p/fresh", "New post. " * 20)
|
| 834 |
+
fresh.title = "Fresh"
|
| 835 |
+
locked = add_doc(db_session, ws, "https://ada.example.com/p/locked")
|
| 836 |
+
locked.title, locked.path, locked.metadata_json = "Locked", None, {"locked": True}
|
| 837 |
+
db_session.commit()
|
| 838 |
+
angles["tag_answers"] = {"Fresh": ["Pricing", "Churn"]}
|
| 839 |
+
chat = add_chat(db_session, ws, [("user", "Q?"), ("assistant", "An answer [1] [2].")])
|
| 840 |
+
cite(db_session, chat, fresh, locked)
|
| 841 |
+
cards = focus(client, headers, chat_ids=[str(chat.id)])
|
| 842 |
+
assert angles["tagged"] == [["Fresh"]] and {c["title"] for c in cards} == {"Pricing", "Churn"}
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def test_a_chat_without_citations_gets_ai_topics(client, owner, db_session, angles):
|
| 846 |
+
headers, ws = owner
|
| 847 |
+
chat = add_chat(db_session, ws, [("user", "Hi there"), ("assistant", "Hello! Ask me about your posts.")])
|
| 848 |
+
cards = focus(client, headers, chat_ids=[str(chat.id)])
|
| 849 |
+
assert len(cards) == 3 and angles["focus"] and not angles["tagged"]
|
| 850 |
+
assert angles["focus"][0]["items"] == ["Pricing chat\nHi there\nHello! Ask me about your posts."]
|
| 851 |
+
|
| 852 |
+
|
| 853 |
@pytest.mark.parametrize("style,length,words", [("custom:20", "short", "overview of that topic"),
|
| 854 |
("custom:21", "medium", "deep dive into that topic")])
|
| 855 |
def test_focus_goes_ahead_of_the_material_worded_for_the_style(client, b2v, owner, house, db_session,
|
|
|
|
| 870 |
assert '"Annual raises"' in head and "What to cover" not in head
|
| 871 |
|
| 872 |
|
| 873 |
+
@pytest.mark.parametrize("style,depth", [("custom:20", "Make it a brief overview."),
|
| 874 |
+
("custom:21", "Make it a deep dive.")])
|
| 875 |
+
def test_a_custom_topic_goes_ahead_of_the_material(client, b2v, owner, house, db_session, style, depth):
|
| 876 |
+
headers, ws = owner
|
| 877 |
+
doc = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year, and say why. " * 3)
|
| 878 |
+
make_video(client, headers, url=None, document_id=str(doc.id), video_style=style,
|
| 879 |
+
focus_prompt=' Why the "yearly" raise\nbeats small ones ')
|
| 880 |
+
sent = creates(b2v)[-1]["json"]
|
| 881 |
+
assert "url" not in sent # the instruction needs the text, not the link
|
| 882 |
+
head, material = sent["content"].split("\n\n---\n\n", 1)
|
| 883 |
+
assert head.startswith("The user wants this video to focus on: \"Why the 'yearly' raise beats small ones\".")
|
| 884 |
+
assert "only as far as the material below supports it" in head and "make a general video" in head
|
| 885 |
+
assert head.endswith(depth) and material.startswith("Raise prices")
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def test_a_blank_custom_topic_is_no_focus(client, b2v, owner, db_session):
|
| 889 |
+
headers, ws = owner
|
| 890 |
+
doc = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year, and say why. " * 3)
|
| 891 |
+
make_video(client, headers, url=None, document_id=str(doc.id), focus_prompt=" ")
|
| 892 |
+
assert creates(b2v)[-1]["json"]["url"] == "https://ada.example.com/p/a" # the link, as with no focus
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
def test_a_suggested_and_a_custom_topic_together_are_refused(client, b2v, owner, db_session):
|
| 896 |
+
headers, ws = owner
|
| 897 |
+
doc = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year, and say why. " * 3)
|
| 898 |
+
r = client.post("/api/videos", json={"document_id": str(doc.id), "focus": "Annual raises",
|
| 899 |
+
"focus_prompt": "My own angle"}, headers=headers)
|
| 900 |
+
assert r.status_code == 422 and "not both" in r.text and not creates(b2v)
|
| 901 |
+
|
| 902 |
+
|
| 903 |
def test_chat_with_a_focus_keeps_the_chat_context_first(client, b2v, owner, house, db_session):
|
| 904 |
from app.routers.videos import CHAT_CONTEXT
|
| 905 |
headers, ws = owner
|
|
|
|
| 1151 |
assert not [p for p in b2v.paths() if "/voices/saved" in p]
|
| 1152 |
|
| 1153 |
|
| 1154 |
+
def notestack_voice(db, ws, voice_id="nv_narrator", name="Narrator") -> None:
|
| 1155 |
+
"""A voice the workspace designed on the Voice page (same ElevenLabs account as blog2video)."""
|
| 1156 |
+
db.add(VoiceProfile(workspace_id=ws.id, host_voices={"custom": [{"voice_id": voice_id, "name": name}]}))
|
| 1157 |
+
db.commit()
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
def test_my_voices_are_capped_at_five(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(4):
|
| 1164 |
+
db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
|
| 1165 |
+
db_session.commit()
|
| 1166 |
+
r = client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers)
|
| 1167 |
+
assert r.status_code == 409 and "5 voices" in r.json()["detail"]
|
| 1168 |
+
assert client.get("/api/video-voices", headers=headers).json()["max_saved"] == 5
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session):
|
| 1172 |
+
headers, ws = owner
|
| 1173 |
+
notestack_voice(db_session, ws)
|
| 1174 |
+
got = client.get("/api/video-voices", headers=headers).json()
|
| 1175 |
+
assert got["notestack"] == [{"voice_id": "nv_narrator", "name": "Narrator", "kind": "designed", "saved": False}]
|
| 1176 |
+
r = client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"}, headers=headers)
|
| 1177 |
+
assert r.status_code == 201 and r.json()["source"] == "notestack" and r.json()["premium"]
|
| 1178 |
+
saved = client.get("/api/video-voices", headers=headers).json()["saved"]
|
| 1179 |
+
assert [v["source"] for v in saved if v["voice_id"] == "nv_narrator"] == ["notestack"]
|
| 1180 |
+
make_video(client, headers, custom_voice_id="nv_narrator")
|
| 1181 |
+
assert creates(b2v)[-1]["json"]["custom_voice_id"] == "nv_narrator"
|
| 1182 |
+
# Another workspace cannot use it.
|
| 1183 |
+
r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"},
|
| 1184 |
+
headers=other[0])
|
| 1185 |
+
assert r.status_code == 404
|
| 1186 |
+
|
| 1187 |
+
|
| 1188 |
+
def test_notestack_voices_need_premium_in_videos(client, b2v, owner, db_session, free_plan):
|
| 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 == 402
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
|
| 1196 |
+
headers, ws = owner
|
| 1197 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "") # no call out to delete the voice
|
| 1198 |
+
user_id = db_session.scalar(select(User.id))
|
| 1199 |
+
db_session.add(VoiceConsent(workspace_id=ws.id, user_id=user_id, sample_key="ws/x/sample.mp3",
|
| 1200 |
+
consent_text="ok", elevenlabs_voice_id="nv_clone"))
|
| 1201 |
+
db_session.commit()
|
| 1202 |
+
assert client.post("/api/video-voices/saved", json={"voice_id": "nv_clone"}, headers=headers).status_code == 201
|
| 1203 |
+
client.delete("/api/voice/consent", headers=headers)
|
| 1204 |
+
assert "nv_clone" not in [v["voice_id"] for v in client.get("/api/video-voices", headers=headers).json()["saved"]]
|
| 1205 |
+
|
| 1206 |
+
|
| 1207 |
def test_design_keep_and_delete_a_custom_voice(client, b2v, owner, other, db_session):
|
| 1208 |
headers, ws = owner
|
| 1209 |
previews = client.post("/api/video-voices/design/prompt", json={"prompt": "A calm, warm narrator voice please"},
|
frontend/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { 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,7 +20,6 @@ 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 VideoVoices from "./pages/VideoVoices";
|
| 24 |
import Videos from "./pages/Videos";
|
| 25 |
import TopicMap from "./pages/TopicMap";
|
| 26 |
import { ToolPage, ToolsHub } from "./pages/Tools";
|
|
@@ -61,7 +60,7 @@ export default function App() {
|
|
| 61 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 62 |
<Route path="videos" element={<Videos />} />
|
| 63 |
<Route path="videos/new" element={<VideoCreate />} />
|
| 64 |
-
<Route path="videos/voices" element={<
|
| 65 |
<Route path="videos/:id" element={<VideoEditor />} />
|
| 66 |
<Route path="sources" element={<Sources />} />
|
| 67 |
<Route path="map" element={<TopicMap />} />
|
|
|
|
| 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 |
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";
|
|
|
|
| 60 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 61 |
<Route path="videos" element={<Videos />} />
|
| 62 |
<Route path="videos/new" element={<VideoCreate />} />
|
| 63 |
+
<Route path="videos/voices" element={<Navigate to="/app/voice?step=hosts#video-voices" replace />} />
|
| 64 |
<Route path="videos/:id" element={<VideoEditor />} />
|
| 65 |
<Route path="sources" element={<Sources />} />
|
| 66 |
<Route path="map" element={<TopicMap />} />
|
frontend/src/api/types.ts
CHANGED
|
@@ -436,6 +436,8 @@ export type VideoSavedVoice = {
|
|
| 436 |
accent: string | null;
|
| 437 |
premium: boolean;
|
| 438 |
is_custom: boolean;
|
|
|
|
|
|
|
| 439 |
};
|
| 440 |
|
| 441 |
export type VideoLibraryVoice = {
|
|
@@ -459,7 +461,17 @@ export type VideoCustomVoice = {
|
|
| 459 |
saved: boolean;
|
| 460 |
};
|
| 461 |
|
| 462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
export type VideoDesignedVoice = {
|
| 465 |
generated_voice_id: string;
|
|
@@ -507,6 +519,8 @@ export type VideoCreateBody = VideoOptions & {
|
|
| 507 |
focus?: string;
|
| 508 |
/** The picked topic's description, sent so blog2video knows what to cover. */
|
| 509 |
focus_detail?: string;
|
|
|
|
|
|
|
| 510 |
document_id?: string;
|
| 511 |
/** A notebook's ticked posts, combined into one video. */
|
| 512 |
document_ids?: string[];
|
|
|
|
| 436 |
accent: string | null;
|
| 437 |
premium: boolean;
|
| 438 |
is_custom: boolean;
|
| 439 |
+
/** notestack: made or added on the Voice page (played through the Voice page's preview, not a link). */
|
| 440 |
+
source?: "notestack" | "blog2video";
|
| 441 |
};
|
| 442 |
|
| 443 |
export type VideoLibraryVoice = {
|
|
|
|
| 461 |
saved: boolean;
|
| 462 |
};
|
| 463 |
|
| 464 |
+
/** A voice the workspace made or added on the Voice page (its clone, a designed voice, a library voice). */
|
| 465 |
+
export type VideoNotestackVoice = { voice_id: string; name: string; kind: "clone" | "designed" | "library"; saved: boolean };
|
| 466 |
+
|
| 467 |
+
export type VideoVoicesResponse = {
|
| 468 |
+
saved: VideoSavedVoice[];
|
| 469 |
+
library: VideoLibraryVoice[];
|
| 470 |
+
custom: VideoCustomVoice[];
|
| 471 |
+
notestack: VideoNotestackVoice[];
|
| 472 |
+
/** How many voices "My voices" can hold. */
|
| 473 |
+
max_saved: number;
|
| 474 |
+
};
|
| 475 |
|
| 476 |
export type VideoDesignedVoice = {
|
| 477 |
generated_voice_id: string;
|
|
|
|
| 519 |
focus?: string;
|
| 520 |
/** The picked topic's description, sent so blog2video knows what to cover. */
|
| 521 |
focus_detail?: string;
|
| 522 |
+
/** Or the user's own focus topic, followed as far as the posts cover it (never with `focus`). */
|
| 523 |
+
focus_prompt?: string;
|
| 524 |
document_id?: string;
|
| 525 |
/** A notebook's ticked posts, combined into one video. */
|
| 526 |
document_ids?: string[];
|
frontend/src/components/video/SourcePicker.tsx
CHANGED
|
@@ -1,17 +1,16 @@
|
|
| 1 |
-
import { useEffect,
|
| 2 |
import { Link } from "react-router-dom";
|
| 3 |
-
import type { Doc, NotebookSummary
|
| 4 |
import { DocPicker } from "../DocPicker";
|
| 5 |
import { Dropdown } from "../Dropdown";
|
| 6 |
-
import {
|
| 7 |
-
import { freshlySeeded,
|
| 8 |
|
| 9 |
-
/** What a video is made from: a notebook's ticked posts
|
| 10 |
-
*
|
| 11 |
-
export type VideoSource = { kind: "notebook"; id: string } | { kind: "post"; id: string }
|
| 12 |
|
| 13 |
const ONE_POST = "__post";
|
| 14 |
-
const CHATS = "__chats";
|
| 15 |
/** Most posts one video can combine. */
|
| 16 |
export const MAX_VIDEO_POSTS = 5;
|
| 17 |
|
|
@@ -21,18 +20,13 @@ export function SourcePicker({
|
|
| 21 |
onSource,
|
| 22 |
postIds,
|
| 23 |
onPostIds,
|
| 24 |
-
chatIds,
|
| 25 |
-
onChatIds,
|
| 26 |
}: {
|
| 27 |
source: VideoSource | null;
|
| 28 |
onSource: (s: VideoSource | null) => void;
|
| 29 |
postIds: string[];
|
| 30 |
onPostIds: (ids: string[]) => void;
|
| 31 |
-
chatIds: string[];
|
| 32 |
-
onChatIds: (ids: string[]) => void;
|
| 33 |
}) {
|
| 34 |
const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(sourceCache.notebooks);
|
| 35 |
-
const [chats, setChats] = useState<WorkspaceChat[] | null>(sourceCache.chats);
|
| 36 |
const [docs, setDocs] = useState<Doc[] | null>(null);
|
| 37 |
const ticks = useRef(postIds);
|
| 38 |
ticks.current = postIds;
|
|
@@ -42,12 +36,10 @@ export function SourcePicker({
|
|
| 42 |
|
| 43 |
useEffect(() => {
|
| 44 |
loadNotebooks().then(setNotebooks).catch(() => setNotebooks((n) => n ?? []));
|
| 45 |
-
loadChats().then(setChats).catch(() => setChats((c) => c ?? []));
|
| 46 |
}, []);
|
| 47 |
|
| 48 |
const notebookId = source?.kind === "notebook" ? source.id : null;
|
| 49 |
const postId = source?.kind === "post" ? source.id : null;
|
| 50 |
-
const onChats = source?.kind === "chats";
|
| 51 |
|
| 52 |
useEffect(() => {
|
| 53 |
if (!notebookId) return;
|
|
@@ -84,15 +76,11 @@ export function SourcePicker({
|
|
| 84 |
<span className="vw-label">Made from</span>
|
| 85 |
<Dropdown
|
| 86 |
label="Made from"
|
| 87 |
-
value={choosing || postId ? ONE_POST :
|
| 88 |
onChange={(v) => {
|
| 89 |
if (v === ONE_POST) {
|
| 90 |
setChoosing(true);
|
| 91 |
onSource(null);
|
| 92 |
-
} else if (v === CHATS) {
|
| 93 |
-
setChoosing(false);
|
| 94 |
-
onChatIds([]); // none ticked to start with
|
| 95 |
-
onSource({ kind: "chats" });
|
| 96 |
} else {
|
| 97 |
setChoosing(false);
|
| 98 |
onSource({ kind: "notebook", id: v });
|
|
@@ -102,32 +90,12 @@ export function SourcePicker({
|
|
| 102 |
// The API lists the "All posts" notebook first, so the sections come out in order.
|
| 103 |
...(notebooks ?? []).map((n) => ({ value: n.id, label: n.is_archive ? "My whole archive" : n.title,
|
| 104 |
hint: `${n.document_count} posts`, group: n.is_archive ? "Archive" : "Notebooks" })),
|
| 105 |
-
...(chats?.length ? [{ value: CHATS, label: "Chats", hint: `${chats.length} chat${chats.length === 1 ? "" : "s"}`,
|
| 106 |
-
group: "Chats" }] : []),
|
| 107 |
{ value: ONE_POST, label: "Create a video on a single post", group: "Single post" },
|
| 108 |
]}
|
| 109 |
/>
|
| 110 |
-
<small className="muted vw-hint">
|
| 111 |
-
{onChats
|
| 112 |
-
? "The video explains what the ticked chats are about."
|
| 113 |
-
: "Choose a notebook, individual posts, a chat or your whole archive."}
|
| 114 |
-
</small>
|
| 115 |
</div>
|
| 116 |
|
| 117 |
-
{onChats && (
|
| 118 |
-
<div className="field">
|
| 119 |
-
<span className="vw-label">Chats</span>
|
| 120 |
-
<ChatPicker chats={chats} selected={chatIds} onChange={onChatIds} />
|
| 121 |
-
{chats && chats.length > 0 && (
|
| 122 |
-
<small className="muted vw-hint">
|
| 123 |
-
{chatIds.length === 0
|
| 124 |
-
? "Tick at least one chat."
|
| 125 |
-
: `You can select up to ${Math.min(MAX_VIDEO_POSTS, chats.length)} (${chatIds.length} selected). They are combined into one video.`}
|
| 126 |
-
</small>
|
| 127 |
-
)}
|
| 128 |
-
</div>
|
| 129 |
-
)}
|
| 130 |
-
|
| 131 |
{(choosing || postId) && (
|
| 132 |
<DocPicker selected={postId ? [postId] : []} onChange={(ids) => onSource({ kind: "post", id: ids[0] })} single />
|
| 133 |
)}
|
|
@@ -165,62 +133,3 @@ export function SourcePicker({
|
|
| 165 |
</>
|
| 166 |
);
|
| 167 |
}
|
| 168 |
-
|
| 169 |
-
/** Searchable checklist of the workspace's chats (title, notebook, date), up to MAX_VIDEO_POSTS ticked. The posts
|
| 170 |
-
* list's look (DocPicker), so both sources read the same. */
|
| 171 |
-
function ChatPicker({ chats, selected, onChange }: {
|
| 172 |
-
chats: WorkspaceChat[] | null;
|
| 173 |
-
selected: string[];
|
| 174 |
-
onChange: (ids: string[]) => void;
|
| 175 |
-
}) {
|
| 176 |
-
const [q, setQ] = useState("");
|
| 177 |
-
const visible = useMemo(() => {
|
| 178 |
-
const needle = q.trim().toLowerCase();
|
| 179 |
-
return (chats ?? []).filter((c) => !needle || (c.title ?? "").toLowerCase().includes(needle)
|
| 180 |
-
|| c.notebook_title.toLowerCase().includes(needle));
|
| 181 |
-
}, [chats, q]);
|
| 182 |
-
|
| 183 |
-
if (!chats) {
|
| 184 |
-
return (
|
| 185 |
-
<div className="picker">
|
| 186 |
-
<div className="picker-bar">
|
| 187 |
-
<input className="input input-sm" placeholder="Search chats" disabled aria-label="Search chats" />
|
| 188 |
-
</div>
|
| 189 |
-
<div className="picker-list vw-posts-wait"><Loading label="Loading chats" /></div>
|
| 190 |
-
</div>
|
| 191 |
-
);
|
| 192 |
-
}
|
| 193 |
-
if (chats.length === 0) return <p className="muted">No chats yet. Ask something in a notebook first.</p>;
|
| 194 |
-
|
| 195 |
-
const full = selected.length >= MAX_VIDEO_POSTS;
|
| 196 |
-
const toggle = (id: string) => {
|
| 197 |
-
if (selected.includes(id)) return onChange(selected.filter((x) => x !== id));
|
| 198 |
-
if (!full) onChange([...selected, id]);
|
| 199 |
-
};
|
| 200 |
-
|
| 201 |
-
return (
|
| 202 |
-
<div className="picker">
|
| 203 |
-
<div className="picker-bar">
|
| 204 |
-
<input className="input input-sm" placeholder="Search chats" value={q} onChange={(e) => setQ(e.target.value)}
|
| 205 |
-
aria-label="Search chats" />
|
| 206 |
-
</div>
|
| 207 |
-
<ul className="picker-list">
|
| 208 |
-
{visible.map((c) => {
|
| 209 |
-
const on = selected.includes(c.id);
|
| 210 |
-
return (
|
| 211 |
-
<li key={c.id}>
|
| 212 |
-
<label className={`picker-row${on ? " on" : ""}`}>
|
| 213 |
-
<input type="checkbox" checked={on} disabled={!on && full} onChange={() => toggle(c.id)} />
|
| 214 |
-
<span className="picker-title">{c.title || "Untitled chat"}</span>
|
| 215 |
-
<span className="mono muted picker-meta">
|
| 216 |
-
{c.notebook_title}{c.updated_at ? ` · ${formatDate(c.updated_at)}` : ""}
|
| 217 |
-
</span>
|
| 218 |
-
</label>
|
| 219 |
-
</li>
|
| 220 |
-
);
|
| 221 |
-
})}
|
| 222 |
-
{visible.length === 0 && <li className="muted">No chats match.</li>}
|
| 223 |
-
</ul>
|
| 224 |
-
</div>
|
| 225 |
-
);
|
| 226 |
-
}
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from "react";
|
| 2 |
import { Link } from "react-router-dom";
|
| 3 |
+
import type { Doc, NotebookSummary } from "../../api/types";
|
| 4 |
import { DocPicker } from "../DocPicker";
|
| 5 |
import { Dropdown } from "../Dropdown";
|
| 6 |
+
import { Loading } from "../ui";
|
| 7 |
+
import { freshlySeeded, loadDocs, loadNotebooks, sourceCache } from "./sourceCache";
|
| 8 |
|
| 9 |
+
/** What a video is made from: a notebook's (or the whole archive's) ticked posts, combined into one video, or one
|
| 10 |
+
* post. */
|
| 11 |
+
export type VideoSource = { kind: "notebook"; id: string } | { kind: "post"; id: string };
|
| 12 |
|
| 13 |
const ONE_POST = "__post";
|
|
|
|
| 14 |
/** Most posts one video can combine. */
|
| 15 |
export const MAX_VIDEO_POSTS = 5;
|
| 16 |
|
|
|
|
| 20 |
onSource,
|
| 21 |
postIds,
|
| 22 |
onPostIds,
|
|
|
|
|
|
|
| 23 |
}: {
|
| 24 |
source: VideoSource | null;
|
| 25 |
onSource: (s: VideoSource | null) => void;
|
| 26 |
postIds: string[];
|
| 27 |
onPostIds: (ids: string[]) => void;
|
|
|
|
|
|
|
| 28 |
}) {
|
| 29 |
const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(sourceCache.notebooks);
|
|
|
|
| 30 |
const [docs, setDocs] = useState<Doc[] | null>(null);
|
| 31 |
const ticks = useRef(postIds);
|
| 32 |
ticks.current = postIds;
|
|
|
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
loadNotebooks().then(setNotebooks).catch(() => setNotebooks((n) => n ?? []));
|
|
|
|
| 39 |
}, []);
|
| 40 |
|
| 41 |
const notebookId = source?.kind === "notebook" ? source.id : null;
|
| 42 |
const postId = source?.kind === "post" ? source.id : null;
|
|
|
|
| 43 |
|
| 44 |
useEffect(() => {
|
| 45 |
if (!notebookId) return;
|
|
|
|
| 76 |
<span className="vw-label">Made from</span>
|
| 77 |
<Dropdown
|
| 78 |
label="Made from"
|
| 79 |
+
value={choosing || postId ? ONE_POST : notebookId ?? ONE_POST}
|
| 80 |
onChange={(v) => {
|
| 81 |
if (v === ONE_POST) {
|
| 82 |
setChoosing(true);
|
| 83 |
onSource(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
} else {
|
| 85 |
setChoosing(false);
|
| 86 |
onSource({ kind: "notebook", id: v });
|
|
|
|
| 90 |
// The API lists the "All posts" notebook first, so the sections come out in order.
|
| 91 |
...(notebooks ?? []).map((n) => ({ value: n.id, label: n.is_archive ? "My whole archive" : n.title,
|
| 92 |
hint: `${n.document_count} posts`, group: n.is_archive ? "Archive" : "Notebooks" })),
|
|
|
|
|
|
|
| 93 |
{ value: ONE_POST, label: "Create a video on a single post", group: "Single post" },
|
| 94 |
]}
|
| 95 |
/>
|
| 96 |
+
<small className="muted vw-hint">Choose a notebook, your whole archive or a single post.</small>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
{(choosing || postId) && (
|
| 100 |
<DocPicker selected={postId ? [postId] : []} onChange={(ids) => onSource({ kind: "post", id: ids[0] })} single />
|
| 101 |
)}
|
|
|
|
| 133 |
</>
|
| 134 |
);
|
| 135 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/video/VideoVoicesSection.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
import { videoVoicesApi } from "../../api/endpoints";
|
| 3 |
import type { VideoVoicesResponse } from "../../api/types";
|
| 4 |
import { ConfirmButton, errorMessage } from "../ui";
|
|
@@ -7,17 +7,21 @@ import { Premium, PlayButton, useAudio } from "./parts";
|
|
| 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 }: {
|
| 11 |
voices: VideoVoicesResponse; premium: boolean; onChange: () => void; onLocked: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}) {
|
| 13 |
-
const [gender, setGender] = useState("");
|
| 14 |
-
const [accent, setAccent] = useState("");
|
| 15 |
const [busy, setBusy] = useState<string | null>(null);
|
| 16 |
const [error, setError] = useState<string | null>(null);
|
| 17 |
const { playing, play } = useAudio();
|
| 18 |
|
| 19 |
-
const
|
| 20 |
-
const
|
| 21 |
|
| 22 |
async function act(key: string, fn: () => Promise<unknown>) {
|
| 23 |
setBusy(key);
|
|
@@ -41,11 +45,11 @@ export function VoiceLibrary({ voices, premium, onChange, onLocked }: {
|
|
| 41 |
|
| 42 |
return (
|
| 43 |
<div className="stack">
|
| 44 |
-
{
|
| 45 |
<section className="stack">
|
| 46 |
-
<h3>Your custom voices</h3>
|
| 47 |
<div className="vw-voice-list">
|
| 48 |
-
{
|
| 49 |
<div key={c.id} className="vw-vrow">
|
| 50 |
<PlayButton on={playing === `c${c.id}`} label={c.name} onClick={() => playCustom(c.id, c.preview_url)} />
|
| 51 |
<div className="vw-vpick static">
|
|
@@ -54,10 +58,10 @@ export function VoiceLibrary({ voices, premium, onChange, onLocked }: {
|
|
| 54 |
</div>
|
| 55 |
{c.saved ? (
|
| 56 |
<button className="btn btn-small" disabled={busy === c.voice_id}
|
| 57 |
-
onClick={() => act(c.voice_id, () => videoVoicesApi.unsave(c.voice_id))}>Remove
|
| 58 |
) : (
|
| 59 |
-
<button className="btn btn-small" disabled={busy === c.voice_id}
|
| 60 |
-
onClick={() => act(c.voice_id, () => videoVoicesApi.save(c.voice_id))}>Add
|
| 61 |
)}
|
| 62 |
<ConfirmButton onConfirm={() => act(`d${c.id}`, () => videoVoicesApi.removeCustom(c.id))}>Delete</ConfirmButton>
|
| 63 |
</div>
|
|
@@ -66,21 +70,9 @@ export function VoiceLibrary({ voices, premium, onChange, onLocked }: {
|
|
| 66 |
</section>
|
| 67 |
)}
|
| 68 |
|
|
|
|
|
|
|
| 69 |
<section className="stack">
|
| 70 |
-
<div className="row between wrap">
|
| 71 |
-
<h3>Voice library</h3>
|
| 72 |
-
<div className="row">
|
| 73 |
-
<select className="input" value={gender} onChange={(e) => setGender(e.target.value)} aria-label="Gender">
|
| 74 |
-
<option value="">Any gender</option>
|
| 75 |
-
<option value="female">Female</option>
|
| 76 |
-
<option value="male">Male</option>
|
| 77 |
-
</select>
|
| 78 |
-
<select className="input" value={accent} onChange={(e) => setAccent(e.target.value)} aria-label="Accent">
|
| 79 |
-
<option value="">Any accent</option>
|
| 80 |
-
{accents.map((a) => <option key={a} value={a}>{cap(a)}</option>)}
|
| 81 |
-
</select>
|
| 82 |
-
</div>
|
| 83 |
-
</div>
|
| 84 |
{error && <p className="error-text">{error}</p>}
|
| 85 |
<div className="vw-voice-list">
|
| 86 |
{shown.map((v) => (
|
|
@@ -97,18 +89,19 @@ export function VoiceLibrary({ voices, premium, onChange, onLocked }: {
|
|
| 97 |
{v.premium && <Premium small />}
|
| 98 |
{v.saved ? (
|
| 99 |
<button className="btn btn-small" disabled={busy === v.voice_id}
|
| 100 |
-
onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>
|
| 101 |
) : (
|
| 102 |
-
<button className="btn btn-small btn-primary" disabled={busy === v.voice_id}
|
| 103 |
onClick={() => (v.premium && !premium ? onLocked() : act(v.voice_id, () => videoVoicesApi.save(v.voice_id)))}>
|
| 104 |
-
|
| 105 |
</button>
|
| 106 |
)}
|
| 107 |
</div>
|
| 108 |
))}
|
| 109 |
-
{shown.length === 0 && <p className="muted">No voices
|
| 110 |
</div>
|
| 111 |
</section>
|
|
|
|
| 112 |
</div>
|
| 113 |
);
|
| 114 |
}
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
import { videoVoicesApi } from "../../api/endpoints";
|
| 3 |
import type { VideoVoicesResponse } from "../../api/types";
|
| 4 |
import { ConfirmButton, errorMessage } from "../ui";
|
|
|
|
| 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);
|
|
|
|
| 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">
|
|
|
|
| 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>
|
|
|
|
| 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) => (
|
|
|
|
| 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/video/sourceCache.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
import { notebooksApi } from "../../api/endpoints";
|
| 2 |
-
import type { Doc, NotebookSummary
|
| 3 |
|
| 4 |
/** The video wizard's step 1 lists, kept for the browser session (until a full reload) so re-opening the wizard
|
| 5 |
* shows them at once. Readers show what is cached, then refresh in the background through the loaders below. */
|
| 6 |
export const sourceCache = {
|
| 7 |
notebooks: null as NotebookSummary[] | null,
|
| 8 |
-
chats: null as WorkspaceChat[] | null,
|
| 9 |
archiveId: null as string | null,
|
| 10 |
docs: new Map<string, Doc[]>(),
|
| 11 |
};
|
|
@@ -13,7 +12,6 @@ export const sourceCache = {
|
|
| 13 |
/** Forget everything, so one account never sees another's posts (called on log in and log out). */
|
| 14 |
export function clearSourceCache(): void {
|
| 15 |
sourceCache.notebooks = null;
|
| 16 |
-
sourceCache.chats = null;
|
| 17 |
sourceCache.archiveId = null;
|
| 18 |
sourceCache.docs.clear();
|
| 19 |
seededAt.clear();
|
|
@@ -25,12 +23,6 @@ export async function loadNotebooks(): Promise<NotebookSummary[]> {
|
|
| 25 |
return n;
|
| 26 |
}
|
| 27 |
|
| 28 |
-
export async function loadChats(): Promise<WorkspaceChat[]> {
|
| 29 |
-
const c = await notebooksApi.allChats();
|
| 30 |
-
sourceCache.chats = c;
|
| 31 |
-
return c;
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
export async function loadArchiveId(): Promise<string> {
|
| 35 |
const nb = await notebooksApi.archive();
|
| 36 |
sourceCache.archiveId = nb.id;
|
|
|
|
| 1 |
import { notebooksApi } from "../../api/endpoints";
|
| 2 |
+
import type { Doc, NotebookSummary } from "../../api/types";
|
| 3 |
|
| 4 |
/** The video wizard's step 1 lists, kept for the browser session (until a full reload) so re-opening the wizard
|
| 5 |
* shows them at once. Readers show what is cached, then refresh in the background through the loaders below. */
|
| 6 |
export const sourceCache = {
|
| 7 |
notebooks: null as NotebookSummary[] | null,
|
|
|
|
| 8 |
archiveId: null as string | null,
|
| 9 |
docs: new Map<string, Doc[]>(),
|
| 10 |
};
|
|
|
|
| 12 |
/** Forget everything, so one account never sees another's posts (called on log in and log out). */
|
| 13 |
export function clearSourceCache(): void {
|
| 14 |
sourceCache.notebooks = null;
|
|
|
|
| 15 |
sourceCache.archiveId = null;
|
| 16 |
sourceCache.docs.clear();
|
| 17 |
seededAt.clear();
|
|
|
|
| 23 |
return n;
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export async function loadArchiveId(): Promise<string> {
|
| 27 |
const nb = await notebooksApi.archive();
|
| 28 |
sourceCache.archiveId = nb.id;
|
frontend/src/pages/VideoCreate.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
-
import { videosApi, videoVoicesApi } from "../api/endpoints";
|
| 4 |
import type {
|
| 5 |
VideoCatalog,
|
| 6 |
VideoConfig,
|
|
|
|
| 7 |
VideoFocusSource,
|
| 8 |
VideoFocusTopic,
|
| 9 |
VideoOptions,
|
|
@@ -18,14 +19,9 @@ import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
|
|
| 18 |
import { errorMessage, Loading, PageHeader } from "../components/ui";
|
| 19 |
import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
|
| 20 |
import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
|
| 21 |
-
import {
|
| 22 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 23 |
|
| 24 |
-
const FORMATS = [
|
| 25 |
-
{ id: "landscape", name: "Landscape", label: "Landscape (16:9), for YouTube", Icon: ScreenIcon },
|
| 26 |
-
{ id: "portrait", name: "Portrait", label: "Portrait (9:16), for TikTok and Reels", Icon: PhoneIcon },
|
| 27 |
-
] as const;
|
| 28 |
-
|
| 29 |
const FALLBACK_COLORS = { accent_color: "#7c3aed", bg_color: "#ffffff", text_color: "#000000" };
|
| 30 |
|
| 31 |
/** A template and the colours it was designed with. */
|
|
@@ -80,15 +76,23 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 80 |
: sourceCache.archiveId ? { kind: "notebook", id: sourceCache.archiveId } : null,
|
| 81 |
);
|
| 82 |
const [postIds, setPostIds] = useState<string[]>([]);
|
| 83 |
-
|
| 84 |
-
// Step 2's "focus on": the AI's three suggestions for the step 1 choice (sourceKey), and the one picked (none =
|
| 85 |
// the whole material).
|
| 86 |
const [topics, setTopics] = useState<VideoFocusTopic[] | null>(null);
|
| 87 |
const topicsFor = useRef(""); // the sourceKey of the suggestions shown or on their way
|
| 88 |
const [focus, setFocus] = useState<VideoFocusTopic | null>(null);
|
|
|
|
|
|
|
| 89 |
const [busy, setBusy] = useState(false);
|
| 90 |
const [error, setError] = useState<string | null>(null);
|
| 91 |
-
const { playing, play } = useAudio();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
const [form, setForm] = useState<VideoOptions>({
|
| 94 |
stock_footage_enabled: true,
|
|
@@ -141,7 +145,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 141 |
}).catch((e) => setLoadError(errorMessage(e)));
|
| 142 |
loadVoices()
|
| 143 |
.then((v) => v.saved[0] && pickVoice(v.saved[0]))
|
| 144 |
-
.catch(() => setVoices({ saved: [], library: [], custom: [] }));
|
| 145 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 146 |
}, [config?.configured]);
|
| 147 |
|
|
@@ -181,19 +185,16 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 181 |
}));
|
| 182 |
}
|
| 183 |
|
| 184 |
-
const sourceReady = source?.kind === "post" || (source?.kind === "
|
| 185 |
-
|| (source?.kind === "notebook" && postIds.length > 0);
|
| 186 |
|
| 187 |
/** Step 1's choice as API fields: the same for the focus suggestions and the create. */
|
| 188 |
const sourceFields = (): VideoFocusSource | null => !source ? null
|
| 189 |
: source.kind === "post" ? { document_id: source.id }
|
| 190 |
-
: source.kind === "chats" ? { chat_ids: chatIds }
|
| 191 |
: { document_ids: postIds, notebook_id: source.id };
|
| 192 |
-
// The focus suggestions belong to one
|
| 193 |
-
//
|
| 194 |
-
//
|
| 195 |
const sourceKey = !source ? ""
|
| 196 |
-
: source.kind === "chats" ? `chats:${[...chatIds].sort().join(",")}`
|
| 197 |
: `${source.kind}:${source.id}:${source.kind === "notebook" ? [...postIds].sort().join(",") : ""}`;
|
| 198 |
|
| 199 |
useEffect(() => {
|
|
@@ -217,6 +218,12 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 217 |
// does Go to step 2). null = still loading; [] = none to show.
|
| 218 |
const shownTopics = topicsFor.current === sourceKey ? topics : null;
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
function options(): VideoOptions {
|
| 221 |
const o: VideoOptions = { ...form };
|
| 222 |
if (o.voice_gender === "none") delete o.custom_voice_id;
|
|
@@ -229,7 +236,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 229 |
try {
|
| 230 |
const from = sourceFields();
|
| 231 |
if (!from) return;
|
| 232 |
-
const a = await videosApi.create({ ...options(), ...from, ...(
|
| 233 |
navigate(`/app/videos/${a.id}`);
|
| 234 |
} catch (e) {
|
| 235 |
setError(errorMessage(e)); // 402 also opens the upgrade popup (api/client.ts)
|
|
@@ -281,39 +288,100 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 281 |
|
| 282 |
{/* Stays mounted on steps 2 and 3, so going Back keeps the loaded posts and the ticks */}
|
| 283 |
<div style={{ display: step === 1 ? "contents" : "none" }}>
|
| 284 |
-
<SourcePicker source={source} onSource={setSource} postIds={postIds} onPostIds={setPostIds}
|
| 285 |
-
chatIds={chatIds} onChatIds={setChatIds} />
|
| 286 |
</div>
|
| 287 |
|
| 288 |
{step === 1 && (
|
| 289 |
<>
|
| 290 |
|
| 291 |
-
|
|
|
|
| 292 |
<div className="field">
|
| 293 |
-
<span className="vw-label">
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
</div>
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
</div>
|
| 312 |
-
|
| 313 |
|
| 314 |
{error && <p className="error-text">{error}</p>}
|
| 315 |
<div className="vw-nav">
|
| 316 |
-
|
|
|
|
|
|
|
| 317 |
Go to step 2
|
| 318 |
</button>
|
| 319 |
</div>
|
|
@@ -347,24 +415,6 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 347 |
</div>
|
| 348 |
|
| 349 |
<div className="vw-style-row">
|
| 350 |
-
<div className="field">
|
| 351 |
-
<span className="vw-label">Video style</span>
|
| 352 |
-
{catalog === null ? <Loading label="Loading styles" /> : (
|
| 353 |
-
<div className="vw-chips vw-style-chips">
|
| 354 |
-
{styles.map((s) => (
|
| 355 |
-
<button key={s.id} type="button" className={`vw-chip${form.video_style === s.id ? " on" : ""}`}
|
| 356 |
-
aria-pressed={form.video_style === s.id} title={s.description ?? undefined}
|
| 357 |
-
onClick={() => set({ video_style: s.id })}>
|
| 358 |
-
{s.name}{s.kind === "custom" ? " (yours)" : ""}
|
| 359 |
-
</button>
|
| 360 |
-
))}
|
| 361 |
-
</div>
|
| 362 |
-
)}
|
| 363 |
-
{houseStyles && picked?.description && (
|
| 364 |
-
<small className="muted vw-style-note">{picked.description}</small>
|
| 365 |
-
)}
|
| 366 |
-
</div>
|
| 367 |
-
|
| 368 |
<div className="field">
|
| 369 |
<span className="vw-label">Video colors</span>
|
| 370 |
<div className="vw-colors">
|
|
@@ -378,48 +428,9 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 378 |
</div>
|
| 379 |
</div>
|
| 380 |
|
| 381 |
-
{/* Asked for on step 1, so usually here already. Hidden only when the request failed */}
|
| 382 |
-
{sourceReady && shownTopics?.length !== 0 && (
|
| 383 |
-
<div className="field vw-focus">
|
| 384 |
-
<span className="vw-label">What should the video focus on?</span>
|
| 385 |
-
<div className="vw-focus-grid" aria-busy={shownTopics === null}>
|
| 386 |
-
{shownTopics === null
|
| 387 |
-
? [0, 1, 2].map((i) => (
|
| 388 |
-
<div key={i} className="vw-focus-card wait" aria-hidden="true">
|
| 389 |
-
<SparkleIcon size={18} />
|
| 390 |
-
<span className="vw-focus-text">
|
| 391 |
-
<span className="vw-focus-bar" />
|
| 392 |
-
<span className="vw-focus-bar short" />
|
| 393 |
-
</span>
|
| 394 |
-
</div>
|
| 395 |
-
))
|
| 396 |
-
: shownTopics.map((t) => {
|
| 397 |
-
const on = focus?.title === t.title;
|
| 398 |
-
return (
|
| 399 |
-
<button key={t.title} type="button" className={`vw-focus-card${on ? " on" : ""}`}
|
| 400 |
-
aria-pressed={on} onClick={() => setFocus(on ? null : t)}>
|
| 401 |
-
<SparkleIcon size={18} />
|
| 402 |
-
<span className="vw-focus-text">
|
| 403 |
-
<strong>{t.title}</strong>
|
| 404 |
-
{t.description && <small className="vw-focus-desc">{t.description}</small>}
|
| 405 |
-
</span>
|
| 406 |
-
{on && <CheckIcon size={18} />}
|
| 407 |
-
</button>
|
| 408 |
-
);
|
| 409 |
-
})}
|
| 410 |
-
</div>
|
| 411 |
-
{shownTopics && (
|
| 412 |
-
<small className="muted vw-hint">
|
| 413 |
-
{focus ? "The video is mainly about this topic." : "None picked: the video covers the material as a whole."}
|
| 414 |
-
</small>
|
| 415 |
-
)}
|
| 416 |
-
</div>
|
| 417 |
-
)}
|
| 418 |
-
|
| 419 |
<div className="vw-nav">
|
| 420 |
<button className="btn" onClick={() => setStep(1)}>Back</button>
|
| 421 |
-
|
| 422 |
-
<button className="btn btn-primary vw-next" disabled={shownTopics === null} onClick={() => setStep(3)}>
|
| 423 |
Go to step 3
|
| 424 |
</button>
|
| 425 |
</div>
|
|
@@ -460,19 +471,19 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 460 |
{voices === null ? (
|
| 461 |
<Loading label="Loading voices" />
|
| 462 |
) : saved.length === 0 ? (
|
| 463 |
-
<p className="muted">No voices saved yet. Add some on the <Link to="/app/
|
| 464 |
) : (
|
| 465 |
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
|
| 466 |
{saved.map((v) => {
|
| 467 |
const on = form.custom_voice_id === v.voice_id && form.voice_gender !== "none";
|
| 468 |
return (
|
| 469 |
<div key={v.voice_id} className={`vw-vrow${on ? " on" : ""}`}>
|
| 470 |
-
<PlayButton on={playing === v.voice_id} disabled={
|
| 471 |
-
onClick={() =>
|
| 472 |
<button type="button" role="radio" aria-checked={on} className="vw-vpick"
|
| 473 |
disabled={form.voice_gender === "none"} onClick={() => pickVoice(v)}>
|
| 474 |
<strong>{v.name}</strong>
|
| 475 |
-
<span className="muted">{v.is_custom ? "Your voice" : [cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}</span>
|
| 476 |
</button>
|
| 477 |
{on && <span className="vw-hero-check"><CheckIcon size={14} /></span>}
|
| 478 |
</div>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
+
import { videosApi, videoVoicesApi, voiceApi } from "../api/endpoints";
|
| 4 |
import type {
|
| 5 |
VideoCatalog,
|
| 6 |
VideoConfig,
|
| 7 |
+
VideoCreateBody,
|
| 8 |
VideoFocusSource,
|
| 9 |
VideoFocusTopic,
|
| 10 |
VideoOptions,
|
|
|
|
| 19 |
import { errorMessage, Loading, PageHeader } from "../components/ui";
|
| 20 |
import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
|
| 21 |
import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
|
| 22 |
+
import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
|
| 23 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const FALLBACK_COLORS = { accent_color: "#7c3aed", bg_color: "#ffffff", text_color: "#000000" };
|
| 26 |
|
| 27 |
/** A template and the colours it was designed with. */
|
|
|
|
| 76 |
: sourceCache.archiveId ? { kind: "notebook", id: sourceCache.archiveId } : null,
|
| 77 |
);
|
| 78 |
const [postIds, setPostIds] = useState<string[]>([]);
|
| 79 |
+
// Step 1's "focus on": the AI's three suggestions for the source chosen (sourceKey), and the one picked (none =
|
|
|
|
| 80 |
// the whole material).
|
| 81 |
const [topics, setTopics] = useState<VideoFocusTopic[] | null>(null);
|
| 82 |
const topicsFor = useRef(""); // the sourceKey of the suggestions shown or on their way
|
| 83 |
const [focus, setFocus] = useState<VideoFocusTopic | null>(null);
|
| 84 |
+
// Or the user's own topic: one or the other (or neither, for a general video).
|
| 85 |
+
const [custom, setCustom] = useState("");
|
| 86 |
const [busy, setBusy] = useState(false);
|
| 87 |
const [error, setError] = useState<string | null>(null);
|
| 88 |
+
const { playing, play, stop } = useAudio();
|
| 89 |
+
|
| 90 |
+
/** Notestack voices have no stored sample: the Voice page speaks a line in them. */
|
| 91 |
+
function playVoice(v: VideoSavedVoice) {
|
| 92 |
+
if (v.source !== "notestack") return play(v.voice_id, v.preview_url);
|
| 93 |
+
if (playing === v.voice_id) return stop();
|
| 94 |
+
voiceApi.preview(v.voice_id).then((r) => play(v.voice_id, r.url)).catch((e) => setError(errorMessage(e)));
|
| 95 |
+
}
|
| 96 |
|
| 97 |
const [form, setForm] = useState<VideoOptions>({
|
| 98 |
stock_footage_enabled: true,
|
|
|
|
| 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: [], max_saved: 5 }));
|
| 149 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 150 |
}, [config?.configured]);
|
| 151 |
|
|
|
|
| 185 |
}));
|
| 186 |
}
|
| 187 |
|
| 188 |
+
const sourceReady = source?.kind === "post" || (source?.kind === "notebook" && postIds.length > 0);
|
|
|
|
| 189 |
|
| 190 |
/** Step 1's choice as API fields: the same for the focus suggestions and the create. */
|
| 191 |
const sourceFields = (): VideoFocusSource | null => !source ? null
|
| 192 |
: source.kind === "post" ? { document_id: source.id }
|
|
|
|
| 193 |
: { document_ids: postIds, notebook_id: source.id };
|
| 194 |
+
// The focus suggestions belong to one source choice. They are asked for as soon as the choice is complete and show
|
| 195 |
+
// below it on step 1; a new source or other ticks asks again. The short wait lets a run of ticks settle into one
|
| 196 |
+
// request.
|
| 197 |
const sourceKey = !source ? ""
|
|
|
|
| 198 |
: `${source.kind}:${source.id}:${source.kind === "notebook" ? [...postIds].sort().join(",") : ""}`;
|
| 199 |
|
| 200 |
useEffect(() => {
|
|
|
|
| 218 |
// does Go to step 2). null = still loading; [] = none to show.
|
| 219 |
const shownTopics = topicsFor.current === sourceKey ? topics : null;
|
| 220 |
|
| 221 |
+
/** The focus for the create: a suggested topic, the user's own, or none (a general video). */
|
| 222 |
+
function focusFields(): Pick<VideoCreateBody, "focus" | "focus_detail" | "focus_prompt"> {
|
| 223 |
+
if (focus) return { focus: focus.title, focus_detail: focus.description };
|
| 224 |
+
return custom.trim() ? { focus_prompt: custom.trim() } : {};
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
function options(): VideoOptions {
|
| 228 |
const o: VideoOptions = { ...form };
|
| 229 |
if (o.voice_gender === "none") delete o.custom_voice_id;
|
|
|
|
| 236 |
try {
|
| 237 |
const from = sourceFields();
|
| 238 |
if (!from) return;
|
| 239 |
+
const a = await videosApi.create({ ...options(), ...from, ...focusFields() });
|
| 240 |
navigate(`/app/videos/${a.id}`);
|
| 241 |
} catch (e) {
|
| 242 |
setError(errorMessage(e)); // 402 also opens the upgrade popup (api/client.ts)
|
|
|
|
| 288 |
|
| 289 |
{/* Stays mounted on steps 2 and 3, so going Back keeps the loaded posts and the ticks */}
|
| 290 |
<div style={{ display: step === 1 ? "contents" : "none" }}>
|
| 291 |
+
<SourcePicker source={source} onSource={setSource} postIds={postIds} onPostIds={setPostIds} />
|
|
|
|
| 292 |
</div>
|
| 293 |
|
| 294 |
{step === 1 && (
|
| 295 |
<>
|
| 296 |
|
| 297 |
+
{/* Landscape with stock footage, always: the form's defaults, no picker */}
|
| 298 |
+
<div className="vw-style-row">
|
| 299 |
<div className="field">
|
| 300 |
+
<span className="vw-label">Video style</span>
|
| 301 |
+
{catalog === null ? <Loading label="Loading styles" /> : (
|
| 302 |
+
<div className="vw-chips vw-style-chips">
|
| 303 |
+
{styles.map((s) => (
|
| 304 |
+
<button key={s.id} type="button" className={`vw-chip${form.video_style === s.id ? " on" : ""}`}
|
| 305 |
+
aria-pressed={form.video_style === s.id} title={s.description ?? undefined}
|
| 306 |
+
onClick={() => set({ video_style: s.id })}>
|
| 307 |
+
{s.name}{s.kind === "custom" ? " (yours)" : ""}
|
| 308 |
+
</button>
|
| 309 |
+
))}
|
| 310 |
+
</div>
|
| 311 |
+
)}
|
| 312 |
+
{houseStyles && picked?.description && (
|
| 313 |
+
<small className="muted vw-style-note">{picked.description}</small>
|
| 314 |
+
)}
|
| 315 |
</div>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
{/* The suggestions are asked for as soon as the source is ready (hidden if there are none); the custom
|
| 319 |
+
topic is always there */}
|
| 320 |
+
{sourceReady && (
|
| 321 |
+
<div className="field vw-focus">
|
| 322 |
+
<span className="vw-label">What should the video focus on?</span>
|
| 323 |
+
{shownTopics?.length !== 0 && (
|
| 324 |
+
<div className="vw-focus-grid" aria-busy={shownTopics === null}>
|
| 325 |
+
{shownTopics === null
|
| 326 |
+
? [0, 1, 2].map((i) => (
|
| 327 |
+
<div key={i} className="vw-focus-card wait" aria-hidden="true">
|
| 328 |
+
<SparkleIcon size={16} />
|
| 329 |
+
<span className="vw-focus-text">
|
| 330 |
+
<span className="vw-focus-bar" />
|
| 331 |
+
<span className="vw-focus-bar short" />
|
| 332 |
+
</span>
|
| 333 |
+
</div>
|
| 334 |
+
))
|
| 335 |
+
: shownTopics.map((t) => {
|
| 336 |
+
const on = focus?.title === t.title;
|
| 337 |
+
return (
|
| 338 |
+
<button key={t.title} type="button" className={`vw-focus-card${on ? " on" : ""}`}
|
| 339 |
+
aria-pressed={on} onClick={() => {
|
| 340 |
+
setFocus(on ? null : t);
|
| 341 |
+
setCustom("");
|
| 342 |
+
}}>
|
| 343 |
+
<SparkleIcon size={16} />
|
| 344 |
+
<span className="vw-focus-text">
|
| 345 |
+
<strong>{t.title}</strong>
|
| 346 |
+
{t.description && <small className="vw-focus-desc">{t.description}</small>}
|
| 347 |
+
</span>
|
| 348 |
+
{on && <CheckIcon size={16} />}
|
| 349 |
+
</button>
|
| 350 |
+
);
|
| 351 |
+
})}
|
| 352 |
+
</div>
|
| 353 |
+
)}
|
| 354 |
+
<label className={`vw-focus-custom${custom.trim() ? " on" : ""}`}>
|
| 355 |
+
<span className="vw-focus-custom-head">Custom topic</span>
|
| 356 |
+
<textarea rows={2} maxLength={500} value={custom}
|
| 357 |
+
placeholder="Describe your own, e.g. how rebalancing protects a long-term portfolio"
|
| 358 |
+
onFocus={() => setFocus(null)} // choosing the custom topic unpicks a suggested one
|
| 359 |
+
onChange={(e) => {
|
| 360 |
+
setCustom(e.target.value);
|
| 361 |
+
setFocus(null);
|
| 362 |
+
}} />
|
| 363 |
+
<span className="vw-focus-custom-foot">
|
| 364 |
+
<small className="muted">
|
| 365 |
+
Keep it to what the selected posts cover; anything they don't cover is left out of the video.
|
| 366 |
+
</small>
|
| 367 |
+
<small className="muted mono">{custom.length}/500</small>
|
| 368 |
+
</span>
|
| 369 |
</label>
|
| 370 |
+
{shownTopics && (
|
| 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 |
|
| 380 |
{error && <p className="error-text">{error}</p>}
|
| 381 |
<div className="vw-nav">
|
| 382 |
+
{/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
|
| 383 |
+
<button className="btn btn-primary vw-next" disabled={!sourceReady || shownTopics === null}
|
| 384 |
+
onClick={() => setStep(2)}>
|
| 385 |
Go to step 2
|
| 386 |
</button>
|
| 387 |
</div>
|
|
|
|
| 415 |
</div>
|
| 416 |
|
| 417 |
<div className="vw-style-row">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
<div className="field">
|
| 419 |
<span className="vw-label">Video colors</span>
|
| 420 |
<div className="vw-colors">
|
|
|
|
| 428 |
</div>
|
| 429 |
</div>
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
<div className="vw-nav">
|
| 432 |
<button className="btn" onClick={() => setStep(1)}>Back</button>
|
| 433 |
+
<button className="btn btn-primary vw-next" onClick={() => setStep(3)}>
|
|
|
|
| 434 |
Go to step 3
|
| 435 |
</button>
|
| 436 |
</div>
|
|
|
|
| 471 |
{voices === null ? (
|
| 472 |
<Loading label="Loading voices" />
|
| 473 |
) : saved.length === 0 ? (
|
| 474 |
+
<p className="muted">No voices saved yet. Add some on the <Link to="/app/voice?step=hosts#video-voices">Voice</Link> page.</p>
|
| 475 |
) : (
|
| 476 |
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
|
| 477 |
{saved.map((v) => {
|
| 478 |
const on = form.custom_voice_id === v.voice_id && form.voice_gender !== "none";
|
| 479 |
return (
|
| 480 |
<div key={v.voice_id} className={`vw-vrow${on ? " on" : ""}`}>
|
| 481 |
+
<PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
|
| 482 |
+
label={v.name} onClick={() => playVoice(v)} />
|
| 483 |
<button type="button" role="radio" aria-checked={on} className="vw-vpick"
|
| 484 |
disabled={form.voice_gender === "none"} onClick={() => pickVoice(v)}>
|
| 485 |
<strong>{v.name}</strong>
|
| 486 |
+
<span className="muted">{v.source === "notestack" ? "Notestack voice" : v.is_custom ? "Your voice" : [cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}</span>
|
| 487 |
</button>
|
| 488 |
{on && <span className="vw-hero-check"><CheckIcon size={14} /></span>}
|
| 489 |
</div>
|
frontend/src/pages/VideoVoices.tsx
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
import { useCallback, useEffect, useState } from "react";
|
| 2 |
-
import { Link } from "react-router-dom";
|
| 3 |
-
import { videosApi, videoVoicesApi } from "../api/endpoints";
|
| 4 |
-
import type { VideoConfig, VideoVoicesResponse } from "../api/types";
|
| 5 |
-
import { errorMessage, Loading, PageHeader } from "../components/ui";
|
| 6 |
-
import { VoiceCreator } from "../components/video/VoiceCreator";
|
| 7 |
-
import { VoiceLibrary } from "../components/video/VoiceLibrary";
|
| 8 |
-
import { Premium, PlayButton, useAudio } from "../components/video/parts";
|
| 9 |
-
import { useUpgrade } from "../hooks/useUpgrade";
|
| 10 |
-
|
| 11 |
-
/** The workspace's video voices: My voices (what the wizard offers), the library, and custom voices. */
|
| 12 |
-
export default function VideoVoices() {
|
| 13 |
-
const { openUpgrade } = useUpgrade();
|
| 14 |
-
const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
|
| 15 |
-
const [config, setConfig] = useState<VideoConfig | null>(null);
|
| 16 |
-
const [creating, setCreating] = useState(false);
|
| 17 |
-
const [error, setError] = useState<string | null>(null);
|
| 18 |
-
const { playing, play } = useAudio();
|
| 19 |
-
|
| 20 |
-
const load = useCallback(() => videoVoicesApi.list().then(setVoices).catch((e) => setError(errorMessage(e))), []);
|
| 21 |
-
useEffect(() => {
|
| 22 |
-
load();
|
| 23 |
-
videosApi.config().then(setConfig).catch(() => undefined);
|
| 24 |
-
}, [load]);
|
| 25 |
-
|
| 26 |
-
const premium = !!config?.premium;
|
| 27 |
-
const custom = config?.limits.custom_voices;
|
| 28 |
-
const designs = config?.limits.voice_designs_daily;
|
| 29 |
-
|
| 30 |
-
return (
|
| 31 |
-
<div className="page-wrap">
|
| 32 |
-
<PageHeader eyebrow="Videos" title="Voices">
|
| 33 |
-
<Link to="/app/videos" className="btn">Back to videos</Link>
|
| 34 |
-
<button className="btn btn-primary" onClick={() => (premium ? setCreating(true) : openUpgrade())}>
|
| 35 |
-
Create a voice {!premium && <Premium small />}
|
| 36 |
-
</button>
|
| 37 |
-
</PageHeader>
|
| 38 |
-
{custom && designs && premium && (
|
| 39 |
-
<p className="mono muted">
|
| 40 |
-
{custom.used} of {custom.limit} custom voices · {designs.used} of {designs.limit} voice designs today
|
| 41 |
-
</p>
|
| 42 |
-
)}
|
| 43 |
-
{error && <p className="error-text">{error}</p>}
|
| 44 |
-
{!voices && !error && <Loading label="Loading voices" />}
|
| 45 |
-
{voices && (
|
| 46 |
-
<div className="stack">
|
| 47 |
-
<section className="card stack">
|
| 48 |
-
<h3>My voices</h3>
|
| 49 |
-
<p className="muted small">These are the voices offered in step 3 of a new video.</p>
|
| 50 |
-
<div className="vw-voice-list">
|
| 51 |
-
{voices.saved.map((v) => (
|
| 52 |
-
<div key={v.voice_id} className="vw-vrow">
|
| 53 |
-
<PlayButton on={playing === v.voice_id} disabled={!v.preview_url} label={v.name}
|
| 54 |
-
onClick={() => play(v.voice_id, v.preview_url)} />
|
| 55 |
-
<div className="vw-vpick static">
|
| 56 |
-
<strong>{v.name}</strong>
|
| 57 |
-
<span className="muted">{v.is_custom ? "Your voice" : [v.gender, v.accent].filter(Boolean).join(" • ")}</span>
|
| 58 |
-
</div>
|
| 59 |
-
{v.premium && <Premium small />}
|
| 60 |
-
<button className="btn btn-small" onClick={() => videoVoicesApi.unsave(v.voice_id).then(load).catch((e) => setError(errorMessage(e)))}>
|
| 61 |
-
Remove
|
| 62 |
-
</button>
|
| 63 |
-
</div>
|
| 64 |
-
))}
|
| 65 |
-
{voices.saved.length === 0 && <p className="muted">No voices saved. Save some from the library below.</p>}
|
| 66 |
-
</div>
|
| 67 |
-
</section>
|
| 68 |
-
<section className="card">
|
| 69 |
-
<VoiceLibrary voices={voices} premium={premium} onChange={load} onLocked={() => openUpgrade()} />
|
| 70 |
-
</section>
|
| 71 |
-
</div>
|
| 72 |
-
)}
|
| 73 |
-
{creating && <VoiceCreator onClose={() => setCreating(false)} onCreated={() => { setCreating(false); load(); videosApi.config().then(setConfig); }} />}
|
| 74 |
-
</div>
|
| 75 |
-
);
|
| 76 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/Videos.tsx
CHANGED
|
@@ -78,7 +78,7 @@ export default function Videos() {
|
|
| 78 |
return (
|
| 79 |
<div className="page-wrap">
|
| 80 |
<PageHeader eyebrow="Videos" title="Your videos">
|
| 81 |
-
<Link to="/app/
|
| 82 |
{out && status?.can_upgrade ? (
|
| 83 |
<button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
|
| 84 |
) : (
|
|
|
|
| 78 |
return (
|
| 79 |
<div className="page-wrap">
|
| 80 |
<PageHeader eyebrow="Videos" title="Your videos">
|
| 81 |
+
<Link to="/app/voice?step=hosts#video-voices" className="btn">Voices</Link>
|
| 82 |
{out && status?.can_upgrade ? (
|
| 83 |
<button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
|
| 84 |
) : (
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
-
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
-
import {
|
|
|
|
| 3 |
import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
|
|
|
|
|
|
| 5 |
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
|
@@ -59,56 +62,12 @@ const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] =
|
|
| 59 |
{ voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
|
| 60 |
];
|
| 61 |
|
| 62 |
-
const ICON_PATHS: Record<Icon, string> = {
|
| 63 |
-
sun: "M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z",
|
| 64 |
-
book: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Zm0 15A2.5 2.5 0 0 0 6.5 23H20v-5",
|
| 65 |
-
mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Zm7 9a7 7 0 0 1-14 0m7 7v4m-4 0h8",
|
| 66 |
-
spark: "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z",
|
| 67 |
-
wave: "M3 12h2m3-5v10m4-13v16m4-11v6m4-3h1",
|
| 68 |
-
person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0",
|
| 69 |
-
};
|
| 70 |
-
|
| 71 |
-
function VoiceIcon({ icon }: { icon: Icon }) {
|
| 72 |
-
return (
|
| 73 |
-
<span className="voice-icon" aria-hidden="true">
|
| 74 |
-
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
| 75 |
-
<path d={ICON_PATHS[icon]} />
|
| 76 |
-
</svg>
|
| 77 |
-
</span>
|
| 78 |
-
);
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
onSaved,
|
| 87 |
-
onBrowse,
|
| 88 |
-
onGenerate,
|
| 89 |
-
}: {
|
| 90 |
-
state: VoiceState;
|
| 91 |
-
voices: Voice[];
|
| 92 |
-
onSaved: (s: VoiceState) => void;
|
| 93 |
-
onBrowse: (host: Host) => void;
|
| 94 |
-
onGenerate: (host: Host) => void;
|
| 95 |
-
}) {
|
| 96 |
-
const [host, setHost] = useState<Host>("host_a");
|
| 97 |
-
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
| 98 |
-
const [dirty, setDirty] = useState(false);
|
| 99 |
-
const [loading, setLoading] = useState<string | null>(null);
|
| 100 |
-
const [error, setError] = useState<string | null>(null);
|
| 101 |
-
const { play, playing } = usePlayer();
|
| 102 |
-
const current = state.host_voices[host];
|
| 103 |
-
const other: Host = host === "host_a" ? "host_b" : "host_a";
|
| 104 |
const byId = new Map(voices.map((v) => [v.voice_id, v]));
|
| 105 |
-
|
| 106 |
-
useEffect(() => {
|
| 107 |
-
setDelivery(state.delivery[host]);
|
| 108 |
-
setDirty(false);
|
| 109 |
-
}, [state.delivery, host]);
|
| 110 |
-
|
| 111 |
-
// Prebuilt first, then the writer's own voices, then whatever else a host is set to (e.g. a library voice).
|
| 112 |
const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
|
| 113 |
if (state.clone.status === "ready" && state.clone.voice_id) {
|
| 114 |
tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
|
|
@@ -120,20 +79,54 @@ function VoicePicker({
|
|
| 120 |
tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
|
| 121 |
}
|
| 122 |
}
|
|
|
|
|
|
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
const listen = async (t: Tile) => {
|
| 125 |
if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
|
| 126 |
setLoading(t.voice_id);
|
| 127 |
-
|
| 128 |
try {
|
| 129 |
const { url } = await voiceApi.preview(t.voice_id, { host });
|
| 130 |
play(url, t.voice_id);
|
| 131 |
} catch (e) {
|
| 132 |
-
|
| 133 |
} finally {
|
| 134 |
setLoading(null);
|
| 135 |
}
|
| 136 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
const choose = async (voice_id: string) => {
|
| 139 |
if (voice_id === current) return;
|
|
@@ -147,6 +140,12 @@ function VoicePicker({
|
|
| 147 |
|
| 148 |
return (
|
| 149 |
<div className="stack">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
<Tabs<Host>
|
| 151 |
tabs={[
|
| 152 |
{ id: "host_a", label: "Host A · leads" },
|
|
@@ -155,74 +154,63 @@ function VoicePicker({
|
|
| 155 |
value={host}
|
| 156 |
onChange={setHost}
|
| 157 |
/>
|
| 158 |
-
|
|
|
|
| 159 |
{tiles.map((t) => {
|
| 160 |
const selected = t.voice_id === current;
|
| 161 |
const usedByOther = t.voice_id === state.host_voices[other];
|
| 162 |
return (
|
| 163 |
-
<
|
| 164 |
-
<
|
| 165 |
-
|
|
|
|
| 166 |
<strong>{t.name}</strong>
|
| 167 |
-
<span className="muted
|
| 168 |
</button>
|
| 169 |
-
{selected && <span className="voice-badge mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
|
| 170 |
-
{!selected && usedByOther && <span className="voice-badge dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
|
| 171 |
-
|
| 172 |
-
type="button"
|
| 173 |
-
className="icon-btn voice-play"
|
| 174 |
-
onClick={() => listen(t)}
|
| 175 |
-
disabled={loading !== null || (!t.preview_url && !state.tts_configured)}
|
| 176 |
-
aria-label={playing === t.voice_id ? `Stop ${t.name}` : `Play ${t.name}`}
|
| 177 |
-
title={playing === t.voice_id ? "Stop" : "Listen"}
|
| 178 |
-
>
|
| 179 |
-
{loading === t.voice_id ? (
|
| 180 |
-
"…"
|
| 181 |
-
) : (
|
| 182 |
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
| 183 |
-
{playing === t.voice_id ? <path d="M6 5h4v14H6zM14 5h4v14h-4z" /> : <path d="M7 4.5v15l13-7.5z" />}
|
| 184 |
-
</svg>
|
| 185 |
-
)}
|
| 186 |
-
</button>
|
| 187 |
-
</li>
|
| 188 |
);
|
| 189 |
})}
|
| 190 |
-
|
| 191 |
-
<button type="button" className="voice-pick" onClick={() => onGenerate(host)} aria-label="Add a voice">
|
| 192 |
-
<span className="voice-icon plus" aria-hidden="true">
|
| 193 |
-
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
| 194 |
-
<path d="M12 5v14M5 12h14" />
|
| 195 |
-
</svg>
|
| 196 |
-
</span>
|
| 197 |
-
<strong>Add a voice</strong>
|
| 198 |
-
<span className="muted small">Generate or clone your own</span>
|
| 199 |
-
</button>
|
| 200 |
-
</li>
|
| 201 |
-
</ul>
|
| 202 |
{error && <p className="error-text">{error}</p>}
|
| 203 |
-
<details className="delivery">
|
| 204 |
-
<summary
|
| 205 |
-
|
| 206 |
-
<
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
{dirty && (
|
| 225 |
-
<div className="row end">
|
| 226 |
<button className="btn btn-small" onClick={() => (setDelivery(state.delivery[host]), setDirty(false))}>
|
| 227 |
Reset
|
| 228 |
</button>
|
|
@@ -238,12 +226,6 @@ function VoicePicker({
|
|
| 238 |
</div>
|
| 239 |
)}
|
| 240 |
</details>
|
| 241 |
-
<p className="muted small">
|
| 242 |
-
Want something else?{" "}
|
| 243 |
-
<button className="link-btn small" onClick={() => onBrowse(host)}>
|
| 244 |
-
Browse the full voice library
|
| 245 |
-
</button>
|
| 246 |
-
</p>
|
| 247 |
</div>
|
| 248 |
);
|
| 249 |
}
|
|
@@ -266,17 +248,20 @@ const PROMPT_IDEAS = [
|
|
| 266 |
"A thoughtful older narrator with a soft Irish lilt",
|
| 267 |
];
|
| 268 |
|
| 269 |
-
|
|
|
|
| 270 |
host,
|
| 271 |
state,
|
| 272 |
-
|
| 273 |
onSaved,
|
| 274 |
onClone,
|
| 275 |
}: {
|
| 276 |
-
host
|
|
|
|
| 277 |
state: VoiceState;
|
| 278 |
-
|
| 279 |
-
|
|
|
|
| 280 |
onClone: () => void;
|
| 281 |
}) {
|
| 282 |
const [mode, setMode] = useState<GenMode>("options");
|
|
@@ -289,7 +274,7 @@ function GenerateVoiceModal({
|
|
| 289 |
const [error, setError] = useState<string | null>(null);
|
| 290 |
const { play, playing } = usePlayer();
|
| 291 |
const { openUpgrade } = useUpgrade();
|
| 292 |
-
const hostLabel = host === "host_a" ? "Host A" : "Host B";
|
| 293 |
|
| 294 |
const generate = async () => {
|
| 295 |
setBusy("generate");
|
|
@@ -312,8 +297,10 @@ function GenerateVoiceModal({
|
|
| 312 |
setBusy("save");
|
| 313 |
setError(null);
|
| 314 |
try {
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
| 317 |
} catch (e) {
|
| 318 |
setError(errorMessage(e));
|
| 319 |
} finally {
|
|
@@ -322,7 +309,7 @@ function GenerateVoiceModal({
|
|
| 322 |
};
|
| 323 |
|
| 324 |
return (
|
| 325 |
-
<
|
| 326 |
<div className="stack">
|
| 327 |
<Tabs<GenMode>
|
| 328 |
tabs={[
|
|
@@ -382,10 +369,7 @@ function GenerateVoiceModal({
|
|
| 382 |
<button
|
| 383 |
className="btn btn-primary"
|
| 384 |
disabled={!state.tts_configured}
|
| 385 |
-
onClick={
|
| 386 |
-
onClose();
|
| 387 |
-
onClone();
|
| 388 |
-
}}
|
| 389 |
>
|
| 390 |
{state.clone.status === "ready" ? "Record my voice again" : "Start recording"}
|
| 391 |
</button>
|
|
@@ -430,7 +414,7 @@ function GenerateVoiceModal({
|
|
| 430 |
<div className="row">
|
| 431 |
<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" />
|
| 432 |
<button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
|
| 433 |
-
{busy === "save" ? "Saving..." : `Save and use as ${hostLabel}`}
|
| 434 |
</button>
|
| 435 |
</div>
|
| 436 |
</div>
|
|
@@ -438,13 +422,13 @@ function GenerateVoiceModal({
|
|
| 438 |
{!state.tts_configured && <p className="muted small">Voice generation is unavailable right now.</p>}
|
| 439 |
{error && <p className="error-text">{error}</p>}
|
| 440 |
</div>
|
| 441 |
-
</
|
| 442 |
);
|
| 443 |
}
|
| 444 |
|
| 445 |
// Voice library (commercially licensed voices from ElevenLabs)
|
| 446 |
|
| 447 |
-
function
|
| 448 |
const [filters, setFilters] = useState({ search: "", gender: "", age: "", accent: "", use_case: "" });
|
| 449 |
const [voices, setVoices] = useState<LibraryVoice[] | null>(null);
|
| 450 |
const [page, setPage] = useState(0);
|
|
@@ -485,7 +469,7 @@ function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => v
|
|
| 485 |
);
|
| 486 |
|
| 487 |
return (
|
| 488 |
-
<
|
| 489 |
<div className="stack">
|
| 490 |
<p className="muted small">
|
| 491 |
Voices from the ElevenLabs Voice Library, licensed for commercial use on paid ElevenLabs plans. Adding one puts it in your ElevenLabs account.
|
|
@@ -521,7 +505,6 @@ function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => v
|
|
| 521 |
setError(null);
|
| 522 |
try {
|
| 523 |
onAdded(await voiceApi.addFromLibrary(v, host));
|
| 524 |
-
onClose();
|
| 525 |
} catch (e) {
|
| 526 |
setError(errorMessage(e));
|
| 527 |
} finally {
|
|
@@ -541,6 +524,48 @@ function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => v
|
|
| 541 |
</button>
|
| 542 |
)}
|
| 543 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
</Modal>
|
| 545 |
);
|
| 546 |
}
|
|
@@ -748,14 +773,21 @@ export default function VoiceProfile() {
|
|
| 748 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 749 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 750 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 751 |
-
const [
|
|
|
|
|
|
|
| 752 |
const [picking, setPicking] = useState(false);
|
| 753 |
const [samples, setSamples] = useState<string[]>([]);
|
| 754 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 755 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 756 |
const [cloning, setCloning] = useState(false);
|
| 757 |
-
const [
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
const [saved, setSaved] = useState(false);
|
| 760 |
const [error, setError] = useState<string | null>(null);
|
| 761 |
const autoBuilt = useRef(false);
|
|
@@ -797,6 +829,7 @@ export default function VoiceProfile() {
|
|
| 797 |
const clone = useJob(cloneJob, () => {
|
| 798 |
load();
|
| 799 |
loadVoices();
|
|
|
|
| 800 |
});
|
| 801 |
|
| 802 |
if (!state || !step) return <Loading />;
|
|
@@ -901,9 +934,41 @@ export default function VoiceProfile() {
|
|
| 901 |
{step === "hosts" && (
|
| 902 |
<section className="card stack vp-card">
|
| 903 |
<h2>Who reads your work aloud</h2>
|
| 904 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
{!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
|
| 906 |
-
<VoicePicker state={state} voices={voices} onSaved={setState}
|
| 907 |
|
| 908 |
{showClone && (
|
| 909 |
<div className="vp-clone">
|
|
@@ -937,8 +1002,15 @@ export default function VoiceProfile() {
|
|
| 937 |
</div>
|
| 938 |
)}
|
| 939 |
|
|
|
|
|
|
|
|
|
|
| 940 |
<div className="row">
|
| 941 |
-
<button className="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
Back to writing voice
|
| 943 |
</button>
|
| 944 |
</div>
|
|
@@ -957,25 +1029,18 @@ export default function VoiceProfile() {
|
|
| 957 |
</Modal>
|
| 958 |
)}
|
| 959 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 960 |
-
{
|
| 961 |
-
<
|
| 962 |
-
host={
|
| 963 |
state={state}
|
| 964 |
-
onClose={() =>
|
| 965 |
onSaved={(s) => {
|
| 966 |
setState(s);
|
| 967 |
loadVoices();
|
| 968 |
}}
|
| 969 |
-
onClone={() =>
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
{browsing && (
|
| 973 |
-
<LibraryModal
|
| 974 |
-
host={browsing}
|
| 975 |
-
onClose={() => setBrowsing(null)}
|
| 976 |
-
onAdded={(s) => {
|
| 977 |
-
setState(s);
|
| 978 |
-
loadVoices();
|
| 979 |
}}
|
| 980 |
/>
|
| 981 |
)}
|
|
|
|
| 1 |
+
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { useSearchParams } from "react-router-dom";
|
| 3 |
+
import { videoVoicesApi, voiceApi } from "../api/endpoints";
|
| 4 |
import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
|
| 5 |
import { DocPicker } from "../components/DocPicker";
|
| 6 |
+
import { PlayButton } from "../components/video/parts";
|
| 7 |
+
import { VideoVoicesSection } from "../components/video/VideoVoicesSection";
|
| 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";
|
|
|
|
| 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 });
|
|
|
|
| 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;
|
|
|
|
| 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" },
|
|
|
|
| 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>
|
|
|
|
| 226 |
</div>
|
| 227 |
)}
|
| 228 |
</details>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
</div>
|
| 230 |
);
|
| 231 |
}
|
|
|
|
| 248 |
"A thoughtful older narrator with a soft Irish lilt",
|
| 249 |
];
|
| 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>("options");
|
|
|
|
| 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 |
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) {
|
| 305 |
setError(errorMessage(e));
|
| 306 |
} finally {
|
|
|
|
| 309 |
};
|
| 310 |
|
| 311 |
return (
|
| 312 |
+
<>
|
| 313 |
<div className="stack">
|
| 314 |
<Tabs<GenMode>
|
| 315 |
tabs={[
|
|
|
|
| 369 |
<button
|
| 370 |
className="btn btn-primary"
|
| 371 |
disabled={!state.tts_configured}
|
| 372 |
+
onClick={onClone}
|
|
|
|
|
|
|
|
|
|
| 373 |
>
|
| 374 |
{state.clone.status === "ready" ? "Record my voice again" : "Start recording"}
|
| 375 |
</button>
|
|
|
|
| 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..." : hostLabel ? `Save and use as ${hostLabel}` : "Save voice"}
|
| 418 |
</button>
|
| 419 |
</div>
|
| 420 |
</div>
|
|
|
|
| 422 |
{!state.tts_configured && <p className="muted small">Voice generation is unavailable right now.</p>}
|
| 423 |
{error && <p className="error-text">{error}</p>}
|
| 424 |
</div>
|
| 425 |
+
</>
|
| 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);
|
|
|
|
| 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.
|
|
|
|
| 505 |
setError(null);
|
| 506 |
try {
|
| 507 |
onAdded(await voiceApi.addFromLibrary(v, host));
|
|
|
|
| 508 |
} catch (e) {
|
| 509 |
setError(errorMessage(e));
|
| 510 |
} finally {
|
|
|
|
| 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 |
}
|
|
|
|
| 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 |
+
// ?step=hosts opens Speaking voices (the old Videos > Voices link lands there, on the video voices).
|
| 778 |
+
const [step, setStep] = useState<Step | null>(params.get("step") === "hosts" ? "hosts" : null);
|
| 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 [managing, setManaging] = useState<Host | null>(null);
|
| 785 |
+
// The Speaking voices card's two tabs. #video-voices (the old Videos > Voices link) opens the video one.
|
| 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);
|
| 793 |
const autoBuilt = useRef(false);
|
|
|
|
| 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 || !step) return <Loading />;
|
|
|
|
| 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 |
+
<VoicePicker state={state} voices={voices} onSaved={setState} onManage={setManaging} />
|
| 972 |
|
| 973 |
{showClone && (
|
| 974 |
<div className="vp-clone">
|
|
|
|
| 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>
|
|
|
|
| 1029 |
</Modal>
|
| 1030 |
)}
|
| 1031 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 1032 |
+
{managing && (
|
| 1033 |
+
<ManageHostVoicesModal
|
| 1034 |
+
host={managing}
|
| 1035 |
state={state}
|
| 1036 |
+
onClose={() => setManaging(null)}
|
| 1037 |
onSaved={(s) => {
|
| 1038 |
setState(s);
|
| 1039 |
loadVoices();
|
| 1040 |
}}
|
| 1041 |
+
onClone={() => {
|
| 1042 |
+
setCloning(true);
|
| 1043 |
+
setManaging(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
}}
|
| 1045 |
/>
|
| 1046 |
)}
|
frontend/src/styles/pages.css
CHANGED
|
@@ -458,6 +458,11 @@ select.input option {
|
|
| 458 |
min-height: 80vh;
|
| 459 |
}
|
| 460 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
/* The label sits under the spinner, both centred */
|
| 462 |
.loading-center .loading {
|
| 463 |
flex-direction: column;
|
|
@@ -2175,7 +2180,8 @@ select.input option {
|
|
| 2175 |
margin: 0;
|
| 2176 |
padding: 0;
|
| 2177 |
display: grid;
|
| 2178 |
-
|
|
|
|
| 2179 |
gap: 12px;
|
| 2180 |
}
|
| 2181 |
|
|
@@ -3601,3 +3607,154 @@ details.map-legend[open] summary {
|
|
| 3601 |
text-overflow: ellipsis;
|
| 3602 |
white-space: nowrap;
|
| 3603 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
min-height: 80vh;
|
| 459 |
}
|
| 460 |
|
| 461 |
+
/* Inside a card (a tab still loading): centred, without claiming most of the window */
|
| 462 |
+
.loading-center.inline {
|
| 463 |
+
min-height: 200px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
/* The label sits under the spinner, both centred */
|
| 467 |
.loading-center .loading {
|
| 468 |
flex-direction: column;
|
|
|
|
| 2180 |
margin: 0;
|
| 2181 |
padding: 0;
|
| 2182 |
display: grid;
|
| 2183 |
+
/* The voices and the Add a voice tile share one row, wrapping only when they no longer fit */
|
| 2184 |
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
| 2185 |
gap: 12px;
|
| 2186 |
}
|
| 2187 |
|
|
|
|
| 3607 |
text-overflow: ellipsis;
|
| 3608 |
white-space: nowrap;
|
| 3609 |
}
|
| 3610 |
+
|
| 3611 |
+
|
| 3612 |
+
.voice-new .voice-pick:disabled {
|
| 3613 |
+
cursor: not-allowed;
|
| 3614 |
+
opacity: 0.6;
|
| 3615 |
+
}
|
| 3616 |
+
|
| 3617 |
+
/* The host a voice row belongs to (Audio hosts list) */
|
| 3618 |
+
.voice-badge-inline {
|
| 3619 |
+
padding: 3px 10px;
|
| 3620 |
+
border-radius: 999px;
|
| 3621 |
+
background: var(--blue);
|
| 3622 |
+
color: var(--white);
|
| 3623 |
+
font-size: 0.72rem;
|
| 3624 |
+
letter-spacing: 0.06em;
|
| 3625 |
+
}
|
| 3626 |
+
|
| 3627 |
+
.voice-badge-inline.dim {
|
| 3628 |
+
background: var(--surface-2, rgba(255, 255, 255, 0.08));
|
| 3629 |
+
color: var(--text-2);
|
| 3630 |
+
}
|
| 3631 |
+
|
| 3632 |
+
/* Audio hosts / Video voiceovers: the explainer takes the room, the Manage button sits at the right end */
|
| 3633 |
+
.manage-top > p {
|
| 3634 |
+
flex: 1;
|
| 3635 |
+
min-width: 240px;
|
| 3636 |
+
margin: 0;
|
| 3637 |
+
}
|
| 3638 |
+
|
| 3639 |
+
.manage-top > .btn {
|
| 3640 |
+
flex-shrink: 0;
|
| 3641 |
+
margin-left: auto;
|
| 3642 |
+
}
|
| 3643 |
+
|
| 3644 |
+
/* A quiet way back: an arrow and text, no button outline */
|
| 3645 |
+
.back-link {
|
| 3646 |
+
display: inline-flex;
|
| 3647 |
+
align-items: center;
|
| 3648 |
+
gap: 8px;
|
| 3649 |
+
padding: 4px 0;
|
| 3650 |
+
border: 0;
|
| 3651 |
+
background: none;
|
| 3652 |
+
color: var(--text-2);
|
| 3653 |
+
font: inherit;
|
| 3654 |
+
font-size: 0.9rem;
|
| 3655 |
+
cursor: pointer;
|
| 3656 |
+
}
|
| 3657 |
+
|
| 3658 |
+
.back-link:hover {
|
| 3659 |
+
color: var(--text);
|
| 3660 |
+
}
|
| 3661 |
+
|
| 3662 |
+
.back-link:hover svg {
|
| 3663 |
+
transform: translateX(-2px);
|
| 3664 |
+
}
|
| 3665 |
+
|
| 3666 |
+
.back-link svg {
|
| 3667 |
+
transition: transform 0.15s;
|
| 3668 |
+
}
|
| 3669 |
+
|
| 3670 |
+
/* Delivery settings for a host: a text link with a chevron that opens a two-column grid of sliders */
|
| 3671 |
+
.delivery-card summary {
|
| 3672 |
+
display: inline-flex;
|
| 3673 |
+
align-items: center;
|
| 3674 |
+
gap: 6px;
|
| 3675 |
+
color: var(--text-2);
|
| 3676 |
+
font-size: 0.9rem;
|
| 3677 |
+
cursor: pointer;
|
| 3678 |
+
list-style: none;
|
| 3679 |
+
}
|
| 3680 |
+
|
| 3681 |
+
.delivery-card summary::-webkit-details-marker {
|
| 3682 |
+
display: none;
|
| 3683 |
+
}
|
| 3684 |
+
|
| 3685 |
+
.delivery-card summary:hover {
|
| 3686 |
+
color: var(--text);
|
| 3687 |
+
}
|
| 3688 |
+
|
| 3689 |
+
.delivery-chevron {
|
| 3690 |
+
flex-shrink: 0;
|
| 3691 |
+
transition: transform 0.2s;
|
| 3692 |
+
}
|
| 3693 |
+
|
| 3694 |
+
.delivery-card[open] .delivery-chevron {
|
| 3695 |
+
transform: rotate(180deg);
|
| 3696 |
+
}
|
| 3697 |
+
|
| 3698 |
+
.delivery-grid {
|
| 3699 |
+
display: grid;
|
| 3700 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 3701 |
+
gap: 22px 32px;
|
| 3702 |
+
padding-top: 18px;
|
| 3703 |
+
}
|
| 3704 |
+
|
| 3705 |
+
.delivery-slider {
|
| 3706 |
+
display: grid;
|
| 3707 |
+
gap: 8px;
|
| 3708 |
+
font-size: 0.9rem;
|
| 3709 |
+
}
|
| 3710 |
+
|
| 3711 |
+
.delivery-value {
|
| 3712 |
+
padding: 2px 8px;
|
| 3713 |
+
border-radius: 999px;
|
| 3714 |
+
background: rgba(33, 124, 255, 0.14);
|
| 3715 |
+
color: var(--blue);
|
| 3716 |
+
font-size: 0.78rem;
|
| 3717 |
+
}
|
| 3718 |
+
|
| 3719 |
+
/* A slim track, filled up to the thumb (--pct) */
|
| 3720 |
+
.delivery-slider input[type="range"] {
|
| 3721 |
+
width: 100%;
|
| 3722 |
+
height: 4px;
|
| 3723 |
+
border-radius: 999px;
|
| 3724 |
+
background: linear-gradient(to right, var(--blue) var(--pct, 50%), var(--line-strong) var(--pct, 50%));
|
| 3725 |
+
appearance: none;
|
| 3726 |
+
outline: none;
|
| 3727 |
+
cursor: pointer;
|
| 3728 |
+
}
|
| 3729 |
+
|
| 3730 |
+
.delivery-slider input[type="range"]::-webkit-slider-thumb {
|
| 3731 |
+
width: 16px;
|
| 3732 |
+
height: 16px;
|
| 3733 |
+
border: 2px solid var(--white);
|
| 3734 |
+
border-radius: 50%;
|
| 3735 |
+
background: var(--blue);
|
| 3736 |
+
box-shadow: 0 0 0 4px rgba(33, 124, 255, 0.2);
|
| 3737 |
+
appearance: none;
|
| 3738 |
+
}
|
| 3739 |
+
|
| 3740 |
+
.delivery-slider input[type="range"]::-moz-range-thumb {
|
| 3741 |
+
width: 14px;
|
| 3742 |
+
height: 14px;
|
| 3743 |
+
border: 2px solid var(--white);
|
| 3744 |
+
border-radius: 50%;
|
| 3745 |
+
background: var(--blue);
|
| 3746 |
+
}
|
| 3747 |
+
|
| 3748 |
+
.delivery-slider input[type="range"]:focus-visible::-webkit-slider-thumb {
|
| 3749 |
+
box-shadow: 0 0 0 6px rgba(33, 124, 255, 0.35);
|
| 3750 |
+
}
|
| 3751 |
+
|
| 3752 |
+
.delivery-actions {
|
| 3753 |
+
padding-top: 14px;
|
| 3754 |
+
}
|
| 3755 |
+
|
| 3756 |
+
@media (max-width: 640px) {
|
| 3757 |
+
.delivery-grid {
|
| 3758 |
+
grid-template-columns: 1fr;
|
| 3759 |
+
}
|
| 3760 |
+
}
|
frontend/src/styles/videos.css
CHANGED
|
@@ -189,35 +189,6 @@
|
|
| 189 |
opacity: 0.5;
|
| 190 |
}
|
| 191 |
|
| 192 |
-
/* Format */
|
| 193 |
-
|
| 194 |
-
/* Format and stock footage side by side, bottoms aligned; stacked on phones */
|
| 195 |
-
.vw-row2 {
|
| 196 |
-
display: grid;
|
| 197 |
-
grid-template-columns: 1fr 1fr;
|
| 198 |
-
gap: 16px;
|
| 199 |
-
align-items: end;
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
.vw-row2 .vw-formats {
|
| 203 |
-
display: flex;
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
.vw-row2 .vw-formats button {
|
| 207 |
-
flex: 1;
|
| 208 |
-
justify-content: center;
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
.vw-row2 .vw-option {
|
| 212 |
-
height: 48px;
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
@media (max-width: 600px) {
|
| 216 |
-
.vw-row2 {
|
| 217 |
-
grid-template-columns: 1fr;
|
| 218 |
-
}
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
.vw-hint {
|
| 222 |
font-size: 0.78rem;
|
| 223 |
}
|
|
@@ -227,41 +198,6 @@
|
|
| 227 |
font-size: 0.75rem;
|
| 228 |
}
|
| 229 |
|
| 230 |
-
.vw-formats {
|
| 231 |
-
display: inline-flex;
|
| 232 |
-
gap: 4px;
|
| 233 |
-
height: 48px;
|
| 234 |
-
padding: 4px;
|
| 235 |
-
border: 1px solid var(--line-strong);
|
| 236 |
-
border-radius: 12px;
|
| 237 |
-
background: rgba(0, 0, 0, 0.6);
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
.vw-formats button {
|
| 241 |
-
display: flex;
|
| 242 |
-
align-items: center;
|
| 243 |
-
gap: 8px;
|
| 244 |
-
padding: 0 16px;
|
| 245 |
-
border: 0;
|
| 246 |
-
border-radius: 8px;
|
| 247 |
-
background: none;
|
| 248 |
-
color: var(--text-2);
|
| 249 |
-
font: 500 0.9rem var(--font-body);
|
| 250 |
-
white-space: nowrap;
|
| 251 |
-
cursor: pointer;
|
| 252 |
-
transition: background 0.15s, color 0.15s;
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
.vw-formats button:hover {
|
| 256 |
-
color: var(--text);
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
.vw-formats button.on {
|
| 260 |
-
background: var(--blue);
|
| 261 |
-
color: var(--white);
|
| 262 |
-
box-shadow: var(--glow-sm);
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
/* Templates */
|
| 266 |
|
| 267 |
.vw-hero-check {
|
|
@@ -434,7 +370,7 @@
|
|
| 434 |
border-radius: 50%;
|
| 435 |
}
|
| 436 |
|
| 437 |
-
/*
|
| 438 |
.vw-style-row {
|
| 439 |
display: flex;
|
| 440 |
flex-wrap: wrap;
|
|
@@ -878,17 +814,17 @@ a.mc-action {
|
|
| 878 |
.vw-focus-grid {
|
| 879 |
display: grid;
|
| 880 |
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 881 |
-
gap:
|
| 882 |
}
|
| 883 |
|
| 884 |
.vw-focus-card {
|
| 885 |
display: flex;
|
| 886 |
align-items: flex-start; /* title and description read from the top */
|
| 887 |
-
gap:
|
| 888 |
-
min-height:
|
| 889 |
-
padding:
|
| 890 |
border: 1px solid var(--line);
|
| 891 |
-
border-radius:
|
| 892 |
background: var(--surface);
|
| 893 |
color: var(--text);
|
| 894 |
font: inherit;
|
|
@@ -904,12 +840,14 @@ a.mc-action {
|
|
| 904 |
.vw-focus-text {
|
| 905 |
display: grid;
|
| 906 |
flex: 1;
|
| 907 |
-
gap:
|
| 908 |
min-width: 0;
|
| 909 |
}
|
| 910 |
|
| 911 |
.vw-focus-text strong {
|
|
|
|
| 912 |
font-weight: 500;
|
|
|
|
| 913 |
}
|
| 914 |
|
| 915 |
/* The topic's description: two lines at most */
|
|
@@ -917,8 +855,8 @@ a.mc-action {
|
|
| 917 |
display: -webkit-box;
|
| 918 |
overflow: hidden;
|
| 919 |
color: var(--text-2);
|
| 920 |
-
font-size: 0.
|
| 921 |
-
line-height: 1.
|
| 922 |
-webkit-box-orient: vertical;
|
| 923 |
-webkit-line-clamp: 2;
|
| 924 |
line-clamp: 2;
|
|
@@ -928,6 +866,62 @@ a.mc-action {
|
|
| 928 |
border-color: var(--blue);
|
| 929 |
}
|
| 930 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
.vw-focus-card.wait {
|
| 932 |
cursor: default;
|
| 933 |
color: var(--text-2);
|
|
|
|
| 189 |
opacity: 0.5;
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
.vw-hint {
|
| 193 |
font-size: 0.78rem;
|
| 194 |
}
|
|
|
|
| 198 |
font-size: 0.75rem;
|
| 199 |
}
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
/* Templates */
|
| 202 |
|
| 203 |
.vw-hero-check {
|
|
|
|
| 370 |
border-radius: 50%;
|
| 371 |
}
|
| 372 |
|
| 373 |
+
/* The style chips (step 1) and the colors (step 2), each in a row of their own, with smaller swatches */
|
| 374 |
.vw-style-row {
|
| 375 |
display: flex;
|
| 376 |
flex-wrap: wrap;
|
|
|
|
| 814 |
.vw-focus-grid {
|
| 815 |
display: grid;
|
| 816 |
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 817 |
+
gap: 10px;
|
| 818 |
}
|
| 819 |
|
| 820 |
.vw-focus-card {
|
| 821 |
display: flex;
|
| 822 |
align-items: flex-start; /* title and description read from the top */
|
| 823 |
+
gap: 10px;
|
| 824 |
+
min-height: 0;
|
| 825 |
+
padding: 10px 12px;
|
| 826 |
border: 1px solid var(--line);
|
| 827 |
+
border-radius: 12px;
|
| 828 |
background: var(--surface);
|
| 829 |
color: var(--text);
|
| 830 |
font: inherit;
|
|
|
|
| 840 |
.vw-focus-text {
|
| 841 |
display: grid;
|
| 842 |
flex: 1;
|
| 843 |
+
gap: 3px;
|
| 844 |
min-width: 0;
|
| 845 |
}
|
| 846 |
|
| 847 |
.vw-focus-text strong {
|
| 848 |
+
font-size: 0.9rem;
|
| 849 |
font-weight: 500;
|
| 850 |
+
line-height: 1.35;
|
| 851 |
}
|
| 852 |
|
| 853 |
/* The topic's description: two lines at most */
|
|
|
|
| 855 |
display: -webkit-box;
|
| 856 |
overflow: hidden;
|
| 857 |
color: var(--text-2);
|
| 858 |
+
font-size: 0.78rem;
|
| 859 |
+
line-height: 1.35;
|
| 860 |
-webkit-box-orient: vertical;
|
| 861 |
-webkit-line-clamp: 2;
|
| 862 |
line-clamp: 2;
|
|
|
|
| 866 |
border-color: var(--blue);
|
| 867 |
}
|
| 868 |
|
| 869 |
+
/* The user's own topic, under the suggestions: same frame as a card, the box full width */
|
| 870 |
+
.vw-focus-custom {
|
| 871 |
+
display: grid;
|
| 872 |
+
gap: 8px;
|
| 873 |
+
margin-top: 10px;
|
| 874 |
+
padding: 10px 12px;
|
| 875 |
+
border: 1px solid var(--line);
|
| 876 |
+
border-radius: 12px;
|
| 877 |
+
background: var(--surface);
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
.vw-focus-custom.on {
|
| 881 |
+
border-color: var(--blue);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
.vw-focus-custom-head {
|
| 885 |
+
font-size: 0.9rem;
|
| 886 |
+
font-weight: 500;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
/* A multi-line box, not the site's pill-shaped one-line .input */
|
| 890 |
+
.vw-focus-custom textarea {
|
| 891 |
+
width: 100%;
|
| 892 |
+
min-height: 72px;
|
| 893 |
+
padding: 10px 12px;
|
| 894 |
+
border: 1px solid var(--line-strong);
|
| 895 |
+
border-radius: 10px;
|
| 896 |
+
background: rgba(0, 0, 0, 0.45);
|
| 897 |
+
color: var(--text);
|
| 898 |
+
font: 400 0.88rem/1.45 var(--font-body);
|
| 899 |
+
resize: none;
|
| 900 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.vw-focus-custom textarea::placeholder {
|
| 904 |
+
color: var(--text-3);
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
.vw-focus-custom textarea:focus {
|
| 908 |
+
outline: none;
|
| 909 |
+
border-color: var(--blue);
|
| 910 |
+
box-shadow: var(--glow-sm);
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
/* The guide on the left, the character count on the right */
|
| 914 |
+
.vw-focus-custom-foot {
|
| 915 |
+
display: flex;
|
| 916 |
+
align-items: baseline;
|
| 917 |
+
justify-content: space-between;
|
| 918 |
+
gap: 12px;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.vw-focus-custom-foot small {
|
| 922 |
+
font-size: 0.74rem;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
.vw-focus-card.wait {
|
| 926 |
cursor: default;
|
| 927 |
color: var(--text-2);
|