Spaces:
Runtime error
Runtime error
Commit ·
db8dd62
1
Parent(s): 070c931
video style added
Browse files- backend/app/llm/provider.py +12 -0
- backend/app/llm/signatures.py +17 -0
- backend/app/routers/notebooks.py +15 -0
- backend/app/routers/videos.py +203 -26
- backend/app/services/b2v_access.py +35 -1
- backend/tests/test_videos.py +226 -0
- frontend/src/api/endpoints.ts +7 -0
- frontend/src/api/types.ts +16 -1
- frontend/src/components/DocPicker.tsx +11 -7
- frontend/src/components/video/SourcePicker.tsx +117 -51
- frontend/src/components/video/sourceCache.ts +25 -1
- frontend/src/pages/NotebookView.tsx +96 -77
- frontend/src/pages/VideoCreate.tsx +148 -39
- frontend/src/pages/VideoEditor.tsx +13 -4
- frontend/src/styles/pages.css +119 -3
- frontend/src/styles/videos.css +156 -0
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.
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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.
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
if
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
text = text.strip()
|
| 235 |
if len(text) < MIN_CONTENT:
|
| 236 |
raise HTTPException(400, f"Add at least {MIN_CONTENT} characters of text")
|
| 237 |
-
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 43 |
-
// unticking it clears them.
|
| 44 |
const pickable = visible.filter((d) => !d.locked);
|
| 45 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 52 |
-
onChange(
|
| 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 {
|
| 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),
|
| 11 |
-
|
|
|
|
| 12 |
|
| 13 |
const ONE_POST = "__post";
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
/** Step 1's source: a notebook (
|
| 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 |
-
|
| 32 |
-
//
|
| 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 |
-
|
|
|
|
| 47 |
const cached = sourceCache.docs.get(notebookId);
|
| 48 |
setDocs(cached ?? null);
|
| 49 |
-
if (cached) onPostIds(
|
|
|
|
|
|
|
| 50 |
loadDocs(notebookId).then((fresh) => {
|
| 51 |
if (!live) return;
|
| 52 |
setDocs(fresh);
|
| 53 |
-
if (!cached) return onPostIds(
|
| 54 |
-
// Keep the user's ticks through the refresh: drop posts that are gone, tick ones
|
| 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 |
-
{
|
| 118 |
-
<
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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 |
-
: `
|
| 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 {
|
|
|
|
| 10 |
import { Reader } from "../components/Reader";
|
| 11 |
-
import {
|
|
|
|
|
|
|
| 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 |
-
/**
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</span>
|
| 57 |
</button>
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<button disabled={off} onClick={() => make({ type: "summary" })}>
|
| 62 |
-
Summary
|
| 63 |
</button>
|
| 64 |
<button disabled={off} onClick={() => make({ type: "quote_card" })}>
|
| 65 |
-
Quote
|
| 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 |
-
<
|
|
|
|
| 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 |
-
<
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 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 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
</button>
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 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", //
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 === "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 175 |
-
|
| 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 244 |
-
<
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ${
|
| 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 |
-
{
|
|
|
|
|
|
|
| 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
|
| 3021 |
@media (min-width: 1100px) {
|
| 3022 |
.nbv {
|
| 3023 |
-
grid-template-columns: minmax(0, 1fr) minmax(
|
| 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 |
+
}
|