Muhammad-Mehdi commited on
Commit
1aa4a16
·
1 Parent(s): db8dd62

video style phase 2

Browse files
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 different temperature, so it is a fresh call and
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
- db.add(DspyTrace(
43
- workspace_id=workspace_id, module=signature.__name__, model=lm.model,
44
- inputs={k: (v if len(str(v)) < 4000 else str(v)[:4000]) for k, v in inputs.items()},
45
- outputs=outputs, training_allowed=bool(workspace and workspace.training_opt_in),
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 VideoFocusTopics(dspy.Signature):
279
- """Suggest three distinct angles a short explainer video could focus on, drawn only from this material. Each has a
280
- short, specific topic (different for each of the three) and a two sentence summary of what the video covers about
281
- that angle. Never use em dashes."""
 
 
 
 
 
 
 
282
 
283
  items: list[str] = dspy.InputField(desc="Each item: a title (when there is one), then the start of its text")
284
- topics: list[FocusTopic] = dspy.OutputField(desc="Exactly 3 focus topics")
 
 
 
 
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 "topics" not in (d.metadata_json or {})]
66
  known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
67
- lm = fast_lm()
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
- try:
71
- out = run.predict(ExtractTopics, db=db, workspace_id=workspace_id, job=job, lm=lm,
72
- title=d.title, text=(d.clean_text or "")[:12000], known_topics=known)
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" in d.metadata_json:
238
- d.metadata_json = {k: v for k, v in d.metadata_json.items() if k != "topics"}
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, and custom voices (design, clone).
 
2
 
3
- "My voices" (user_saved_voices) lives only here: blog2video's saved-voice list is one list for the whole account.
 
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
- return {"saved": [_voice_out(v) for v in saved],
 
 
 
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
- ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
167
- preview_url=row.preview_url, premium=True, is_custom=True))
 
 
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": True}
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 VideoFocusTopics
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 (worded
302
- for the house style's depth). Empty when neither applies."""
303
  parts = [CHAT_CONTEXT] if body.chat_ids else []
304
- if focus := (body.focus or "").strip():
 
 
 
 
 
 
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) -> list[str]:
453
- """What the focus suggestions read: each post's title and the start of its text, or a chat's title and first
454
- exchange (per chat). A little text, so a vague title or an untitled chat still says what it is about. Checked like a create
455
- (another workspace's source 404s, locked posts 400)."""
 
456
  if body.chat_ids:
457
  loaded = _load_chats(ctx, body.chat_ids)
458
  answer_len = 600 if len(loaded) == 1 else 300
459
- items = []
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
- items.append(item)
466
- return items
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 = ["\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs[:5]]
476
- return [i for i in items if i]
477
 
478
 
479
  @router.post("/focus-topics")
480
  def focus_topics(body: FocusIn, ctx: Ctx = Depends(get_ctx)):
481
- """Three topics a video from this source could focus on, suggested by the AI from each item's title and the start
482
- of its text. Asked for while the wizard is still on step 1, shown on step 2."""
483
- items = _focus_material(ctx, body)
484
- if not items:
 
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(VideoFocusTopics, db=ctx.db, workspace_id=ctx.workspace.id, lm=focus_lm(retry), items=items)
 
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
- vp = _ensure_profile(ctx)
211
- vp.host_voices = {**(vp.host_voices or {}), body.use_as: voice_id}
212
- ctx.db.commit()
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 voices."""
 
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 claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
 
 
 
 
 
 
 
 
51
  from app.services.plans import effective_plan
52
  from app.services.renderer import PermanentJobError, request_render
53
  from app.services.storage import keys, storage
@@ -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 _pending(db, workspace_id, "resurface_scan"):
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 _pending(db, workspace_id, "voice_profile"):
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] == ["VideoFocusTopics"] * 3
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={<VideoVoices />} />
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
- export type VideoVoicesResponse = { saved: VideoSavedVoice[]; library: VideoLibraryVoice[]; custom: VideoCustomVoice[] };
 
 
 
 
 
 
 
 
 
 
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, useMemo, useRef, useState } from "react";
2
  import { Link } from "react-router-dom";
3
- import type { Doc, NotebookSummary, WorkspaceChat } from "../../api/types";
4
  import { DocPicker } from "../DocPicker";
5
  import { Dropdown } from "../Dropdown";
6
- import { formatDate, Loading } from "../ui";
7
- import { freshlySeeded, loadChats, loadDocs, loadNotebooks, sourceCache } from "./sourceCache";
8
 
9
- /** What a video is made from: a notebook's ticked posts (combined into one video), one post, or ticked notebook chats
10
- * (combined too; the ticks are held next to the source, like a notebook's posts). */
11
- export type VideoSource = { kind: "notebook"; id: string } | { kind: "post"; id: string } | { kind: "chats" };
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 : onChats ? CHATS : notebookId ?? 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 { useMemo, useState } from "react";
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 accents = useMemo(() => [...new Set(voices.library.map((v) => v.accent).filter(Boolean) as string[])].sort(), [voices]);
20
- const shown = voices.library.filter((v) => (!gender || v.gender === gender) && (!accent || v.accent === accent));
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
- {voices.custom.length > 0 && (
45
  <section className="stack">
46
- <h3>Your custom voices</h3>
47
  <div className="vw-voice-list">
48
- {voices.custom.map((c) => (
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 from my voices</button>
58
  ) : (
59
- <button className="btn btn-small" disabled={busy === c.voice_id}
60
- onClick={() => act(c.voice_id, () => videoVoicesApi.save(c.voice_id))}>Add to my voices</button>
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))}>Saved ✓</button>
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
- Save
105
  </button>
106
  )}
107
  </div>
108
  ))}
109
- {shown.length === 0 && <p className="muted">No voices match.</p>}
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, WorkspaceChat } 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
- 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 { PhoneIcon, PlayButton, ScreenIcon, TemplateThumb, useAudio } from "../components/video/parts";
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
- const [chatIds, setChatIds] = useState<string[]>([]);
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 === "chats" && chatIds.length > 0)
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 step 1 choice. They are asked for while still on step 1, as soon as the choice
193
- // is complete, so they are usually there by step 2; a new source or other ticks asks again. The short wait lets a
194
- // run of ticks settle into one request.
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, ...(focus ? { focus: focus.title, focus_detail: focus.description } : {}) });
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
- <div className="vw-row2">
 
292
  <div className="field">
293
- <span className="vw-label">Format</span>
294
- <div className="vw-formats" role="radiogroup" aria-label="Video format">
295
- {FORMATS.map(({ id, name, label, Icon }) => (
296
- <button key={id} type="button" role="radio" aria-checked={form.aspect_ratio === id} title={label}
297
- className={form.aspect_ratio === id ? "on" : ""} onClick={() => set({ aspect_ratio: id })}>
298
- <Icon />
299
- {name}
300
- </button>
301
- ))}
302
- </div>
 
 
 
 
 
303
  </div>
304
- <div className="field">
305
- <span className="vw-label">Stock footage</span>
306
- <label className="vw-option">
307
- <input type="checkbox" checked={form.stock_footage_enabled}
308
- onChange={(e) => set({ stock_footage_enabled: e.target.checked })} />
309
- Insert stock footage automatically
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  </label>
 
 
 
 
 
 
 
311
  </div>
312
- </div>
313
 
314
  {error && <p className="error-text">{error}</p>}
315
  <div className="vw-nav">
316
- <button className="btn btn-primary vw-next" disabled={!sourceReady} onClick={() => setStep(2)}>
 
 
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
- {/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
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/videos/voices">Voices</Link> page.</p>
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={!v.preview_url} label={v.name}
471
- onClick={() => play(v.voice_id, v.preview_url)} />
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/videos/voices" className="btn">Voices</Link>
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 { voiceApi } from "../api/endpoints";
 
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
- function VoicePicker({
84
- state,
85
- voices,
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
- setError(null);
128
  try {
129
  const { url } = await voiceApi.preview(t.voice_id, { host });
130
  play(url, t.voice_id);
131
  } catch (e) {
132
- setError(errorMessage(e));
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
- <ul className="voice-grid" 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
- <li key={t.voice_id} className={`voice-tile${selected ? " on" : ""}`}>
164
- <button type="button" className="voice-pick" onClick={() => choose(t.voice_id)} aria-pressed={selected}>
165
- <VoiceIcon icon={t.icon} />
 
166
  <strong>{t.name}</strong>
167
- <span className="muted small">{t.tag}</span>
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
- <button
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
- <li className="voice-tile voice-new">
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 className="mono muted small">Delivery settings for {host === "host_a" ? "Host A" : "Host B"}</summary>
205
- {SLIDERS.map((s) => (
206
- <label key={s.key} className="slider" title={s.hint}>
207
- <span className="row between">
208
- <span>{s.label}</span>
209
- <span className="mono muted">{Number(delivery[s.key]).toFixed(2)}</span>
210
- </span>
211
- <input
212
- type="range"
213
- min={s.min}
214
- max={s.max}
215
- step={s.step}
216
- value={Number(delivery[s.key])}
217
- onChange={(e) => {
218
- setDelivery({ ...delivery, [s.key]: Number(e.target.value) });
219
- setDirty(true);
220
- }}
221
- />
222
- </label>
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
- function GenerateVoiceModal({
 
270
  host,
271
  state,
272
- onClose,
273
  onSaved,
274
  onClone,
275
  }: {
276
- host: Host;
 
277
  state: VoiceState;
278
- onClose: () => void;
279
- onSaved: (s: VoiceState) => void;
 
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
- onSaved(await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description, use_as: host }));
316
- onClose();
 
 
317
  } catch (e) {
318
  setError(errorMessage(e));
319
  } finally {
@@ -322,7 +309,7 @@ function GenerateVoiceModal({
322
  };
323
 
324
  return (
325
- <Modal title="Generate a new voice" onClose={onClose} wide>
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
- </Modal>
442
  );
443
  }
444
 
445
  // Voice library (commercially licensed voices from ElevenLabs)
446
 
447
- function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
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
- <Modal title={`Voice library for ${host === "host_a" ? "Host A" : "Host B"}`} onClose={onClose} wide>
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 [step, setStep] = useState<Step | null>(null);
 
 
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 [browsing, setBrowsing] = useState<Host | null>(null);
758
- const [generating, setGenerating] = useState<Host | null>(null);
 
 
 
 
 
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
- <p className="muted">Audio overviews are a conversation between two hosts. Tap a voice to use it, or add your own.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
  {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
906
- <VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
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="btn btn-small" onClick={() => setStep("writing")}>
 
 
 
 
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
- {generating && (
961
- <GenerateVoiceModal
962
- host={generating}
963
  state={state}
964
- onClose={() => setGenerating(null)}
965
  onSaved={(s) => {
966
  setState(s);
967
  loadVoices();
968
  }}
969
- onClone={() => setCloning(true)}
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
- grid-template-columns: repeat(4, minmax(0, 1fr));
 
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
- /* Step 2: video style and colors share one row (wrapping when the chips run long), with smaller swatches */
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: 12px;
882
  }
883
 
884
  .vw-focus-card {
885
  display: flex;
886
  align-items: flex-start; /* title and description read from the top */
887
- gap: 12px;
888
- min-height: 72px;
889
- padding: 14px 16px;
890
  border: 1px solid var(--line);
891
- border-radius: 16px;
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: 6px;
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.82rem;
921
- line-height: 1.4;
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);