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

video style added

Browse files
backend/app/llm/provider.py CHANGED
@@ -60,6 +60,18 @@ def triage_lm() -> dspy.LM:
60
  return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def configure_default() -> None:
64
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
65
 
 
60
  return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
61
 
62
 
63
+ FOCUS_MAX_TOKENS = 2000 # three titles and two-line descriptions plus GLM's low-effort reasoning
64
+
65
+
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
+
74
+
75
  def configure_default() -> None:
76
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
77
 
backend/app/llm/signatures.py CHANGED
@@ -265,3 +265,20 @@ class TriageMessage(dspy.Signature):
265
  standalone_question: str = dspy.OutputField(desc="For archive: the question with follow ups resolved")
266
  depth: Literal["quick", "deep"] = dspy.OutputField()
267
  reply: str = dspy.OutputField(desc="For non archive kinds: the reply to show. Empty for archive")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  standalone_question: str = dspy.OutputField(desc="For archive: the question with follow ups resolved")
266
  depth: Literal["quick", "deep"] = dspy.OutputField()
267
  reply: str = dspy.OutputField(desc="For non archive kinds: the reply to show. Empty for archive")
268
+
269
+
270
+ class FocusTopic(BaseModel):
271
+ # Not "title"/"description": with those names GLM sometimes wrote the word "Description" as every title.
272
+ topic: str = Field(description="The angle itself in 3 to 7 words, e.g. \"Why tokens break software margins\". "
273
+ "Never a label such as Title, Topic or Description. No trailing period")
274
+ summary: str = Field(description="Two short sentences (at most 40 words): what the video would cover and the "
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")
backend/app/routers/notebooks.py CHANGED
@@ -144,6 +144,21 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
144
  ]
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  @router.get("/{notebook_id}")
148
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
149
  nb = _get(ctx, notebook_id)
 
144
  ]
145
 
146
 
147
+ # Above /{notebook_id}, so "chats" is not read as a notebook id.
148
+ @router.get("/chats")
149
+ def list_all_chats(ctx: Ctx = Depends(get_ctx)):
150
+ """Every chat in the workspace that has an answer, newest first (the video wizard's "Made from" menu)."""
151
+ answered = select(Message.chat_id).where(Message.role == "assistant")
152
+ rows = ctx.db.execute(
153
+ select(Chat, Notebook.title).join(Notebook, Notebook.id == Chat.notebook_id)
154
+ .where(Chat.workspace_id == ctx.workspace.id, Chat.id.in_(answered))
155
+ .order_by(Chat.updated_at.desc())
156
+ ).all()
157
+ return [{"id": str(c.id), "title": c.title, "notebook_id": str(c.notebook_id), "notebook_title": nb_title,
158
+ "updated_at": c.updated_at.isoformat() if c.updated_at else None}
159
+ for c, nb_title in rows]
160
+
161
+
162
  @router.get("/{notebook_id}")
163
  def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
164
  nb = _get(ctx, notebook_id)
backend/app/routers/videos.py CHANGED
@@ -26,7 +26,10 @@ from sqlalchemy import select
26
 
27
  from app.auth import Ctx, get_ctx
28
  from app.config import settings
29
- from app.models import Artifact, B2VVideo, Document, Notebook, Upload
 
 
 
30
  from app.routers.sources import is_locked
31
  from app.services import b2v_access, video_limits, video_quota
32
  from app.services import blog2video as b2v
@@ -43,7 +46,14 @@ MIN_CONTENT = 50
43
  MAX_CONTENT = 100_000
44
  MAX_LINKS = 10
45
  MAX_POSTS = 200 # a notebook's posts, combined into one video (the ingest SCAN_CAP)
 
46
  MAX_FILES = 5
 
 
 
 
 
 
47
  FILE_MAX_BYTES = 5 * 1024 * 1024
48
  FILE_TYPES = {".pdf", ".docx", ".pptx", ".md", ".txt", ".vtt"}
49
  LOGO_MAX_BYTES = 2 * 1024 * 1024
@@ -163,19 +173,38 @@ class VideoOptions(BaseModel):
163
  return data
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  class VideoCreateIn(VideoOptions):
167
- # Source: exactly one of these. document_ids are a notebook's ticked posts, combined into one video.
168
  document_id: uuid.UUID | None = None
169
  document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
 
170
  url: str | None = Field(None, max_length=2000)
171
  content: str | None = Field(None, max_length=MAX_CONTENT)
172
  title: str | None = Field(None, max_length=300)
173
  notebook_id: uuid.UUID | None = None # names a combined video after its notebook
 
 
 
174
 
175
  @model_validator(mode="after")
176
  def one_source(self):
177
- if sum(x is not None for x in (self.document_id, self.document_ids, self.url, self.content)) != 1:
178
- raise ValueError("Pick one source: a notebook, a post, a link or pasted text")
179
  if self.document_ids and len(self.document_ids) == 1:
180
  self.document_id, self.document_ids = self.document_ids[0], None
181
  return self
@@ -190,7 +219,7 @@ def _not_indexed(count: int) -> HTTPException:
190
  "Untick them or upgrade to index your whole archive.")
191
 
192
 
193
- def _combined(ctx: Ctx, body: VideoCreateIn) -> tuple[str, str]:
194
  """Several posts as one text, oldest first, each given an equal share so none is cut off entirely."""
195
  ids = set(body.document_ids or [])
196
  docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
@@ -201,40 +230,116 @@ def _combined(ctx: Ctx, body: VideoCreateIn) -> tuple[str, str]:
201
  if locked:
202
  raise _not_indexed(locked)
203
  docs.sort(key=lambda d: (d.published_at is None, d.published_at.timestamp() if d.published_at else 0))
204
- share = MAX_CONTENT // len(docs)
205
  text = "\n\n".join(f"# {d.title}\n\n{(d.clean_text or '').strip()}"[:share] for d in docs)
206
  nb = ctx.db.scalar(select(Notebook).where(Notebook.id == body.notebook_id,
207
  Notebook.workspace_id == ctx.workspace.id)) if body.notebook_id else None
208
  return (nb.title if nb else f"{docs[0].title} + {len(docs) - 1} more"), text
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
212
  """The blog2video source fields and a title for our artifact."""
213
- if body.document_ids:
214
- title, text = _combined(ctx, body)
215
- elif body.document_id:
216
- doc = ctx.db.scalar(select(Document).where(Document.id == body.document_id,
217
- Document.workspace_id == ctx.workspace.id))
218
- if not doc:
219
- raise HTTPException(404, "Post not found")
220
- if is_locked(doc):
221
- raise _not_indexed(1)
222
- if re.match(r"https?://", doc.url or ""):
223
- return {"url": doc.url, "title": doc.title[:255]}, doc.title
224
- # Uploaded files have no public link (upload://name): send their text instead.
225
- title, text = doc.title, doc.clean_text or ""
226
- elif body.url:
227
  if not re.match(r"https?://", body.url):
228
  raise HTTPException(400, "Enter a link that starts with http:// or https://")
229
  return {"url": body.url}, body.title or body.url
230
- else:
231
- title, text = body.title or "", body.content or ""
232
- if not title.strip():
233
- raise HTTPException(400, "Give the pasted text a title")
 
 
 
 
 
 
 
234
  text = text.strip()
235
  if len(text) < MIN_CONTENT:
236
  raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
237
- return {"content": text[:MAX_CONTENT], "title": title[:255]}, title
 
238
 
239
 
240
  def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
@@ -245,6 +350,9 @@ def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
245
  def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
246
  payload = options.payload()
247
  with upstream(): # checking a template or voice may need blog2video's catalog
 
 
 
248
  plan = b2v_access.check_refs(ctx, payload)
249
  accent = (ctx.workspace.brand_json or {}).get("accent")
250
  if plan.brand_kit and accent and "accent_color" not in payload:
@@ -323,7 +431,8 @@ def create_video(body: VideoCreateIn, ctx: Ctx = Depends(get_ctx), _: None = Dep
323
  document_id=str(body.document_id) if body.document_id
324
  else None,
325
  document_ids=[str(d) for d in body.document_ids or []]
326
- or None)},
 
327
  row.idempotency_key)
328
  return int(created["video_id"])
329
 
@@ -332,6 +441,74 @@ def create_video(body: VideoCreateIn, ctx: Ctx = Depends(get_ctx), _: None = Dep
332
  return serialize_artifact(a)
333
 
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  @router.post("/batch", status_code=201)
336
  def create_videos(body: VideoBatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
337
  """Multi-link: one video per link, each charged and keyed on its own. Background music is single-video only."""
 
26
 
27
  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
 
46
  MAX_CONTENT = 100_000
47
  MAX_LINKS = 10
48
  MAX_POSTS = 200 # a notebook's posts, combined into one video (the ingest SCAN_CAP)
49
+ MAX_CHATS = 5 # notebook chats, combined into one video (the wizard's cap, as for posts)
50
  MAX_FILES = 5
51
+ # Sent ahead of chat transcripts: blog2video only takes content, so this tells its script writer what it is reading.
52
+ CHAT_CONTEXT = ("The text below is one or more conversations between a user and an AI assistant. Make the video about "
53
+ "the subject of these conversations: the questions the user asked and the answers and conclusions "
54
+ "reached. Present it "
55
+ "as a clear explainer for a general audience, in your own narration. Do not read it out as a dialogue, "
56
+ "and do not mention the chat, the user or the AI assistant.")
57
  FILE_MAX_BYTES = 5 * 1024 * 1024
58
  FILE_TYPES = {".pdf", ".docx", ".pptx", ".md", ".txt", ".vtt"}
59
  LOGO_MAX_BYTES = 2 * 1024 * 1024
 
173
  return data
174
 
175
 
176
+ class FocusIn(BaseModel):
177
+ # A source from the workspace: exactly one of these. document_ids are a notebook's ticked posts, combined into one
178
+ # video; chat_ids are notebook chats, sent as their transcripts (combined too).
179
+ document_id: uuid.UUID | None = None
180
+ document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
181
+ chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_CHATS)
182
+ notebook_id: uuid.UUID | None = None # names a combined video after its notebook
183
+
184
+ @model_validator(mode="after")
185
+ def one_source(self):
186
+ if sum(x is not None for x in (self.document_id, self.document_ids, self.chat_ids)) != 1:
187
+ raise ValueError("Pick one source: a notebook, a post or chats")
188
+ return self
189
+
190
+
191
  class VideoCreateIn(VideoOptions):
192
+ # Source: exactly one of these (the first three as in FocusIn).
193
  document_id: uuid.UUID | None = None
194
  document_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_POSTS)
195
+ chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=MAX_CHATS)
196
  url: str | None = Field(None, max_length=2000)
197
  content: str | None = Field(None, max_length=MAX_CONTENT)
198
  title: str | None = Field(None, max_length=300)
199
  notebook_id: uuid.UUID | None = None # names a combined video after its notebook
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):
206
+ if sum(x is not None for x in (self.document_id, self.document_ids, self.url, self.content, self.chat_ids)) != 1:
207
+ raise ValueError("Pick one source: a notebook, a post, chats, a link or pasted text")
208
  if self.document_ids and len(self.document_ids) == 1:
209
  self.document_id, self.document_ids = self.document_ids[0], None
210
  return self
 
219
  "Untick them or upgrade to index your whole archive.")
220
 
221
 
222
+ def _combined(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int) -> tuple[str, str]:
223
  """Several posts as one text, oldest first, each given an equal share so none is cut off entirely."""
224
  ids = set(body.document_ids or [])
225
  docs = list(ctx.db.scalars(select(Document).where(Document.id.in_(ids),
 
230
  if locked:
231
  raise _not_indexed(locked)
232
  docs.sort(key=lambda d: (d.published_at is None, d.published_at.timestamp() if d.published_at else 0))
233
+ share = limit // len(docs)
234
  text = "\n\n".join(f"# {d.title}\n\n{(d.clean_text or '').strip()}"[:share] for d in docs)
235
  nb = ctx.db.scalar(select(Notebook).where(Notebook.id == body.notebook_id,
236
  Notebook.workspace_id == ctx.workspace.id)) if body.notebook_id else None
237
  return (nb.title if nb else f"{docs[0].title} + {len(docs) - 1} more"), text
238
 
239
 
240
+ def _cite_free(text: str) -> str:
241
+ """Citation markers ([1], [2]) point at the notebook's posts, which blog2video never sees."""
242
+ return re.sub(r"\s*\[\d+\]", "", text).strip()
243
+
244
+
245
+ def _load_chats(ctx: Ctx, ids: list[uuid.UUID]) -> list[tuple[Chat, list[Message]]]:
246
+ """This workspace's chats, oldest first, each with its messages in order. 404 if any is not ours."""
247
+ chats = list(ctx.db.scalars(select(Chat).where(Chat.id.in_(set(ids)), Chat.workspace_id == ctx.workspace.id)
248
+ .order_by(Chat.created_at)))
249
+ if len(chats) != len(set(ids)):
250
+ raise HTTPException(404, "Chat not found")
251
+ msgs = ctx.db.scalars(select(Message).where(Message.chat_id.in_([c.id for c in chats]))
252
+ .order_by(Message.created_at)).all()
253
+ return [(c, [m for m in msgs if m.chat_id == c.id]) for c in chats]
254
+
255
+
256
+ def _chat_title(chat: Chat, msgs: list[Message]) -> str:
257
+ return chat.title or next((m.content for m in msgs if m.role == "user"), "")[:80] or "Chat"
258
+
259
+
260
+ def _chats(ctx: Ctx, ids: list[uuid.UUID], limit: int) -> tuple[str, str]:
261
+ """Chats' turns as one text, each chat given an equal share. Over its share, a chat's oldest turns go so its
262
+ conclusion stays. Several chats are headed by their titles."""
263
+ loaded = _load_chats(ctx, ids)
264
+ share = limit // len(loaded)
265
+ parts = []
266
+ for chat, msgs in loaded:
267
+ if not any(m.role == "assistant" for m in msgs):
268
+ raise HTTPException(400, f'"{_chat_title(chat, msgs)}" has no answers yet')
269
+ turns = [f"{'User' if m.role == 'user' else 'Assistant'}: {t}" for m in msgs if (t := _cite_free(m.content))]
270
+ head = f"# {_chat_title(chat, msgs)}\n\n" if len(loaded) > 1 else ""
271
+ size = len(head) + sum(len(t) + 2 for t in turns)
272
+ while len(turns) > 1 and size > share:
273
+ size -= len(turns.pop(0)) + 2
274
+ parts.append(head + "\n\n".join(turns))
275
+ first = _chat_title(*loaded[0])
276
+ return (first if len(loaded) == 1 else f"{first} + {len(loaded) - 1} more"), "\n\n".join(parts)
277
+
278
+
279
+ def _doc(ctx: Ctx, document_id: uuid.UUID) -> Document:
280
+ doc = ctx.db.scalar(select(Document).where(Document.id == document_id, Document.workspace_id == ctx.workspace.id))
281
+ if not doc:
282
+ raise HTTPException(404, "Post not found")
283
+ if is_locked(doc):
284
+ raise _not_indexed(1)
285
+ return doc
286
+
287
+
288
+ def _source_text(ctx: Ctx, body: FocusIn | VideoCreateIn, limit: int = MAX_CONTENT) -> tuple[str, str]:
289
+ """A workspace source (posts, a chat, one post) or pasted text as a title and one text."""
290
+ if body.document_ids:
291
+ return _combined(ctx, body, limit)
292
+ if body.chat_ids:
293
+ return _chats(ctx, body.chat_ids, limit)
294
+ if body.document_id:
295
+ doc = _doc(ctx, body.document_id)
296
+ return doc.title, (doc.clean_text or "")[:limit]
297
+ return getattr(body, "title", None) or "", getattr(body, "content", None) or ""
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)
308
+ if length == "short":
309
+ parts.append(f'The main focus of this video is one topic only: "{focus}".{cover} Make it an overview of '
310
+ "that topic: cover just the parts of the material about it, briefly, and leave the rest out.")
311
+ elif length == "medium":
312
+ parts.append(f'The main focus of this video is one topic only: "{focus}".{cover} Make it a deep dive into '
313
+ "that topic: explore it in depth using the parts of the material about it, and leave the rest "
314
+ "out.")
315
+ else:
316
+ parts.append(f'The main focus of this video is: "{focus}".{cover} Leave out parts of the material not '
317
+ "about it.")
318
+ return " ".join(parts)
319
+
320
+
321
  def _source(ctx: Ctx, body: VideoCreateIn) -> tuple[dict, str]:
322
  """The blog2video source fields and a title for our artifact."""
323
+ if body.url:
 
 
 
 
 
 
 
 
 
 
 
 
 
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
331
+ # Uploaded files have no public link (upload://name): their text is sent instead (below). So is any post
332
+ # given a focus, which goes in front of the text.
333
+ with upstream(): # the focus wording needs the house styles
334
+ head = _instructions(body)
335
+ title, text = _source_text(ctx, body, MAX_CONTENT - len(head) - 10)
336
+ if body.content is not None and not title.strip():
337
+ raise HTTPException(400, "Give the pasted text a title")
338
  text = text.strip()
339
  if len(text) < MIN_CONTENT:
340
  raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
341
+ content = f"{head}\n\n---\n\n{text}" if head else text
342
+ return {"content": content[:MAX_CONTENT], "title": title[:255]}, title
343
 
344
 
345
  def _metadata(ctx: Ctx, row: B2VVideo, **extra) -> dict:
 
350
  def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
351
  payload = options.payload()
352
  with upstream(): # checking a template or voice may need blog2video's catalog
353
+ # A house style decides the length, whatever the client sent (before the checks, which gate some lengths).
354
+ if length := b2v_access.house_length(payload.get("video_style")):
355
+ payload["video_length"] = length
356
  plan = b2v_access.check_refs(ctx, payload)
357
  accent = (ctx.workspace.brand_json or {}).get("accent")
358
  if plan.brand_kit and accent and "accent_color" not in payload:
 
431
  document_id=str(body.document_id) if body.document_id
432
  else None,
433
  document_ids=[str(d) for d in body.document_ids or []]
434
+ or None,
435
+ chat_ids=[str(c) for c in body.chat_ids or []] or None)},
436
  row.idempotency_key)
437
  return int(created["video_id"])
438
 
 
441
  return serialize_artifact(a)
442
 
443
 
444
+ def _excerpt(text: str | None, limit: int) -> str:
445
+ """The start of a text, cut on a word boundary."""
446
+ text = re.sub(r"\s+", " ", text or "").strip()
447
+ if len(text) <= limit:
448
+ return text
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)))
470
+ if len(docs) != len(ids):
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
+
499
+ def _add_topics(topics: list[dict], raw) -> None:
500
+ """Add the model's usable topics to `topics` (as {title, description}): a real title, not seen before."""
501
+ for t in raw or []:
502
+ if not isinstance(t, dict):
503
+ continue
504
+ title = str(t.get("topic") or t.get("title") or "").strip().strip(".").strip()[:120]
505
+ description = re.sub(r"\s+", " ", str(t.get("summary") or t.get("description") or "")).strip()[:300]
506
+ if not title or title.lower() in FOCUS_PLACEHOLDERS:
507
+ continue
508
+ if title.lower() not in {x["title"].lower() for x in topics}:
509
+ topics.append({"title": title, "description": description})
510
+
511
+
512
  @router.post("/batch", status_code=201)
513
  def create_videos(body: VideoBatchIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
514
  """Multi-link: one video per link, each charged and keyed on its own. Background music is single-video only."""
backend/app/services/b2v_access.py CHANGED
@@ -18,6 +18,16 @@ from app.services.plans import Plan, effective_plan
18
  from app.services.video_limits import require_premium
19
 
20
  BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
 
 
 
 
 
 
 
 
 
 
21
  PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
22
 
23
 
@@ -116,10 +126,31 @@ def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
116
  raise not_found("Template not found")
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def check_style(ctx: Ctx, style: str) -> None:
120
  if style in BUILTIN_STYLES:
121
  return
122
  if (sid := _custom_id(style, "custom:")) is not None:
 
 
123
  owned_style(ctx, sid)
124
  return
125
  raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
@@ -159,7 +190,10 @@ def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
159
 
160
 
161
  def visible_styles(ctx: Ctx) -> list[dict]:
162
- """Built-in styles (read-only) and this workspace's custom styles. Never "Your Style", never others'."""
 
 
 
163
  mine = set(ctx.db.scalars(select(B2VStyle.b2v_style_id).where(B2VStyle.workspace_id == ctx.workspace.id)))
164
  out = []
165
  for s in (b2v.video_styles() or {}).get("styles") or []:
 
18
  from app.services.video_limits import require_premium
19
 
20
  BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
21
+ # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
22
+ # exist (matched by name, the first is the default). Each sets the video length; the wizard has no length picker and
23
+ # only says roughly how long the video comes out.
24
+ HOUSE_STYLES = [ # (name on blog2video, video length, what the wizard says it is: about three lines)
25
+ ("Overview", "short", "A clear, neutral rundown of the key points. It gets straight to the main idea, one point per "
26
+ "scene in plain words. Best for quick social posts and teasers. Usually under a minute long."),
27
+ ("Deep dive", "medium", "Sets up the context first, then works through the reasoning, examples and trade-offs, each "
28
+ "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
29
+ "2 minutes long."),
30
+ ]
31
  PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
32
 
33
 
 
126
  raise not_found("Template not found")
127
 
128
 
129
+ def house_styles() -> list[dict]:
130
+ """HOUSE_STYLES found among the account's custom styles, in HOUSE_STYLES order, with the length each sets."""
131
+ by_name = {str(s.get("name") or "").strip().lower(): s for s in (b2v.video_styles() or {}).get("styles") or []
132
+ if s.get("kind") == "custom" and s.get("custom_id") is not None}
133
+ out = []
134
+ for name, length, blurb in HOUSE_STYLES:
135
+ if s := by_name.get(name.lower()):
136
+ out.append({"id": f"custom:{s['custom_id']}", "custom_id": s["custom_id"], "name": s.get("name"),
137
+ "description": blurb, "kind": "house", "length": length})
138
+ return out
139
+
140
+
141
+ def house_length(style: str | None) -> str | None:
142
+ """The video length a house style sets; None for any other style."""
143
+ if not style or not style.startswith("custom:"):
144
+ return None
145
+ return next((s["length"] for s in house_styles() if s["id"] == style), None)
146
+
147
+
148
  def check_style(ctx: Ctx, style: str) -> None:
149
  if style in BUILTIN_STYLES:
150
  return
151
  if (sid := _custom_id(style, "custom:")) is not None:
152
+ if any(s["custom_id"] == sid for s in house_styles()):
153
+ return
154
  owned_style(ctx, sid)
155
  return
156
  raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
 
190
 
191
 
192
  def visible_styles(ctx: Ctx) -> list[dict]:
193
+ """The house styles when the account has them; otherwise built-in styles (read-only) and this workspace's custom
194
+ styles. Never "Your Style", never others'."""
195
+ if house := house_styles():
196
+ return [{k: v for k, v in s.items() if k != "length"} for s in house]
197
  mine = set(ctx.db.scalars(select(B2VStyle.b2v_style_id).where(B2VStyle.workspace_id == ctx.workspace.id)))
198
  out = []
199
  for s in (b2v.video_styles() or {}).get("styles") or []:
backend/tests/test_videos.py CHANGED
@@ -19,7 +19,9 @@ from app.models import (
19
  B2VStyle,
20
  B2VTemplate,
21
  B2VVideo,
 
22
  Document,
 
23
  Notebook,
24
  Subscription,
25
  UsageCounter,
@@ -296,6 +298,78 @@ def test_locked_posts_are_refused_before_charging(client, b2v, owner, db_session
296
  assert not creates(b2v) and used(db_session, ws) == 0
297
 
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  def test_another_workspaces_post_is_404(client, b2v, owner, other, db_session):
300
  headers, ws = owner
301
  mine = add_doc(db_session, ws, "https://ada.example.com/p/a", "Enough text to make a video from, really. " * 3)
@@ -464,6 +538,158 @@ def test_catalog_shows_only_this_workspace(client, b2v, owner, theirs, db_sessio
464
  assert [t["id"] for t in cat["my_templates"]] == ["custom_58"]
465
 
466
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  # Premium (★) and per-workspace counters
468
 
469
 
 
19
  B2VStyle,
20
  B2VTemplate,
21
  B2VVideo,
22
+ Chat,
23
  Document,
24
+ Message,
25
  Notebook,
26
  Subscription,
27
  UsageCounter,
 
298
  assert not creates(b2v) and used(db_session, ws) == 0
299
 
300
 
301
+ def add_chat(db, ws, turns: list[tuple[str, str]], title: str | None = "Pricing chat") -> Chat:
302
+ nb = Notebook(workspace_id=ws.id, title="Pricing series")
303
+ db.add(nb)
304
+ db.flush()
305
+ chat = Chat(workspace_id=ws.id, notebook_id=nb.id, title=title)
306
+ db.add(chat)
307
+ db.flush()
308
+ for i, (role, text) in enumerate(turns):
309
+ db.add(Message(chat_id=chat.id, role=role, content=text,
310
+ created_at=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(minutes=i)))
311
+ db.commit()
312
+ return chat
313
+
314
+
315
+ def test_chat_is_sent_as_its_transcript_after_the_context_prompt(client, b2v, owner, db_session):
316
+ from app.routers.videos import CHAT_CONTEXT
317
+ headers, ws = owner
318
+ chat = add_chat(db_session, ws, [("user", "How often should I raise prices?"),
319
+ ("assistant", "Once a year, and tell readers why [1]. Grandfather the early ones [2].")])
320
+ r = client.post("/api/videos", json={"chat_ids": [str(chat.id)]}, headers=headers)
321
+ assert r.status_code == 201 and r.json()["content"]["title"] == "Pricing chat"
322
+ sent = creates(b2v)[-1]["json"]
323
+ assert sent["title"] == "Pricing chat" and "chat_ids" not in sent and "url" not in sent
324
+ assert sent["content"].startswith(CHAT_CONTEXT)
325
+ assert "User: How often should I raise prices?" in sent["content"]
326
+ assert "Assistant: Once a year, and tell readers why. Grandfather the early ones." in sent["content"]
327
+ assert used(db_session, ws) == 1
328
+
329
+
330
+ def test_several_chats_make_one_video(client, b2v, owner, other, db_session):
331
+ from app.routers.videos import CHAT_CONTEXT
332
+ headers, ws = owner
333
+ a = add_chat(db_session, ws, [("user", "How often should I raise prices?"),
334
+ ("assistant", "Once a year, and tell readers why it is happening.")])
335
+ b = add_chat(db_session, ws, [("user", "Should early readers keep their price?"),
336
+ ("assistant", "Yes, grandfather them: they took the risk first.")], title="Early readers")
337
+ r = client.post("/api/videos", json={"chat_ids": [str(a.id), str(b.id)]}, headers=headers)
338
+ assert r.status_code == 201 and r.json()["content"]["title"] == "Pricing chat + 1 more"
339
+ content = creates(b2v)[-1]["json"]["content"]
340
+ assert content.startswith(CHAT_CONTEXT)
341
+ assert content.index("# Pricing chat") < content.index("User: How often") < content.index("# Early readers")
342
+ assert "Assistant: Yes, grandfather them" in content
343
+ # Six is over the cap; one chat that is not ours makes it 404.
344
+ six = [str(add_chat(db_session, ws, [("user", "Q?"), ("assistant", "A long enough answer.")]).id) for _ in range(6)]
345
+ assert client.post("/api/videos", json={"chat_ids": six}, headers=headers).status_code == 422
346
+ theirs = add_chat(db_session, other[1], [("user", "Q?"), ("assistant", "A.")])
347
+ r = client.post("/api/videos", json={"chat_ids": [str(a.id), str(theirs.id)]}, headers=headers)
348
+ assert r.status_code == 404 and len(creates(b2v)) == 1
349
+
350
+
351
+ def test_chats_list_across_notebooks_only_answered_ones(client, owner, other, db_session):
352
+ headers, ws = owner
353
+ answered = add_chat(db_session, ws, [("user", "Q?"), ("assistant", "A.")])
354
+ add_chat(db_session, ws, [("user", "Unanswered?")])
355
+ add_chat(db_session, other[1], [("user", "Q?"), ("assistant", "A.")])
356
+ r = client.get("/api/notebooks/chats", headers=headers)
357
+ assert r.status_code == 200
358
+ assert [(c["id"], c["notebook_title"]) for c in r.json()] == [(str(answered.id), "Pricing series")]
359
+
360
+
361
+ def test_chat_source_is_checked(client, b2v, owner, other, db_session):
362
+ headers, ws = owner
363
+ theirs = add_chat(db_session, other[1], [("user", "Q?"), ("assistant", "A long enough answer, really.")])
364
+ assert client.post("/api/videos", json={"chat_ids": [str(theirs.id)]}, headers=headers).status_code == 404
365
+ empty = add_chat(db_session, ws, [("user", "Anyone there?")])
366
+ assert client.post("/api/videos", json={"chat_ids": [str(empty.id)]}, headers=headers).status_code == 400
367
+ doc = add_doc(db_session, ws, "https://ada.example.com/p/a")
368
+ r = client.post("/api/videos", json={"chat_ids": [str(empty.id)], "document_id": str(doc.id)}, headers=headers)
369
+ assert r.status_code == 422
370
+ assert not creates(b2v) and used(db_session, ws) == 0
371
+
372
+
373
  def test_another_workspaces_post_is_404(client, b2v, owner, other, db_session):
374
  headers, ws = owner
375
  mine = add_doc(db_session, ws, "https://ada.example.com/p/a", "Enough text to make a video from, really. " * 3)
 
538
  assert [t["id"] for t in cat["my_templates"]] == ["custom_58"]
539
 
540
 
541
+ @pytest.fixture()
542
+ def house(monkeypatch):
543
+ """Our account has the two house styles (made on blog2video itself, not by any workspace)."""
544
+ styles = DEFAULTS["GET /api/video-styles"]["styles"] + [
545
+ {"id": "custom:21", "custom_id": 21, "name": "Deep dive", "kind": "custom", "guidance": "Context first."},
546
+ {"id": "custom:20", "custom_id": 20, "name": "Overview", "kind": "custom", "guidance": "Neutral."}]
547
+ monkeypatch.setitem(DEFAULTS, "GET /api/video-styles", {"styles": styles})
548
+
549
+
550
+ def test_catalog_offers_only_the_house_styles(client, b2v, owner, house, db_session):
551
+ headers, ws = owner
552
+ db_session.add(B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"))
553
+ db_session.commit()
554
+ styles = client.get("/api/videos/catalog", headers=headers).json()["video_styles"]
555
+ assert [(s["id"], s["name"], s["kind"]) for s in styles] == [("custom:20", "Overview", "house"),
556
+ ("custom:21", "Deep dive", "house")]
557
+ assert all(s["description"] and "length" not in s for s in styles)
558
+
559
+
560
+ @pytest.mark.parametrize("style,length", [("custom:20", "short"), ("custom:21", "medium")])
561
+ def test_house_style_sets_the_length(client, b2v, owner, house, free_plan, style, length):
562
+ make_video(client, owner[0], video_style=style, video_length="detailed")
563
+ sent = creates(b2v)[-1]["json"]
564
+ assert sent["video_style"] == style and sent["video_length"] == length
565
+
566
+
567
+ def test_other_account_styles_stay_refused(client, b2v, owner, house):
568
+ r = client.post("/api/videos", json={"url": "https://a.test/x", "video_style": "custom:9"}, headers=owner[0])
569
+ assert r.status_code == 404 and not creates(b2v)
570
+
571
+
572
+ @pytest.fixture()
573
+ def suggest(monkeypatch):
574
+ """The AI's focus suggestions, canned: records what it was given."""
575
+ from app.llm import run
576
+ seen: list[dict] = []
577
+
578
+ def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
579
+ seen.append({"signature": signature.__name__, **inputs})
580
+ topic = lambda title, desc="": {"title": title, "description": desc} # noqa: E731
581
+ return {"topics": [topic("Why prices go up.", "Why raises happen. Once a year,\n with notice."),
582
+ topic("why prices go up"), topic("Grandfathering early readers", "Keep early prices."),
583
+ topic("Annual raises", "One raise a year."), topic("A fourth one")]}
584
+
585
+ monkeypatch.setattr(run, "predict", predict)
586
+ return seen
587
+
588
+
589
+ def test_focus_topics_for_posts_a_chat_and_one_post(client, owner, db_session, suggest):
590
+ headers, ws = owner
591
+ a = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year, and say why. " * 3)
592
+ b = add_doc(db_session, ws, "https://ada.example.com/p/b", "Start cheaper than you think you should. " * 3)
593
+ chat = add_chat(db_session, ws, [("user", "How often should I raise prices?"),
594
+ ("assistant", "Once a year, and tell readers why it is happening [1].")])
595
+ for body in ({"document_ids": [str(a.id), str(b.id)]}, {"chat_ids": [str(chat.id)]}, {"document_id": str(a.id)}):
596
+ r = client.post("/api/videos/focus-topics", json=body, headers=headers)
597
+ assert r.status_code == 200, r.text
598
+ assert r.json()["topics"] == [
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)
606
+ assert any("Raise prices once a year" in p for p in posts) and any("Start cheaper" in p for p in posts)
607
+ assert chat_item == ["Pricing chat\nHow often should I raise prices?\nOnce a year, and tell readers why it is "
608
+ "happening."]
609
+ assert one[0].startswith("On Pricing\nRaise prices")
610
+
611
+
612
+ def test_focus_material_is_short_and_untitled_chats_still_get_topics(client, owner, db_session, suggest):
613
+ headers, ws = owner
614
+ long = add_doc(db_session, ws, "https://ada.example.com/p/a", "Pricing is a promise to your readers. " * 200)
615
+ other = add_doc(db_session, ws, "https://ada.example.com/p/b", "Grandfather the people who came early. " * 200)
616
+ untitled = add_chat(db_session, ws, [("user", "Should I raise prices?"), ("assistant", "Yes, once a year [1].")],
617
+ title=None)
618
+ r = client.post("/api/videos/focus-topics", json={"document_ids": [str(long.id), str(other.id)]}, headers=headers)
619
+ assert len(r.json()["topics"]) == 3
620
+ assert all(len(i) < 450 and i.endswith("...") for i in suggest[-1]["items"]) # 400 characters of text per post
621
+ titled = add_chat(db_session, ws, [("user", "What about early readers?"), ("assistant", "Keep their price.")])
622
+ r = client.post("/api/videos/focus-topics", json={"chat_ids": [str(untitled.id), str(titled.id)]}, headers=headers)
623
+ assert len(r.json()["topics"]) == 3 and len(suggest[-1]["items"]) == 2
624
+ r = client.post("/api/videos/focus-topics", json={"chat_ids": [str(untitled.id)]}, headers=headers)
625
+ assert len(r.json()["topics"]) == 3
626
+ assert suggest[-1]["items"] == ["Should I raise prices?\nYes, once a year."]
627
+
628
+
629
+ def test_unusable_topics_get_one_fresh_retry(client, owner, db_session, monkeypatch):
630
+ from app.llm import run
631
+ headers, ws = owner
632
+ doc = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year, and say why. " * 3)
633
+ lms = []
634
+ answers = [ # what GLM did: the word "Description" as every title, then a proper answer
635
+ {"topics": [{"topic": "Description", "summary": f"Angle {i}."} for i in range(3)]},
636
+ {"topics": [{"topic": "Annual raises", "summary": "One raise a year."},
637
+ {"topic": "Grandfathering early readers", "summary": "Keep early prices."},
638
+ {"topic": "Saying why prices go up", "summary": "Explain the raise."}]},
639
+ ]
640
+
641
+ def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
642
+ lms.append(lm)
643
+ return answers[len(lms) - 1]
644
+
645
+ monkeypatch.setattr(run, "predict", predict)
646
+ r = client.post("/api/videos/focus-topics", json={"document_id": str(doc.id)}, headers=headers)
647
+ assert [t["title"] for t in r.json()["topics"]] == ["Annual raises", "Grandfathering early readers",
648
+ "Saying why prices go up"]
649
+ assert len(lms) == 2 and lms[0] is not lms[1] # the retry is a fresh call, not the cached bad answer
650
+
651
+
652
+ def test_focus_topics_check_the_source(client, owner, other, db_session, suggest):
653
+ headers, ws = owner
654
+ theirs = add_chat(db_session, other[1], [("user", "Q?"), ("assistant", "A long enough answer, really.")])
655
+ assert client.post("/api/videos/focus-topics", json={"chat_ids": [str(theirs.id)]}, headers=headers).status_code == 404
656
+ locked = add_doc(db_session, ws, "https://ada.example.com/p/b")
657
+ locked.path, locked.metadata_json = None, {"locked": True}
658
+ db_session.commit()
659
+ r = client.post("/api/videos/focus-topics", json={"document_id": str(locked.id)}, headers=headers)
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,
666
+ style, length, words):
667
+ headers, ws = owner
668
+ doc = add_doc(db_session, ws, "https://ada.example.com/p/a", "Raise prices once a year, and say why. " * 3)
669
+ make_video(client, headers, url=None, document_id=str(doc.id), video_style=style, focus="Annual raises",
670
+ focus_detail="One raise a year, announced early. Why readers accept it.")
671
+ sent = creates(b2v)[-1]["json"]
672
+ assert "url" not in sent and sent["video_length"] == length # a focus needs the text, not the link
673
+ head, material = sent["content"].split("\n\n---\n\n", 1)
674
+ assert head.index('"Annual raises"') < head.index("What to cover: One raise a year, announced early. Why "
675
+ "readers accept it.") < head.index(words)
676
+ assert material.startswith("Raise prices")
677
+ # The topic alone (no description) still works, without the "What to cover" part.
678
+ make_video(client, headers, url=None, document_id=str(doc.id), video_style=style, focus="Annual raises")
679
+ head = creates(b2v)[-1]["json"]["content"].split("\n\n---\n\n", 1)[0]
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
686
+ chat = add_chat(db_session, ws, [("user", "How often should I raise prices?"),
687
+ ("assistant", "Once a year, and tell readers why it is happening.")])
688
+ make_video(client, headers, url=None, chat_ids=[str(chat.id)], video_style="custom:20", focus="Annual raises")
689
+ content = creates(b2v)[-1]["json"]["content"]
690
+ assert content.startswith(CHAT_CONTEXT) and content.index('"Annual raises"') < content.index("User:")
691
+
692
+
693
  # Premium (★) and per-workspace counters
694
 
695
 
frontend/src/api/endpoints.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
  LibraryVoice,
13
  Notebook,
14
  NotebookSummary,
 
15
  Page,
16
  Platform,
17
  Quota,
@@ -38,6 +39,8 @@ import type {
38
  VideoCatalog,
39
  VideoConfig,
40
  VideoCreateBody,
 
 
41
  VideoCustomVoice,
42
  VideoDesignedVoice,
43
  VideoJobState,
@@ -89,6 +92,8 @@ export const notebooksApi = {
89
  removeDoc: (id: string, docId: string) => del(`/api/notebooks/${id}/documents/${docId}`),
90
  artifacts: (id: string) => api<Artifact[]>(`/api/notebooks/${id}/artifacts`),
91
  chats: (id: string) => api<ChatSummary[]>(`/api/notebooks/${id}/chats`),
 
 
92
  messages: (chatId: string) => api<ChatMessage[]>(`/api/notebooks/chats/${chatId}/messages`),
93
  removeChat: (chatId: string) => del(`/api/notebooks/chats/${chatId}`),
94
  };
@@ -241,6 +246,8 @@ export const videosApi = {
241
  catalog: () => api<VideoCatalog>("/api/videos/catalog"),
242
  list: () => api<VideoListItem[]>("/api/videos"),
243
  create: (body: VideoCreateBody) => post<Artifact>("/api/videos", body),
 
 
244
  /** Multi-link: one video per link. Items with `error` say where it stopped (usually the allowance ran out). */
245
  createBatch: (urls: string[], options: VideoOptions) =>
246
  post<(Artifact | { error: string; url: string })[]>("/api/videos/batch", { ...options, urls }),
 
12
  LibraryVoice,
13
  Notebook,
14
  NotebookSummary,
15
+ WorkspaceChat,
16
  Page,
17
  Platform,
18
  Quota,
 
39
  VideoCatalog,
40
  VideoConfig,
41
  VideoCreateBody,
42
+ VideoFocusSource,
43
+ VideoFocusTopic,
44
  VideoCustomVoice,
45
  VideoDesignedVoice,
46
  VideoJobState,
 
92
  removeDoc: (id: string, docId: string) => del(`/api/notebooks/${id}/documents/${docId}`),
93
  artifacts: (id: string) => api<Artifact[]>(`/api/notebooks/${id}/artifacts`),
94
  chats: (id: string) => api<ChatSummary[]>(`/api/notebooks/${id}/chats`),
95
+ /** Every answered chat in the workspace, newest first. */
96
+ allChats: () => api<WorkspaceChat[]>("/api/notebooks/chats"),
97
  messages: (chatId: string) => api<ChatMessage[]>(`/api/notebooks/chats/${chatId}/messages`),
98
  removeChat: (chatId: string) => del(`/api/notebooks/chats/${chatId}`),
99
  };
 
246
  catalog: () => api<VideoCatalog>("/api/videos/catalog"),
247
  list: () => api<VideoListItem[]>("/api/videos"),
248
  create: (body: VideoCreateBody) => post<Artifact>("/api/videos", body),
249
+ /** Three topics the AI suggests a video from this source could focus on. */
250
+ focusTopics: (body: VideoFocusSource) => post<{ topics: VideoFocusTopic[] }>("/api/videos/focus-topics", body),
251
  /** Multi-link: one video per link. Items with `error` say where it stopped (usually the allowance ran out). */
252
  createBatch: (urls: string[], options: VideoOptions) =>
253
  post<(Artifact | { error: string; url: string })[]>("/api/videos/batch", { ...options, urls }),
frontend/src/api/types.ts CHANGED
@@ -82,6 +82,8 @@ export type Citation = {
82
  };
83
 
84
  export type ChatSummary = { id: string; title: string | null; updated_at: string | null };
 
 
85
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[] };
86
 
87
  export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit";
@@ -407,7 +409,8 @@ export type VideoStyleItem = {
407
  id: string;
408
  name: string;
409
  description?: string | null;
410
- kind: "builtin" | "custom";
 
411
  custom_id?: number;
412
  guidance?: string | null;
413
  version?: number;
@@ -493,12 +496,24 @@ export type VideoOptions = {
493
  bgm_volume: number;
494
  };
495
 
 
 
 
 
 
 
496
  export type VideoCreateBody = VideoOptions & {
 
 
 
 
497
  document_id?: string;
498
  /** A notebook's ticked posts, combined into one video. */
499
  document_ids?: string[];
500
  /** Names a combined video after its notebook. */
501
  notebook_id?: string;
 
 
502
  url?: string;
503
  content?: string;
504
  title?: string;
 
82
  };
83
 
84
  export type ChatSummary = { id: string; title: string | null; updated_at: string | null };
85
+ /** A chat listed across the whole workspace, with the notebook it belongs to. */
86
+ export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
87
  export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[] };
88
 
89
  export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit";
 
409
  id: string;
410
  name: string;
411
  description?: string | null;
412
+ /** house: made on our blog2video account and offered to everyone; the server sets the video length from it. */
413
+ kind: "builtin" | "custom" | "house";
414
  custom_id?: number;
415
  guidance?: string | null;
416
  version?: number;
 
496
  bgm_volume: number;
497
  };
498
 
499
+ /** A workspace source the AI can suggest focus topics for. */
500
+ /** A focus topic the AI suggests: a short title, and two sentences on what the video would cover. */
501
+ export type VideoFocusTopic = { title: string; description: string };
502
+
503
+ export type VideoFocusSource = { document_id?: string; document_ids?: string[]; chat_ids?: string[]; notebook_id?: string };
504
+
505
  export type VideoCreateBody = VideoOptions & {
506
+ /** One of the suggested focus topics: the video is mainly about it. */
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[];
513
  /** Names a combined video after its notebook. */
514
  notebook_id?: string;
515
+ /** Notebook chats: their transcripts, after a prompt saying what they are (combined into one video). */
516
+ chat_ids?: string[];
517
  url?: string;
518
  content?: string;
519
  title?: string;
frontend/src/components/DocPicker.tsx CHANGED
@@ -39,17 +39,21 @@ export function DocPicker({
39
  );
40
  }, [docs, q, exclude]);
41
 
42
- // The tick-all box: ticked only while every pickable post in view is ticked. Ticking it picks them all,
43
- // unticking it clears them.
44
  const pickable = visible.filter((d) => !d.locked);
45
- const allOn = pickable.length > 0 && pickable.every((d) => selected.includes(d.id));
 
 
 
 
46
 
47
  if (!docs) return <Loading label="Loading posts" />;
48
  if (docs.length === 0) return <p className="muted">No posts yet. Connect a source first.</p>;
49
 
50
  const selectAll = () => {
51
- const ids = Array.from(new Set([...selected, ...visible.filter((d) => !d.locked).map((d) => d.id)]));
52
- onChange(max ? ids.slice(0, max) : ids);
53
  };
54
 
55
  const toggle = (id: string) => {
@@ -68,7 +72,7 @@ export function DocPicker({
68
  <label className="picker-all">
69
  <input type="checkbox" checked={allOn} disabled={pickable.length === 0}
70
  onChange={() => (allOn ? onChange(selected.filter((id) => !pickable.some((d) => d.id === id))) : selectAll())} />
71
- Select All
72
  </label>
73
  {count && <span>{selected.length} selected{max ? ` of ${max}` : ""}</span>}
74
  </div>
@@ -78,7 +82,7 @@ export function DocPicker({
78
  {visible.map((d) => (
79
  <li key={d.id}>
80
  <label className={`picker-row${selected.includes(d.id) ? " on" : ""}${d.locked ? " locked" : ""}`}>
81
- <input type={single ? "radio" : "checkbox"} name="doc-picker" checked={selected.includes(d.id)} disabled={d.locked}
82
  onChange={() => toggle(d.id)} />
83
  <span className="picker-title">{d.title}</span>
84
  <span className="mono muted picker-meta">
 
39
  );
40
  }, [docs, q, exclude]);
41
 
42
+ // The tick-all box: ticked only while every pickable post in view is ticked, or with a `max`, exactly the first `max`
43
+ // of them. Ticking it picks those, unticking it clears them.
44
  const pickable = visible.filter((d) => !d.locked);
45
+ const full = !!max && selected.length >= max;
46
+ const firstFew = max ? pickable.slice(0, max).map((d) => d.id) : null;
47
+ const allOn = pickable.length > 0 && (firstFew
48
+ ? selected.length === firstFew.length && firstFew.every((id) => selected.includes(id))
49
+ : pickable.every((d) => selected.includes(d.id)));
50
 
51
  if (!docs) return <Loading label="Loading posts" />;
52
  if (docs.length === 0) return <p className="muted">No posts yet. Connect a source first.</p>;
53
 
54
  const selectAll = () => {
55
+ if (firstFew) return onChange(firstFew);
56
+ onChange(Array.from(new Set([...selected, ...pickable.map((d) => d.id)])));
57
  };
58
 
59
  const toggle = (id: string) => {
 
72
  <label className="picker-all">
73
  <input type="checkbox" checked={allOn} disabled={pickable.length === 0}
74
  onChange={() => (allOn ? onChange(selected.filter((id) => !pickable.some((d) => d.id === id))) : selectAll())} />
75
+ {max && pickable.length > max ? `Select first ${max}` : "Select All"}
76
  </label>
77
  {count && <span>{selected.length} selected{max ? ` of ${max}` : ""}</span>}
78
  </div>
 
82
  {visible.map((d) => (
83
  <li key={d.id}>
84
  <label className={`picker-row${selected.includes(d.id) ? " on" : ""}${d.locked ? " locked" : ""}`}>
85
+ <input type={single ? "radio" : "checkbox"} name="doc-picker" checked={selected.includes(d.id)} disabled={d.locked || (!single && full && !selected.includes(d.id))}
86
  onChange={() => toggle(d.id)} />
87
  <span className="picker-title">{d.title}</span>
88
  <span className="mono muted picker-meta">
frontend/src/components/video/SourcePicker.tsx CHANGED
@@ -1,91 +1,80 @@
1
- import { useEffect, useRef, useState } from "react";
2
  import { Link } from "react-router-dom";
3
- import { docsApi } from "../../api/endpoints";
4
- import type { Doc, NotebookSummary } from "../../api/types";
5
  import { DocPicker } from "../DocPicker";
6
  import { Dropdown } from "../Dropdown";
7
  import { formatDate, Loading } from "../ui";
8
- import { loadDocs, loadNotebooks, sourceCache } from "./sourceCache";
9
 
10
- /** What a video is made from: a notebook's ticked posts (combined into one video), or one post. */
11
- export type VideoSource = { kind: "notebook"; id: string } | { kind: "post"; id: string };
 
12
 
13
  const ONE_POST = "__post";
 
 
 
14
 
15
- /** Step 1's source: a notebook (every indexed post ticked, untick to leave out) or a single post. */
16
  export function SourcePicker({
17
  source,
18
  onSource,
19
  postIds,
20
  onPostIds,
 
 
21
  }: {
22
  source: VideoSource | null;
23
  onSource: (s: VideoSource | null) => void;
24
  postIds: string[];
25
  onPostIds: (ids: string[]) => void;
 
 
26
  }) {
27
  const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(sourceCache.notebooks);
 
28
  const [docs, setDocs] = useState<Doc[] | null>(null);
29
  const ticks = useRef(postIds);
30
  ticks.current = postIds;
31
- const [post, setPost] = useState<Doc | null>(null);
32
- // "One post..." picked in the select: the archive is listed until a post is chosen.
33
  const [choosing, setChoosing] = useState(false);
34
 
35
  useEffect(() => {
36
  loadNotebooks().then(setNotebooks).catch(() => setNotebooks((n) => n ?? []));
 
37
  }, []);
38
 
39
  const notebookId = source?.kind === "notebook" ? source.id : null;
40
  const postId = source?.kind === "post" ? source.id : null;
 
41
 
42
  useEffect(() => {
43
  if (!notebookId) return;
44
  let live = true;
45
  const tickable = (list: Doc[]) => list.filter((d) => !d.locked).map((d) => d.id);
46
- // A cached list shows at once, every post ticked; the refresh below then only updates it.
 
47
  const cached = sourceCache.docs.get(notebookId);
48
  setDocs(cached ?? null);
49
- if (cached) onPostIds(tickable(cached));
 
 
50
  loadDocs(notebookId).then((fresh) => {
51
  if (!live) return;
52
  setDocs(fresh);
53
- if (!cached) return onPostIds(tickable(fresh)); // all in by default
54
- // Keep the user's ticks through the refresh: drop posts that are gone, tick ones that are new.
55
  const now = new Set(tickable(fresh));
56
  const before = new Set(cached.map((d) => d.id));
57
- onPostIds([...ticks.current.filter((id) => now.has(id)), ...[...now].filter((id) => !before.has(id))]);
 
58
  }).catch(() => undefined);
59
  return () => {
60
  live = false;
61
  };
62
  }, [notebookId, onPostIds]);
63
 
64
- useEffect(() => {
65
- if (postId) docsApi.get(postId).then(setPost).catch(() => setPost(null));
66
- }, [postId]);
67
-
68
- if (postId && !choosing) {
69
- return (
70
- <div className="field">
71
- <span className="vw-label">Post</span>
72
- <div className="vw-source-post">
73
- <div>
74
- <strong>{post?.title ?? "Loading..."}</strong>
75
- {post && (
76
- <span className="mono muted small">
77
- {formatDate(post.published_at)} · {post.words} words
78
- </span>
79
- )}
80
- </div>
81
- <button type="button" className="btn btn-small" onClick={() => setChoosing(true)}>
82
- Change
83
- </button>
84
- </div>
85
- </div>
86
- );
87
- }
88
-
89
  const ticked = docs ? postIds.length : 0;
90
  const pickable = docs?.filter((d) => !d.locked).length ?? 0;
91
 
@@ -95,11 +84,15 @@ export function SourcePicker({
95
  <span className="vw-label">Made from</span>
96
  <Dropdown
97
  label="Made from"
98
- value={choosing || postId ? ONE_POST : notebookId ?? ONE_POST}
99
  onChange={(v) => {
100
  if (v === ONE_POST) {
101
  setChoosing(true);
102
  onSource(null);
 
 
 
 
103
  } else {
104
  setChoosing(false);
105
  onSource({ kind: "notebook", id: v });
@@ -109,20 +102,34 @@ export function SourcePicker({
109
  // The API lists the "All posts" notebook first, so the sections come out in order.
110
  ...(notebooks ?? []).map((n) => ({ value: n.id, label: n.is_archive ? "My whole archive" : n.title,
111
  hint: `${n.document_count} posts`, group: n.is_archive ? "Archive" : "Notebooks" })),
 
 
112
  { value: ONE_POST, label: "Create a video on a single post", group: "Single post" },
113
  ]}
114
  />
 
 
 
 
 
115
  </div>
116
 
117
- {choosing && (
118
- <DocPicker
119
- selected={postId ? [postId] : []}
120
- onChange={(ids) => {
121
- setChoosing(false);
122
- onSource({ kind: "post", id: ids[0] });
123
- }}
124
- single
125
- />
 
 
 
 
 
 
 
126
  )}
127
 
128
  {notebookId && !choosing && (
@@ -146,11 +153,11 @@ export function SourcePicker({
146
  ) : (
147
  <div className="field">
148
  <span className="vw-label">Posts</span>
149
- <DocPicker docs={docs} selected={postIds} onChange={onPostIds} count={false} />
150
  <small className="muted vw-hint">
151
  {ticked === 0
152
  ? "Tick at least one post."
153
- : `${ticked} of ${pickable} post${pickable === 1 ? "" : "s"} ticked. They are combined into one video.`}
154
  </small>
155
  </div>
156
  )
@@ -158,3 +165,62 @@ export function SourcePicker({
158
  </>
159
  );
160
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
18
+ /** Step 1's source: a notebook (its first MAX_VIDEO_POSTS indexed posts ticked, retick to change) or a single post. */
19
  export function SourcePicker({
20
  source,
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;
39
+ // "Create a video on a single post" picked in the menu: the archive is listed, and stays listed once a post is
40
+ // chosen (its radio ticked), so another can be picked from the same list.
41
  const [choosing, setChoosing] = useState(false);
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;
54
  let live = true;
55
  const tickable = (list: Doc[]) => list.filter((d) => !d.locked).map((d) => d.id);
56
+ const firstFew = (list: Doc[]) => tickable(list).slice(0, MAX_VIDEO_POSTS);
57
+ // A cached list shows at once, the first few posts ticked; the refresh below then only updates it.
58
  const cached = sourceCache.docs.get(notebookId);
59
  setDocs(cached ?? null);
60
+ if (cached) onPostIds(firstFew(cached));
61
+ // Just handed over by the page that holds them (the notebook page): already current, no need to load them again.
62
+ if (cached && freshlySeeded(notebookId)) return;
63
  loadDocs(notebookId).then((fresh) => {
64
  if (!live) return;
65
  setDocs(fresh);
66
+ if (!cached) return onPostIds(firstFew(fresh)); // the first few in by default
67
+ // Keep the user's ticks through the refresh: drop posts that are gone, tick new ones while there is room.
68
  const now = new Set(tickable(fresh));
69
  const before = new Set(cached.map((d) => d.id));
70
+ onPostIds([...ticks.current.filter((id) => now.has(id)), ...[...now].filter((id) => !before.has(id))]
71
+ .slice(0, MAX_VIDEO_POSTS));
72
  }).catch(() => undefined);
73
  return () => {
74
  live = false;
75
  };
76
  }, [notebookId, onPostIds]);
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  const ticked = docs ? postIds.length : 0;
79
  const pickable = docs?.filter((d) => !d.locked).length ?? 0;
80
 
 
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
  // 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
  )}
134
 
135
  {notebookId && !choosing && (
 
153
  ) : (
154
  <div className="field">
155
  <span className="vw-label">Posts</span>
156
+ <DocPicker docs={docs} selected={postIds} onChange={onPostIds} max={MAX_VIDEO_POSTS} count={false} />
157
  <small className="muted vw-hint">
158
  {ticked === 0
159
  ? "Tick at least one post."
160
+ : `You can select up to ${Math.min(MAX_VIDEO_POSTS, pickable)} (${ticked} selected). They are combined into one video.`}
161
  </small>
162
  </div>
163
  )
 
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
+ }
frontend/src/components/video/sourceCache.ts CHANGED
@@ -1,10 +1,11 @@
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,8 +13,10 @@ export const sourceCache = {
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
  }
18
 
19
  export async function loadNotebooks(): Promise<NotebookSummary[]> {
@@ -22,12 +25,33 @@ export async function loadNotebooks(): Promise<NotebookSummary[]> {
22
  return n;
23
  }
24
 
 
 
 
 
 
 
25
  export async function loadArchiveId(): Promise<string> {
26
  const nb = await notebooksApi.archive();
27
  sourceCache.archiveId = nb.id;
28
  return nb.id;
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  export async function loadDocs(notebookId: string): Promise<Doc[]> {
32
  const nb = await notebooksApi.get(notebookId);
33
  sourceCache.docs.set(notebookId, nb.documents);
 
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
  /** 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();
20
  }
21
 
22
  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;
37
  return nb.id;
38
  }
39
 
40
+ /** A page that already holds a notebook's current posts (the notebook page) hands them over, so the video wizard
41
+ * shows them without loading them again. For a minute SourcePicker trusts them and skips its own refresh. */
42
+ const seededAt = new Map<string, number>();
43
+ const SEED_TRUST_MS = 60_000;
44
+
45
+ export function seedDocs(notebookId: string, docs: Doc[]): void {
46
+ sourceCache.docs.set(notebookId, docs);
47
+ seededAt.set(notebookId, Date.now());
48
+ }
49
+
50
+ export function freshlySeeded(notebookId: string): boolean {
51
+ const at = seededAt.get(notebookId);
52
+ return at !== undefined && Date.now() - at < SEED_TRUST_MS;
53
+ }
54
+
55
  export async function loadDocs(notebookId: string): Promise<Doc[]> {
56
  const nb = await notebooksApi.get(notebookId);
57
  sourceCache.docs.set(notebookId, nb.documents);
frontend/src/pages/NotebookView.tsx CHANGED
@@ -6,24 +6,27 @@ import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
6
  import { ArtifactCard, CitationList } from "../components/ArtifactCard";
7
  import { Markdown } from "../components/Markdown";
8
  import { DocPicker } from "../components/DocPicker";
9
- import { TelescopeIcon } from "../components/icons/Icons";
 
10
  import { Reader } from "../components/Reader";
11
- import { ConfirmButton, errorMessage, formatDate, Loading, Modal, Tabs } from "../components/ui";
 
 
12
 
13
  type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
14
 
15
- type Pane = "create" | "posts";
16
 
17
  const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"];
18
 
19
- /** The Create tab: one click per format with sensible defaults, options folded away. */
20
- function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: boolean }) {
21
  const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
22
  const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
23
  const [minutes, setMinutes] = useState(6);
24
  const [error, setError] = useState<string | null>(null);
25
  const [busy, setBusy] = useState(false);
26
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
 
27
 
28
  const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
29
  useEffect(() => {
@@ -48,43 +51,57 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
48
 
49
  return (
50
  <div className="stack">
 
51
  <div className="nbv-make">
52
- <button className="nbv-make-main" disabled={off} onClick={() => make({ type: "audio_overview", format, minutes })}>
53
- <strong>Audio overview</strong>
54
- <span className="muted small">
55
- {format === "deep_dive" ? "Deep dive" : format === "brief" ? "Brief" : "Debate"} · {minutes} min
 
 
 
 
 
56
  </span>
57
  </button>
58
- <Link className={off ? "disabled" : ""} to={`/app/videos/new?notebook_id=${notebookId}`} aria-disabled={off}>
59
- Video
60
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <button disabled={off} onClick={() => make({ type: "summary" })}>
62
- Summary
63
  </button>
64
  <button disabled={off} onClick={() => make({ type: "quote_card" })}>
65
- Quote card
66
  </button>
67
  </div>
68
- <details className="delivery">
69
- <summary className="mono muted small">Audio options</summary>
70
- <div className="row">
71
- <select className="input input-sm" value={format} onChange={(e) => setFormat(e.target.value as typeof format)} aria-label="Audio format">
72
- <option value="deep_dive">Deep dive</option>
73
- <option value="brief">Brief</option>
74
- <option value="debate">Debate</option>
75
- </select>
76
- <select className="input input-sm" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))} aria-label="Length">
77
- {[3, 6, 10, 15].map((m) => (
78
- <option key={m} value={m}>
79
- {m} min
80
- </option>
81
- ))}
82
- </select>
83
- </div>
84
- </details>
85
  {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
86
  {error && <p className="error-text">{error}</p>}
87
- <div className="studio-list">
 
88
  {!artifacts && <Loading />}
89
  {artifacts?.map((a) => (
90
  <ArtifactCard
@@ -101,7 +118,7 @@ function StudioPanel({ notebookId, disabled }: { notebookId: string; disabled: b
101
  }
102
  />
103
  ))}
104
- </div>
105
  {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
106
  </div>
107
  );
@@ -117,12 +134,12 @@ export default function NotebookView() {
117
  const [chats, setChats] = useState<ChatSummary[]>([]);
118
  const [busy, setBusy] = useState(false);
119
  const [adding, setAdding] = useState(false);
 
120
  const [toAdd, setToAdd] = useState<string[]>([]);
121
  const [addError, setAddError] = useState<string | null>(null);
122
  const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null);
123
  const [editingTitle, setEditingTitle] = useState(false);
124
  const [title, setTitle] = useState("");
125
- const [pane, setPane] = useState<Pane>("create");
126
  const [params, setParams] = useSearchParams();
127
  const asked = useRef(false);
128
  const bottom = useRef<HTMLDivElement>(null);
@@ -326,53 +343,55 @@ export default function NotebookView() {
326
  </section>
327
 
328
  <aside className="card nbv-pane nbv-side">
329
- <Tabs<Pane>
330
- tabs={[
331
- { id: "create", label: "Create" },
332
- { id: "posts", label: "Posts", count: nb.documents.length },
333
- ]}
334
- value={pane}
335
- onChange={setPane}
336
- />
337
- {pane === "create" && <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0} />}
338
- {pane === "posts" && (
339
- <>
340
- {nb.is_archive ? (
341
- <p className="muted small">Every indexed post. New posts join automatically.</p>
342
- ) : (
343
- <button className="btn btn-small" onClick={() => setAdding(true)}>
344
- Add posts
345
- </button>
346
  )}
347
- <ul className="nbv-docs">
348
- {nb.documents.map((d) => (
349
- <li key={d.id} className="nbv-doc">
350
- <button className="link-btn" onClick={() => setReading({ id: d.id })}>
351
- {d.title}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  </button>
353
- <span className="mono muted">{formatDate(d.published_at)}</span>
354
- {!nb.is_archive && (
355
- <button
356
- className="icon-btn nbv-remove"
357
- aria-label={`Remove ${d.title}`}
358
- title="Remove from notebook"
359
- onClick={async () => {
360
- await notebooksApi.removeDoc(nb.id, d.id);
361
- load();
362
- }}
363
- >
364
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
365
- <path d="M6 6l12 12M18 6L6 18" />
366
- </svg>
367
- </button>
368
- )}
369
- </li>
370
- ))}
371
- </ul>
372
- </>
373
- )}
374
  </aside>
375
 
 
 
 
 
 
376
  {adding && (
377
  <Modal title="Add posts" onClose={() => setAdding(false)} wide>
378
  <DocPicker selected={toAdd} onChange={setToAdd} exclude={nb.documents.map((d) => d.id)} />
 
6
  import { ArtifactCard, CitationList } from "../components/ArtifactCard";
7
  import { Markdown } from "../components/Markdown";
8
  import { DocPicker } from "../components/DocPicker";
9
+ import { Dropdown } from "../components/Dropdown";
10
+ import { ChevronIcon, TelescopeIcon } from "../components/icons/Icons";
11
  import { Reader } from "../components/Reader";
12
+ import { seedDocs } from "../components/video/sourceCache";
13
+ import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
14
+ import { VideoCreateForm } from "./VideoCreate";
15
 
16
  type Turn = { role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[] };
17
 
 
18
 
19
  const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"];
20
 
21
+ /** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */
22
+ function StudioPanel({ notebookId, disabled, onCreateVideo }: { notebookId: string; disabled: boolean; onCreateVideo: () => void }) {
23
  const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
24
  const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
25
  const [minutes, setMinutes] = useState(6);
26
  const [error, setError] = useState<string | null>(null);
27
  const [busy, setBusy] = useState(false);
28
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
29
+ const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
30
 
31
  const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
32
  useEffect(() => {
 
51
 
52
  return (
53
  <div className="stack">
54
+ <span className="vw-label">Create</span>
55
  <div className="nbv-make">
56
+ {/* Opens its options (format, length) right under it; the audio is made from there */}
57
+ <button className={`nbv-make-main${audioOpen ? " open" : ""}`} disabled={off} aria-expanded={audioOpen}
58
+ onClick={() => setAudioOpen(!audioOpen)}>
59
+ <span>Audio overview</span>
60
+ <span className="nbv-make-meta">
61
+ <span className="muted small">
62
+ {format === "deep_dive" ? "Deep dive" : format === "brief" ? "Brief" : "Debate"} · {minutes} min
63
+ </span>
64
+ <ChevronIcon size={16} className={`chevron${audioOpen ? " open" : ""}`} />
65
  </span>
66
  </button>
67
+ {audioOpen && (
68
+ <div className="nbv-audio-opts">
69
+ <div className="nbv-audio-row">
70
+ <div className="field">
71
+ <span className="small muted">Format</span>
72
+ <Dropdown<typeof format> label="Audio format" value={format} onChange={setFormat}
73
+ options={[
74
+ { value: "deep_dive", label: "Deep dive" },
75
+ { value: "brief", label: "Brief" },
76
+ { value: "debate", label: "Debate" },
77
+ ]} />
78
+ </div>
79
+ <div className="field">
80
+ <span className="small muted">Length</span>
81
+ <Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
82
+ options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
83
+ </div>
84
+ </div>
85
+ <button className="btn btn-primary btn-small" disabled={off}
86
+ onClick={() => make({ type: "audio_overview", format, minutes }).then(() => setAudioOpen(false))}>
87
+ {busy ? "Starting..." : "Create audio overview"}
88
+ </button>
89
+ </div>
90
+ )}
91
+ <button disabled={off} onClick={onCreateVideo}>
92
+ Create Video
93
+ </button>
94
  <button disabled={off} onClick={() => make({ type: "summary" })}>
95
+ Generate Summary
96
  </button>
97
  <button disabled={off} onClick={() => make({ type: "quote_card" })}>
98
+ Make Quote Card
99
  </button>
100
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  {disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
102
  {error && <p className="error-text">{error}</p>}
103
+ {(!artifacts || artifacts.length > 0) && <span className="vw-label nbv-made-label">Made from this notebook</span>}
104
+ {(!artifacts || artifacts.length > 0) && <div className="studio-list nbv-made">
105
  {!artifacts && <Loading />}
106
  {artifacts?.map((a) => (
107
  <ArtifactCard
 
118
  }
119
  />
120
  ))}
121
+ </div>}
122
  {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
123
  </div>
124
  );
 
134
  const [chats, setChats] = useState<ChatSummary[]>([]);
135
  const [busy, setBusy] = useState(false);
136
  const [adding, setAdding] = useState(false);
137
+ const [makingVideo, setMakingVideo] = useState(false); // the video wizard is open in a modal
138
  const [toAdd, setToAdd] = useState<string[]>([]);
139
  const [addError, setAddError] = useState<string | null>(null);
140
  const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null);
141
  const [editingTitle, setEditingTitle] = useState(false);
142
  const [title, setTitle] = useState("");
 
143
  const [params, setParams] = useSearchParams();
144
  const asked = useRef(false);
145
  const bottom = useRef<HTMLDivElement>(null);
 
343
  </section>
344
 
345
  <aside className="card nbv-pane nbv-side">
346
+ <StudioPanel notebookId={nb.id} disabled={nb.documents.length === 0}
347
+ onCreateVideo={() => {
348
+ seedDocs(nb.id, nb.documents); // the posts this page already has: the wizard shows them at once
349
+ setMakingVideo(true);
350
+ }} />
351
+
352
+ {/* Posts: below the create buttons and what was made */}
353
+ <section className="nbv-section">
354
+ <div className="nbv-section-head">
355
+ <span className="vw-label">Posts <span className="mono muted">{nb.documents.length}</span></span>
356
+ {!nb.is_archive && (
357
+ <button className="btn btn-small" onClick={() => setAdding(true)}>+ Add posts</button>
 
 
 
 
 
358
  )}
359
+ </div>
360
+ {nb.is_archive && <p className="muted small">Every indexed post. New posts join automatically.</p>}
361
+ {!nb.is_archive && nb.documents.length === 0 && <p className="muted small">No posts yet. Add some to start asking and creating.</p>}
362
+ <ul className="nbv-docs">
363
+ {nb.documents.map((d) => (
364
+ <li key={d.id} className="nbv-doc">
365
+ <button className="link-btn" onClick={() => setReading({ id: d.id })}>
366
+ {d.title}
367
+ </button>
368
+ <span className="mono muted">{formatDate(d.published_at)}</span>
369
+ {!nb.is_archive && (
370
+ <button
371
+ className="icon-btn nbv-remove"
372
+ aria-label={`Remove ${d.title} from this notebook`}
373
+ title="Remove from this notebook (the post itself is kept)"
374
+ onClick={async () => {
375
+ await notebooksApi.removeDoc(nb.id, d.id);
376
+ load();
377
+ }}
378
+ >
379
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
380
+ <path d="M6 6l12 12M18 6L6 18" />
381
+ </svg>
382
  </button>
383
+ )}
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  </aside>
389
 
390
+ {makingVideo && (
391
+ <Modal title="New video" onClose={() => setMakingVideo(false)} wide>
392
+ <VideoCreateForm startNotebook={nb.id} inModal />
393
+ </Modal>
394
+ )}
395
  {adding && (
396
  <Modal title="Add posts" onClose={() => setAdding(false)} wide>
397
  <DocPicker selected={toAdd} onChange={setToAdd} exclude={nb.documents.map((d) => d.id)} />
frontend/src/pages/VideoCreate.tsx CHANGED
@@ -4,6 +4,8 @@ import { videosApi, videoVoicesApi } from "../api/endpoints";
4
  import type {
5
  VideoCatalog,
6
  VideoConfig,
 
 
7
  VideoOptions,
8
  VideoQuota,
9
  VideoSavedVoice,
@@ -12,7 +14,7 @@ import type {
12
  VideoVoicesResponse,
13
  } from "../api/types";
14
  import { Dropdown } from "../components/Dropdown";
15
- import { CheckIcon } from "../components/icons/Icons";
16
  import { errorMessage, Loading, PageHeader } from "../components/ui";
17
  import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
18
  import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
@@ -52,12 +54,19 @@ function Steps({ step }: { step: number }) {
52
  );
53
  }
54
 
 
55
  export default function VideoCreate() {
56
- const navigate = useNavigate();
57
  const [params] = useSearchParams();
 
 
 
 
 
 
 
 
 
58
  const { openUpgrade } = useUpgrade();
59
- const startNotebook = params.get("notebook_id");
60
- const startDoc = params.get("document_id");
61
 
62
  const [config, setConfig] = useState<VideoConfig | null>(null);
63
  const [catalog, setCatalog] = useState<VideoCatalog | null>(null);
@@ -71,6 +80,12 @@ export default function VideoCreate() {
71
  : sourceCache.archiveId ? { kind: "notebook", id: sourceCache.archiveId } : null,
72
  );
73
  const [postIds, setPostIds] = useState<string[]>([]);
 
 
 
 
 
 
74
  const [busy, setBusy] = useState(false);
75
  const [error, setError] = useState<string | null>(null);
76
  const { playing, play } = useAudio();
@@ -78,7 +93,7 @@ export default function VideoCreate() {
78
  const [form, setForm] = useState<VideoOptions>({
79
  stock_footage_enabled: true,
80
  aspect_ratio: "landscape",
81
- video_length: "medium", // every video is medium (1 to 2 min); there is no length picker
82
  logo_position: "bottom_right",
83
  logo_opacity: 0.9,
84
  template: "default",
@@ -120,6 +135,9 @@ export default function VideoCreate() {
120
  const first = pool[Math.floor(Math.random() * pool.length)];
121
  const known = new Set([...c.my_templates, ...c.crafted_templates, ...c.templates].map((t) => t.id));
122
  if (first) setForm((f) => (known.has(f.template) ? f : { ...f, ...templateFields(first) }));
 
 
 
123
  }).catch((e) => setLoadError(errorMessage(e)));
124
  loadVoices()
125
  .then((v) => v.saved[0] && pickVoice(v.saved[0]))
@@ -140,7 +158,11 @@ export default function VideoCreate() {
140
 
141
  const templates = catalog?.templates ?? [];
142
  const shown: VideoTemplate[] = templates;
143
- const styles: VideoStyleItem[] = [{ id: "auto", name: "Auto", kind: "builtin" }, ...(catalog?.video_styles ?? [])];
 
 
 
 
144
  const saved = voices?.saved ?? [];
145
  const left = quota ? Math.max(quota.limit - quota.used, 0) : null;
146
 
@@ -159,7 +181,41 @@ export default function VideoCreate() {
159
  }));
160
  }
161
 
162
- const sourceReady = source?.kind === "post" || (source?.kind === "notebook" && postIds.length > 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
  function options(): VideoOptions {
165
  const o: VideoOptions = { ...form };
@@ -171,9 +227,9 @@ export default function VideoCreate() {
171
  setError(null);
172
  setBusy(true);
173
  try {
174
- if (!source) return;
175
- const from = source.kind === "post" ? { document_id: source.id } : { document_ids: postIds, notebook_id: source.id };
176
- const a = await videosApi.create({ ...options(), ...from });
177
  navigate(`/app/videos/${a.id}`);
178
  } catch (e) {
179
  setError(errorMessage(e)); // 402 also opens the upgrade popup (api/client.ts)
@@ -183,8 +239,8 @@ export default function VideoCreate() {
183
 
184
  if (loadError) {
185
  return (
186
- <div className="page-wrap">
187
- <PageHeader eyebrow="Videos" title="New video" />
188
  <p className="error-text">{loadError}</p>
189
  </div>
190
  );
@@ -192,8 +248,8 @@ export default function VideoCreate() {
192
  if (!config) return <Loading label="Loading" />;
193
  if (!config.configured) {
194
  return (
195
- <div className="page-wrap">
196
- <PageHeader eyebrow="Videos" title="New video" />
197
  <p className="notice">Video is not set up on this server yet.</p>
198
  </div>
199
  );
@@ -202,14 +258,18 @@ export default function VideoCreate() {
202
  const currentVoice = saved.find((v) => v.voice_id === form.custom_voice_id);
203
 
204
  return (
205
- <div className="page-wrap">
206
- <PageHeader eyebrow="Videos" title="New video">
207
- {left !== null && (
208
- <span className="mono muted">{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left</span>
209
- )}
210
- </PageHeader>
 
 
 
 
211
 
212
- {step === 1 && (
213
  // Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
214
  <button type="button" className="vw-back"
215
  onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/videos"))}>
@@ -217,11 +277,12 @@ export default function VideoCreate() {
217
  </button>
218
  )}
219
  <Steps step={step} />
220
- <section className="card stack vw">
221
 
222
  {/* Stays mounted on steps 2 and 3, so going Back keeps the loaded posts and the ticks */}
223
  <div style={{ display: step === 1 ? "contents" : "none" }}>
224
- <SourcePicker source={source} onSource={setSource} postIds={postIds} onPostIds={setPostIds} />
 
225
  </div>
226
 
227
  {step === 1 && (
@@ -240,13 +301,15 @@ export default function VideoCreate() {
240
  ))}
241
  </div>
242
  </div>
243
- <label className="vw-option">
244
- <input type="checkbox" checked={form.stock_footage_enabled}
245
- onChange={(e) => set({ stock_footage_enabled: e.target.checked })} />
246
- Insert stock footage automatically
247
- </label>
 
 
 
248
  </div>
249
- <small className="muted vw-note">Your video will be about 1 to 2 minutes long.</small>
250
 
251
  {error && <p className="error-text">{error}</p>}
252
  <div className="vw-nav">
@@ -286,15 +349,20 @@ export default function VideoCreate() {
286
  <div className="vw-style-row">
287
  <div className="field">
288
  <span className="vw-label">Video style</span>
289
- <div className="vw-chips">
290
- {styles.map((s) => (
291
- <button key={s.id} type="button" className={`vw-chip${form.video_style === s.id ? " on" : ""}`}
292
- aria-pressed={form.video_style === s.id} title={s.description ?? undefined}
293
- onClick={() => set({ video_style: s.id })}>
294
- {s.name}{s.kind === "custom" ? " (yours)" : ""}
295
- </button>
296
- ))}
297
- </div>
 
 
 
 
 
298
  </div>
299
 
300
  <div className="field">
@@ -310,9 +378,50 @@ export default function VideoCreate() {
310
  </div>
311
  </div>
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  <div className="vw-nav">
314
  <button className="btn" onClick={() => setStep(1)}>Back</button>
315
- <button className="btn btn-primary vw-next" onClick={() => setStep(3)}>Go to step 3</button>
 
 
 
316
  </div>
317
  </>
318
  )}
 
4
  import type {
5
  VideoCatalog,
6
  VideoConfig,
7
+ VideoFocusSource,
8
+ VideoFocusTopic,
9
  VideoOptions,
10
  VideoQuota,
11
  VideoSavedVoice,
 
14
  VideoVoicesResponse,
15
  } from "../api/types";
16
  import { Dropdown } from "../components/Dropdown";
17
+ 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";
 
54
  );
55
  }
56
 
57
+ /** The New video page (/app/videos/new?notebook_id=… | document_id=…). */
58
  export default function VideoCreate() {
 
59
  const [params] = useSearchParams();
60
+ return <VideoCreateForm startNotebook={params.get("notebook_id")} startDoc={params.get("document_id")} />;
61
+ }
62
+
63
+ /** The three-step video wizard: as a page, or inside a modal (inModal: no page header, no step-1 Back, no outer card;
64
+ * the modal's own close button leaves it). */
65
+ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal = false }: {
66
+ startNotebook?: string | null; startDoc?: string | null; inModal?: boolean;
67
+ }) {
68
+ const navigate = useNavigate();
69
  const { openUpgrade } = useUpgrade();
 
 
70
 
71
  const [config, setConfig] = useState<VideoConfig | null>(null);
72
  const [catalog, setCatalog] = useState<VideoCatalog | null>(null);
 
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();
 
93
  const [form, setForm] = useState<VideoOptions>({
94
  stock_footage_enabled: true,
95
  aspect_ratio: "landscape",
96
+ video_length: "medium", // no length picker: the server sets it from a house style
97
  logo_position: "bottom_right",
98
  logo_opacity: 0.9,
99
  template: "default",
 
135
  const first = pool[Math.floor(Math.random() * pool.length)];
136
  const known = new Set([...c.my_templates, ...c.crafted_templates, ...c.templates].map((t) => t.id));
137
  if (first) setForm((f) => (known.has(f.template) ? f : { ...f, ...templateFields(first) }));
138
+ // Start on the first house style (Overview) unless the form already names one on offer.
139
+ const house = c.video_styles.filter((s) => s.kind === "house");
140
+ if (house.length) setForm((f) => (house.some((s) => s.id === f.video_style) ? f : { ...f, video_style: house[0].id }));
141
  }).catch((e) => setLoadError(errorMessage(e)));
142
  loadVoices()
143
  .then((v) => v.saved[0] && pickVoice(v.saved[0]))
 
158
 
159
  const templates = catalog?.templates ?? [];
160
  const shown: VideoTemplate[] = templates;
161
+ // The house styles alone when the account has them; otherwise "Auto" and the rest, as before.
162
+ const listed = catalog?.video_styles ?? [];
163
+ const houseStyles = listed.some((s) => s.kind === "house");
164
+ const styles: VideoStyleItem[] = houseStyles ? listed : [{ id: "auto", name: "Auto", kind: "builtin" }, ...listed];
165
+ const picked = styles.find((s) => s.id === form.video_style);
166
  const saved = voices?.saved ?? [];
167
  const left = quota ? Math.max(quota.limit - quota.used, 0) : null;
168
 
 
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(() => {
200
+ if (!sourceReady || sourceKey === topicsFor.current) return;
201
+ const fields = sourceFields();
202
+ if (!fields) return;
203
+ const timer = setTimeout(() => {
204
+ topicsFor.current = sourceKey;
205
+ setTopics(null);
206
+ setFocus(null);
207
+ // An answer for a choice that has since changed is dropped (topicsFor moved on). A failure counts as no
208
+ // topics: the block hides, and this choice is not asked again.
209
+ videosApi.focusTopics(fields)
210
+ .then((r) => topicsFor.current === sourceKey && setTopics(r.topics))
211
+ .catch(() => topicsFor.current === sourceKey && setTopics([]));
212
+ }, 600);
213
+ return () => clearTimeout(timer);
214
+ // eslint-disable-next-line react-hooks/exhaustive-deps
215
+ }, [sourceKey, sourceReady]);
216
+ // Suggestions made for an earlier choice are never shown for this one: the cards wait for the new ones (and so
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 };
 
227
  setError(null);
228
  setBusy(true);
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)
 
239
 
240
  if (loadError) {
241
  return (
242
+ <div className={inModal ? "vw-in-modal" : "page-wrap"}>
243
+ {!inModal && <PageHeader eyebrow="Videos" title="New video" />}
244
  <p className="error-text">{loadError}</p>
245
  </div>
246
  );
 
248
  if (!config) return <Loading label="Loading" />;
249
  if (!config.configured) {
250
  return (
251
+ <div className={inModal ? "vw-in-modal" : "page-wrap"}>
252
+ {!inModal && <PageHeader eyebrow="Videos" title="New video" />}
253
  <p className="notice">Video is not set up on this server yet.</p>
254
  </div>
255
  );
 
258
  const currentVoice = saved.find((v) => v.voice_id === form.custom_voice_id);
259
 
260
  return (
261
+ <div className={inModal ? "stack vw-in-modal" : "page-wrap"}>
262
+ {inModal ? (
263
+ left !== null && <span className="mono muted small vw-modal-quota">{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left</span>
264
+ ) : (
265
+ <PageHeader eyebrow="Videos" title="New video">
266
+ {left !== null && (
267
+ <span className="mono muted">{left} of {quota!.limit} video{quota!.limit === 1 ? "" : "s"} left</span>
268
+ )}
269
+ </PageHeader>
270
+ )}
271
 
272
+ {step === 1 && !inModal && (
273
  // Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
274
  <button type="button" className="vw-back"
275
  onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/videos"))}>
 
277
  </button>
278
  )}
279
  <Steps step={step} />
280
+ <section className={inModal ? "stack vw" : "card stack vw"}>
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 && (
 
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">
 
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">
 
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>
426
  </>
427
  )}
frontend/src/pages/VideoEditor.tsx CHANGED
@@ -298,8 +298,16 @@ export default function VideoEditor() {
298
 
299
  const title = project?.artifact.title ?? "Video";
300
  const failed = !!status && (!!status.error || status.status === "failed");
 
 
 
301
  // First generation only: an edit job on a finished video has its own progress screen.
302
- const generating = !!status && !status.ready && !failed && !REVIEW.has(status.status) && !(status.status in JOBS);
 
 
 
 
 
303
  // The live preview only while blog2video says the video is ready (generated / done, or rendering its MP4). The preview link is minted once
304
  // and kept, so it is there in every state; mid-job it would show a half-changed video. Until /status answers, our
305
  // stored status decides.
@@ -374,10 +382,9 @@ export default function VideoEditor() {
374
  {generating && (
375
  <section className="card stack vw-making">
376
  <h2>Making your video</h2>
377
- <ol className="vw-progress" aria-label={`Step ${status?.step ?? 1} of ${STEPS.length}`}>
378
  {STEPS.map((s, i) => {
379
  const n = i + 1;
380
- const step = status?.step ?? 1;
381
  const done = n < step;
382
  return (
383
  <li key={s} className={done ? "done" : n === step ? "on" : ""} aria-current={n === step ? "step" : undefined}>
@@ -432,7 +439,9 @@ export default function VideoEditor() {
432
  {/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */}
433
  {fullScreenJob && running && <JobProgress label={running.label} name={title} state={job.data ?? null} />}
434
 
435
- {project && panelProps && !generating && !REVIEW.has(status?.status ?? "") && !fullScreenJob && (
 
 
436
  <div className="stack vw-editor-v2">
437
  <section className="card vw-preview-card wide">
438
  {project.preview_url && previewReady ? (
 
298
 
299
  const title = project?.artifact.title ?? "Video";
300
  const failed = !!status && (!!status.error || status.status === "failed");
301
+ // Until the first /status answer, our stored status decides (as for edit jobs), so reopening a video that is still
302
+ // being made goes straight to its progress screen instead of showing the editor first.
303
+ const stored = !status && genKey === 0 ? project?.artifact.status : null;
304
  // First generation only: an edit job on a finished video has its own progress screen.
305
+ const generating = status
306
+ ? !status.ready && !failed && !REVIEW.has(status.status) && !(status.status in JOBS)
307
+ : stored === "generating" || stored === "pending";
308
+ // Stored as waiting on a review or failed: /status says which screen, so the editor stays hidden until it answers.
309
+ const awaiting = stored === "review" || stored === "failed";
310
+ const step: number = status?.step ?? (typeof project?.artifact.content?.step === "number" ? project.artifact.content.step : 1);
311
  // The live preview only while blog2video says the video is ready (generated / done, or rendering its MP4). The preview link is minted once
312
  // and kept, so it is there in every state; mid-job it would show a half-changed video. Until /status answers, our
313
  // stored status decides.
 
382
  {generating && (
383
  <section className="card stack vw-making">
384
  <h2>Making your video</h2>
385
+ <ol className="vw-progress" aria-label={`Step ${step} of ${STEPS.length}`}>
386
  {STEPS.map((s, i) => {
387
  const n = i + 1;
 
388
  const done = n < step;
389
  return (
390
  <li key={s} className={done ? "done" : n === step ? "on" : ""} aria-current={n === step ? "step" : undefined}>
 
439
  {/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */}
440
  {fullScreenJob && running && <JobProgress label={running.label} name={title} state={job.data ?? null} />}
441
 
442
+ {awaiting && <div className="loading-center"><Loading label="Loading video" /></div>}
443
+
444
+ {project && panelProps && !generating && !awaiting && !REVIEW.has(status?.status ?? "") && !fullScreenJob && (
445
  <div className="stack vw-editor-v2">
446
  <section className="card vw-preview-card wide">
447
  {project.preview_url && previewReady ? (
frontend/src/styles/pages.css CHANGED
@@ -937,7 +937,7 @@ select.input option {
937
  grid-column: 2;
938
  width: 26px;
939
  height: 26px;
940
- opacity: 0;
941
  }
942
 
943
  .nbv-doc:hover .nbv-remove,
@@ -3017,10 +3017,10 @@ select.input,
3017
  font: 600 1.6rem var(--font-display, inherit);
3018
  }
3019
 
3020
- /* Notebook view: chat takes the room, one side panel with Create / Posts tabs */
3021
  @media (min-width: 1100px) {
3022
  .nbv {
3023
- grid-template-columns: minmax(0, 1fr) minmax(340px, 420px);
3024
  }
3025
  }
3026
 
@@ -3485,3 +3485,119 @@ details.map-legend[open] summary {
3485
  .tabs a.tab {
3486
  text-decoration: none;
3487
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
  grid-column: 2;
938
  width: 26px;
939
  height: 26px;
940
+ opacity: 0.55; /* always visible, so removing a post is easy to find */
941
  }
942
 
943
  .nbv-doc:hover .nbv-remove,
 
3017
  font: 600 1.6rem var(--font-display, inherit);
3018
  }
3019
 
3020
+ /* Notebook view: chat takes the room, one narrower side panel (Create, what was made, Posts) */
3021
  @media (min-width: 1100px) {
3022
  .nbv {
3023
+ grid-template-columns: minmax(0, 1fr) minmax(290px, 340px);
3024
  }
3025
  }
3026
 
 
3485
  .tabs a.tab {
3486
  text-decoration: none;
3487
  }
3488
+
3489
+ /* Notebook side panel: Create, what was made, then Posts, in one scrolling column */
3490
+ .nbv-section {
3491
+ display: grid;
3492
+ gap: 10px;
3493
+ padding-top: 14px;
3494
+ border-top: 1px solid var(--line);
3495
+ }
3496
+
3497
+ .nbv-section-head {
3498
+ display: flex;
3499
+ align-items: center;
3500
+ justify-content: space-between;
3501
+ gap: 10px;
3502
+ }
3503
+
3504
+ .nbv-section-head .mono {
3505
+ margin-left: 4px;
3506
+ letter-spacing: 0;
3507
+ }
3508
+
3509
+ .nbv-made-label {
3510
+ padding-top: 6px;
3511
+ }
3512
+
3513
+ /* What was made keeps its own scroll so a long list cannot push the posts far down */
3514
+ .nbv-made {
3515
+ max-height: 40vh;
3516
+ overflow-y: auto;
3517
+ scrollbar-width: thin;
3518
+ }
3519
+
3520
+ .nbv-side {
3521
+ scrollbar-width: thin;
3522
+ }
3523
+
3524
+ /* Create buttons: one per row, compact (Audio overview opens its options right under it) */
3525
+ .nbv-make {
3526
+ grid-template-columns: minmax(0, 1fr);
3527
+ }
3528
+
3529
+ .nbv-make > button,
3530
+ .nbv-make > a {
3531
+ display: flex;
3532
+ align-items: center;
3533
+ justify-content: space-between;
3534
+ gap: 10px;
3535
+ min-height: 46px;
3536
+ padding: 0 14px;
3537
+ }
3538
+
3539
+ /* Audio overview looks like the others (no highlight); its current format / length and chevron on the right */
3540
+ .nbv-make .nbv-make-main {
3541
+ grid-column: auto;
3542
+ display: flex; /* label left, format / length + chevron right (the older rule made it a stacked grid) */
3543
+ padding: 0 14px;
3544
+ border-color: var(--line);
3545
+ background: transparent;
3546
+ }
3547
+
3548
+ .nbv-make-meta {
3549
+ display: inline-flex;
3550
+ align-items: center;
3551
+ gap: 8px;
3552
+ }
3553
+
3554
+ /* Audio overview opened: a blue edge, its options panel just below */
3555
+ .nbv-make .nbv-make-main.open {
3556
+ border-color: var(--blue);
3557
+ }
3558
+
3559
+ .nbv-audio-opts {
3560
+ display: grid;
3561
+ gap: 12px;
3562
+ padding: 14px;
3563
+ border: 1px solid var(--blue-35);
3564
+ border-radius: var(--radius-sm);
3565
+ background: var(--blue-05);
3566
+ }
3567
+
3568
+ .nbv-audio-row {
3569
+ display: grid;
3570
+ grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); /* Format needs a little more room than Length */
3571
+ gap: 10px;
3572
+ }
3573
+
3574
+ /* Each dropdown fills its column and can shrink, instead of spilling into its neighbour */
3575
+ .nbv-audio-row > .field {
3576
+ min-width: 0;
3577
+ }
3578
+
3579
+ .nbv-audio-row > .field > * {
3580
+ width: 100%;
3581
+ min-width: 0;
3582
+ }
3583
+
3584
+ .nbv-audio-opts .dropdown-btn {
3585
+ padding: 0 12px;
3586
+ font-size: 0.9rem;
3587
+ }
3588
+
3589
+ .nbv-audio-opts .dropdown-btn {
3590
+ height: 40px;
3591
+ border-radius: 12px;
3592
+ }
3593
+
3594
+ .nbv-audio-opts > .btn {
3595
+ justify-self: end;
3596
+ }
3597
+
3598
+ .nbv-audio-opts .dropdown-value {
3599
+ min-width: 0;
3600
+ overflow: hidden;
3601
+ text-overflow: ellipsis;
3602
+ white-space: nowrap;
3603
+ }
frontend/src/styles/videos.css CHANGED
@@ -871,6 +871,126 @@ a.mc-action {
871
  color: var(--white);
872
  }
873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  .vw-premium-badge.small {
875
  padding: 1px 8px;
876
  font-size: 0.68rem;
@@ -3064,3 +3184,39 @@ a.mc-action {
3064
  justify-self: start;
3065
  font-size: 0.875rem;
3066
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
  color: var(--white);
872
  }
873
 
874
+ .vw-focus {
875
+ margin-bottom: 12px;
876
+ }
877
+
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;
895
+ text-align: left;
896
+ cursor: pointer;
897
+ }
898
+
899
+ .vw-focus-card > .icon {
900
+ flex-shrink: 0;
901
+ margin-top: 2px;
902
+ }
903
+
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 */
916
+ .vw-focus-desc {
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;
925
+ }
926
+
927
+ .vw-focus-card.on {
928
+ border-color: var(--blue);
929
+ }
930
+
931
+ .vw-focus-card.wait {
932
+ cursor: default;
933
+ color: var(--text-2);
934
+ }
935
+
936
+ /* The loading line in place of a topic: a light-to-dark bar with a sweep across it */
937
+ .vw-focus-bar {
938
+ display: block;
939
+ height: 14px;
940
+ border-radius: 6px;
941
+ background: linear-gradient(90deg, #b8bcc6, #3d4658, #b8bcc6);
942
+ background-size: 200% 100%;
943
+ opacity: 0.85;
944
+ animation: vw-focus-sweep 1.6s linear infinite;
945
+ }
946
+
947
+ .vw-focus-bar.short {
948
+ width: 60%;
949
+ height: 10px;
950
+ }
951
+
952
+ @keyframes vw-focus-sweep {
953
+ from { background-position: 100% 0; }
954
+ to { background-position: -100% 0; }
955
+ }
956
+
957
+ @media (prefers-reduced-motion: reduce) {
958
+ .vw-focus-bar {
959
+ animation: none;
960
+ background: linear-gradient(90deg, #b8bcc6, #3d4658);
961
+ }
962
+ }
963
+
964
+ @media (max-width: 720px) {
965
+ .vw-focus-grid {
966
+ grid-template-columns: 1fr;
967
+ }
968
+ }
969
+
970
+ /* Video style pills: a little wider than the generic chips */
971
+ .vw-style-chips .vw-chip {
972
+ min-width: 132px;
973
+ justify-content: center;
974
+ padding-inline: 24px;
975
+ }
976
+
977
+ .vw-style-chips .vw-chip.on,
978
+ .vw-style-chips .vw-chip[aria-pressed="true"] {
979
+ border-color: var(--blue);
980
+ background: var(--blue);
981
+ color: var(--white);
982
+ box-shadow: var(--glow-sm);
983
+ }
984
+
985
+ .vw-style-note {
986
+ display: -webkit-box;
987
+ margin-top: 10px;
988
+ overflow: hidden;
989
+ -webkit-box-orient: vertical;
990
+ -webkit-line-clamp: 3; /* the description is written to fit three lines */
991
+ line-clamp: 3;
992
+ }
993
+
994
  .vw-premium-badge.small {
995
  padding: 1px 8px;
996
  font-size: 0.68rem;
 
3184
  justify-self: start;
3185
  font-size: 0.875rem;
3186
  }
3187
+
3188
+ /* Modals that scroll (e.g. Edit Scene): a very thin, quiet scrollbar */
3189
+ .modal {
3190
+ scrollbar-width: thin; /* Firefox */
3191
+ scrollbar-color: var(--line-strong, rgba(255, 255, 255, 0.2)) transparent;
3192
+ }
3193
+
3194
+ .modal::-webkit-scrollbar {
3195
+ width: 4px;
3196
+ }
3197
+
3198
+ .modal::-webkit-scrollbar-track {
3199
+ background: transparent;
3200
+ }
3201
+
3202
+ .modal::-webkit-scrollbar-thumb {
3203
+ border-radius: 999px;
3204
+ background: var(--line-strong, rgba(255, 255, 255, 0.2));
3205
+ }
3206
+
3207
+ .modal::-webkit-scrollbar-thumb:hover {
3208
+ background: var(--text-3, rgba(255, 255, 255, 0.4));
3209
+ }
3210
+
3211
+ /* The video wizard inside a modal (from a notebook): a wider modal, no page-level width or outer card */
3212
+ .modal-wide:has(.vw-in-modal) {
3213
+ width: min(700px, 100%);
3214
+ }
3215
+
3216
+ .vw-in-modal .vw {
3217
+ max-width: none;
3218
+ }
3219
+
3220
+ .vw-modal-quota {
3221
+ justify-self: end;
3222
+ }