Spaces:
Runtime error
Runtime error
Merge pull request #6 from FireBird-Technologies/feat/slide-deck
Browse files- backend/app/db.py +7 -2
- backend/app/infographics/image.py +19 -6
- backend/app/llm/signatures.py +183 -7
- backend/app/pipeline/ingest.py +3 -0
- backend/app/pipeline/media.py +61 -21
- backend/app/pipeline/slides.py +374 -0
- backend/app/routers/artifacts.py +87 -5
- backend/app/routers/launchpad.py +82 -20
- backend/app/services/artifacts.py +8 -2
- backend/app/services/launchpad.py +3 -1
- backend/app/services/social/media.py +32 -3
- backend/app/services/social/x.py +1 -1
- backend/app/slides/__init__.py +3 -0
- backend/app/slides/build.py +151 -0
- backend/app/slides/content.py +204 -0
- backend/app/slides/decor.py +185 -0
- backend/app/slides/export.py +356 -0
- backend/app/slides/layout.py +734 -0
- backend/app/slides/metrics.py +39 -0
- backend/app/slides/render.py +184 -0
- backend/app/slides/themes.py +47 -0
- backend/app/worker.py +16 -3
- backend/requirements.txt +1 -0
- backend/scripts/render_slide_thumbnails.py +44 -0
- backend/tests/test_buildout.py +59 -5
- backend/tests/test_slides.py +590 -0
- backend/tests/test_social_posting.py +170 -2
- frontend/src/App.tsx +6 -6
- frontend/src/api/client.ts +6 -1
- frontend/src/api/endpoints.ts +36 -2
- frontend/src/api/types.ts +7 -2
- frontend/src/components/AppShell.tsx +2 -0
- frontend/src/components/ArtifactCard.tsx +1 -1
- frontend/src/components/ArtifactRow.tsx +23 -3
- frontend/src/components/AudioOverview.tsx +318 -0
- frontend/src/components/PaneResizer.tsx +188 -0
- frontend/src/components/ScheduleModal.tsx +6 -5
- frontend/src/components/SlideDeck.tsx +1054 -0
- frontend/src/components/icons/Icons.tsx +56 -0
- frontend/src/components/launchpad/Attachment.tsx +89 -14
- frontend/src/components/launchpad/PostButton.tsx +3 -2
- frontend/src/components/slideDeckEdit.ts +173 -0
- frontend/src/components/ui.tsx +45 -3
- frontend/src/pages/Archive.tsx +40 -13
- frontend/src/pages/Launchpad.tsx +72 -9
- frontend/src/pages/NotebookView.tsx +62 -97
- frontend/src/pages/VideoCreate.tsx +1 -1
- frontend/src/pages/VoiceProfile.tsx +40 -187
- frontend/src/pages/Voices.tsx +12 -0
- frontend/src/styles/pages.css +1158 -100
backend/app/db.py
CHANGED
|
@@ -11,8 +11,13 @@ class Base(DeclarativeBase):
|
|
| 11 |
|
| 12 |
|
| 13 |
_sqlite = settings.database_url.startswith("sqlite")
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
if _sqlite:
|
| 18 |
# Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
_sqlite = settings.database_url.startswith("sqlite")
|
| 14 |
+
# Postgres: a connection that dies silently (laptop sleep, network change, Neon dropping it) otherwise leaves a query
|
| 15 |
+
# waiting on the socket forever, and the worker loop with it. Keepalives notice a dead peer within about a minute
|
| 16 |
+
# and the query fails instead; connect_timeout does the same for a connect that never completes.
|
| 17 |
+
_pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
|
| 18 |
+
"keepalives_count": 3}
|
| 19 |
+
_connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
|
| 20 |
+
engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=300, connect_args=_connect_args)
|
| 21 |
|
| 22 |
if _sqlite:
|
| 23 |
# Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
|
backend/app/infographics/image.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""An infographic's PNG: its stored HTML page drawn by a headless Chrome at the page's own size. No Remotion, no
|
| 2 |
browser library: one `chrome --headless --screenshot` run per download (the result is kept for the next one)."""
|
| 3 |
|
|
|
|
| 4 |
import glob
|
| 5 |
import hashlib
|
| 6 |
import os
|
|
@@ -24,17 +25,29 @@ class ImageUnavailable(Exception):
|
|
| 24 |
"""No Chrome to draw with, or it failed."""
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def chrome() -> str | None:
|
| 28 |
-
"""CHROME, or a Chrome / Chromium found on the machine."""
|
| 29 |
if env := os.environ.get("CHROME"):
|
| 30 |
return env if Path(env).exists() else None
|
| 31 |
-
for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome")
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/"
|
| 36 |
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def render_png(html: str, layout: str = "portrait") -> bytes:
|
|
|
|
| 1 |
"""An infographic's PNG: its stored HTML page drawn by a headless Chrome at the page's own size. No Remotion, no
|
| 2 |
browser library: one `chrome --headless --screenshot` run per download (the result is kept for the next one)."""
|
| 3 |
|
| 4 |
+
import functools
|
| 5 |
import glob
|
| 6 |
import hashlib
|
| 7 |
import os
|
|
|
|
| 25 |
"""No Chrome to draw with, or it failed."""
|
| 26 |
|
| 27 |
|
| 28 |
+
@functools.cache
|
| 29 |
+
def _runs(path: str) -> bool:
|
| 30 |
+
"""Whether the binary actually starts (a package manager's wrapper can outlive the browser it points at)."""
|
| 31 |
+
try:
|
| 32 |
+
return subprocess.run([path, "--version"], capture_output=True, timeout=20).returncode == 0
|
| 33 |
+
except (subprocess.SubprocessError, OSError):
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def chrome() -> str | None:
|
| 38 |
+
"""CHROME, or a working Chrome / Chromium found on the machine."""
|
| 39 |
if env := os.environ.get("CHROME"):
|
| 40 |
return env if Path(env).exists() else None
|
| 41 |
+
found = [p for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome")
|
| 42 |
+
if (p := shutil.which(name))]
|
| 43 |
+
# Playwright's headless shell first: desktop Chrome on macOS can hang taking a headless screenshot.
|
| 44 |
+
shell = "chromium_headless_shell-*/chrome-headless-shell-*/chrome-headless-shell"
|
| 45 |
+
patterns = [os.path.expanduser(f"~/Library/Caches/ms-playwright/{shell}"), os.path.expanduser(f"~/.cache/ms-playwright/{shell}"),
|
| 46 |
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
| 47 |
os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/"
|
| 48 |
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
|
| 49 |
+
found += [p for pat in patterns for p in glob.glob(pat)]
|
| 50 |
+
return next((p for p in found if _runs(p)), None)
|
| 51 |
|
| 52 |
|
| 53 |
def render_png(html: str, layout: str = "portrait") -> bytes:
|
backend/app/llm/signatures.py
CHANGED
|
@@ -260,14 +260,25 @@ class ExtractClaims(dspy.Signature):
|
|
| 260 |
claims: list[Claim] = dspy.OutputField()
|
| 261 |
|
| 262 |
|
| 263 |
-
class
|
| 264 |
-
"""Write
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
class HookGenerator(dspy.Signature):
|
|
@@ -680,3 +691,168 @@ class VideoFocusAngles(dspy.Signature):
|
|
| 680 |
desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
|
| 681 |
avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
|
| 682 |
topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
claims: list[Claim] = dspy.OutputField()
|
| 261 |
|
| 262 |
|
| 263 |
+
class WriteAudioScript(dspy.Signature):
|
| 264 |
+
"""Write the script of an audio overview of the material, to be read aloud. Use only what the material says:
|
| 265 |
+
never add facts, numbers or names from outside it, and cite the lines each turn draws on. Follow the focus when
|
| 266 |
+
one is given: it says what to cover, what to emphasize and what to skip, as far as the material supports it. Write
|
| 267 |
+
in the style given. With two hosts, write a real back and forth: host_a and host_b take turns, every turn is one
|
| 268 |
+
to three sentences, and they react to each other (a question, a surprise, an example, a disagreement in a
|
| 269 |
+
debate). With one host, write a single narrator speaking straight to the listener, every turn host_a. Make it
|
| 270 |
+
sound spoken: plain sentences, no markdown, no lists, no stage directions, no sound effects, no reading out of
|
| 271 |
+
file names or line numbers. Aim for the target number of words. Write in the language requested. Never use em
|
| 272 |
+
dashes."""
|
| 273 |
|
| 274 |
+
title: str = dspy.InputField(desc="What the material is from")
|
| 275 |
+
material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
|
| 276 |
+
style: str = dspy.InputField(desc="The style's name and what it should sound like")
|
| 277 |
+
hosts: int = dspy.InputField(desc="1 (a single narrator) or 2 (a conversation)")
|
| 278 |
+
target_words: int = dspy.InputField(desc="About how many words the whole script should be")
|
| 279 |
+
focus: str = dspy.InputField(desc="What the person wants the episode to focus on, or (none)")
|
| 280 |
+
language: str = dspy.InputField(desc="The language to write in")
|
| 281 |
+
turns: list[ScriptLine] = dspy.OutputField()
|
| 282 |
|
| 283 |
|
| 284 |
class HookGenerator(dspy.Signature):
|
|
|
|
| 691 |
desc="Topics this material is about: 'Name (in how many items): what it covers'. May be empty")
|
| 692 |
avoid_titles: list[str] = dspy.InputField(desc="Titles already suggested. Do not repeat or reword them")
|
| 693 |
topics: list[FocusAngle] = dspy.OutputField(desc="Exactly 3 focus topics")
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
# Slide decks: an outline, each slide written from the lines it cites, every claim checked against those lines,
|
| 697 |
+
# then a conclusion written from the slides themselves. The model writes words only; code lays them out.
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
class DeckSlidePlan(BaseModel):
|
| 701 |
+
layout: Literal["section", "points", "two_column", "stat", "quote"] = Field(
|
| 702 |
+
description="points: an idea explained in 2 to 5 parts; two_column: a comparison of two sides; stat: one "
|
| 703 |
+
"number the material states; quote: one line the material says word for word; section: a short "
|
| 704 |
+
"turning point between parts")
|
| 705 |
+
heading: str = Field(description="The slide's headline: a claim or a clear topic, at most 9 words")
|
| 706 |
+
purpose: str = Field(description="What this slide must get across, in one sentence")
|
| 707 |
+
sources: list[SourceRef] = Field(default_factory=list, description="The lines this slide draws on")
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
class DeckOutline(BaseModel):
|
| 711 |
+
title: str = Field(description="The deck's title, at most 8 words")
|
| 712 |
+
subtitle: str = Field(description="One line that makes the audience want to listen, at most 18 words")
|
| 713 |
+
opening_kicker: str = Field(description="1 to 3 words above the title naming the field or the occasion")
|
| 714 |
+
agenda_heading: str = Field(description="Heading for the agenda slide, e.g. What we will cover")
|
| 715 |
+
slides: list[DeckSlidePlan]
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
class OutlineDeck(dspy.Signature):
|
| 719 |
+
"""Plan a slide deck from the material: the story it tells, in order, as content slides (an opening slide, an agenda
|
| 720 |
+
and a conclusion are added separately, so do not plan them). Use only what the material says. Each slide makes
|
| 721 |
+
one point and builds on the one before, so the deck reads as an argument from start to finish, not a list of
|
| 722 |
+
facts, and no two slides cover the same facts. When the posts are about unrelated subjects, give each its own
|
| 723 |
+
part of the deck and never connect them: a fact from one post is never said of another post's subject, in a
|
| 724 |
+
slide, the title or the subtitle (and the deck does not remark on keeping them apart). Vary the layouts: never
|
| 725 |
+
put two slides of the same layout in a row when another layout fits, use a stat slide only for a number the
|
| 726 |
+
material states, a quote slide only for a line the material says word for word, and a two_column slide only when
|
| 727 |
+
the material compares two things. The request, when given, decides the deck's focus, audience, angle, structure
|
| 728 |
+
and what to include or leave out: plan the deck it describes, as far as the material supports it, and leave out
|
| 729 |
+
what the material does not cover rather than invent it. The number of slides and the format are fixed by
|
| 730 |
+
slide_count and deck_format, whatever the request says. Cite the lines each slide draws on. Follow the request
|
| 731 |
+
when one is given. Write the headings in the language requested. Never use em dashes."""
|
| 732 |
+
|
| 733 |
+
title: str = dspy.InputField(desc="What the material is from")
|
| 734 |
+
material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
|
| 735 |
+
request: str = dspy.InputField(desc="What the person wants the deck to be about or how it should feel, or (none)")
|
| 736 |
+
deck_format: str = dspy.InputField(desc="detailed (read on its own) or presenter (shown while someone speaks)")
|
| 737 |
+
slide_count: str = dspy.InputField(desc="How many content slides to plan, e.g. 3 to 5")
|
| 738 |
+
language: str = dspy.InputField(desc="The language to write in")
|
| 739 |
+
outline: DeckOutline = dspy.OutputField()
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
class SlidePoint(BaseModel):
|
| 743 |
+
term: str = Field(default="", description="Detailed: the idea's name in 1 to 4 words. Presenter: leave empty")
|
| 744 |
+
text: str = Field(description="Detailed: one or two sentences that explain the idea, at most 30 words. "
|
| 745 |
+
"Presenter: a highlight of at most 8 words")
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
class SlideColumn(BaseModel):
|
| 749 |
+
label: str = Field(description="The side's name, 1 to 3 words")
|
| 750 |
+
items: list[str] = Field(description="2 to 4 short items, at most 14 words each (presenter: 8)")
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
class SlideStat(BaseModel):
|
| 754 |
+
value: str = Field(description="The number as the material gives it, with its sign or unit, e.g. #1, 3x, 42%, $9")
|
| 755 |
+
label: str = Field(description="What the number measures, at most 16 words")
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
class SlideQuote(BaseModel):
|
| 759 |
+
text: str = Field(description="Copied word for word from the source lines, at most 40 words")
|
| 760 |
+
by: str = Field(default="", description="Who said it, when the material says, else empty")
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
class SlideOut(BaseModel):
|
| 764 |
+
kicker: str = Field(description="1 to 3 words above the heading: the slide's topic")
|
| 765 |
+
heading: str = Field(description="The headline, at most 9 words (presenter: 7)")
|
| 766 |
+
lead: str = Field(default="", description="Detailed: 1 or 2 sentences framing the slide, at most 35 words. "
|
| 767 |
+
"Presenter: one short line or empty")
|
| 768 |
+
points: list[SlidePoint] = Field(default_factory=list, description="For points slides: detailed 3 to 5, presenter 2 to 4")
|
| 769 |
+
left: SlideColumn | None = Field(default=None, description="For two_column slides: the first side")
|
| 770 |
+
right: SlideColumn | None = Field(default=None, description="For two_column slides: the second side")
|
| 771 |
+
stat: SlideStat | None = Field(default=None, description="For stat slides")
|
| 772 |
+
quote: SlideQuote | None = Field(default=None, description="For quote slides")
|
| 773 |
+
notes: str = Field(description="Speaker notes: what to say over this slide, 60 to 150 words, explaining it fully")
|
| 774 |
+
sources: list[SourceRef] = Field(default_factory=list, description="The lines this slide's words come from")
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
class WriteSlide(dspy.Signature):
|
| 778 |
+
"""Write one slide of a deck, using only the source lines given: never add facts, numbers or names from outside them.
|
| 779 |
+
The slide must make the point its plan describes and fit the deck's flow. Detailed format: the slide is read on
|
| 780 |
+
its own, so explain each idea: a clear heading, a lead that frames it, and points that each name an idea and
|
| 781 |
+
explain it in a full sentence (why it matters or how it works, not just what it is). Presenter format: the slide
|
| 782 |
+
supports a speaker, so keep it sparse: a short heading and a few highlights of a handful of words, and put the
|
| 783 |
+
full explanation in the speaker notes. Say only what this slide is for: the other slides in the outline cover
|
| 784 |
+
the rest, so do not repeat them. Apply the request's audience, tone and style to this slide, and anything it
|
| 785 |
+
asks of this part of the deck, as far as the source lines support it. Fill only the fields the layout uses. Keep
|
| 786 |
+
every line plain text: no markdown, no bullets characters, no numbering. Write in the language requested. Never
|
| 787 |
+
use em dashes."""
|
| 788 |
+
|
| 789 |
+
deck_title: str = dspy.InputField()
|
| 790 |
+
outline: list[str] = dspy.InputField(desc="Every slide in the deck, in order, so this one fits the flow")
|
| 791 |
+
position: str = dspy.InputField(desc="Which slide this is, e.g. Slide 3 of 8")
|
| 792 |
+
layout: str = dspy.InputField(desc="points, two_column, stat, quote or section")
|
| 793 |
+
heading: str = dspy.InputField(desc="The planned headline (improve it if the sources suggest a sharper one)")
|
| 794 |
+
purpose: str = dspy.InputField(desc="What the slide must get across")
|
| 795 |
+
deck_format: str = dspy.InputField(desc="detailed or presenter")
|
| 796 |
+
request: str = dspy.InputField(desc="What the person asked of the deck, or (none)")
|
| 797 |
+
source_lines: list[str] = dspy.InputField(desc="The lines to write from, with path and line numbers")
|
| 798 |
+
language: str = dspy.InputField(desc="The language to write in")
|
| 799 |
+
slide: SlideOut = dspy.OutputField()
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
class ClaimCheck(BaseModel):
|
| 803 |
+
n: int = Field(description="The claim's number")
|
| 804 |
+
supported: bool = Field(description="True when the source lines say this (paraphrase is fine)")
|
| 805 |
+
fix: str = Field(default="", description="When not supported: the claim rewritten so the sources support it, in "
|
| 806 |
+
"the same style and length, or empty when they say nothing close")
|
| 807 |
+
|
| 808 |
+
|
| 809 |
+
class CheckSlide(dspy.Signature):
|
| 810 |
+
"""Check each numbered claim from a slide against the source lines. A claim is supported when the lines say it, in
|
| 811 |
+
other words or in fewer words. It is not supported when it adds a fact, number, name, cause or certainty the
|
| 812 |
+
lines do not give, or changes what they mean. For each unsupported claim, give a fixed version the lines do
|
| 813 |
+
support, or leave the fix empty. A fix is only the sentence itself, in the slide's own voice: no name or label
|
| 814 |
+
before it, and never a mention of the sources, the post, the resume or the material. Keep the claim's language.
|
| 815 |
+
Never use em dashes."""
|
| 816 |
+
|
| 817 |
+
claims: list[str] = dspy.InputField(desc="Numbered claims from one slide (a name before a colon is context only: "
|
| 818 |
+
"check and fix only what follows it)")
|
| 819 |
+
source_lines: list[str] = dspy.InputField(desc="What the slide was written from")
|
| 820 |
+
checks: list[ClaimCheck] = dspy.OutputField(desc="One check per claim")
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
class DeckClosing(BaseModel):
|
| 824 |
+
kicker: str = Field(description="1 to 2 words above the heading, e.g. Conclusion")
|
| 825 |
+
heading: str = Field(description="Heading for the closing slide, e.g. Key takeaways")
|
| 826 |
+
takeaways: list[str] = Field(description="Exactly 3 takeaways, each a full sentence of at most 20 words "
|
| 827 |
+
"(presenter: 10)")
|
| 828 |
+
closing: str = Field(description="The one idea to remember, at most 12 words: a strong last line, not a thank you")
|
| 829 |
+
notes: str = Field(description="Speaker notes for the close, 50 to 120 words")
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
class WriteClosing(dspy.Signature):
|
| 833 |
+
"""Write the closing slide of a deck from its slides: three takeaways that sum up what the deck showed, in the order it
|
| 834 |
+
showed them, and one closing line that leaves the audience with the single idea to remember. Follow the request
|
| 835 |
+
when it asks something of the ending (for example next steps, a call to action or who it is for), as far as the
|
| 836 |
+
slides support it. Use only what the slides say. Write in the language requested. Never use em dashes."""
|
| 837 |
+
|
| 838 |
+
deck_title: str = dspy.InputField()
|
| 839 |
+
slides: list[str] = dspy.InputField(desc="Each content slide's heading and main text, in order")
|
| 840 |
+
deck_format: str = dspy.InputField(desc="detailed or presenter")
|
| 841 |
+
request: str = dspy.InputField(desc="What the person asked of the deck, or (none)")
|
| 842 |
+
language: str = dspy.InputField(desc="The language to write in")
|
| 843 |
+
closing: DeckClosing = dspy.OutputField()
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
class RequestAsk(BaseModel):
|
| 847 |
+
ask: str = Field(description="One concrete thing the person asked of the deck, in a few words")
|
| 848 |
+
covered: bool = Field(description="True when the planned slides already do it")
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
class CheckRequest(dspy.Signature):
|
| 852 |
+
"""Read what the person asked of a slide deck and list each concrete ask in it: a topic or part to cover, an
|
| 853 |
+
audience or angle, a structure, something to include, to leave out, or to end with. For each, say whether the
|
| 854 |
+
planned slides already do it. Ignore asks about the number of slides or the visual style. Never use em dashes."""
|
| 855 |
+
|
| 856 |
+
request: str = dspy.InputField(desc="What the person asked of the deck")
|
| 857 |
+
slides: list[str] = dspy.InputField(desc="The planned slides: heading, then what each must get across")
|
| 858 |
+
asks: list[RequestAsk] = dspy.OutputField()
|
backend/app/pipeline/ingest.py
CHANGED
|
@@ -551,6 +551,9 @@ def store_entries(
|
|
| 551 |
if not sections:
|
| 552 |
skipped += 1
|
| 553 |
continue
|
|
|
|
|
|
|
|
|
|
| 554 |
if not doc:
|
| 555 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 556 |
db.add(doc)
|
|
|
|
| 551 |
if not sections:
|
| 552 |
skipped += 1
|
| 553 |
continue
|
| 554 |
+
# Postgres text cannot hold NUL bytes, and some PDFs (icon glyphs) extract with them.
|
| 555 |
+
sections = [(h and h.replace("\x00", ""), t.replace("\x00", "")) for h, t in sections]
|
| 556 |
+
entry.title = entry.title.replace("\x00", "")
|
| 557 |
if not doc:
|
| 558 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 559 |
db.add(doc)
|
backend/app/pipeline/media.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
"""Audio overviews (two hosts, ElevenLabs), audiograms and stills (Remotion renderer)."""
|
| 2 |
|
|
|
|
| 3 |
import uuid
|
| 4 |
|
| 5 |
from sqlalchemy import select
|
|
@@ -8,10 +9,12 @@ from sqlalchemy.orm import Session
|
|
| 8 |
from app.config import settings
|
| 9 |
from app.corpus import Corpus
|
| 10 |
from app.llm import run
|
| 11 |
-
from app.llm.signatures import PickQuotes,
|
| 12 |
from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
|
| 13 |
from app.pipeline.generate import NothingToDo
|
|
|
|
| 14 |
from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
|
|
|
|
| 15 |
from app.services import tts
|
| 16 |
from app.services.jobs import record_usage, update_job
|
| 17 |
from app.services.renderer import PermanentJobError, brand_props, request_render
|
|
@@ -48,28 +51,61 @@ def _require_tts() -> None:
|
|
| 48 |
# Audio overview
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
| 52 |
_require_tts()
|
| 53 |
-
params = job.params
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
if not lines:
|
| 65 |
raise RuntimeError("The script came back empty")
|
| 66 |
-
tools = tools_for(corpus, docs)
|
| 67 |
default_a, default_b = host_voices(db, artifact.workspace_id)
|
| 68 |
voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b # picked when made
|
| 69 |
delivery = host_settings(db, artifact.workspace_id)
|
| 70 |
script = [
|
| 71 |
-
tts.Line(text=ln["text"], voice_id=voice_b if ln
|
| 72 |
-
settings=delivery[
|
| 73 |
for ln in lines
|
| 74 |
]
|
| 75 |
update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")
|
|
@@ -80,14 +116,14 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 80 |
message=f"Recorded {done} of {len(script)} lines")
|
| 81 |
|
| 82 |
clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
|
|
|
|
| 83 |
audio = bytearray()
|
| 84 |
segments = []
|
| 85 |
t = 0.0
|
| 86 |
for line, clip in zip(lines, clips, strict=True):
|
| 87 |
seconds = tts.mp3_seconds(clip)
|
| 88 |
-
segments.append({"speaker": line
|
| 89 |
-
"start": round(t, 2), "end": round(t + seconds, 2),
|
| 90 |
-
"sources": verify_refs(tools, line.get("sources") or [])})
|
| 91 |
audio += clip
|
| 92 |
t += seconds
|
| 93 |
key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
|
|
@@ -96,10 +132,14 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
|
| 96 |
artifact.status = "ready"
|
| 97 |
artifact.content_json = {
|
| 98 |
**(artifact.content_json or {}),
|
| 99 |
-
"format":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
"duration_s": round(t, 1),
|
| 101 |
"segments": segments,
|
| 102 |
-
"voices": {"host_a": voice_a, "host_b": voice_b},
|
| 103 |
}
|
| 104 |
db.commit()
|
| 105 |
record_usage(db, workspace_id=artifact.workspace_id, kind="tts", provider="elevenlabs",
|
|
|
|
| 1 |
+
"""Audio overviews (one or two hosts, ElevenLabs), audiograms and stills (Remotion renderer)."""
|
| 2 |
|
| 3 |
+
import re
|
| 4 |
import uuid
|
| 5 |
|
| 6 |
from sqlalchemy import select
|
|
|
|
| 9 |
from app.config import settings
|
| 10 |
from app.corpus import Corpus
|
| 11 |
from app.llm import run
|
| 12 |
+
from app.llm.signatures import PickQuotes, WriteAudioScript
|
| 13 |
from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
|
| 14 |
from app.pipeline.generate import NothingToDo
|
| 15 |
+
from app.pipeline.material import load_material
|
| 16 |
from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
|
| 17 |
+
from app.pipeline.report import SAME_LANGUAGE
|
| 18 |
from app.services import tts
|
| 19 |
from app.services.jobs import record_usage, update_job
|
| 20 |
from app.services.renderer import PermanentJobError, brand_props, request_render
|
|
|
|
| 51 |
# Audio overview
|
| 52 |
|
| 53 |
|
| 54 |
+
STYLES = {
|
| 55 |
+
"deep_dive": "Deep Dive: a lively, curious conversation that unpacks the ideas and connects them to each other",
|
| 56 |
+
"brief": "Brief: a short, bite sized overview that gets the core ideas across quickly",
|
| 57 |
+
"critique": "Critique: an expert review of the material, with honest, constructive feedback on how to improve it",
|
| 58 |
+
"debate": "Debate: two hosts take different sides on the material's main questions and argue them respectfully",
|
| 59 |
+
}
|
| 60 |
+
WORDS_PER_MINUTE = 150
|
| 61 |
+
_MARKUP = re.compile(r"(\*\*|__|`|#{1,6}\s|^\s*[-*•]\s+|\[\d+\])", re.M)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def script_turns(turns: list[dict], hosts: int) -> list[dict]:
|
| 65 |
+
"""The model's turns ready to voice: empty ones dropped and markup removed. With two hosts, consecutive turns by
|
| 66 |
+
one host are joined and the speakers then strictly alternate, host A first, so the conversation always goes A, B,
|
| 67 |
+
A, B. With one host, every turn is host A's."""
|
| 68 |
+
clean = []
|
| 69 |
+
for t in turns or []:
|
| 70 |
+
text = re.sub(r"\s+", " ", _MARKUP.sub("", str((t or {}).get("text") or ""))).replace("\u2014", ", ").strip()
|
| 71 |
+
if text:
|
| 72 |
+
clean.append({"speaker": (t or {}).get("speaker") or "host_a", "text": text,
|
| 73 |
+
"sources": list((t or {}).get("sources") or [])})
|
| 74 |
+
if hosts == 1:
|
| 75 |
+
return [{**t, "speaker": "host_a"} for t in clean]
|
| 76 |
+
merged: list[dict] = []
|
| 77 |
+
for t in clean:
|
| 78 |
+
if merged and merged[-1]["speaker"] == t["speaker"]:
|
| 79 |
+
merged[-1]["text"] += " " + t["text"]
|
| 80 |
+
merged[-1]["sources"] += t["sources"]
|
| 81 |
+
else:
|
| 82 |
+
merged.append(dict(t))
|
| 83 |
+
return [{**t, "speaker": "host_a" if i % 2 == 0 else "host_b"} for i, t in enumerate(merged)]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
| 87 |
_require_tts()
|
| 88 |
+
params = dict(job.params)
|
| 89 |
+
if artifact.document_id and not params.get("document_ids") and not params.get("chat_ids"):
|
| 90 |
+
params["document_ids"] = [str(artifact.document_id)] # made from one post
|
| 91 |
+
fmt = params.get("format") if params.get("format") in STYLES else "deep_dive"
|
| 92 |
+
hosts = 1 if params.get("hosts") == 1 else 2 # older jobs had no choice: two hosts
|
| 93 |
+
minutes = int(params.get("minutes", 6))
|
| 94 |
+
focus = (params.get("instructions") or "").strip()
|
| 95 |
+
material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="audio overview")
|
| 96 |
+
update_job(db, job, progress=0.15, message="Writing the conversation" if hosts == 2 else "Writing the narration")
|
| 97 |
+
out = run.predict(WriteAudioScript, db=db, workspace_id=artifact.workspace_id, job=job, title=material.title,
|
| 98 |
+
material=material.text, style=STYLES[fmt], hosts=hosts, target_words=minutes * WORDS_PER_MINUTE,
|
| 99 |
+
focus=focus or "(none)", language=(params.get("language") or "").strip() or SAME_LANGUAGE)
|
| 100 |
+
lines = script_turns(out.get("turns") or [], hosts)
|
| 101 |
if not lines:
|
| 102 |
raise RuntimeError("The script came back empty")
|
|
|
|
| 103 |
default_a, default_b = host_voices(db, artifact.workspace_id)
|
| 104 |
voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b # picked when made
|
| 105 |
delivery = host_settings(db, artifact.workspace_id)
|
| 106 |
script = [
|
| 107 |
+
tts.Line(text=ln["text"], voice_id=voice_b if ln["speaker"] == "host_b" else voice_a,
|
| 108 |
+
settings=delivery[ln["speaker"]])
|
| 109 |
for ln in lines
|
| 110 |
]
|
| 111 |
update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")
|
|
|
|
| 116 |
message=f"Recorded {done} of {len(script)} lines")
|
| 117 |
|
| 118 |
clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
|
| 119 |
+
# Every clip is the same MP3 format (mp3_44100_128), so the clips join into one file in order.
|
| 120 |
audio = bytearray()
|
| 121 |
segments = []
|
| 122 |
t = 0.0
|
| 123 |
for line, clip in zip(lines, clips, strict=True):
|
| 124 |
seconds = tts.mp3_seconds(clip)
|
| 125 |
+
segments.append({"speaker": line["speaker"], "text": line["text"],
|
| 126 |
+
"start": round(t, 2), "end": round(t + seconds, 2), "sources": material.refs(line["sources"])})
|
|
|
|
| 127 |
audio += clip
|
| 128 |
t += seconds
|
| 129 |
key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
|
|
|
|
| 132 |
artifact.status = "ready"
|
| 133 |
artifact.content_json = {
|
| 134 |
**(artifact.content_json or {}),
|
| 135 |
+
"format": fmt,
|
| 136 |
+
"hosts": hosts,
|
| 137 |
+
"focus": focus,
|
| 138 |
+
"language": params.get("language") or "",
|
| 139 |
+
"source": material.source,
|
| 140 |
"duration_s": round(t, 1),
|
| 141 |
"segments": segments,
|
| 142 |
+
"voices": {"host_a": voice_a, **({"host_b": voice_b} if hosts == 2 else {})},
|
| 143 |
}
|
| 144 |
db.commit()
|
| 145 |
record_usage(db, workspace_id=artifact.workspace_id, kind="tts", provider="elevenlabs",
|
backend/app/pipeline/slides.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slide decks. The model plans the deck (OutlineDeck), writes each content slide from the lines it cites
|
| 2 |
+
(WriteSlide, in parallel), has every claim checked against those lines (CheckSlide), and writes the conclusion from
|
| 3 |
+
the finished slides (WriteClosing). The opening slide and the agenda come from the outline. Code then lays the deck
|
| 4 |
+
out (app.slides): the model never writes markup, positions or colours, and only the deck's words are stored."""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.llm import run
|
| 14 |
+
from app.llm.provider import fast_lm, report_lm
|
| 15 |
+
from app.llm.signatures import CheckRequest, CheckSlide, OutlineDeck, WriteClosing, WriteSlide
|
| 16 |
+
from app.models import Artifact, Job
|
| 17 |
+
from app.pipeline.generate import NothingToDo
|
| 18 |
+
from app.pipeline.material import Material, load_material
|
| 19 |
+
from app.pipeline.report import SAME_LANGUAGE
|
| 20 |
+
from app.services.jobs import update_job
|
| 21 |
+
from app.slides.build import finish
|
| 22 |
+
from app.slides.content import FORMATS, LENGTHS, clamp_deck, clamp_slide, plain
|
| 23 |
+
from app.slides.layout import seed_of
|
| 24 |
+
from app.slides.themes import theme_id
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
CONTENT_LAYOUTS = ("section", "points", "two_column", "stat", "quote")
|
| 29 |
+
SOURCE_BUDGET = 7_000 # characters of cited lines one slide is written from (a PDF's lines can be whole pages)
|
| 30 |
+
CONTEXT_BUDGET = 9_000 # characters a slide without usable citations is written from
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def content_range(length: str) -> tuple[int, int]:
|
| 34 |
+
"""How many content slides a deck of this length has: its total minus the opening, the closing and (above the
|
| 35 |
+
short length) the agenda."""
|
| 36 |
+
lo, hi = LENGTHS[length]
|
| 37 |
+
fixed = 3 if has_agenda(length) else 2
|
| 38 |
+
return lo - fixed, hi - fixed
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def has_agenda(length: str) -> bool:
|
| 42 |
+
return length != "short"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _missed(db: Session, job: Job, artifact: Artifact, request: str, plans: list[dict]) -> list[str]:
|
| 46 |
+
"""What the request asked for that the planned slides do not do (empty when nothing is missing or the check fails)."""
|
| 47 |
+
if not request or not plans:
|
| 48 |
+
return []
|
| 49 |
+
try:
|
| 50 |
+
out = run.predict(CheckRequest, db=db, workspace_id=artifact.workspace_id, job=job, lm=fast_lm(), request=request,
|
| 51 |
+
slides=[f"{p.get('heading')}: {p.get('purpose') or ''}" for p in plans])
|
| 52 |
+
except Exception:
|
| 53 |
+
return []
|
| 54 |
+
return [plain(a.get("ask")) for a in out.get("asks") or [] if isinstance(a, dict) and a.get("covered") is False
|
| 55 |
+
and plain(a.get("ask"))]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _outline(db: Session, job: Job, artifact: Artifact, material: Material, fmt: str, length: str, request: str,
|
| 59 |
+
language: str) -> dict:
|
| 60 |
+
lo, hi = content_range(length)
|
| 61 |
+
count = f"{lo} to {hi}" + (", including 2 to 4 section slides that divide the deck into parts" if length == "long" else "")
|
| 62 |
+
ask = request or "(none)"
|
| 63 |
+
best: dict = {}
|
| 64 |
+
for attempt in range(2):
|
| 65 |
+
try:
|
| 66 |
+
out = run.predict(OutlineDeck, db=db, workspace_id=artifact.workspace_id, job=job, lm=report_lm(),
|
| 67 |
+
title=material.title, material=material.text, request=ask, deck_format=fmt,
|
| 68 |
+
slide_count=count, language=language)
|
| 69 |
+
except Exception:
|
| 70 |
+
if attempt:
|
| 71 |
+
raise
|
| 72 |
+
continue
|
| 73 |
+
outline = out.get("outline") or {}
|
| 74 |
+
plans = [p for p in outline.get("slides") or []
|
| 75 |
+
if isinstance(p, dict) and p.get("layout") in CONTENT_LAYOUTS and plain(p.get("heading"))]
|
| 76 |
+
outline["slides"] = plans
|
| 77 |
+
if len(plans) > len(best.get("slides") or []):
|
| 78 |
+
best = outline
|
| 79 |
+
if lo <= len(plans):
|
| 80 |
+
break
|
| 81 |
+
ask = f"{request or '(none)'}\n\nPlan between {lo} and {hi} content slides."
|
| 82 |
+
plans = best.get("slides") or []
|
| 83 |
+
if len(plans) < lo:
|
| 84 |
+
raise NothingToDo(f"The sources picked do not have enough in them for a {'short' if length == 'short' else 'full'} "
|
| 85 |
+
"deck. Add posts, or choose Short.")
|
| 86 |
+
# The request decides what the deck is about: when the outline misses something it asked for, plan once more with
|
| 87 |
+
# the missing asks spelled out (kept only if the new outline is still long enough).
|
| 88 |
+
missed = _missed(db, job, artifact, request, plans)
|
| 89 |
+
if missed:
|
| 90 |
+
retry = f"{request}\n\nThe previous outline missed these asks; plan slides for them as far as the material supports: " \
|
| 91 |
+
+ "; ".join(missed)
|
| 92 |
+
try:
|
| 93 |
+
out = run.predict(OutlineDeck, db=db, workspace_id=artifact.workspace_id, job=job, lm=report_lm(),
|
| 94 |
+
title=material.title, material=material.text, request=retry, deck_format=fmt,
|
| 95 |
+
slide_count=count, language=language)
|
| 96 |
+
again = out.get("outline") or {}
|
| 97 |
+
again_plans = [p for p in again.get("slides") or []
|
| 98 |
+
if isinstance(p, dict) and p.get("layout") in CONTENT_LAYOUTS and plain(p.get("heading"))]
|
| 99 |
+
if len(again_plans) >= lo:
|
| 100 |
+
best, plans = {**again, "slides": again_plans}, again_plans
|
| 101 |
+
except Exception:
|
| 102 |
+
pass # keep the first outline
|
| 103 |
+
# Too many: keep the first and last (the argument's start and end) and drop from the middle.
|
| 104 |
+
while len(plans) > hi:
|
| 105 |
+
plans.pop(len(plans) // 2)
|
| 106 |
+
best["slides"] = plans
|
| 107 |
+
return best
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _source_lines(material: Material, refs: list[dict]) -> list[str]:
|
| 111 |
+
"""What one slide is written from: the lines it cites with a little around them, or the material itself."""
|
| 112 |
+
out, used = [], 0
|
| 113 |
+
if material.tools:
|
| 114 |
+
for r in refs:
|
| 115 |
+
text = material.tools.quote(r["path"], max(1, r["line_start"] - 2), r["line_end"] + 6) or r["quote"]
|
| 116 |
+
block = f"FILE {r['path']} ({r.get('title') or ''}) from line {max(1, r['line_start'] - 2)}\n{text}"
|
| 117 |
+
if used + len(block) > SOURCE_BUDGET and out:
|
| 118 |
+
break
|
| 119 |
+
out.append(block[:SOURCE_BUDGET])
|
| 120 |
+
used += len(block)
|
| 121 |
+
if out:
|
| 122 |
+
return out
|
| 123 |
+
budget = CONTEXT_BUDGET // max(len(material.text), 1)
|
| 124 |
+
return [t[:budget] for t in material.text]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _norm(text: str) -> str:
|
| 128 |
+
return re.sub(r"[^\w%$€£]+", " ", text.lower()).strip()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _fallback_slide(plan: dict, sources: list[str]) -> dict:
|
| 132 |
+
"""A slide written by code when the model could not write one: its planned heading and its first source lines."""
|
| 133 |
+
body = " ".join(re.sub(r"^\s*\d+\|\s?", "", line) for s in sources for line in s.splitlines()[1:])
|
| 134 |
+
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", body) if len(s.strip()) > 30][:3]
|
| 135 |
+
return {"layout": "points" if len(sentences) >= 2 else "section", "heading": plan.get("heading"),
|
| 136 |
+
"kicker": "", "lead": plan.get("purpose") or "", "points": [{"term": "", "text": s} for s in sentences],
|
| 137 |
+
"notes": plan.get("purpose") or ""}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _claims(slide: dict) -> list[tuple[tuple, str]]:
|
| 141 |
+
"""Every checkable statement on a slide, with where it lives."""
|
| 142 |
+
out: list[tuple[tuple, str]] = []
|
| 143 |
+
if slide.get("layout") == "section" and slide.get("heading"): # a section's heading is its claim
|
| 144 |
+
out.append((("heading",), slide["heading"]))
|
| 145 |
+
if slide.get("lead"):
|
| 146 |
+
out.append((("lead",), slide["lead"]))
|
| 147 |
+
for i, p in enumerate(slide.get("points") or []):
|
| 148 |
+
if p.get("text"): # the term is context; only the sentence is checked and rewritten
|
| 149 |
+
out.append((("points", i), f"{p['term']}: {p['text']}" if p.get("term") else p["text"]))
|
| 150 |
+
for side in ("left", "right"):
|
| 151 |
+
for i, item in enumerate((slide.get(side) or {}).get("items") or []):
|
| 152 |
+
out.append(((side, i), item))
|
| 153 |
+
stat = slide.get("stat") or {}
|
| 154 |
+
if stat.get("value") and stat.get("label"):
|
| 155 |
+
out.append((("stat",), f"{stat['value']} {stat['label']}"))
|
| 156 |
+
return out
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _apply_checks(slide: dict, claims: list[tuple[tuple, str]], checks: list[dict]) -> None:
|
| 160 |
+
"""Replace unsupported claims with their fix, or remove them."""
|
| 161 |
+
drop: dict[str, set[int]] = {"points": set(), "left": set(), "right": set()}
|
| 162 |
+
for c in checks or []:
|
| 163 |
+
try:
|
| 164 |
+
n = int(c.get("n")) - 1
|
| 165 |
+
except (TypeError, ValueError):
|
| 166 |
+
continue
|
| 167 |
+
if not 0 <= n < len(claims) or c.get("supported") is not False:
|
| 168 |
+
continue
|
| 169 |
+
where, _ = claims[n]
|
| 170 |
+
fix = plain(c.get("fix"))
|
| 171 |
+
if where[0] == "heading":
|
| 172 |
+
if fix:
|
| 173 |
+
slide["heading"] = fix
|
| 174 |
+
elif where[0] == "lead":
|
| 175 |
+
slide["lead"] = fix
|
| 176 |
+
elif where[0] == "points":
|
| 177 |
+
term = slide["points"][where[1]].get("term") or ""
|
| 178 |
+
if term and fix.lower().startswith(term.lower()): # the fix repeated the name: keep only the sentence
|
| 179 |
+
fix = fix[len(term):].lstrip(" :.-")
|
| 180 |
+
elif term and re.match(r"^[^:.!?]{1,40}:\s", fix): # or put a label of its own before it
|
| 181 |
+
fix = fix.split(":", 1)[1].strip()
|
| 182 |
+
if fix:
|
| 183 |
+
slide["points"][where[1]]["text"] = fix[:1].upper() + fix[1:]
|
| 184 |
+
else:
|
| 185 |
+
drop["points"].add(where[1])
|
| 186 |
+
elif where[0] in ("left", "right"):
|
| 187 |
+
if fix:
|
| 188 |
+
slide[where[0]]["items"][where[1]] = fix
|
| 189 |
+
else:
|
| 190 |
+
drop[where[0]].add(where[1])
|
| 191 |
+
elif where[0] == "stat":
|
| 192 |
+
slide["stat"] = None # a number is never rewritten: it is in the sources as it is, or it goes
|
| 193 |
+
slide["points"] = [p for i, p in enumerate(slide.get("points") or []) if i not in drop["points"]]
|
| 194 |
+
for side in ("left", "right"):
|
| 195 |
+
if slide.get(side):
|
| 196 |
+
slide[side]["items"] = [t for i, t in enumerate(slide[side]["items"]) if i not in drop[side]]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _verbatim(slide: dict, sources: list[str]) -> None:
|
| 200 |
+
"""A quote must be in the sources word for word and a number as written; otherwise it is removed."""
|
| 201 |
+
text = _norm(" ".join(re.sub(r"^\s*\d+\|\s?", "", line) for s in sources for line in s.splitlines()))
|
| 202 |
+
quote = slide.get("quote") or {}
|
| 203 |
+
if quote.get("text") and _norm(quote["text"]) not in text:
|
| 204 |
+
slide["quote"] = None
|
| 205 |
+
stat = slide.get("stat") or {}
|
| 206 |
+
if stat.get("value") and _norm(stat["value"]) not in text:
|
| 207 |
+
slide["stat"] = None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _summary(slide: dict) -> str:
|
| 211 |
+
bits = [slide.get("heading") or "", slide.get("lead") or ""]
|
| 212 |
+
bits += [f"{p.get('term') or ''} {p.get('text') or ''}".strip() for p in slide.get("points") or []]
|
| 213 |
+
for side in ("left", "right"):
|
| 214 |
+
col = slide.get(side) or {}
|
| 215 |
+
if col.get("items"):
|
| 216 |
+
bits.append(f"{col.get('label') or ''}: " + "; ".join(col["items"]))
|
| 217 |
+
if (slide.get("stat") or {}).get("value"):
|
| 218 |
+
bits.append(f"{slide['stat']['value']} {slide['stat'].get('label') or ''}")
|
| 219 |
+
if (slide.get("quote") or {}).get("text"):
|
| 220 |
+
bits.append(f"“{slide['quote']['text']}”")
|
| 221 |
+
return " | ".join(b for b in bits if b)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _check_frame(db: Session, job: Job, ws: uuid.UUID, slides: list[dict], subtitle: str, takeaways: list[str],
|
| 225 |
+
last_line: str) -> tuple[str, list[str], str]:
|
| 226 |
+
"""The opening's subtitle, the takeaways and the closing line say only what the (already checked) slides say:
|
| 227 |
+
each is checked against them, and fixed or removed when it does not."""
|
| 228 |
+
claims = ([("subtitle", subtitle)] if subtitle else []) + [("takeaway", t) for t in takeaways] + \
|
| 229 |
+
([("closing", last_line)] if last_line else [])
|
| 230 |
+
if not claims:
|
| 231 |
+
return subtitle, takeaways, last_line
|
| 232 |
+
try:
|
| 233 |
+
out = run.predict(CheckSlide, db=db, workspace_id=ws, job=job, lm=fast_lm(),
|
| 234 |
+
claims=[f"{n + 1}. {t}" for n, (_, t) in enumerate(claims)],
|
| 235 |
+
source_lines=[_summary(s) for s in slides])
|
| 236 |
+
except Exception:
|
| 237 |
+
return subtitle, takeaways, last_line
|
| 238 |
+
verdict: dict[int, str | None] = {} # claim index -> its fix ("" to remove)
|
| 239 |
+
for c in out.get("checks") or []:
|
| 240 |
+
try:
|
| 241 |
+
n = int(c.get("n")) - 1
|
| 242 |
+
except (TypeError, ValueError):
|
| 243 |
+
continue
|
| 244 |
+
if 0 <= n < len(claims) and c.get("supported") is False:
|
| 245 |
+
verdict[n] = plain(c.get("fix"))
|
| 246 |
+
kept = [verdict.get(n, text) for n, (_, text) in enumerate(claims)]
|
| 247 |
+
subtitle = kept.pop(0) if subtitle else ""
|
| 248 |
+
last_line = kept.pop() if last_line else ""
|
| 249 |
+
return subtitle, [t for t in kept if t] or [s["heading"] for s in slides[:3]], last_line
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _previous_variants(db: Session, artifact: Artifact) -> list[tuple[str, str]]:
|
| 253 |
+
"""The layout and variant of each slide in the notebook's last deck, so this one can look different."""
|
| 254 |
+
if not artifact.notebook_id:
|
| 255 |
+
return []
|
| 256 |
+
last = db.scalars(select(Artifact).where(Artifact.notebook_id == artifact.notebook_id, Artifact.type == "slide_deck",
|
| 257 |
+
Artifact.status == "ready", Artifact.id != artifact.id)
|
| 258 |
+
.order_by(Artifact.created_at.desc()).limit(1)).first()
|
| 259 |
+
slides = (((last.content_json or {}).get("deck") or {}).get("slides") or []) if last else []
|
| 260 |
+
return [(s.get("layout"), s.get("variant")) for s in slides if isinstance(s, dict)]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def build_slide_deck(db: Session, job: Job, artifact: Artifact) -> dict:
|
| 264 |
+
params = job.params
|
| 265 |
+
ws = artifact.workspace_id
|
| 266 |
+
fmt = params.get("deck_format") if params.get("deck_format") in FORMATS else "detailed"
|
| 267 |
+
length = params.get("deck_length") if params.get("deck_length") in LENGTHS else "default"
|
| 268 |
+
theme = theme_id(params.get("theme"))
|
| 269 |
+
language = (params.get("language") or "").strip() or SAME_LANGUAGE
|
| 270 |
+
request = (params.get("instructions") or "").strip()
|
| 271 |
+
material = load_material(db, ws, artifact.notebook_id, params, job=job, what="slide deck")
|
| 272 |
+
|
| 273 |
+
update_job(db, job, progress=0.2, message="Planning the story")
|
| 274 |
+
outline = _outline(db, job, artifact, material, fmt, length, request, language)
|
| 275 |
+
plans = outline["slides"]
|
| 276 |
+
refs = [material.refs(p.get("sources")) for p in plans]
|
| 277 |
+
sources = [_source_lines(material, r) for r in refs]
|
| 278 |
+
lines = [f"{i + 2}. {p['heading']}" for i, p in enumerate(plans)]
|
| 279 |
+
total = len(plans) + (3 if has_agenda(length) else 2)
|
| 280 |
+
|
| 281 |
+
update_job(db, job, progress=0.35, message=f"Writing {len(plans)} slides")
|
| 282 |
+
first = 3 if has_agenda(length) else 2 # where content starts, counting from 1
|
| 283 |
+
|
| 284 |
+
def inputs(i: int) -> dict:
|
| 285 |
+
return {"deck_title": outline.get("title") or material.title, "outline": lines,
|
| 286 |
+
"position": f"Slide {first + i} of {total}", "layout": plans[i]["layout"],
|
| 287 |
+
"heading": plans[i]["heading"], "purpose": plans[i].get("purpose") or "", "deck_format": fmt,
|
| 288 |
+
"request": request or "(none)", "source_lines": sources[i], "language": language}
|
| 289 |
+
|
| 290 |
+
written = run.predict_many(WriteSlide, [inputs(i) for i in range(len(plans))], db=db, workspace_id=ws, lm=report_lm())
|
| 291 |
+
missing = [i for i, w in enumerate(written) if not (w and w.get("slide"))]
|
| 292 |
+
if missing: # one more try for the slides that failed
|
| 293 |
+
again = run.predict_many(WriteSlide, [inputs(i) for i in missing], db=db, workspace_id=ws, lm=report_lm())
|
| 294 |
+
for i, w in zip(missing, again, strict=True):
|
| 295 |
+
written[i] = w
|
| 296 |
+
slides = []
|
| 297 |
+
for i, w in enumerate(written):
|
| 298 |
+
s = (w or {}).get("slide") or _fallback_slide(plans[i], sources[i])
|
| 299 |
+
s = dict(s)
|
| 300 |
+
s["layout"] = plans[i]["layout"] if not s.get("layout") else s["layout"]
|
| 301 |
+
s["heading"] = s.get("heading") or plans[i]["heading"]
|
| 302 |
+
cited = material.refs(s.get("sources")) if material.tools else []
|
| 303 |
+
s["sources"] = cited or refs[i]
|
| 304 |
+
_verbatim(s, sources[i])
|
| 305 |
+
slides.append(s)
|
| 306 |
+
|
| 307 |
+
update_job(db, job, progress=0.6, message="Checking every slide against your posts")
|
| 308 |
+
claims = [_claims(s) for s in slides]
|
| 309 |
+
todo = [i for i, c in enumerate(claims) if c]
|
| 310 |
+
checked = run.predict_many(
|
| 311 |
+
CheckSlide, [{"claims": [f"{n + 1}. {t}" for n, (_, t) in enumerate(claims[i])], "source_lines": sources[i]}
|
| 312 |
+
for i in todo], db=db, workspace_id=ws, lm=fast_lm())
|
| 313 |
+
for i, out in zip(todo, checked, strict=True):
|
| 314 |
+
if out:
|
| 315 |
+
_apply_checks(slides[i], claims[i], out.get("checks") or [])
|
| 316 |
+
|
| 317 |
+
# A slide left with only its heading (its number or quote was not in the posts, its claims did not hold) says
|
| 318 |
+
# nothing checked: it goes, as long as the deck keeps its length.
|
| 319 |
+
lo = content_range(length)[0]
|
| 320 |
+
for i in reversed(range(len(slides))):
|
| 321 |
+
bare = (clamp_slide({**slides[i], "layout": slides[i].get("layout")}, fmt) or {}).get("layout") == "section"
|
| 322 |
+
if bare and plans[i]["layout"] != "section" and len(slides) > lo:
|
| 323 |
+
del slides[i]
|
| 324 |
+
# Two slides with the same heading (a part's title repeated by its first slide): the section one goes.
|
| 325 |
+
for i in reversed(range(len(slides))):
|
| 326 |
+
same = [j for j, o in enumerate(slides) if j != i and _norm(o.get("heading") or "") == _norm(slides[i].get("heading") or "")]
|
| 327 |
+
if same and slides[i].get("layout") == "section" and len(slides) > lo:
|
| 328 |
+
del slides[i]
|
| 329 |
+
|
| 330 |
+
update_job(db, job, progress=0.8, message="Writing the conclusion")
|
| 331 |
+
closing: dict = {}
|
| 332 |
+
for _ in range(2): # one more try: a failed call would leave the deck without its real conclusion
|
| 333 |
+
try:
|
| 334 |
+
closing = run.predict(WriteClosing, db=db, workspace_id=ws, job=job, lm=fast_lm(),
|
| 335 |
+
deck_title=outline.get("title") or material.title, slides=[_summary(s) for s in slides],
|
| 336 |
+
deck_format=fmt, request=request or "(none)", language=language).get("closing") or {}
|
| 337 |
+
except Exception:
|
| 338 |
+
log.warning("WriteClosing failed", exc_info=True)
|
| 339 |
+
closing = {}
|
| 340 |
+
if closing.get("takeaways"):
|
| 341 |
+
break
|
| 342 |
+
takeaways = closing.get("takeaways") or [s["heading"] for s in slides[:3]]
|
| 343 |
+
subtitle = outline.get("subtitle") or ""
|
| 344 |
+
subtitle, takeaways, last_line = _check_frame(db, job, ws, slides, subtitle, takeaways, closing.get("closing") or "")
|
| 345 |
+
|
| 346 |
+
opening = {"layout": "title", "kicker": outline.get("opening_kicker") or "", "heading": outline.get("title") or material.title,
|
| 347 |
+
"lead": subtitle, "notes": subtitle}
|
| 348 |
+
deck_slides = [opening]
|
| 349 |
+
if has_agenda(length):
|
| 350 |
+
# A long deck's agenda lists its parts (its section slides); otherwise every slide.
|
| 351 |
+
parts = [s for s in slides if s.get("layout") == "section"]
|
| 352 |
+
listed = parts if len(parts) >= 2 else slides
|
| 353 |
+
deck_slides.append({"layout": "agenda", "kicker": "", "heading": outline.get("agenda_heading") or "Agenda",
|
| 354 |
+
"points": [{"term": "", "text": s["heading"]} for s in listed]})
|
| 355 |
+
deck_slides += slides
|
| 356 |
+
deck_slides.append({"layout": "closing", "kicker": closing.get("kicker") or "",
|
| 357 |
+
"heading": closing.get("heading") or opening["heading"], "takeaways": takeaways,
|
| 358 |
+
"closing": last_line or opening["lead"], "notes": closing.get("notes") or ""})
|
| 359 |
+
|
| 360 |
+
update_job(db, job, progress=0.9, message="Laying out the slides")
|
| 361 |
+
deck = clamp_deck({"title": opening["heading"], "subtitle": opening["lead"], "slides": deck_slides}, fmt)
|
| 362 |
+
if not deck or len(deck["slides"]) < LENGTHS[length][0]:
|
| 363 |
+
raise NothingToDo("The deck came out too thin to show. Try other posts or add a description.")
|
| 364 |
+
seed = artifact.id.hex if isinstance(artifact.id, uuid.UUID) else str(artifact.id)
|
| 365 |
+
deck = finish(deck, theme, fmt, seed_of(seed), _previous_variants(db, artifact))
|
| 366 |
+
|
| 367 |
+
artifact.content_json = {**(artifact.content_json or {}), "title": f"Slide deck: {deck['title']}"[:140],
|
| 368 |
+
"theme": theme, "format": fmt, "length": length,
|
| 369 |
+
"language": params.get("language") or "", "instructions": request,
|
| 370 |
+
"source": material.source, "seed": seed, "deck": deck}
|
| 371 |
+
artifact.storage_key = None
|
| 372 |
+
artifact.status = "ready"
|
| 373 |
+
db.commit()
|
| 374 |
+
return {"artifact_id": str(artifact.id), "slides": len(deck["slides"])}
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -18,14 +18,21 @@ from app.services.plans import effective_plan, plan_limit_error
|
|
| 18 |
from app.services.renderer import COMPOSITIONS
|
| 19 |
from app.services.storage import storage
|
| 20 |
from app.services.usage import check_limit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
| 23 |
|
| 24 |
ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
|
| 25 |
-
"flashcards", "report", "infographic"]
|
| 26 |
|
| 27 |
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
class RenderIn(BaseModel):
|
|
@@ -39,7 +46,8 @@ class GenerateIn(BaseModel):
|
|
| 39 |
document_id: uuid.UUID | None = None
|
| 40 |
archive: bool = False # no notebook or post picked: use the "All posts" notebook
|
| 41 |
# audio_overview
|
| 42 |
-
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
|
|
|
| 43 |
minutes: int = Field(6, ge=1, le=30)
|
| 44 |
# the two hosts' voices, from the workspace's voices (none: the defaults)
|
| 45 |
host_a: str | None = Field(None, max_length=100)
|
|
@@ -61,7 +69,10 @@ class GenerateIn(BaseModel):
|
|
| 61 |
question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
|
| 62 |
default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
|
| 63 |
language: str | None = Field(None, max_length=40)
|
| 64 |
-
theme: str | None = Field(None, max_length=30) # infographic:
|
|
|
|
|
|
|
|
|
|
| 65 |
# report: a written "document" or an "interactive" one, the template it started from, and the instructions the
|
| 66 |
# writer is given (the dialog's editable text; empty means the template's own)
|
| 67 |
report_format: Literal["document", "interactive"] = "document"
|
|
@@ -152,8 +163,16 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 152 |
raise HTTPException(400, "Pick at least one post or chat to make this from")
|
| 153 |
target = _target_title(ctx, body.notebook_id, body.document_id)
|
| 154 |
if body.type == "audio_overview":
|
|
|
|
|
|
|
| 155 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
title = f"Audio overview: {target}"
|
| 158 |
elif body.type == "mind_map":
|
| 159 |
if not body.notebook_id:
|
|
@@ -180,6 +199,12 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 180 |
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 181 |
"theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
|
| 182 |
title = f"Infographic: {params['instructions'][:60] or target}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
elif body.type == "quiz":
|
| 184 |
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 185 |
params = {"document_ids": [str(i) for i in ids],
|
|
@@ -257,6 +282,63 @@ def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "port
|
|
| 257 |
return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
|
| 258 |
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
@router.patch("/{artifact_id}")
|
| 261 |
def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
|
| 262 |
a = get_artifact_or_404(ctx, artifact_id)
|
|
|
|
| 18 |
from app.services.renderer import COMPOSITIONS
|
| 19 |
from app.services.storage import storage
|
| 20 |
from app.services.usage import check_limit
|
| 21 |
+
from app.slides import export as slide_export
|
| 22 |
+
from app.slides.build import BadEdit, restructure
|
| 23 |
+
from app.slides.build import slots as slide_slots
|
| 24 |
+
from app.slides.build import stored as stored_deck
|
| 25 |
+
from app.slides.build import view as slide_pages
|
| 26 |
+
from app.slides.themes import theme_id as slide_theme_id
|
| 27 |
|
| 28 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
| 29 |
|
| 30 |
ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
|
| 31 |
+
"flashcards", "report", "infographic", "slide_deck"]
|
| 32 |
|
| 33 |
|
| 34 |
+
# Made from posts or chats picked in the dialog
|
| 35 |
+
SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic", "slide_deck", "audio_overview")
|
| 36 |
|
| 37 |
|
| 38 |
class RenderIn(BaseModel):
|
|
|
|
| 46 |
document_id: uuid.UUID | None = None
|
| 47 |
archive: bool = False # no notebook or post picked: use the "All posts" notebook
|
| 48 |
# audio_overview
|
| 49 |
+
format: Literal["deep_dive", "brief", "critique", "debate"] = "deep_dive"
|
| 50 |
+
hosts: Literal[1, 2] = 2 # audio_overview: a single narrator or a two host conversation
|
| 51 |
minutes: int = Field(6, ge=1, le=30)
|
| 52 |
# the two hosts' voices, from the workspace's voices (none: the defaults)
|
| 53 |
host_a: str | None = Field(None, max_length=100)
|
|
|
|
| 69 |
question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
|
| 70 |
default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
|
| 71 |
language: str | None = Field(None, max_length=40)
|
| 72 |
+
theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes
|
| 73 |
+
# slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
|
| 74 |
+
deck_format: Literal["detailed", "presenter"] = "detailed"
|
| 75 |
+
deck_length: Literal["short", "default", "long"] = "default"
|
| 76 |
# report: a written "document" or an "interactive" one, the template it started from, and the instructions the
|
| 77 |
# writer is given (the dialog's editable text; empty means the template's own)
|
| 78 |
report_format: Literal["document", "interactive"] = "document"
|
|
|
|
| 163 |
raise HTTPException(400, "Pick at least one post or chat to make this from")
|
| 164 |
target = _target_title(ctx, body.notebook_id, body.document_id)
|
| 165 |
if body.type == "audio_overview":
|
| 166 |
+
if body.format == "debate" and body.hosts == 1:
|
| 167 |
+
raise HTTPException(400, "A debate needs two hosts")
|
| 168 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 169 |
+
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 170 |
+
hosts = _hosts(ctx, body)
|
| 171 |
+
if body.hosts == 1:
|
| 172 |
+
hosts.pop("host_b", None)
|
| 173 |
+
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 174 |
+
"format": body.format, "minutes": body.minutes, "hosts": body.hosts, "language": body.language,
|
| 175 |
+
"instructions": (body.instructions or "").strip(), **hosts}
|
| 176 |
title = f"Audio overview: {target}"
|
| 177 |
elif body.type == "mind_map":
|
| 178 |
if not body.notebook_id:
|
|
|
|
| 199 |
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 200 |
"theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
|
| 201 |
title = f"Infographic: {params['instructions'][:60] or target}"
|
| 202 |
+
elif body.type == "slide_deck":
|
| 203 |
+
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 204 |
+
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 205 |
+
"theme": slide_theme_id(body.theme), "deck_format": body.deck_format, "deck_length": body.deck_length,
|
| 206 |
+
"language": body.language, "instructions": (body.instructions or "").strip()}
|
| 207 |
+
title = f"Slide deck: {target}"
|
| 208 |
elif body.type == "quiz":
|
| 209 |
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 210 |
params = {"document_ids": [str(i) for i in ids],
|
|
|
|
| 282 |
return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
|
| 283 |
|
| 284 |
|
| 285 |
+
@router.get("/{artifact_id}/slides.{ext}")
|
| 286 |
+
def slide_deck_file(artifact_id: uuid.UUID, ext: Literal["pdf", "pptx"], ctx: Ctx = Depends(get_ctx)):
|
| 287 |
+
"""A finished slide deck as a PDF (exactly as shown) or an editable PowerPoint."""
|
| 288 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 289 |
+
got = stored_deck(a.content_json or {}) if a.type == "slide_deck" and a.status == "ready" else None
|
| 290 |
+
if not got:
|
| 291 |
+
raise HTTPException(404, "This slide deck has nothing to download yet.")
|
| 292 |
+
deck, theme, fmt, seed = got
|
| 293 |
+
try:
|
| 294 |
+
data = (slide_export.render_pdf if ext == "pdf" else slide_export.build_pptx)(deck, theme, fmt, seed)
|
| 295 |
+
except ImageUnavailable as exc:
|
| 296 |
+
raise HTTPException(503, str(exc)) from exc
|
| 297 |
+
name = re.sub(r"[^\w\- ]+", "", deck.get("title") or "Slide deck").strip()[:80] or "Slide deck"
|
| 298 |
+
media = "application/pdf" if ext == "pdf" else \
|
| 299 |
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
| 300 |
+
return Response(data, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}.{ext}"'})
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class DeckIn(BaseModel):
|
| 304 |
+
# The deck's slides as the editor has them, in the stored shape (app.slides.content.empty_slide); coerced on arrival
|
| 305 |
+
slides: list[dict] = Field(min_length=1, max_length=60)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _editable_deck(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
|
| 309 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 310 |
+
if a.type != "slide_deck" or a.status != "ready":
|
| 311 |
+
raise HTTPException(400, "Only a finished slide deck can be edited.")
|
| 312 |
+
return a
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@router.post("/{artifact_id}/deck-preview")
|
| 316 |
+
def preview_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
|
| 317 |
+
"""The editor's slides drawn, without saving: after a slide is added, removed, moved or changed to another layout,
|
| 318 |
+
or a text box is added or removed. Returns the slides as they will be kept (variants given, text fitted)."""
|
| 319 |
+
a = _editable_deck(ctx, artifact_id)
|
| 320 |
+
try:
|
| 321 |
+
deck = restructure(a.content_json or {}, body.slides, final=False)
|
| 322 |
+
except BadEdit as exc:
|
| 323 |
+
raise HTTPException(400, str(exc)) from exc
|
| 324 |
+
content = {**(a.content_json or {}), "deck": deck}
|
| 325 |
+
return {"slides": deck["slides"], "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@router.put("/{artifact_id}/deck")
|
| 329 |
+
def save_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
|
| 330 |
+
"""The editor's slides kept: text, speaker notes, and which slides there are, in what order and layout. The deck is
|
| 331 |
+
fitted again (with the Chrome check), so the slides, the PDF and the PowerPoint all show it."""
|
| 332 |
+
a = _editable_deck(ctx, artifact_id)
|
| 333 |
+
try:
|
| 334 |
+
deck = restructure(a.content_json or {}, body.slides, final=True)
|
| 335 |
+
except BadEdit as exc:
|
| 336 |
+
raise HTTPException(400, str(exc)) from exc
|
| 337 |
+
a.content_json = {**(a.content_json or {}), "deck": deck}
|
| 338 |
+
ctx.db.commit()
|
| 339 |
+
return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
|
| 340 |
+
|
| 341 |
+
|
| 342 |
@router.patch("/{artifact_id}")
|
| 343 |
def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
|
| 344 |
a = get_artifact_or_404(ctx, artifact_id)
|
backend/app/routers/launchpad.py
CHANGED
|
@@ -21,11 +21,21 @@ from app.config import settings
|
|
| 21 |
from app.db import get_db
|
| 22 |
from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
|
| 23 |
from app.services import launchpad as lp
|
| 24 |
-
from app.services.artifacts import TYPE_LABELS
|
| 25 |
from app.services.crypto import decrypt, encrypt
|
| 26 |
from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
|
| 27 |
from app.services.social.health import pause_account_posts, resume_account_posts
|
| 28 |
-
from app.services.social.media import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
from app.services.storage import storage
|
| 30 |
|
| 31 |
router = APIRouter(prefix="/api", tags=["launchpad"])
|
|
@@ -61,7 +71,7 @@ class When(BaseModel):
|
|
| 61 |
|
| 62 |
class ItemIn(When):
|
| 63 |
platform: Platform
|
| 64 |
-
content: str = Field(
|
| 65 |
thread: list[str] = Field(default_factory=list, max_length=25)
|
| 66 |
artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
|
| 67 |
kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
|
|
@@ -69,11 +79,13 @@ class ItemIn(When):
|
|
| 69 |
social_account_id: uuid.UUID | None = None
|
| 70 |
remind_by_email: bool = False
|
| 71 |
draft: bool = False
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
class ItemPatch(When):
|
| 75 |
artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
|
| 76 |
-
content: str | None = Field(None,
|
| 77 |
thread: list[str] | None = None
|
| 78 |
social_account_id: uuid.UUID | None = None
|
| 79 |
remind_by_email: bool | None = None
|
|
@@ -85,7 +97,9 @@ def _aware(dt: datetime) -> datetime:
|
|
| 85 |
|
| 86 |
|
| 87 |
# audio can't be posted; "upload" is a file the user brought from their computer
|
| 88 |
-
POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload")
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def _cite_free(text: str) -> str:
|
|
@@ -107,22 +121,38 @@ def _prefill(a: Artifact) -> dict:
|
|
| 107 |
"quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
|
| 108 |
else title,
|
| 109 |
"carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
|
|
|
|
| 110 |
}.get(a.type, title)
|
| 111 |
return {"x": [text], "linkedin": text}
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
def artifact_brief(a: Artifact) -> dict:
|
| 115 |
"""What the composer and the calendar show of the attached artifact."""
|
| 116 |
c = a.content_json or {}
|
| 117 |
thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
|
| 118 |
else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
|
| 119 |
return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
|
| 120 |
-
|
| 121 |
-
"
|
|
|
|
|
|
|
| 122 |
"media_count": media_count(a), "duration_s": duration(a),
|
| 123 |
"thumb_url": storage.presign_get(thumb) if thumb else None,
|
| 124 |
# The user's own file, to preview before posting it.
|
| 125 |
"view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
|
|
|
|
|
|
|
|
|
|
| 126 |
# A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
|
| 127 |
# Made by the video service (blog2video): it opens in the video editor.
|
| 128 |
"editable_video": is_blog2video(a),
|
|
@@ -197,6 +227,12 @@ def _require_connected(platform: str, account: SocialAccount | None, artifact: A
|
|
| 197 |
raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
def _validate_lengths(platform: str, posts: list[str]) -> None:
|
| 201 |
limit = LIMITS.get(platform)
|
| 202 |
if platform in ("x", "bluesky") and limit:
|
|
@@ -251,12 +287,13 @@ def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end:
|
|
| 251 |
|
| 252 |
@router.post("/calendar")
|
| 253 |
def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
|
| 254 |
-
when = body.utc()
|
| 255 |
if when is None:
|
| 256 |
raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
|
| 257 |
account = _account(ctx, body.social_account_id, body.platform)
|
| 258 |
_validate_lengths(body.platform, [body.content, *body.thread])
|
| 259 |
artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
|
|
|
|
| 260 |
kit = _kit(ctx, body.kit_id) if body.kit_id else None
|
| 261 |
_require_connected(body.platform, account, artifact)
|
| 262 |
if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
|
|
@@ -268,13 +305,24 @@ def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 268 |
kit_id=kit.id if kit else None,
|
| 269 |
document_id=body.document_id, social_account_id=account.id if account else None,
|
| 270 |
remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
|
| 271 |
-
status="draft" if body.draft else "scheduled",
|
| 272 |
)
|
| 273 |
ctx.db.add(item)
|
| 274 |
ctx.db.commit()
|
|
|
|
|
|
|
| 275 |
return _enrich(ctx, [item])[0]
|
| 276 |
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
@router.patch("/calendar/{item_id}")
|
| 279 |
def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
|
| 280 |
item = _item(ctx, item_id)
|
|
@@ -301,15 +349,17 @@ def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)
|
|
| 301 |
if body.status == "paused":
|
| 302 |
item.status = "paused" # held by hand: never attempted until resumed
|
| 303 |
elif body.status is not None or item.status in ("failed", "reminded", "paused"):
|
| 304 |
-
# Going out (again): only through a live connection, and only at a time still ahead
|
|
|
|
| 305 |
_require_connected(item.platform, account, artifact)
|
| 306 |
-
if
|
| 307 |
raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
|
| 308 |
item.status = body.status or "scheduled"
|
| 309 |
elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
|
| 310 |
_require_connected(item.platform, account, artifact)
|
| 311 |
item.error = None
|
| 312 |
item.attempts = 0
|
|
|
|
| 313 |
_validate_lengths(item.platform, [item.content, *(item.thread or [])])
|
| 314 |
ctx.db.commit()
|
| 315 |
return _enrich(ctx, [item])[0]
|
|
@@ -324,17 +374,16 @@ def delete_item(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 324 |
|
| 325 |
|
| 326 |
@router.post("/calendar/{item_id}/publish")
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
item = _item(ctx, item_id)
|
| 329 |
if item.status in ("posted", "publishing"):
|
| 330 |
raise HTTPException(409, "This post already went out.")
|
| 331 |
item.status = "publishing"
|
| 332 |
item.attempts = 0
|
| 333 |
ctx.db.commit()
|
| 334 |
-
|
| 335 |
-
lp.queue_publish(ctx.db, item) # uploading takes a while: a job does it, the page follows the status
|
| 336 |
-
else:
|
| 337 |
-
await run_in_threadpool(lp.publish_item, ctx.db, item)
|
| 338 |
return _enrich(ctx, [item])[0]
|
| 339 |
|
| 340 |
|
|
@@ -373,11 +422,12 @@ def register_upload(body: UploadIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 373 |
|
| 374 |
@router.get("/launchpad/postable")
|
| 375 |
def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
|
| 376 |
-
"""What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps
|
| 377 |
-
|
| 378 |
-
posted once rendered), and so are videos being
|
|
|
|
| 379 |
rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
|
| 380 |
-
Artifact.type.in_(
|
| 381 |
.order_by(Artifact.created_at.desc()).limit(400))
|
| 382 |
needle = q.strip().lower()
|
| 383 |
out = []
|
|
@@ -391,6 +441,18 @@ def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
|
|
| 391 |
out.append(artifact_brief(a))
|
| 392 |
if len(out) >= min(limit, 200):
|
| 393 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
return out
|
| 395 |
|
| 396 |
|
|
|
|
| 21 |
from app.db import get_db
|
| 22 |
from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, Upload
|
| 23 |
from app.services import launchpad as lp
|
| 24 |
+
from app.services.artifacts import TYPE_LABELS, download_name
|
| 25 |
from app.services.crypto import decrypt, encrypt
|
| 26 |
from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
|
| 27 |
from app.services.social.health import pause_account_posts, resume_account_posts
|
| 28 |
+
from app.services.social.media import (
|
| 29 |
+
MB,
|
| 30 |
+
SIZE_LIMITS,
|
| 31 |
+
UPLOAD_TYPES,
|
| 32 |
+
carries_media,
|
| 33 |
+
check_postable,
|
| 34 |
+
duration,
|
| 35 |
+
is_blog2video,
|
| 36 |
+
media_count,
|
| 37 |
+
upload_kind,
|
| 38 |
+
)
|
| 39 |
from app.services.storage import storage
|
| 40 |
|
| 41 |
router = APIRouter(prefix="/api", tags=["launchpad"])
|
|
|
|
| 71 |
|
| 72 |
class ItemIn(When):
|
| 73 |
platform: Platform
|
| 74 |
+
content: str = Field("", max_length=10000) # may be empty when the post carries images or a video
|
| 75 |
thread: list[str] = Field(default_factory=list, max_length=25)
|
| 76 |
artifact_id: uuid.UUID | None = None # the attachment (a video, quote card, carousel...), never a Launch Kit
|
| 77 |
kit_id: uuid.UUID | None = None # the Launch Kit the post was written from (tracked, not attached)
|
|
|
|
| 79 |
social_account_id: uuid.UUID | None = None
|
| 80 |
remind_by_email: bool = False
|
| 81 |
draft: bool = False
|
| 82 |
+
# Post now: published (or, by email, reminded) in this request, never left scheduled; no time needed
|
| 83 |
+
publish_now: bool = False
|
| 84 |
|
| 85 |
|
| 86 |
class ItemPatch(When):
|
| 87 |
artifact_id: uuid.UUID | None = None # set or (null) clear what the post carries
|
| 88 |
+
content: str | None = Field(None, max_length=10000)
|
| 89 |
thread: list[str] | None = None
|
| 90 |
social_account_id: uuid.UUID | None = None
|
| 91 |
remind_by_email: bool | None = None
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
# audio can't be posted; "upload" is a file the user brought from their computer
|
| 100 |
+
POSTABLE = ("video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "upload", "slide_deck")
|
| 101 |
+
# Listed on the Launchpad too, but only to download: X and LinkedIn take no audio files (check_postable refuses it)
|
| 102 |
+
LISTED = (*POSTABLE, "audio_overview")
|
| 103 |
|
| 104 |
|
| 105 |
def _cite_free(text: str) -> str:
|
|
|
|
| 121 |
"quote_card": f'"{c.get("quote")}"' + (f" ({c['source']})" if c.get("source") else "") if c.get("quote")
|
| 122 |
else title,
|
| 123 |
"carousel": ((c.get("slides") or [{}])[0].get("heading") or title),
|
| 124 |
+
"slide_deck": _deck_prefill(c) or title,
|
| 125 |
}.get(a.type, title)
|
| 126 |
return {"x": [text], "linkedin": text}
|
| 127 |
|
| 128 |
|
| 129 |
+
def _deck_prefill(c: dict) -> str:
|
| 130 |
+
"""A slide deck's post: its title, its subtitle, and the takeaways of its closing slide."""
|
| 131 |
+
deck = c.get("deck") or {}
|
| 132 |
+
slides = deck.get("slides") or []
|
| 133 |
+
closing = next((s for s in reversed(slides) if s.get("layout") == "closing"), {})
|
| 134 |
+
head = "\n\n".join(x for x in (deck.get("title"), deck.get("subtitle")) if x)
|
| 135 |
+
takeaways = "\n".join(f"- {t}" for t in closing.get("takeaways") or [])
|
| 136 |
+
return "\n\n".join(x for x in (head, takeaways) if x)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
def artifact_brief(a: Artifact) -> dict:
|
| 140 |
"""What the composer and the calendar show of the attached artifact."""
|
| 141 |
c = a.content_json or {}
|
| 142 |
thumb = (a.storage_key if a.type == "quote_card" or upload_kind(a) == "image"
|
| 143 |
else (c.get("slide_keys") or [a.storage_key])[0] if a.type == "carousel" else None)
|
| 144 |
return {"id": str(a.id), "type": a.type, "type_label": TYPE_LABELS.get(a.type, a.type),
|
| 145 |
+
# A deck's title starts "Slide deck: ", which its tag already says
|
| 146 |
+
"title": (c.get("title") or TYPE_LABELS.get(a.type, a.type)).removeprefix("Slide deck: " if a.type == "slide_deck" else ""),
|
| 147 |
+
"media": upload_kind(a) or ("video" if a.type == "video" else "audio" if a.type == "audio_overview"
|
| 148 |
+
else "image" if media_count(a) else "text"),
|
| 149 |
"media_count": media_count(a), "duration_s": duration(a),
|
| 150 |
"thumb_url": storage.presign_get(thumb) if thumb else None,
|
| 151 |
# The user's own file, to preview before posting it.
|
| 152 |
"view_url": storage.presign_get(a.storage_key) if a.type == "upload" and a.storage_key else None,
|
| 153 |
+
# An audio overview can't be posted: it is offered to download instead.
|
| 154 |
+
"download_url": (storage.presign_get(a.storage_key, download_name=download_name(a))
|
| 155 |
+
if a.type == "audio_overview" and a.storage_key else None),
|
| 156 |
# A blog2video video with scenes but no MP4 yet: listed, but it has to be rendered before it can go out.
|
| 157 |
# Made by the video service (blog2video): it opens in the video editor.
|
| 158 |
"editable_video": is_blog2video(a),
|
|
|
|
| 227 |
raise HTTPException(409, {"code": "reconnect", "message": f"Reconnect {label} to post images and videos."})
|
| 228 |
|
| 229 |
|
| 230 |
+
def _require_words(posts: list[str], artifact: Artifact | None) -> None:
|
| 231 |
+
"""A post needs words, unless it carries images or a video (those can go out without a caption)."""
|
| 232 |
+
if not any(p.strip() for p in posts) and not carries_media(artifact):
|
| 233 |
+
raise HTTPException(422, {"code": "no_text", "message": "Write something to post, or attach an image or video."})
|
| 234 |
+
|
| 235 |
+
|
| 236 |
def _validate_lengths(platform: str, posts: list[str]) -> None:
|
| 237 |
limit = LIMITS.get(platform)
|
| 238 |
if platform in ("x", "bluesky") and limit:
|
|
|
|
| 287 |
|
| 288 |
@router.post("/calendar")
|
| 289 |
def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
|
| 290 |
+
when = datetime.now(UTC) if body.publish_now else body.utc()
|
| 291 |
if when is None:
|
| 292 |
raise HTTPException(422, {"code": "no_time", "message": "Pick when it goes out."})
|
| 293 |
account = _account(ctx, body.social_account_id, body.platform)
|
| 294 |
_validate_lengths(body.platform, [body.content, *body.thread])
|
| 295 |
artifact = _postable(ctx, body.artifact_id, body.platform) if body.artifact_id else None
|
| 296 |
+
_require_words([body.content, *body.thread], artifact)
|
| 297 |
kit = _kit(ctx, body.kit_id) if body.kit_id else None
|
| 298 |
_require_connected(body.platform, account, artifact)
|
| 299 |
if body.document_id and not ctx.db.scalar(select(Document.id).where(Document.id == body.document_id,
|
|
|
|
| 305 |
kit_id=kit.id if kit else None,
|
| 306 |
document_id=body.document_id, social_account_id=account.id if account else None,
|
| 307 |
remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
|
| 308 |
+
status="draft" if body.draft else "publishing" if body.publish_now else "scheduled",
|
| 309 |
)
|
| 310 |
ctx.db.add(item)
|
| 311 |
ctx.db.commit()
|
| 312 |
+
if body.publish_now:
|
| 313 |
+
_go_now(ctx, item)
|
| 314 |
return _enrich(ctx, [item])[0]
|
| 315 |
|
| 316 |
|
| 317 |
+
def _go_now(ctx: Ctx, item: CalendarItem) -> None:
|
| 318 |
+
"""Publish an item claimed for it ("publishing") now: with images or a video through a job (uploading takes a
|
| 319 |
+
while; the page follows its status), anything else right here."""
|
| 320 |
+
if lp.has_media(ctx.db, item):
|
| 321 |
+
lp.queue_publish(ctx.db, item)
|
| 322 |
+
else:
|
| 323 |
+
lp.publish_item(ctx.db, item)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
@router.patch("/calendar/{item_id}")
|
| 327 |
def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
|
| 328 |
item = _item(ctx, item_id)
|
|
|
|
| 349 |
if body.status == "paused":
|
| 350 |
item.status = "paused" # held by hand: never attempted until resumed
|
| 351 |
elif body.status is not None or item.status in ("failed", "reminded", "paused"):
|
| 352 |
+
# Going out (again), rescheduled: only through a live connection, and only at a time still ahead (a failed or
|
| 353 |
+
# reminded post's old time has passed, and it would otherwise go out at the very next run).
|
| 354 |
_require_connected(item.platform, account, artifact)
|
| 355 |
+
if (body.status or "scheduled") == "scheduled" and _aware(item.scheduled_at) <= datetime.now(UTC):
|
| 356 |
raise HTTPException(409, {"code": "missed", "message": "Pick a time that is still ahead."})
|
| 357 |
item.status = body.status or "scheduled"
|
| 358 |
elif item.platform in CONNECTED_ONLY and body.model_fields_set & {"social_account_id", "artifact_id"}:
|
| 359 |
_require_connected(item.platform, account, artifact)
|
| 360 |
item.error = None
|
| 361 |
item.attempts = 0
|
| 362 |
+
_require_words([item.content or "", *(item.thread or [])], artifact) # e.g. the caption emptied, or the video removed
|
| 363 |
_validate_lengths(item.platform, [item.content, *(item.thread or [])])
|
| 364 |
ctx.db.commit()
|
| 365 |
return _enrich(ctx, [item])[0]
|
|
|
|
| 374 |
|
| 375 |
|
| 376 |
@router.post("/calendar/{item_id}/publish")
|
| 377 |
+
def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 378 |
+
"""Publish a scheduled post now. A plain (threadpool) endpoint: its database calls and an inline publish must
|
| 379 |
+
never hold up the event loop, and with it every other request."""
|
| 380 |
item = _item(ctx, item_id)
|
| 381 |
if item.status in ("posted", "publishing"):
|
| 382 |
raise HTTPException(409, "This post already went out.")
|
| 383 |
item.status = "publishing"
|
| 384 |
item.attempts = 0
|
| 385 |
ctx.db.commit()
|
| 386 |
+
_go_now(ctx, item)
|
|
|
|
|
|
|
|
|
|
| 387 |
return _enrich(ctx, [item])[0]
|
| 388 |
|
| 389 |
|
|
|
|
| 422 |
|
| 423 |
@router.get("/launchpad/postable")
|
| 424 |
def postable(ctx: Ctx = Depends(get_ctx), q: str = "", limit: int = 60):
|
| 425 |
+
"""What can be attached to a post: finished videos, quote cards, carousels, summaries, launch kits, mind maps,
|
| 426 |
+
slide decks, newest first, and audio overviews (listed to download: they can't be posted). Videos made but not
|
| 427 |
+
rendered yet are listed too, flagged needs_render (they can only be posted once rendered), and so are videos being
|
| 428 |
+
rendered, flagged rendering."""
|
| 429 |
rows = ctx.db.scalars(select(Artifact).where(Artifact.workspace_id == ctx.workspace.id,
|
| 430 |
+
Artifact.type.in_(LISTED))
|
| 431 |
.order_by(Artifact.created_at.desc()).limit(400))
|
| 432 |
needle = q.strip().lower()
|
| 433 |
out = []
|
|
|
|
| 441 |
out.append(artifact_brief(a))
|
| 442 |
if len(out) >= min(limit, 200):
|
| 443 |
break
|
| 444 |
+
# What each one already has coming up (scheduled, still ahead), soonest first: the list offers to reschedule it
|
| 445 |
+
upcoming = ctx.db.scalars(select(CalendarItem).where(
|
| 446 |
+
CalendarItem.workspace_id == ctx.workspace.id, CalendarItem.status == "scheduled",
|
| 447 |
+
CalendarItem.scheduled_at > datetime.now(UTC),
|
| 448 |
+
CalendarItem.artifact_id.in_([uuid.UUID(b["id"]) for b in out])).order_by(CalendarItem.scheduled_at))
|
| 449 |
+
by_artifact: dict[str, list[dict]] = {}
|
| 450 |
+
for it in upcoming:
|
| 451 |
+
by_artifact.setdefault(str(it.artifact_id), []).append(
|
| 452 |
+
{"id": str(it.id), "scheduled_at": _aware(it.scheduled_at).isoformat(),
|
| 453 |
+
"platform_label": PLATFORM_LABELS.get(it.platform, it.platform)})
|
| 454 |
+
for b in out:
|
| 455 |
+
b["scheduled"] = by_artifact.get(b["id"], [])
|
| 456 |
return out
|
| 457 |
|
| 458 |
|
backend/app/services/artifacts.py
CHANGED
|
@@ -9,6 +9,8 @@ from sqlalchemy.orm import Session, object_session
|
|
| 9 |
from app.models import Artifact, Job
|
| 10 |
from app.services.jobs import create_job, serialize_job
|
| 11 |
from app.services.storage import storage
|
|
|
|
|
|
|
| 12 |
|
| 13 |
TYPE_LABELS = {
|
| 14 |
"summary": "Summary",
|
|
@@ -22,6 +24,7 @@ TYPE_LABELS = {
|
|
| 22 |
"flashcards": "Flashcards",
|
| 23 |
"report": "Report",
|
| 24 |
"infographic": "Infographic",
|
|
|
|
| 25 |
"upload": "Upload",
|
| 26 |
}
|
| 27 |
|
|
@@ -84,6 +87,8 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
|
|
| 84 |
from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
|
| 85 |
|
| 86 |
content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
|
|
|
|
|
|
|
| 87 |
if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
|
| 88 |
content = {**content, "blocks": report_blocks(a)}
|
| 89 |
slides = content.get("slide_keys") or []
|
|
@@ -99,7 +104,7 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
|
|
| 99 |
"storage_key": a.storage_key,
|
| 100 |
# blog2video videos live on blog2video's storage (video_url once rendered)
|
| 101 |
"url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
|
| 102 |
-
"download_url": (storage.presign_get(a.storage_key, download_name=
|
| 103 |
else content.get("video_url")),
|
| 104 |
"provider": content.get("provider"),
|
| 105 |
"slide_urls": [storage.presign_get(k) for k in slides],
|
|
@@ -108,7 +113,8 @@ def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
|
|
| 108 |
}
|
| 109 |
|
| 110 |
|
| 111 |
-
def
|
|
|
|
| 112 |
title = (a.content_json or {}).get("title") or a.type
|
| 113 |
ext = (a.storage_key or "").rsplit(".", 1)[-1]
|
| 114 |
return f"{title[:60]}.{ext}"
|
|
|
|
| 9 |
from app.models import Artifact, Job
|
| 10 |
from app.services.jobs import create_job, serialize_job
|
| 11 |
from app.services.storage import storage
|
| 12 |
+
from app.slides.build import slots as slide_slots
|
| 13 |
+
from app.slides.build import view as slide_pages
|
| 14 |
|
| 15 |
TYPE_LABELS = {
|
| 16 |
"summary": "Summary",
|
|
|
|
| 24 |
"flashcards": "Flashcards",
|
| 25 |
"report": "Report",
|
| 26 |
"infographic": "Infographic",
|
| 27 |
+
"slide_deck": "Slide deck",
|
| 28 |
"upload": "Upload",
|
| 29 |
}
|
| 30 |
|
|
|
|
| 87 |
from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
|
| 88 |
|
| 89 |
content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
|
| 90 |
+
if a.type == "slide_deck" and a.status == "ready":
|
| 91 |
+
content = {**content, "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}
|
| 92 |
if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
|
| 93 |
content = {**content, "blocks": report_blocks(a)}
|
| 94 |
slides = content.get("slide_keys") or []
|
|
|
|
| 104 |
"storage_key": a.storage_key,
|
| 105 |
# blog2video videos live on blog2video's storage (video_url once rendered)
|
| 106 |
"url": storage.presign_get(a.storage_key) if a.storage_key else content.get("video_url"),
|
| 107 |
+
"download_url": (storage.presign_get(a.storage_key, download_name=download_name(a)) if a.storage_key
|
| 108 |
else content.get("video_url")),
|
| 109 |
"provider": content.get("provider"),
|
| 110 |
"slide_urls": [storage.presign_get(k) for k in slides],
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
|
| 116 |
+
def download_name(a: Artifact) -> str:
|
| 117 |
+
"""The file name a download is saved under: the artifact's title and its file's extension."""
|
| 118 |
title = (a.content_json or {}).get("title") or a.type
|
| 119 |
ext = (a.storage_key or "").rsplit(".", 1)[-1]
|
| 120 |
return f"{title[:60]}.{ext}"
|
backend/app/services/launchpad.py
CHANGED
|
@@ -21,7 +21,7 @@ from app.services.email import email_service
|
|
| 21 |
from app.services.jobs import create_job
|
| 22 |
from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
|
| 23 |
from app.services.social.health import mark_down
|
| 24 |
-
from app.services.social.media import media_count, media_for
|
| 25 |
|
| 26 |
log = logging.getLogger("notestack.launchpad")
|
| 27 |
_URL = re.compile(r"https?://[^\s)\]>]+")
|
|
@@ -60,6 +60,8 @@ def owner_email(db: Session, workspace_id) -> str | None:
|
|
| 60 |
def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
|
| 61 |
"""Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
|
| 62 |
posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
|
|
|
|
|
|
|
| 63 |
if not posts:
|
| 64 |
item.status, item.error = "failed", "Nothing to post."
|
| 65 |
db.commit()
|
|
|
|
| 21 |
from app.services.jobs import create_job
|
| 22 |
from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
|
| 23 |
from app.services.social.health import mark_down
|
| 24 |
+
from app.services.social.media import carries_media, media_count, media_for
|
| 25 |
|
| 26 |
log = logging.getLogger("notestack.launchpad")
|
| 27 |
_URL = re.compile(r"https?://[^\s)\]>]+")
|
|
|
|
| 60 |
def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
|
| 61 |
"""Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
|
| 62 |
posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
|
| 63 |
+
if not posts and carries_media(db.get(Artifact, item.artifact_id) if item.artifact_id else None):
|
| 64 |
+
posts = [""] # a video or images on their own: the post goes out without a caption
|
| 65 |
if not posts:
|
| 66 |
item.status, item.error = "failed", "Nothing to post."
|
| 67 |
db.commit()
|
backend/app/services/social/media.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
"""What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides
|
|
|
|
| 2 |
video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
|
| 3 |
one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
|
| 4 |
be posted (X and LinkedIn take no audio files).
|
|
@@ -69,11 +70,18 @@ def media_count(a: Artifact) -> int:
|
|
| 69 |
return 1
|
| 70 |
if a.type == "carousel":
|
| 71 |
return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
|
|
|
|
|
|
|
| 72 |
if a.type in ("video", "upload"):
|
| 73 |
return 1
|
| 74 |
return 0
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def upload_kind(a: Artifact) -> str | None:
|
| 78 |
"""An uploaded file's kind: image | video."""
|
| 79 |
return (a.content_json or {}).get("media") if a.type == "upload" else None
|
|
@@ -91,13 +99,16 @@ def check_postable(a: Artifact, platform: str) -> None:
|
|
| 91 |
raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
|
| 92 |
if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
|
| 93 |
raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
|
| 94 |
-
if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload"):
|
| 95 |
raise HTTPException(400, "This can't be posted.")
|
| 96 |
if a.type in TEXT_ONLY:
|
| 97 |
return
|
| 98 |
if platform not in MEDIA_PLATFORMS:
|
| 99 |
raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
|
| 100 |
-
if a.type == "
|
|
|
|
|
|
|
|
|
|
| 101 |
if not (a.content_json or {}).get("video_url"):
|
| 102 |
raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
|
| 103 |
elif a.status != "ready" or not a.storage_key:
|
|
@@ -134,6 +145,9 @@ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
|
|
| 134 |
keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
|
| 135 |
out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
|
| 136 |
for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
|
|
|
|
|
|
|
|
|
|
| 137 |
elif a.type == "upload" and upload_kind(a) == "image":
|
| 138 |
c = a.content_json or {}
|
| 139 |
out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
|
|
@@ -160,6 +174,21 @@ def media_for(a: Artifact | None, platform: str) -> Iterator[list[Media]]:
|
|
| 160 |
os.unlink(path)
|
| 161 |
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
def _temp_file(temps: list[str]) -> str:
|
| 164 |
fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
|
| 165 |
os.close(fd)
|
|
|
|
| 1 |
+
"""What a scheduled post carries from the artifact it was made from: images (quote cards, carousel slides, a slide
|
| 2 |
+
deck's slides, drawn when the post goes out) or one
|
| 3 |
video (an audiogram, or a blog2video video), or a file the user uploaded from their computer (an `upload`: one image or
|
| 4 |
one MP4). Summaries, launch kits and mind maps post as text; audio overviews can't
|
| 5 |
be posted (X and LinkedIn take no audio files).
|
|
|
|
| 70 |
return 1
|
| 71 |
if a.type == "carousel":
|
| 72 |
return len((a.content_json or {}).get("slide_keys") or ([a.storage_key] if a.storage_key else []))
|
| 73 |
+
if a.type == "slide_deck":
|
| 74 |
+
return len(((a.content_json or {}).get("deck") or {}).get("slides") or [])
|
| 75 |
if a.type in ("video", "upload"):
|
| 76 |
return 1
|
| 77 |
return 0
|
| 78 |
|
| 79 |
|
| 80 |
+
def carries_media(a: Artifact | None) -> bool:
|
| 81 |
+
"""Whether a post of this artifact carries images or a video: such a post may go out without a caption."""
|
| 82 |
+
return a is not None and a.type not in TEXT_ONLY and media_count(a) > 0
|
| 83 |
+
|
| 84 |
+
|
| 85 |
def upload_kind(a: Artifact) -> str | None:
|
| 86 |
"""An uploaded file's kind: image | video."""
|
| 87 |
return (a.content_json or {}).get("media") if a.type == "upload" else None
|
|
|
|
| 99 |
raise HTTPException(400, "Audio can't be posted: X and LinkedIn don't take audio files.")
|
| 100 |
if a.type == "launch_kit": # a post is written from a kit (kit_id); the kit itself is never an attachment
|
| 101 |
raise HTTPException(400, "A launch kit can't be attached. Attach a video, quote card or carousel instead.")
|
| 102 |
+
if a.type not in TEXT_ONLY and a.type not in ("quote_card", "carousel", "video", "upload", "slide_deck"):
|
| 103 |
raise HTTPException(400, "This can't be posted.")
|
| 104 |
if a.type in TEXT_ONLY:
|
| 105 |
return
|
| 106 |
if platform not in MEDIA_PLATFORMS:
|
| 107 |
raise HTTPException(400, "Images and videos can only be posted to X and LinkedIn.")
|
| 108 |
+
if a.type == "slide_deck": # drawn from the deck when the post goes out: nothing stored to wait for
|
| 109 |
+
if a.status != "ready" or not media_count(a):
|
| 110 |
+
raise HTTPException(400, "This slide deck isn't ready yet. Post it once it has finished.")
|
| 111 |
+
elif a.type == "video" and is_blog2video(a):
|
| 112 |
if not (a.content_json or {}).get("video_url"):
|
| 113 |
raise HTTPException(400, "This video isn't finished yet. Post it once it has rendered.")
|
| 114 |
elif a.status != "ready" or not a.storage_key:
|
|
|
|
| 145 |
keys = (a.content_json or {}).get("slide_keys") or [a.storage_key]
|
| 146 |
out = [Media("image", f"slide-{i + 1}.png", "image/png", data=storage.get_bytes(k))
|
| 147 |
for i, k in enumerate(keys[:MAX_IMAGES[platform]])]
|
| 148 |
+
elif a.type == "slide_deck":
|
| 149 |
+
out = [Media("image", f"slide-{i + 1}.png", "image/png", data=png)
|
| 150 |
+
for i, png in enumerate(_slide_images(a, MAX_IMAGES[platform]))]
|
| 151 |
elif a.type == "upload" and upload_kind(a) == "image":
|
| 152 |
c = a.content_json or {}
|
| 153 |
out = [Media("image", c.get("filename") or "image", c.get("content_type") or "image/png",
|
|
|
|
| 174 |
os.unlink(path)
|
| 175 |
|
| 176 |
|
| 177 |
+
def _slide_images(a: Artifact, limit: int) -> list[bytes]:
|
| 178 |
+
"""A slide deck's first `limit` slides as PNGs, exactly as the deck shows them."""
|
| 179 |
+
from app.infographics.image import ImageUnavailable
|
| 180 |
+
from app.slides import export
|
| 181 |
+
from app.slides.build import stored
|
| 182 |
+
|
| 183 |
+
got = stored(a.content_json or {})
|
| 184 |
+
if not got:
|
| 185 |
+
raise SocialError("This slide deck has no slides to post.", permanent=True)
|
| 186 |
+
try:
|
| 187 |
+
return export.render_slide_pngs(*got, limit=limit)
|
| 188 |
+
except ImageUnavailable as exc: # no Chrome on this worker right now: worth another try
|
| 189 |
+
raise SocialError(f"The slides could not be drawn: {exc}") from exc
|
| 190 |
+
|
| 191 |
+
|
| 192 |
def _temp_file(temps: list[str]) -> str:
|
| 193 |
fd, path = tempfile.mkstemp(prefix="notestack-post-", suffix=".mp4")
|
| 194 |
os.close(fd)
|
backend/app/services/social/x.py
CHANGED
|
@@ -157,7 +157,7 @@ def publish(db: Session, account: SocialAccount, posts: list[str], media: list |
|
|
| 157 |
media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
|
| 158 |
first_id, reply_to = None, None
|
| 159 |
for text in posts:
|
| 160 |
-
body: dict = {"text": text}
|
| 161 |
if reply_to:
|
| 162 |
body["reply"] = {"in_reply_to_tweet_id": reply_to}
|
| 163 |
elif media_ids:
|
|
|
|
| 157 |
media_ids = [(_upload_video if m.kind == "video" else _upload_image)(headers, m) for m in media]
|
| 158 |
first_id, reply_to = None, None
|
| 159 |
for text in posts:
|
| 160 |
+
body: dict = {"text": text} if text else {} # X takes a post with media and no text, but not an empty text
|
| 161 |
if reply_to:
|
| 162 |
body["reply"] = {"in_reply_to_tweet_id": reply_to}
|
| 163 |
elif media_ids:
|
backend/app/slides/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slide decks: the model writes a deck as JSON (content.py clamps it to a fixed shape), layout.py places every slide
|
| 2 |
+
on a 1920 x 1080 canvas, and the same placed elements are drawn as HTML (render.py: preview, presenting, PDF) and as
|
| 3 |
+
an editable PowerPoint (export.py). The model never writes markup or picks positions, colours or sizes."""
|
backend/app/slides/build.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""From a written deck to what is stored and shown. `finish` picks each slide's variant, fits its text, and checks
|
| 2 |
+
the result in Chrome when one is installed. `restructure` takes a deck back from the editor and fits it again.
|
| 3 |
+
`view` reads a stored deck back (clamped again, so older or edited decks still draw) and gives its pages; one slide
|
| 4 |
+
that cannot be drawn becomes a plain one instead of breaking the deck."""
|
| 5 |
+
|
| 6 |
+
import copy
|
| 7 |
+
import logging
|
| 8 |
+
import random
|
| 9 |
+
from collections import Counter
|
| 10 |
+
|
| 11 |
+
from app.infographics.image import ImageUnavailable
|
| 12 |
+
from app.slides import export
|
| 13 |
+
from app.slides.content import clamp_deck, clamp_slide, empty_slide
|
| 14 |
+
from app.slides.layout import GEOMETRY, SCALES, VARIANTS, assign_variants, compose, fits_variant, seed_of
|
| 15 |
+
from app.slides.render import slide_html
|
| 16 |
+
from app.slides.themes import theme_id
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def fit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
|
| 22 |
+
"""Every slide as it will be drawn: text shortened where it had to be, so what is stored is what is shown."""
|
| 23 |
+
out = copy.deepcopy(deck)
|
| 24 |
+
out["slides"] = [compose(out, i, theme, fmt, seed).slide for i in range(len(out["slides"]))]
|
| 25 |
+
return out
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def finish(deck: dict, theme: str, fmt: str, seed: int, previous: list[tuple[str, str]] | None = None) -> dict:
|
| 29 |
+
"""Variants, then refit. Raises ValueError when a slide cannot be drawn at all."""
|
| 30 |
+
theme = theme_id(theme)
|
| 31 |
+
deck = copy.deepcopy(deck)
|
| 32 |
+
assign_variants(deck["slides"], theme, seed, previous)
|
| 33 |
+
return refit(deck, theme, fmt, seed)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def refit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
|
| 37 |
+
"""Fitted text and a Chrome check that no text box overflows (slides that do are drawn a step smaller and fitted
|
| 38 |
+
again), keeping each slide's variant. Raises ValueError when a slide cannot be drawn at all."""
|
| 39 |
+
theme = theme_id(theme)
|
| 40 |
+
deck = fit(deck, theme, fmt, seed)
|
| 41 |
+
for _ in range(3):
|
| 42 |
+
try:
|
| 43 |
+
bad = export.overflowing(deck, theme, fmt, seed)
|
| 44 |
+
except ImageUnavailable:
|
| 45 |
+
break # no Chrome here: the measured fit alone keeps text inside its boxes
|
| 46 |
+
if not bad:
|
| 47 |
+
break
|
| 48 |
+
log.info("slides %s overflow in Chrome, drawing them smaller", sorted(bad))
|
| 49 |
+
for i in bad:
|
| 50 |
+
deck["slides"][i]["shrink"] = min(int(deck["slides"][i].get("shrink") or 0) + 1, len(SCALES) - 1)
|
| 51 |
+
deck = fit(deck, theme, fmt, seed)
|
| 52 |
+
for i in range(len(deck["slides"])):
|
| 53 |
+
slide_html(deck, i, theme, fmt, seed) # raises if a slide cannot be drawn: better now than when shown
|
| 54 |
+
return deck
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class BadEdit(ValueError):
|
| 58 |
+
"""A deck sent back from the editor that cannot be kept (no slides, or too many)."""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
MAX_SLIDES = 30
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def fill_variants(slides: list[dict], theme: str, seed: int) -> None:
|
| 65 |
+
"""Give a variant, in place, to each slide that has none or whose variant no longer suits it (a new slide, a changed
|
| 66 |
+
layout, a card row now with five points). Every other slide keeps its own, so editing one slide never redraws the
|
| 67 |
+
rest. The choice follows assign_variants: suited to the content, not the same as a neighbour, unused ones first."""
|
| 68 |
+
theme = theme_id(theme)
|
| 69 |
+
def ok(i: int) -> bool:
|
| 70 |
+
s = slides[i]
|
| 71 |
+
return s["variant"] in VARIANTS[s["layout"]] and fits_variant(s, theme, s["variant"])
|
| 72 |
+
keep = [ok(i) for i in range(len(slides))]
|
| 73 |
+
for i, s in enumerate(slides):
|
| 74 |
+
before = (slides[i - 1]["layout"], slides[i - 1]["variant"]) if i else None
|
| 75 |
+
if keep[i] and (s["layout"], s["variant"]) != before:
|
| 76 |
+
continue
|
| 77 |
+
after = (slides[i + 1]["layout"], slides[i + 1]["variant"]) if i + 1 < len(slides) and keep[i + 1] else None
|
| 78 |
+
options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]])
|
| 79 |
+
fresh = [v for v in options if (s["layout"], v) not in (before, after)] or options
|
| 80 |
+
used = Counter((x["layout"], x["variant"]) for j, x in enumerate(slides) if j != i)
|
| 81 |
+
rnd = random.Random(seed + i)
|
| 82 |
+
s["variant"] = min(fresh, key=lambda v: (used[(s["layout"], v)], rnd.random()))
|
| 83 |
+
keep[i] = True
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def restructure(content: dict, slides: object, final: bool) -> dict:
|
| 87 |
+
"""The stored deck with the slides the editor sent (text edited, slides added, removed, moved, or changed to another
|
| 88 |
+
layout). They are coerced like model output (clamp_deck: plain text, the format's limits, valid layouts), slides
|
| 89 |
+
that need one get a variant, and the deck is fitted: quickly for a preview, with the Chrome check when `final`."""
|
| 90 |
+
got = stored(content)
|
| 91 |
+
if not got:
|
| 92 |
+
raise BadEdit("This deck has no slides to edit.")
|
| 93 |
+
deck, theme, fmt, seed = got
|
| 94 |
+
if not isinstance(slides, list) or not slides:
|
| 95 |
+
raise BadEdit("A deck needs at least one slide.")
|
| 96 |
+
if len(slides) > MAX_SLIDES:
|
| 97 |
+
raise BadEdit(f"A deck can have at most {MAX_SLIDES} slides.")
|
| 98 |
+
new = clamp_deck({"title": deck["title"], "subtitle": deck["subtitle"], "slides": slides}, fmt)
|
| 99 |
+
if not new:
|
| 100 |
+
raise BadEdit("A deck needs at least one slide with a heading.")
|
| 101 |
+
for s in new["slides"]:
|
| 102 |
+
s["shrink"] = 0 # text changed: fitted afresh, so shorter text can be drawn larger again
|
| 103 |
+
fill_variants(new["slides"], theme, seed)
|
| 104 |
+
return refit(new, theme, fmt, seed) if final else fit(new, theme, fmt, seed)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def slots(content: dict) -> list[list[str]]:
|
| 108 |
+
"""For each slide, the parts its variant has a place for (kicker, lead, body, by, closing...): what the editor
|
| 109 |
+
can offer to add."""
|
| 110 |
+
got = stored(content)
|
| 111 |
+
if not got:
|
| 112 |
+
return []
|
| 113 |
+
deck, theme, _fmt, _seed = got
|
| 114 |
+
out = []
|
| 115 |
+
for s in deck["slides"]:
|
| 116 |
+
variants = GEOMETRY[theme][s["layout"]]
|
| 117 |
+
out.append(sorted(k for k, v in (variants.get(s["variant"]) or next(iter(variants.values()))).items() if v))
|
| 118 |
+
return out
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def stored(content: dict) -> tuple[dict, str, str, int] | None:
|
| 122 |
+
"""A stored deck, clamped, with its theme, format and seed (None when there is none)."""
|
| 123 |
+
fmt = content.get("format") if content.get("format") in ("detailed", "presenter") else "detailed"
|
| 124 |
+
deck = clamp_deck(content.get("deck") or {}, fmt)
|
| 125 |
+
if not deck:
|
| 126 |
+
return None
|
| 127 |
+
return deck, theme_id(content.get("theme")), fmt, seed_of(content.get("seed"))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _placeholder(deck: dict, index: int) -> dict:
|
| 131 |
+
s = empty_slide("section")
|
| 132 |
+
s["heading"] = deck.get("title") or "Slide"
|
| 133 |
+
return clamp_slide(s, "detailed")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def view(content: dict) -> list[str]:
|
| 137 |
+
"""Each slide of a stored deck as a standalone page."""
|
| 138 |
+
got = stored(content)
|
| 139 |
+
if not got:
|
| 140 |
+
return []
|
| 141 |
+
deck, theme, fmt, seed = got
|
| 142 |
+
pages = []
|
| 143 |
+
for i in range(len(deck["slides"])):
|
| 144 |
+
try:
|
| 145 |
+
pages.append(slide_html(deck, i, theme, fmt, seed))
|
| 146 |
+
except Exception:
|
| 147 |
+
log.exception("slide %s could not be drawn", i)
|
| 148 |
+
safe = copy.deepcopy(deck)
|
| 149 |
+
safe["slides"][i] = _placeholder(deck, i)
|
| 150 |
+
pages.append(slide_html(safe, i, theme, fmt, seed))
|
| 151 |
+
return pages
|
backend/app/slides/content.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What a deck says, in one fixed shape. Model output is never trusted: every slide is coerced to the same keys (empty
|
| 2 |
+
defaults for what it lacks), stripped to plain text, cut to the format's limits, and moved to a simpler layout when
|
| 3 |
+
it lacks what its own layout needs. Whatever goes in, what comes out can always be drawn."""
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
LAYOUTS = ("title", "agenda", "section", "points", "two_column", "stat", "quote", "closing")
|
| 8 |
+
FORMATS = ("detailed", "presenter")
|
| 9 |
+
LENGTHS = {"short": (6, 8), "default": (9, 11), "long": (12, 16)} # slides in the deck, opening and closing included
|
| 10 |
+
|
| 11 |
+
# Characters per field. Detailed slides explain; presenter slides only cue the speaker.
|
| 12 |
+
LIMITS = {
|
| 13 |
+
"detailed": {"title": 80, "subtitle": 170, "kicker": 32, "heading": 90, "lead": 240, "term": 44, "text": 220,
|
| 14 |
+
"points": (2, 5), "label": 40, "item": 120, "items": 4, "value": 16, "stat_label": 120,
|
| 15 |
+
"quote": 260, "by": 70, "takeaway": 150, "takeaways": 3, "closing": 140, "agenda": 64,
|
| 16 |
+
"agenda_items": 14, "notes": 1500},
|
| 17 |
+
"presenter": {"title": 70, "subtitle": 130, "kicker": 32, "heading": 64, "lead": 130, "term": 36, "text": 80,
|
| 18 |
+
"points": (2, 4), "label": 32, "item": 70, "items": 4, "value": 16, "stat_label": 90,
|
| 19 |
+
"quote": 200, "by": 70, "takeaway": 90, "takeaways": 3, "closing": 110, "agenda": 56,
|
| 20 |
+
"agenda_items": 14, "notes": 1500},
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
_TAGS = re.compile(r"<[^>]*>")
|
| 24 |
+
_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
| 25 |
+
_MD_EMPH = re.compile(r"(\*\*|__|\*|`|~~)")
|
| 26 |
+
_MD_LEAD = re.compile(r"^\s*(?:#{1,6}\s+|[-*+•]\s+|\d{1,2}[.)]\s+|>\s*)")
|
| 27 |
+
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
| 28 |
+
_CITE = re.compile(r"\s*\[\d+\]")
|
| 29 |
+
_SPACE = re.compile(r"\s+")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def plain(value: object) -> str:
|
| 33 |
+
"""One line of plain text: no markup, markdown, citation markers, control characters or em dashes."""
|
| 34 |
+
if value is None or isinstance(value, dict | list | tuple | set):
|
| 35 |
+
return ""
|
| 36 |
+
s = _CTRL.sub(" ", str(value))
|
| 37 |
+
s = _MD_LINK.sub(r"\1", _TAGS.sub(" ", s))
|
| 38 |
+
s = _MD_LEAD.sub("", s)
|
| 39 |
+
s = _CITE.sub("", _MD_EMPH.sub("", s))
|
| 40 |
+
s = s.replace("—", ", ").replace("–", "-").replace(" ", " ")
|
| 41 |
+
s = _SPACE.sub(" ", s).strip()
|
| 42 |
+
return s.replace(" ,", ",").strip(" ,;")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def clip(value: object, limit: int) -> str:
|
| 46 |
+
"""Plain text of at most `limit` characters. A longer text ends at its last full sentence when one fits well,
|
| 47 |
+
otherwise at a word, with an ellipsis."""
|
| 48 |
+
s = plain(value)
|
| 49 |
+
if len(s) <= limit:
|
| 50 |
+
return s
|
| 51 |
+
head = s[:limit]
|
| 52 |
+
end = max(head.rfind(". "), head.rfind("! "), head.rfind("? "))
|
| 53 |
+
if end >= limit * 0.55:
|
| 54 |
+
return head[: end + 1]
|
| 55 |
+
# No sentence ends in reach: end at the last clause instead (", ensuring..." dropped), as a full stop.
|
| 56 |
+
clause = max(head.rfind(", "), head.rfind("; "), head.rfind(" and "), head.rfind(" while "), head.rfind(" which "))
|
| 57 |
+
if clause >= limit * 0.6:
|
| 58 |
+
return head[:clause].rstrip(" ,;:-") + "."
|
| 59 |
+
cut = head[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or head[: limit - 1]
|
| 60 |
+
return cut + "…"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _dict(value: object) -> dict:
|
| 64 |
+
if hasattr(value, "model_dump"):
|
| 65 |
+
value = value.model_dump()
|
| 66 |
+
return value if isinstance(value, dict) else {}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _list(value: object) -> list:
|
| 70 |
+
return list(value) if isinstance(value, list | tuple) else []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _sources(raw: object) -> list[dict]:
|
| 74 |
+
out = []
|
| 75 |
+
for r in _list(raw):
|
| 76 |
+
r = _dict(r)
|
| 77 |
+
try:
|
| 78 |
+
start, end = int(r["line_start"]), int(r["line_end"])
|
| 79 |
+
except (KeyError, TypeError, ValueError):
|
| 80 |
+
continue
|
| 81 |
+
path = plain(r.get("path"))
|
| 82 |
+
if not path:
|
| 83 |
+
continue
|
| 84 |
+
item = {"path": path, "line_start": start, "line_end": max(start, end)}
|
| 85 |
+
for key in ("title", "document_id"):
|
| 86 |
+
if r.get(key):
|
| 87 |
+
item[key] = plain(r[key])[:200]
|
| 88 |
+
if r.get("quote"):
|
| 89 |
+
item["quote"] = clip(r["quote"], 400)
|
| 90 |
+
out.append(item)
|
| 91 |
+
return out[:8]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _points(raw: object, lim: dict) -> list[dict]:
|
| 95 |
+
out = []
|
| 96 |
+
for p in _list(raw):
|
| 97 |
+
if isinstance(p, str):
|
| 98 |
+
p = {"term": "", "text": p}
|
| 99 |
+
p = _dict(p)
|
| 100 |
+
term, text = clip(p.get("term"), lim["term"]), clip(p.get("text"), lim["text"])
|
| 101 |
+
if not text and term: # a bare term is the point itself
|
| 102 |
+
term, text = "", clip(p.get("term"), lim["text"])
|
| 103 |
+
if text:
|
| 104 |
+
out.append({"term": term, "text": text})
|
| 105 |
+
return out
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _column(raw: object, lim: dict) -> dict:
|
| 109 |
+
c = _dict(raw)
|
| 110 |
+
items = [t for t in (clip(i, lim["item"]) for i in _list(c.get("items"))) if t][: lim["items"]]
|
| 111 |
+
return {"label": clip(c.get("label"), lim["label"]), "items": items}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
_LIMIT_KEY = {"kicker": "kicker", "heading": "heading", "lead": "lead", "closing": "closing", "stat.value": "value",
|
| 115 |
+
"stat.label": "stat_label", "quote.text": "quote", "quote.by": "by", "left.label": "label",
|
| 116 |
+
"right.label": "label", "left.items": "item", "right.items": "item", "points.term": "term",
|
| 117 |
+
"points.text": "text", "takeaways": "takeaway"}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def field_limit(layout: str, field: str, fmt: str) -> int:
|
| 121 |
+
"""How many characters `field` may hold on a slide of this layout: what clamp_slide cuts it to."""
|
| 122 |
+
lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
|
| 123 |
+
key = re.sub(r"\.\d+", "", field) # "points.2.text" -> "points.text"
|
| 124 |
+
if layout == "title" and key in ("heading", "lead"):
|
| 125 |
+
return lim["title" if key == "heading" else "subtitle"]
|
| 126 |
+
if layout == "agenda" and key == "points.text":
|
| 127 |
+
return lim["agenda"]
|
| 128 |
+
return lim[_LIMIT_KEY[key]]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def empty_slide(layout: str = "points") -> dict:
|
| 132 |
+
return {"layout": layout, "variant": "", "kicker": "", "heading": "", "lead": "", "points": [],
|
| 133 |
+
"left": {"label": "", "items": []}, "right": {"label": "", "items": []},
|
| 134 |
+
"stat": {"value": "", "label": ""}, "quote": {"text": "", "by": ""}, "takeaways": [], "closing": "",
|
| 135 |
+
"notes": "", "sources": [], "shrink": 0}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def clamp_slide(raw: object, fmt: str) -> dict | None:
|
| 139 |
+
"""One slide in the fixed shape, or None when nothing on it can be shown."""
|
| 140 |
+
lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
|
| 141 |
+
r = _dict(raw)
|
| 142 |
+
s = empty_slide(r.get("layout") if r.get("layout") in LAYOUTS else "points")
|
| 143 |
+
s["variant"] = plain(r.get("variant"))[:4].lower()
|
| 144 |
+
s["kicker"] = clip(r.get("kicker"), lim["kicker"])
|
| 145 |
+
s["heading"] = clip(r.get("heading"), lim["title"] if s["layout"] == "title" else lim["heading"])
|
| 146 |
+
s["lead"] = clip(r.get("lead"), lim["subtitle"] if s["layout"] == "title" else lim["lead"])
|
| 147 |
+
lo, hi = lim["points"]
|
| 148 |
+
points = _points(r.get("points"), lim)
|
| 149 |
+
if s["layout"] == "agenda":
|
| 150 |
+
s["points"] = [{"term": "", "text": clip(p["text"], lim["agenda"])} for p in points][: lim["agenda_items"]]
|
| 151 |
+
else:
|
| 152 |
+
s["points"] = points[:hi]
|
| 153 |
+
s["left"], s["right"] = _column(r.get("left"), lim), _column(r.get("right"), lim)
|
| 154 |
+
stat = _dict(r.get("stat"))
|
| 155 |
+
s["stat"] = {"value": clip(stat.get("value"), lim["value"]), "label": clip(stat.get("label"), lim["stat_label"])}
|
| 156 |
+
quote = _dict(r.get("quote"))
|
| 157 |
+
s["quote"] = {"text": clip(quote.get("text"), lim["quote"]).strip("\"'“”"), "by": clip(quote.get("by"), lim["by"])}
|
| 158 |
+
s["takeaways"] = [t for t in (clip(t, lim["takeaway"]) for t in _list(r.get("takeaways"))) if t][: lim["takeaways"]]
|
| 159 |
+
s["closing"] = clip(r.get("closing"), lim["closing"])
|
| 160 |
+
s["notes"] = clip(r.get("notes"), lim["notes"])
|
| 161 |
+
s["sources"] = _sources(r.get("sources"))
|
| 162 |
+
try:
|
| 163 |
+
s["shrink"] = max(0, min(int(r.get("shrink") or 0), 4))
|
| 164 |
+
except (TypeError, ValueError):
|
| 165 |
+
s["shrink"] = 0
|
| 166 |
+
|
| 167 |
+
# A layout missing what it needs becomes a simpler one, so the drawing never meets an empty slot.
|
| 168 |
+
lay = s["layout"]
|
| 169 |
+
if lay == "two_column" and not (s["left"]["items"] and s["right"]["items"]):
|
| 170 |
+
merged = [{"term": "", "text": t} for t in s["left"]["items"] + s["right"]["items"]]
|
| 171 |
+
s["points"] = s["points"] or merged[:hi]
|
| 172 |
+
lay = "points"
|
| 173 |
+
if lay == "stat" and not (s["stat"]["value"] and s["stat"]["label"]):
|
| 174 |
+
lay = "points"
|
| 175 |
+
if lay == "quote" and not s["quote"]["text"]:
|
| 176 |
+
lay = "points"
|
| 177 |
+
if lay == "agenda" and len(s["points"]) < 2:
|
| 178 |
+
lay = "section"
|
| 179 |
+
if lay == "closing" and not s["takeaways"] and not s["closing"]:
|
| 180 |
+
lay = "section"
|
| 181 |
+
if lay == "points" and len(s["points"]) < lo:
|
| 182 |
+
if s["points"] and not s["lead"]: # one point: say it as the slide's lead
|
| 183 |
+
s["lead"] = clip(s["points"][0]["text"], lim["lead"])
|
| 184 |
+
s["points"] = []
|
| 185 |
+
lay = "section"
|
| 186 |
+
if lay != s["layout"]:
|
| 187 |
+
s["variant"] = ""
|
| 188 |
+
s["layout"] = lay
|
| 189 |
+
if not s["heading"]:
|
| 190 |
+
s["heading"] = s["kicker"] or (s["closing"] if lay == "closing" else "")
|
| 191 |
+
if not s["heading"] and lay not in ("quote", "closing"):
|
| 192 |
+
return None
|
| 193 |
+
return s
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def clamp_deck(raw: object, fmt: str) -> dict | None:
|
| 197 |
+
"""The deck in the fixed shape, or None when it has no slide that can be shown."""
|
| 198 |
+
r = _dict(raw)
|
| 199 |
+
lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
|
| 200 |
+
slides = [s for s in (clamp_slide(x, fmt) for x in _list(r.get("slides"))) if s]
|
| 201 |
+
if not slides:
|
| 202 |
+
return None
|
| 203 |
+
title = clip(r.get("title"), lim["title"]) or (slides[0]["heading"] if slides[0]["layout"] == "title" else "") or "Slide deck"
|
| 204 |
+
return {"title": title, "subtitle": clip(r.get("subtitle"), lim["subtitle"]), "slides": slides}
|
backend/app/slides/decor.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The drawing behind a slide's text: stars, glows, planets, orbit rings and constellations. Each layout variant puts
|
| 2 |
+
its drawing where its text is not, and the deck's seed moves it a little, so two decks never look alike while one deck
|
| 3 |
+
always looks the same. The same list is drawn as SVG for the page and as a picture for the PowerPoint."""
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import math
|
| 7 |
+
import random
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
|
| 10 |
+
from app.slides.themes import H, W
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class Shape:
|
| 15 |
+
kind: str # glow | planet | ring | band | const | stars
|
| 16 |
+
cx: float = 0
|
| 17 |
+
cy: float = 0
|
| 18 |
+
r: float = 0
|
| 19 |
+
rx: float = 0
|
| 20 |
+
ry: float = 0
|
| 21 |
+
rot: float = 0
|
| 22 |
+
color: str = "accent" # palette key
|
| 23 |
+
opacity: float = 1.0
|
| 24 |
+
width: float = 2.0
|
| 25 |
+
points: list[tuple[float, float, float, float]] = field(default_factory=list) # stars: x, y, r, opacity
|
| 26 |
+
edges: list[tuple[int, int]] = field(default_factory=list)
|
| 27 |
+
# ring, band: only this part of the path, in degrees round it (0 to 180 is the half nearer the viewer, below the
|
| 28 |
+
# centre before the tilt). None: all of it.
|
| 29 |
+
arc: tuple[float, float] | None = None
|
| 30 |
+
inner: float = 0 # band: the inner edge, as a share of rx and ry (the band fills from there out to the ring)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class Decor:
|
| 35 |
+
hot: tuple[float, float] # where the sky is brightest
|
| 36 |
+
shapes: list[Shape]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Where a variant has room for a drawing: (x0, y0, x1, y1). None: only stars and a soft glow.
|
| 40 |
+
FREE = {
|
| 41 |
+
"dark-space": {
|
| 42 |
+
("points", "a"): (1520, 140, 1900, 940), ("points", "c"): None, ("points", "b"): None, ("points", "d"): None,
|
| 43 |
+
("agenda", "a"): (120, 660, 820, 1000), ("agenda", "b"): None,
|
| 44 |
+
("two_column", "a"): None, ("two_column", "b"): None,
|
| 45 |
+
("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (140, 60, 760, 140),
|
| 46 |
+
("section", "b"): (1500, 60, 1900, 560), ("closing", "b"): None, ("closing", "c"): (140, 860, 960, 1060),
|
| 47 |
+
},
|
| 48 |
+
"light-space": {
|
| 49 |
+
("points", "a"): None, ("points", "b"): None, ("points", "c"): None, ("points", "d"): None,
|
| 50 |
+
("agenda", "a"): None, ("agenda", "b"): (120, 760, 820, 1000),
|
| 51 |
+
("two_column", "a"): None, ("two_column", "b"): None,
|
| 52 |
+
("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (100, 360, 400, 760),
|
| 53 |
+
("closing", "a"): None, ("closing", "b"): None, ("closing", "c"): None,
|
| 54 |
+
},
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _stars(rnd: random.Random, dark: bool) -> Shape:
|
| 59 |
+
n = 120 if dark else 70
|
| 60 |
+
pts = []
|
| 61 |
+
for _ in range(n):
|
| 62 |
+
if dark:
|
| 63 |
+
r, op = rnd.choice((0.8, 1.0, 1.2, 1.2, 1.6, 2.2)), round(rnd.uniform(0.18, 0.85), 2)
|
| 64 |
+
else:
|
| 65 |
+
r, op = rnd.choice((0.9, 1.2, 1.5, 2.0)), round(rnd.uniform(0.08, 0.26), 2)
|
| 66 |
+
pts.append((rnd.uniform(0, W), rnd.uniform(0, H), r, op))
|
| 67 |
+
return Shape("stars", points=pts, color="star")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _constellation(rnd: random.Random, box: tuple[float, float, float, float], n: int = 7) -> Shape:
|
| 71 |
+
x0, y0, x1, y1 = box
|
| 72 |
+
pts = [(rnd.uniform(x0, x1), rnd.uniform(y0, y1), rnd.choice((4.0, 5.0, 6.0, 8.0)), 1.0) for _ in range(n)]
|
| 73 |
+
pts.sort(key=lambda p: p[0])
|
| 74 |
+
edges = [(i, i + 1) for i in range(n - 1)]
|
| 75 |
+
if n > 4:
|
| 76 |
+
edges.append((rnd.randrange(0, n - 3), rnd.randrange(n - 2, n)))
|
| 77 |
+
return Shape("const", points=pts, edges=edges, color="accent", opacity=0.85)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def on_ring(ring: Shape, degrees: float) -> tuple[float, float]:
|
| 81 |
+
"""The point at `degrees` round a ring's path, after its tilt: where a planet sits so it is on the drawn line.
|
| 82 |
+
Same maths as the renderers (SVG rotate(rot cx cy), the PowerPoint's polyline)."""
|
| 83 |
+
a, rot = math.radians(degrees), math.radians(ring.rot)
|
| 84 |
+
x, y = ring.rx * math.cos(a), ring.ry * math.sin(a)
|
| 85 |
+
return ring.cx + x * math.cos(rot) - y * math.sin(rot), ring.cy + x * math.sin(rot) + y * math.cos(rot)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def ring_path(s: Shape, scale: float = 1.0, steps: int = 180) -> list[tuple[float, float]]:
|
| 89 |
+
"""Points along a ring's (scaled) path, over its arc, for the renderers to draw."""
|
| 90 |
+
a0, a1 = s.arc or (0, 360)
|
| 91 |
+
scaled = Shape("ring", s.cx, s.cy, rx=s.rx * scale, ry=s.ry * scale, rot=s.rot)
|
| 92 |
+
return [on_ring(scaled, a0 + (a1 - a0) * k / steps) for k in range(steps + 1)]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# A ringed planet's band, from the inside out: (inner, outer, opacity) as shares of the ring. A brighter inner ring
|
| 96 |
+
# and a fainter outer one with a dark gap between, as Saturn's are.
|
| 97 |
+
BANDS = ((0.72, 0.86, 0.34), (0.89, 1.0, 0.22))
|
| 98 |
+
NEAR, FAR = (0, 180), (180, 360)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def ringed_planet(cx: float, cy: float, r: float, rx: float, ry: float, rot: float, opacity: float = 1.0) -> list[Shape]:
|
| 102 |
+
"""A planet inside its rings: the far half of the rings goes behind the planet and the near half in front, so the
|
| 103 |
+
rings wrap round it instead of lying flat over its face. The far half is a little dimmer, being further away."""
|
| 104 |
+
def half(arc: tuple[float, float], op: float) -> list[Shape]:
|
| 105 |
+
out = [Shape("band", cx, cy, rx=rx * hi, ry=ry * hi, rot=rot, color="accent", opacity=o * op, arc=arc, inner=lo / hi)
|
| 106 |
+
for lo, hi, o in BANDS]
|
| 107 |
+
out.append(Shape("ring", cx, cy, rx=rx, ry=ry, rot=rot, color="accent", opacity=0.8 * op, width=2, arc=arc))
|
| 108 |
+
return out
|
| 109 |
+
return [*half(FAR, 0.6 * opacity), Shape("planet", cx, cy, r), *half(NEAR, opacity)]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor:
|
| 113 |
+
"""The drawing for one slide."""
|
| 114 |
+
dark = theme == "dark-space"
|
| 115 |
+
rnd = random.Random(int(hashlib.sha1(f"{seed}:{index}:{layout}:{variant}".encode()).hexdigest()[:12], 16))
|
| 116 |
+
j = lambda span: rnd.uniform(-span, span) # noqa: E731 (a small, seeded nudge)
|
| 117 |
+
shapes = [_stars(rnd, dark)]
|
| 118 |
+
hot = (W * rnd.uniform(0.25, 0.75), H * rnd.uniform(0.15, 0.5))
|
| 119 |
+
key = (layout, variant)
|
| 120 |
+
|
| 121 |
+
if dark:
|
| 122 |
+
if key == ("title", "a"):
|
| 123 |
+
hot = (960, 760)
|
| 124 |
+
shapes += [Shape("glow", 960, 900, 900, color="accent2", opacity=0.55),
|
| 125 |
+
Shape("planet", 960 + j(60), 1080 + 560 + j(40), 820),
|
| 126 |
+
Shape("ring", 960, 860 + j(20), rx=1150, ry=150, rot=-4 + j(3), color="accent", opacity=0.45, width=2.5)]
|
| 127 |
+
elif key == ("title", "b"):
|
| 128 |
+
cx, cy = 1520 + j(60), 300 + j(50)
|
| 129 |
+
hot = (cx, cy)
|
| 130 |
+
shapes += [Shape("glow", cx, cy, 520, color="accent2", opacity=0.5),
|
| 131 |
+
*ringed_planet(cx, cy, 230 + j(25), rx=395, ry=82, rot=-18 + j(6))]
|
| 132 |
+
elif key == ("title", "c"):
|
| 133 |
+
hot = (1450, 540)
|
| 134 |
+
shapes += [Shape("glow", 1450, 540, 620, color="accent2", opacity=0.35),
|
| 135 |
+
_constellation(rnd, (1120, 170, 1800, 910), 8)]
|
| 136 |
+
elif layout == "section" and variant == "a":
|
| 137 |
+
hot = (960, 540)
|
| 138 |
+
shapes += [Shape("glow", 960, 540, 760, color="accent2", opacity=0.35),
|
| 139 |
+
Shape("ring", 960, 540 + j(20), rx=980, ry=260, rot=-8 + j(5), color="accent", opacity=0.3, width=2)]
|
| 140 |
+
elif layout == "closing" and variant == "a":
|
| 141 |
+
hot = (960, 980)
|
| 142 |
+
shapes += [Shape("glow", 960, 1120, 760, color="accent2", opacity=0.5),
|
| 143 |
+
Shape("ring", 960, 1210, rx=1080, ry=880, rot=0, color="accent", opacity=0.35, width=2.5)]
|
| 144 |
+
else:
|
| 145 |
+
corner = rnd.choice(((0, 0), (W, 0), (0, H), (W, H)))
|
| 146 |
+
shapes.append(Shape("glow", corner[0], corner[1], 700, color="accent2", opacity=0.32))
|
| 147 |
+
hot = (corner[0] * 0.8 + W * 0.1, corner[1] * 0.8 + H * 0.1)
|
| 148 |
+
free = FREE["dark-space"].get(key)
|
| 149 |
+
if free:
|
| 150 |
+
x0, y0, x1, y1 = free
|
| 151 |
+
r = min(x1 - x0, y1 - y0) * rnd.uniform(0.26, 0.34)
|
| 152 |
+
cx, cy = rnd.uniform(x0 + r, x1 - r), rnd.uniform(y0 + r, y1 - r)
|
| 153 |
+
shapes += [Shape("glow", cx, cy, r * 2.2, color="accent2", opacity=0.35),
|
| 154 |
+
*ringed_planet(cx, cy, r, rx=r * 1.8, ry=r * 0.38, rot=-16 + j(10), opacity=0.9)]
|
| 155 |
+
else:
|
| 156 |
+
ring = lambda cx, cy, r, op=0.3: Shape("ring", cx, cy, rx=r, ry=r, color="accent2", opacity=op, width=2) # noqa: E731
|
| 157 |
+
if key == ("title", "a"):
|
| 158 |
+
cx, cy = 2010 + j(40), 540 + j(60)
|
| 159 |
+
hot = (1500, 540)
|
| 160 |
+
orbit = ring(cx, cy, 540, 0.24)
|
| 161 |
+
shapes += [ring(cx, cy, 360), orbit, ring(cx, cy, 740, 0.18), Shape("planet", *on_ring(orbit, 200 + j(20)), 34)]
|
| 162 |
+
elif key == ("title", "b"):
|
| 163 |
+
hot = (1500, 820)
|
| 164 |
+
shapes += [Shape("glow", 1560, 840, 520, color="accent", opacity=0.12),
|
| 165 |
+
_constellation(rnd, (1360, 660, 1800, 980), 7)]
|
| 166 |
+
elif key == ("title", "c"):
|
| 167 |
+
hot = (960, 540)
|
| 168 |
+
rot = -10 + j(4) # one tilt for both, so the rings stay concentric
|
| 169 |
+
orbit = Shape("ring", 960, 520, rx=900, ry=330, rot=rot, color="accent2", opacity=0.28, width=2.5)
|
| 170 |
+
shapes += [orbit, Shape("ring", 960, 520, rx=1040, ry=400, rot=rot, color="accent2", opacity=0.14, width=2),
|
| 171 |
+
Shape("planet", *on_ring(orbit, rnd.choice((20, 160, 200, 340)) + j(10)), 30)]
|
| 172 |
+
elif key in (("section", "a"), ("section", "b")):
|
| 173 |
+
cx, toward = (1960, 180) if variant == "a" else (-40, 0) # the planet sits on the side facing the text
|
| 174 |
+
orbit = ring(cx, 560, 460, 0.22)
|
| 175 |
+
shapes += [ring(cx, 560, 300), orbit, ring(cx, 560, 640, 0.15), Shape("planet", *on_ring(orbit, toward + j(10)), 26)]
|
| 176 |
+
elif key == ("closing", "c"):
|
| 177 |
+
hot = (960, 860)
|
| 178 |
+
shapes += [Shape("ring", 960, 540, rx=1000, ry=470, rot=6 + j(4), color="accent2", opacity=0.2, width=2.5)]
|
| 179 |
+
else:
|
| 180 |
+
corner = rnd.choice(((W + 60, -60), (W + 60, H + 60), (-60, H + 60)))
|
| 181 |
+
shapes += [ring(corner[0], corner[1], 260, 0.22), ring(corner[0], corner[1], 400, 0.15)]
|
| 182 |
+
free = FREE["light-space"].get(key)
|
| 183 |
+
if free:
|
| 184 |
+
shapes.append(_constellation(rnd, free, 5))
|
| 185 |
+
return Decor(hot, shapes)
|
backend/app/slides/export.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Files made from a deck: a PDF (the deck's own HTML printed by a headless Chrome, so it matches the preview) and an
|
| 2 |
+
editable PowerPoint (the same placed elements as native text boxes and shapes over a drawn background). Also the
|
| 3 |
+
overflow check: Chrome lays the slides out and reports any text box whose text is taller than the box."""
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import math
|
| 9 |
+
import re
|
| 10 |
+
import subprocess
|
| 11 |
+
import tempfile
|
| 12 |
+
from collections import OrderedDict
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from threading import Lock
|
| 15 |
+
|
| 16 |
+
from PIL import Image, ImageDraw
|
| 17 |
+
from pptx import Presentation
|
| 18 |
+
from pptx.dml.color import RGBColor
|
| 19 |
+
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
|
| 20 |
+
from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN
|
| 21 |
+
from pptx.oxml.ns import qn
|
| 22 |
+
from pptx.util import Emu, Pt
|
| 23 |
+
|
| 24 |
+
from app.infographics.image import ImageUnavailable, chrome
|
| 25 |
+
from app.slides.decor import Decor, Shape, build, ring_path
|
| 26 |
+
from app.slides.layout import El, compose
|
| 27 |
+
from app.slides.render import SHADE, color, deck_print_html, planet_colors, ring_shade, slides_html
|
| 28 |
+
from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
|
| 29 |
+
|
| 30 |
+
TIMEOUT = 120
|
| 31 |
+
KEEP = 6
|
| 32 |
+
EMU_PX = 6350 # 1920 px = 13.333 in: one pixel is half a point
|
| 33 |
+
|
| 34 |
+
_cache: OrderedDict[str, bytes] = OrderedDict()
|
| 35 |
+
_lock = Lock()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _cached(key: str, make) -> bytes:
|
| 39 |
+
with _lock:
|
| 40 |
+
if key in _cache:
|
| 41 |
+
_cache.move_to_end(key)
|
| 42 |
+
return _cache[key]
|
| 43 |
+
data = make()
|
| 44 |
+
with _lock:
|
| 45 |
+
_cache[key] = data
|
| 46 |
+
while len(_cache) > KEEP:
|
| 47 |
+
_cache.popitem(last=False)
|
| 48 |
+
return data
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _key(kind: str, deck: dict, theme: str, fmt: str, seed: int) -> str:
|
| 52 |
+
blob = json.dumps([kind, deck, theme, fmt, seed], sort_keys=True, default=str)
|
| 53 |
+
return hashlib.sha256(blob.encode()).hexdigest()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _run_chrome(html: str, args: list[str], out_name: str | None) -> tuple[bytes, str]:
|
| 57 |
+
binary = chrome()
|
| 58 |
+
if not binary:
|
| 59 |
+
raise ImageUnavailable("No Chrome is installed here to draw the slides.")
|
| 60 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 61 |
+
src = Path(tmp) / "deck.html"
|
| 62 |
+
src.write_text(html, encoding="utf-8")
|
| 63 |
+
out = Path(tmp) / out_name if out_name else None
|
| 64 |
+
cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
|
| 65 |
+
f"--user-data-dir={tmp}/profile", "--virtual-time-budget=10000", "--run-all-compositor-stages-before-draw",
|
| 66 |
+
*[a.replace("{out}", str(out)) for a in args], f"file://{src}"]
|
| 67 |
+
try:
|
| 68 |
+
proc = subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT)
|
| 69 |
+
except (subprocess.SubprocessError, OSError) as exc:
|
| 70 |
+
raise ImageUnavailable("The slides could not be drawn.") from exc
|
| 71 |
+
if out and not out.exists():
|
| 72 |
+
raise ImageUnavailable("The slides could not be drawn.")
|
| 73 |
+
return (out.read_bytes() if out else b""), proc.stdout.decode("utf-8", "replace")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --- PDF ------------------------------------------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
def render_pdf(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
|
| 79 |
+
"""The deck as a PDF, one slide per page at the slide's own size."""
|
| 80 |
+
theme = theme_id(theme)
|
| 81 |
+
|
| 82 |
+
def make() -> bytes:
|
| 83 |
+
html = deck_print_html(deck, theme, fmt, seed)
|
| 84 |
+
data, _ = _run_chrome(html, ["--print-to-pdf={out}", "--no-pdf-header-footer", "--print-to-pdf-no-header"],
|
| 85 |
+
"deck.pdf")
|
| 86 |
+
return data
|
| 87 |
+
|
| 88 |
+
return _cached(_key("pdf", deck, theme, fmt, seed), make)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
PNG_BATCH = 8 # slides per Chrome run: one tall screenshot cut into slides (one launch per slide was the slow part)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def render_slide_pngs(deck: dict, theme: str, fmt: str, seed: int, limit: int) -> list[bytes]:
|
| 95 |
+
"""The first `limit` slides as PNG images at the slide's own size (1920 x 1080), as a post carries them. A batch
|
| 96 |
+
of slides is drawn stacked on one page and the screenshot is cut at each slide."""
|
| 97 |
+
theme = theme_id(theme)
|
| 98 |
+
n = min(limit, len(deck["slides"]))
|
| 99 |
+
out: list[bytes] = []
|
| 100 |
+
for start in range(0, n, PNG_BATCH):
|
| 101 |
+
count = min(PNG_BATCH, n - start)
|
| 102 |
+
html = slides_html(deck, theme, fmt, seed, start, start + count)
|
| 103 |
+
shot, _ = _run_chrome(html, [f"--window-size={W},{H * count}", "--screenshot={out}"], "slides.png")
|
| 104 |
+
sheet = Image.open(io.BytesIO(shot)).convert("RGB")
|
| 105 |
+
for i in range(count):
|
| 106 |
+
buf = io.BytesIO()
|
| 107 |
+
sheet.crop((0, i * H, W, (i + 1) * H)).save(buf, "PNG", optimize=True)
|
| 108 |
+
out.append(buf.getvalue())
|
| 109 |
+
return out
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# --- overflow check -------------------------------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
_MEASURE = """<script>
|
| 115 |
+
document.fonts.ready.then(function () {
|
| 116 |
+
var bad = [];
|
| 117 |
+
document.querySelectorAll('.slide').forEach(function (s, i) {
|
| 118 |
+
s.querySelectorAll('.t').forEach(function (t) {
|
| 119 |
+
var inner = t.firstElementChild; // the text's own height, not its glyphs' ink
|
| 120 |
+
if (inner.offsetHeight > t.clientHeight + 2 || inner.scrollWidth > t.clientWidth + 2) { if (bad.indexOf(i) < 0) bad.push(i); }
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
var out = document.createElement('pre'); out.id = 'overflow'; out.textContent = 'OVERFLOW:' + bad.join(',') + ':END';
|
| 124 |
+
document.body.appendChild(out);
|
| 125 |
+
});
|
| 126 |
+
</script>"""
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def overflowing(deck: dict, theme: str, fmt: str, seed: int) -> set[int]:
|
| 130 |
+
"""Indexes of slides where Chrome finds text taller or wider than its box. Raises ImageUnavailable without Chrome."""
|
| 131 |
+
html = deck_print_html(deck, theme_id(theme), fmt, seed, script=_MEASURE)
|
| 132 |
+
_, dom = _run_chrome(html, ["--dump-dom"], None)
|
| 133 |
+
m = re.search(r"OVERFLOW:([0-9,]*):END", dom)
|
| 134 |
+
if not m:
|
| 135 |
+
raise ImageUnavailable("The slides could not be measured.")
|
| 136 |
+
return {int(i) for i in m.group(1).split(",") if i}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# --- PowerPoint -----------------------------------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def _rgb(hex_: str) -> RGBColor:
|
| 142 |
+
return RGBColor.from_string(hex_.lstrip("#").upper())
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _hex_rgb(hex_: str) -> tuple[int, int, int]:
|
| 146 |
+
h = hex_.lstrip("#")
|
| 147 |
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _gradient_disc(size: int, inner: tuple[int, int, int, int], outer: tuple[int, int, int, int]) -> Image.Image:
|
| 151 |
+
"""A size x size image fading from `inner` at the centre to `outer` at the edge (RGBA)."""
|
| 152 |
+
mask = Image.radial_gradient("L").resize((size, size)) # 0 in the centre, 255 at the corners
|
| 153 |
+
mask = mask.point(lambda v: min(255, int(v * 1.42))) # 255 at the disc's edge
|
| 154 |
+
a, b = Image.new("RGBA", (size, size), inner), Image.new("RGBA", (size, size), outer)
|
| 155 |
+
return Image.composite(b, a, mask)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def background(decor: Decor, pal: Palette, dark: bool) -> bytes:
|
| 159 |
+
"""The slide's sky and drawing as a JPEG, for the PowerPoint's background."""
|
| 160 |
+
hx, hy = decor.hot
|
| 161 |
+
sky = Image.new("RGBA", (W, H), _hex_rgb(pal.bg1) + (255,))
|
| 162 |
+
big = int(W * 1.7)
|
| 163 |
+
disc = _gradient_disc(big, _hex_rgb(pal.bg2) + (255,), _hex_rgb(pal.bg1) + (255,))
|
| 164 |
+
sky.paste(disc, (int(hx - big / 2), int(hy - big / 2)))
|
| 165 |
+
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
| 166 |
+
draw = ImageDraw.Draw(layer)
|
| 167 |
+
for s in decor.shapes:
|
| 168 |
+
if s.kind in ("glow", "planet", "band"): # pasted straight onto the sky: put down the lines drawn so far
|
| 169 |
+
sky.alpha_composite(layer) # first, so the order is the page's (a ring's far half behind its planet)
|
| 170 |
+
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
| 171 |
+
draw = ImageDraw.Draw(layer)
|
| 172 |
+
_draw(sky, layer, draw, s, pal, dark)
|
| 173 |
+
sky = Image.alpha_composite(sky, layer).convert("RGB")
|
| 174 |
+
buf = io.BytesIO()
|
| 175 |
+
sky.save(buf, "JPEG", quality=90)
|
| 176 |
+
return buf.getvalue()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _draw(sky: Image.Image, layer: Image.Image, draw: ImageDraw.ImageDraw, s: Shape, pal: Palette, dark: bool) -> None:
|
| 180 |
+
c = _hex_rgb(color(pal, s.color))
|
| 181 |
+
if s.kind == "stars":
|
| 182 |
+
for x, y, r, o in s.points:
|
| 183 |
+
draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * o),))
|
| 184 |
+
elif s.kind == "glow":
|
| 185 |
+
size = int(s.r * 2)
|
| 186 |
+
if size < 4:
|
| 187 |
+
return
|
| 188 |
+
_paste_clipped(sky, _gradient_disc(size, c + (int(255 * s.opacity),), c + (0,)), s.cx - s.r, s.cy - s.r)
|
| 189 |
+
elif s.kind == "planet":
|
| 190 |
+
hi, lo, rim = planet_colors(pal, dark)
|
| 191 |
+
size = int(s.r * 2)
|
| 192 |
+
if size < 4:
|
| 193 |
+
return
|
| 194 |
+
disc = Image.new("RGBA", (size, size), _hex_rgb(lo) + (255,))
|
| 195 |
+
shine = _gradient_disc(int(size * 1.6), _hex_rgb(hi) + (255,), _hex_rgb(lo) + (0,))
|
| 196 |
+
_paste_clipped(disc, shine, -size * 0.45, -size * 0.52) # lit from the upper left, as in the page's gradient
|
| 197 |
+
mask = Image.new("L", (size, size), 0)
|
| 198 |
+
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
|
| 199 |
+
disc.putalpha(mask)
|
| 200 |
+
_paste_clipped(sky, disc, s.cx - s.r, s.cy - s.r)
|
| 201 |
+
draw.ellipse((s.cx - s.r, s.cy - s.r, s.cx + s.r, s.cy + s.r), outline=_hex_rgb(rim) + (180,), width=3)
|
| 202 |
+
elif s.kind == "band":
|
| 203 |
+
sky.alpha_composite(_band(s, c))
|
| 204 |
+
elif s.kind == "ring" and s.arc:
|
| 205 |
+
draw.line(ring_path(s), fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)), joint="curve")
|
| 206 |
+
elif s.kind == "ring":
|
| 207 |
+
steps = 360
|
| 208 |
+
rot = math.radians(s.rot)
|
| 209 |
+
pts = []
|
| 210 |
+
for k in range(steps + 1):
|
| 211 |
+
a = 2 * math.pi * k / steps
|
| 212 |
+
x, y = s.rx * math.cos(a), s.ry * math.sin(a)
|
| 213 |
+
pts.append((s.cx + x * math.cos(rot) - y * math.sin(rot), s.cy + x * math.sin(rot) + y * math.cos(rot)))
|
| 214 |
+
draw.line(pts, fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)))
|
| 215 |
+
elif s.kind == "const":
|
| 216 |
+
for a, b in s.edges:
|
| 217 |
+
(x1, y1, *_), (x2, y2, *_) = s.points[a], s.points[b]
|
| 218 |
+
draw.line((x1, y1, x2, y2), fill=c + (int(255 * s.opacity * 0.5),), width=2)
|
| 219 |
+
for x, y, r, _ in s.points:
|
| 220 |
+
draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * s.opacity),))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _band(s: Shape, c: tuple[int, int, int]) -> Image.Image:
|
| 224 |
+
"""A ringed planet's band as a slide-sized layer: the shape filled, faded left to right as the page's SHADE."""
|
| 225 |
+
shape = Image.new("L", (W, H), 0)
|
| 226 |
+
ImageDraw.Draw(shape).polygon(ring_path(s) + ring_path(s, s.inner)[::-1], fill=255)
|
| 227 |
+
lo, hi = ring_shade(s)
|
| 228 |
+
ramp = []
|
| 229 |
+
for x in range(W):
|
| 230 |
+
t = min(1.0, max(0.0, (x - lo) / (hi - lo))) if hi > lo else 0.0
|
| 231 |
+
(o0, a0), (o1, a1) = next((SHADE[k], SHADE[k + 1]) for k in range(len(SHADE) - 1) if t <= SHADE[k + 1][0])
|
| 232 |
+
ramp.append(int(255 * s.opacity * (a0 + (a1 - a0) * (t - o0) / (o1 - o0))))
|
| 233 |
+
fade = Image.new("L", (W, 1))
|
| 234 |
+
fade.putdata(ramp)
|
| 235 |
+
alpha = Image.new("L", (W, H), 0)
|
| 236 |
+
alpha.paste(fade.resize((W, H)), mask=shape)
|
| 237 |
+
out = Image.new("RGBA", (W, H), c + (0,))
|
| 238 |
+
out.putalpha(alpha)
|
| 239 |
+
return out
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _paste_clipped(base: Image.Image, img: Image.Image, x: float, y: float) -> None:
|
| 243 |
+
"""alpha_composite `img` onto `base` at (x, y), clipping whatever falls outside."""
|
| 244 |
+
x, y = int(x), int(y)
|
| 245 |
+
left, top = max(0, -x), max(0, -y)
|
| 246 |
+
right, bottom = min(img.width, base.width - x), min(img.height, base.height - y)
|
| 247 |
+
if right <= left or bottom <= top:
|
| 248 |
+
return
|
| 249 |
+
base.alpha_composite(img.crop((left, top, right, bottom)), (x + left, y + top))
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _emu(px: float) -> Emu:
|
| 253 |
+
return Emu(int(round(px * EMU_PX)))
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _no_style(shape) -> None:
|
| 257 |
+
"""Drop the theme's default shape style (shadows, outlines) so only what we set is drawn."""
|
| 258 |
+
style = shape._element.find(qn("p:style"))
|
| 259 |
+
if style is not None:
|
| 260 |
+
shape._element.remove(style)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _alpha(fill_fore_color_el, opacity: float) -> None:
|
| 264 |
+
if opacity >= 1:
|
| 265 |
+
return
|
| 266 |
+
clr = fill_fore_color_el.find(qn("a:srgbClr"))
|
| 267 |
+
if clr is not None:
|
| 268 |
+
a = clr.makeelement(qn("a:alpha"), {"val": str(int(opacity * 100000))})
|
| 269 |
+
clr.append(a)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _add_el(slide, e: El, pal: Palette) -> None:
|
| 273 |
+
if e.kind == "text":
|
| 274 |
+
box = slide.shapes.add_textbox(_emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
|
| 275 |
+
tf = box.text_frame
|
| 276 |
+
tf.word_wrap = True
|
| 277 |
+
tf.auto_size = MSO_AUTO_SIZE.NONE
|
| 278 |
+
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
| 279 |
+
tf.vertical_anchor = {"middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(e.valign, MSO_ANCHOR.TOP)
|
| 280 |
+
for i, runs in enumerate(e.paras):
|
| 281 |
+
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
| 282 |
+
p.alignment = {"center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(e.align, PP_ALIGN.LEFT)
|
| 283 |
+
p.line_spacing = e.lh * 0.96
|
| 284 |
+
if i and e.gap:
|
| 285 |
+
p.space_before = Pt(e.gap * 0.5)
|
| 286 |
+
for r in runs:
|
| 287 |
+
run = p.add_run()
|
| 288 |
+
run.text = r.text
|
| 289 |
+
f = run.font
|
| 290 |
+
f.name = FONTS[e.font]
|
| 291 |
+
f.size = Pt(e.size * 0.5)
|
| 292 |
+
f.bold = r.bold or e.font == "display"
|
| 293 |
+
f.color.rgb = _rgb(color(pal, r.color))
|
| 294 |
+
rpr = run._r.get_or_add_rPr()
|
| 295 |
+
if e.upper:
|
| 296 |
+
rpr.set("cap", "all")
|
| 297 |
+
if e.track:
|
| 298 |
+
rpr.set("spc", str(int(e.track * e.size * 0.5 * 100)))
|
| 299 |
+
return
|
| 300 |
+
if e.kind == "line":
|
| 301 |
+
horizontal = e.h == 0
|
| 302 |
+
x2, y2 = (e.x + e.w, e.y) if horizontal else (e.x, e.y + e.h)
|
| 303 |
+
ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, _emu(e.x), _emu(e.y), _emu(x2), _emu(y2))
|
| 304 |
+
_no_style(ln)
|
| 305 |
+
ln.line.color.rgb = _rgb(color(pal, e.stroke))
|
| 306 |
+
ln.line.width = _emu(e.stroke_w)
|
| 307 |
+
return
|
| 308 |
+
kind = MSO_SHAPE.OVAL if e.kind == "circle" else (MSO_SHAPE.ROUNDED_RECTANGLE if e.radius else MSO_SHAPE.RECTANGLE)
|
| 309 |
+
sh = slide.shapes.add_shape(kind, _emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
|
| 310 |
+
_no_style(sh)
|
| 311 |
+
if kind == MSO_SHAPE.ROUNDED_RECTANGLE:
|
| 312 |
+
sh.adjustments[0] = min(0.5, e.radius / max(min(e.w, e.h), 1))
|
| 313 |
+
if e.fill:
|
| 314 |
+
sh.fill.solid()
|
| 315 |
+
sh.fill.fore_color.rgb = _rgb(color(pal, e.fill))
|
| 316 |
+
_alpha(sh.fill._xPr.find(qn("a:solidFill")), e.opacity)
|
| 317 |
+
else:
|
| 318 |
+
sh.fill.background()
|
| 319 |
+
if e.stroke and e.stroke_w:
|
| 320 |
+
sh.line.color.rgb = _rgb(color(pal, e.stroke))
|
| 321 |
+
sh.line.width = _emu(e.stroke_w)
|
| 322 |
+
else:
|
| 323 |
+
sh.line.fill.background()
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _notes(slide_data: dict) -> str:
|
| 327 |
+
notes = slide_data.get("notes") or ""
|
| 328 |
+
srcs = [f"{s.get('title') or s['path']} (lines {s['line_start']}-{s['line_end']})" for s in slide_data.get("sources") or []]
|
| 329 |
+
return notes + (("\n\nSources: " + "; ".join(dict.fromkeys(srcs))) if srcs else "")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def build_pptx(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
|
| 333 |
+
"""The deck as an editable PowerPoint: text boxes you can type in, over each slide's drawn background."""
|
| 334 |
+
theme = theme_id(theme)
|
| 335 |
+
|
| 336 |
+
def make() -> bytes:
|
| 337 |
+
t = THEMES[theme]
|
| 338 |
+
prs = Presentation()
|
| 339 |
+
prs.slide_width, prs.slide_height = _emu(W), _emu(H)
|
| 340 |
+
blank = prs.slide_layouts[6]
|
| 341 |
+
for i in range(len(deck["slides"])):
|
| 342 |
+
c = compose(deck, i, theme, fmt, seed)
|
| 343 |
+
slide = prs.slides.add_slide(blank)
|
| 344 |
+
d = build(theme, c.slide["layout"], c.slide["variant"], seed, i)
|
| 345 |
+
slide.shapes.add_picture(io.BytesIO(background(d, t.pal, t.dark)), 0, 0, _emu(W), _emu(H))
|
| 346 |
+
for e in c.elements:
|
| 347 |
+
_add_el(slide, e, t.pal)
|
| 348 |
+
note = _notes(c.slide)
|
| 349 |
+
if note:
|
| 350 |
+
slide.notes_slide.notes_text_frame.text = note
|
| 351 |
+
prs.core_properties.title = deck.get("title") or "Slide deck"
|
| 352 |
+
buf = io.BytesIO()
|
| 353 |
+
prs.save(buf)
|
| 354 |
+
return buf.getvalue()
|
| 355 |
+
|
| 356 |
+
return _cached(_key("pptx", deck, theme, fmt, seed), make)
|
backend/app/slides/layout.py
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Where everything on a slide goes. Each theme has its own composition for every layout variant (GEOMETRY), the
|
| 2 |
+
variant of each slide is picked by code (assign_variants), and compose() turns one slide into placed elements: boxes
|
| 3 |
+
in pixels on the 1920 x 1080 canvas, each with its text and font size. The HTML and the PowerPoint both draw these
|
| 4 |
+
same elements, so the two always match.
|
| 5 |
+
|
| 6 |
+
Text is measured here before it is placed: every text box gets the largest font size at which its text fits, and
|
| 7 |
+
text that cannot fit even at the smallest size is shortened. Nothing is ever drawn outside its box."""
|
| 8 |
+
|
| 9 |
+
import copy
|
| 10 |
+
import math
|
| 11 |
+
import random
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
|
| 15 |
+
from app.slides.content import LIMITS, clip
|
| 16 |
+
from app.slides.metrics import WIDTHS
|
| 17 |
+
from app.slides.themes import theme_id
|
| 18 |
+
|
| 19 |
+
# --- geometry -------------------------------------------------------------------------------------------------------
|
| 20 |
+
# A block is (x, y, w, h[, align[, valign[, kind]]]). `kind` says how a body, a column or the closing line is drawn.
|
| 21 |
+
# Night stellar (dark-space): cinematic, centred openers and closers, asymmetric splits. Moon light (light-space):
|
| 22 |
+
# editorial, left aligned, top anchored, mirrored splits.
|
| 23 |
+
|
| 24 |
+
GEOMETRY: dict[str, dict[str, dict[str, dict[str, tuple]]]] = {
|
| 25 |
+
"dark-space": {
|
| 26 |
+
"title": {
|
| 27 |
+
"a": {"kicker": (360, 258, 1200, 40, "center"), "heading": (160, 312, 1600, 300, "center", "middle"),
|
| 28 |
+
"lead": (360, 636, 1200, 130, "center")},
|
| 29 |
+
"b": {"kicker": (140, 512, 1100, 40), "heading": (140, 562, 1150, 290, "left", "bottom"),
|
| 30 |
+
"lead": (140, 872, 1000, 120)},
|
| 31 |
+
"c": {"kicker": (140, 286, 860, 40), "heading": (140, 340, 880, 400, "left", "middle"),
|
| 32 |
+
"lead": (140, 762, 860, 150)},
|
| 33 |
+
},
|
| 34 |
+
"agenda": {
|
| 35 |
+
"a": {"kicker": (140, 160, 700, 40), "heading": (140, 212, 700, 420),
|
| 36 |
+
"body": (960, 140, 820, 820, "left", "middle", "agenda_list")},
|
| 37 |
+
"b": {"kicker": (360, 110, 1200, 40, "center"), "heading": (260, 160, 1400, 130, "center", "middle"),
|
| 38 |
+
"body": (220, 340, 1480, 620, "left", "top", "agenda_grid")},
|
| 39 |
+
},
|
| 40 |
+
"section": {
|
| 41 |
+
"a": {"kicker": (360, 368, 1200, 40, "center"), "heading": (210, 420, 1500, 240, "center", "middle"),
|
| 42 |
+
"lead": (410, 684, 1100, 150, "center")},
|
| 43 |
+
"b": {"kicker": (140, 552, 1100, 40), "heading": (140, 604, 1300, 240, "left", "bottom"),
|
| 44 |
+
"lead": (140, 864, 1100, 120)},
|
| 45 |
+
},
|
| 46 |
+
"points": {
|
| 47 |
+
"a": {"kicker": (140, 100, 1300, 40), "heading": (140, 150, 1360, 150), "lead": (140, 314, 1300, 110),
|
| 48 |
+
"body": (140, 446, 1300, 534, "left", "top", "list")},
|
| 49 |
+
"b": {"kicker": (140, 160, 640, 40), "heading": (140, 212, 640, 500), "lead": (140, 730, 640, 240),
|
| 50 |
+
"body": (880, 130, 900, 840, "left", "middle", "list")},
|
| 51 |
+
"c": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
|
| 52 |
+
"body": (140, 432, 1640, 548, "left", "top", "cards")},
|
| 53 |
+
"d": {"kicker": (140, 160, 700, 40), "heading": (140, 212, 700, 430), "lead": (140, 662, 700, 300),
|
| 54 |
+
"body": (930, 110, 850, 860, "left", "middle", "timeline_v")},
|
| 55 |
+
},
|
| 56 |
+
"two_column": {
|
| 57 |
+
"a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
|
| 58 |
+
"left": (140, 432, 790, 548, "left", "top", "col"), "right": (990, 432, 790, 548, "left", "top", "col")},
|
| 59 |
+
"b": {"kicker": (360, 90, 1200, 40, "center"), "heading": (210, 140, 1500, 130, "center", "middle"),
|
| 60 |
+
"lead": (360, 282, 1200, 90, "center"),
|
| 61 |
+
"left": (140, 410, 780, 570, "left", "top", "panel"), "right": (1000, 410, 780, 570, "left", "top", "panel")},
|
| 62 |
+
},
|
| 63 |
+
"stat": {
|
| 64 |
+
"a": {"kicker": (360, 120, 1200, 40, "center"), "heading": (260, 170, 1400, 130, "center", "middle"),
|
| 65 |
+
"value": (160, 318, 1600, 300, "center", "middle"), "label": (360, 630, 1200, 120, "center"),
|
| 66 |
+
"lead": (410, 776, 1100, 180, "center")},
|
| 67 |
+
"b": {"kicker": (140, 150, 820, 40), "value": (140, 206, 820, 420, "left", "middle"),
|
| 68 |
+
"label": (140, 650, 820, 300), "heading": (1060, 160, 720, 290, "left", "bottom"),
|
| 69 |
+
"lead": (1060, 480, 720, 480)},
|
| 70 |
+
},
|
| 71 |
+
"quote": {
|
| 72 |
+
"a": {"kicker": (360, 150, 1200, 40, "center"), "quote": (210, 214, 1500, 500, "center", "middle"),
|
| 73 |
+
"by": (360, 740, 1200, 60, "center"), "heading": (360, 846, 1200, 110, "center")},
|
| 74 |
+
"b": {"kicker": (140, 150, 640, 40), "heading": (140, 202, 640, 400), "lead": (140, 630, 640, 320),
|
| 75 |
+
"quote": (900, 160, 880, 640, "left", "middle"), "by": (900, 820, 880, 60)},
|
| 76 |
+
},
|
| 77 |
+
"closing": {
|
| 78 |
+
"a": {"kicker": (360, 90, 1200, 40, "center"), "heading": (210, 140, 1500, 120, "center", "middle"),
|
| 79 |
+
"body": (140, 300, 1640, 430, "center", "top", "take_arc"),
|
| 80 |
+
"closing": (260, 760, 1400, 190, "center", "middle", "line")},
|
| 81 |
+
"b": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 120),
|
| 82 |
+
"body": (140, 308, 1640, 450, "left", "top", "take_cols"),
|
| 83 |
+
"closing": (140, 800, 1640, 170, "left", "middle", "band")},
|
| 84 |
+
"c": {"kicker": (140, 190, 820, 40), "closing": (140, 244, 820, 600, "left", "middle", "big"),
|
| 85 |
+
"heading": (1060, 150, 720, 110), "body": (1060, 290, 720, 680, "left", "top", "take_stack")},
|
| 86 |
+
},
|
| 87 |
+
},
|
| 88 |
+
"light-space": {
|
| 89 |
+
"title": {
|
| 90 |
+
"a": {"kicker": (140, 318, 1150, 40), "heading": (140, 368, 1150, 350), "lead": (140, 738, 1000, 150)},
|
| 91 |
+
"b": {"kicker": (140, 120, 1640, 40), "heading": (140, 174, 1640, 430, "left", "bottom"), "lead": (140, 640, 1150, 170)},
|
| 92 |
+
"c": {"kicker": (360, 326, 1200, 40, "center"), "heading": (300, 378, 1320, 270, "center", "middle"),
|
| 93 |
+
"lead": (410, 670, 1100, 130, "center")},
|
| 94 |
+
},
|
| 95 |
+
"agenda": {
|
| 96 |
+
"a": {"kicker": (140, 110, 1640, 40), "heading": (140, 160, 1640, 130),
|
| 97 |
+
"body": (140, 336, 1640, 630, "left", "top", "agenda_grid")},
|
| 98 |
+
"b": {"kicker": (140, 376, 640, 40), "heading": (140, 428, 640, 320),
|
| 99 |
+
"body": (900, 130, 880, 840, "left", "middle", "agenda_list")},
|
| 100 |
+
},
|
| 101 |
+
"section": {
|
| 102 |
+
"a": {"kicker": (140, 408, 1300, 40), "heading": (140, 460, 1300, 240), "lead": (140, 716, 1100, 150)},
|
| 103 |
+
"b": {"kicker": (620, 408, 1160, 40, "right"), "heading": (480, 460, 1300, 240, "right"),
|
| 104 |
+
"lead": (680, 716, 1100, 150, "right")},
|
| 105 |
+
},
|
| 106 |
+
"points": {
|
| 107 |
+
"a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1500, 100),
|
| 108 |
+
"body": (140, 432, 1640, 548, "left", "top", "list2")},
|
| 109 |
+
"b": {"kicker": (1180, 160, 600, 40), "heading": (1180, 212, 600, 500), "lead": (1180, 730, 600, 240),
|
| 110 |
+
"body": (140, 130, 940, 840, "left", "middle", "list")},
|
| 111 |
+
"c": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
|
| 112 |
+
"body": (140, 432, 1640, 548, "left", "top", "cards")},
|
| 113 |
+
"d": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
|
| 114 |
+
"body": (140, 440, 1640, 540, "left", "top", "timeline_h")},
|
| 115 |
+
},
|
| 116 |
+
"two_column": {
|
| 117 |
+
"a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100),
|
| 118 |
+
"left": (140, 432, 790, 548, "left", "top", "panel"), "right": (990, 432, 790, 548, "left", "top", "panel")},
|
| 119 |
+
"b": {"kicker": (140, 160, 560, 40), "heading": (140, 212, 560, 420), "lead": (140, 650, 560, 320),
|
| 120 |
+
"left": (780, 130, 1000, 400, "left", "top", "col"), "right": (780, 566, 1000, 400, "left", "top", "col")},
|
| 121 |
+
},
|
| 122 |
+
"stat": {
|
| 123 |
+
"a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140),
|
| 124 |
+
"value": (140, 330, 900, 360, "left", "middle"), "label": (140, 700, 900, 260),
|
| 125 |
+
"lead": (1120, 360, 660, 600)},
|
| 126 |
+
"b": {"kicker": (360, 130, 1200, 40, "center"), "value": (260, 186, 1400, 320, "center", "middle"),
|
| 127 |
+
"label": (360, 526, 1200, 120, "center"), "heading": (260, 690, 1400, 110, "center", "middle"),
|
| 128 |
+
"lead": (410, 820, 1100, 150, "center")},
|
| 129 |
+
},
|
| 130 |
+
"quote": {
|
| 131 |
+
"a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 110),
|
| 132 |
+
"quote": (250, 300, 1430, 470, "left", "middle"), "by": (250, 796, 1430, 60)},
|
| 133 |
+
"b": {"kicker": (620, 130, 1160, 40, "right"), "heading": (620, 180, 1160, 110, "right"),
|
| 134 |
+
"quote": (420, 330, 1360, 450, "right", "middle"), "by": (620, 806, 1160, 60, "right")},
|
| 135 |
+
},
|
| 136 |
+
"closing": {
|
| 137 |
+
"a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 120),
|
| 138 |
+
"body": (140, 318, 1640, 440, "left", "top", "take_cols"),
|
| 139 |
+
"closing": (140, 800, 1640, 170, "left", "middle", "band")},
|
| 140 |
+
"b": {"kicker": (140, 110, 1640, 40), "closing": (140, 160, 1500, 390, "left", "top", "big"),
|
| 141 |
+
"heading": (140, 590, 1640, 80), "body": (140, 690, 1640, 290, "left", "top", "take_row")},
|
| 142 |
+
"c": {"kicker": (360, 130, 1200, 40, "center"), "heading": (260, 180, 1400, 110, "center", "middle"),
|
| 143 |
+
"body": (410, 330, 1100, 410, "left", "top", "take_stack"),
|
| 144 |
+
"closing": (260, 772, 1400, 180, "center", "middle", "line")},
|
| 145 |
+
},
|
| 146 |
+
},
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
VARIANTS = {layout: tuple(GEOMETRY["dark-space"][layout]) for layout in GEOMETRY["dark-space"]}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def block(theme: str, layout: str, variant: str, name: str) -> tuple | None:
|
| 153 |
+
g = GEOMETRY[theme_id(theme)][layout].get(variant) or next(iter(GEOMETRY[theme_id(theme)][layout].values()))
|
| 154 |
+
b = g.get(name)
|
| 155 |
+
if not b:
|
| 156 |
+
return None
|
| 157 |
+
x, y, w, h, *rest = b
|
| 158 |
+
return (x, y, w, h, rest[0] if rest else "left", rest[1] if len(rest) > 1 else "top", rest[2] if len(rest) > 2 else "")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# --- picking variants -----------------------------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
def fits_variant(slide: dict, theme: str, variant: str) -> bool:
|
| 164 |
+
"""Whether a variant suits this slide's content (a card row needs 3 or 4 points, a timeline 3 to 5)."""
|
| 165 |
+
layout = slide["layout"]
|
| 166 |
+
n = len(slide["points"])
|
| 167 |
+
kind = (block(theme, layout, variant, "body") or (0, 0, 0, 0, "", "", ""))[6]
|
| 168 |
+
if kind == "cards":
|
| 169 |
+
return 3 <= n <= 4
|
| 170 |
+
if kind in ("timeline_v", "timeline_h"):
|
| 171 |
+
return 3 <= n <= 5
|
| 172 |
+
if kind == "list2":
|
| 173 |
+
return n >= 3
|
| 174 |
+
if kind == "agenda_list":
|
| 175 |
+
return n <= 7
|
| 176 |
+
return True
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def assign_variants(slides: list[dict], theme: str, seed: int, previous: list[tuple[str, str]] | None = None) -> None:
|
| 180 |
+
"""Give every slide a variant, in place. Only variants that suit the slide are considered; the previous slide's
|
| 181 |
+
layout and variant is never repeated; variants not used yet in this deck come first; and the variant the last deck
|
| 182 |
+
in the notebook had at the same position is avoided. Ties are broken by `seed` (the deck's own id), so every new
|
| 183 |
+
deck differs while one deck always draws the same."""
|
| 184 |
+
theme = theme_id(theme)
|
| 185 |
+
rnd = random.Random(seed)
|
| 186 |
+
used: dict[tuple[str, str], int] = {}
|
| 187 |
+
before: tuple[str, str] | None = None
|
| 188 |
+
previous = previous or []
|
| 189 |
+
for i, s in enumerate(slides):
|
| 190 |
+
options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]])
|
| 191 |
+
fresh = [v for v in options if (s["layout"], v) != before]
|
| 192 |
+
if not fresh: # the only variant that suits it was just used: any other one draws it too, so never repeat
|
| 193 |
+
fresh = [v for v in VARIANTS[s["layout"]] if (s["layout"], v) != before] or options
|
| 194 |
+
last_time = previous[i] if i < len(previous) else None
|
| 195 |
+
|
| 196 |
+
scores = {v: (used.get((s["layout"], v), 0), 1 if last_time == (s["layout"], v) else 0, rnd.random())
|
| 197 |
+
for v in fresh}
|
| 198 |
+
choice = min(fresh, key=scores.__getitem__)
|
| 199 |
+
s["variant"] = choice
|
| 200 |
+
used[(s["layout"], choice)] = used.get((s["layout"], choice), 0) + 1
|
| 201 |
+
before = (s["layout"], choice)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# --- measuring text -------------------------------------------------------------------------------------------------
|
| 205 |
+
|
| 206 |
+
def _char_w(c: str, font: str, bold: bool) -> float:
|
| 207 |
+
"""Advance of one character in em, from the fonts' measured widths, padded a little so real text is never wider."""
|
| 208 |
+
if font == "mono":
|
| 209 |
+
return 0.62
|
| 210 |
+
table = WIDTHS["display_bold" if font == "display" else "body_bold" if bold else "body"]
|
| 211 |
+
w = table.get(c)
|
| 212 |
+
if w is None:
|
| 213 |
+
w = 1.0 if ord(c) >= 0x2E80 else 0.64 # full width scripts, and anything else not measured
|
| 214 |
+
return w * 1.03
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@dataclass
|
| 218 |
+
class Run:
|
| 219 |
+
text: str
|
| 220 |
+
bold: bool = False
|
| 221 |
+
color: str = "ink"
|
| 222 |
+
field: str = "" # where the text lives in the slide ("heading", "points.2.text"), so the page can edit it; "" is ours
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _lines(runs: list[Run], width: float, size: float, font: str, upper: bool, track: float) -> int:
|
| 226 |
+
"""Lines a paragraph wraps to in `width` pixels."""
|
| 227 |
+
words: list[tuple[str, bool]] = []
|
| 228 |
+
for r in runs:
|
| 229 |
+
t = r.text.upper() if upper else r.text
|
| 230 |
+
words += [(w, r.bold) for w in t.split(" ") if w]
|
| 231 |
+
if not words:
|
| 232 |
+
return 0
|
| 233 |
+
space = (_char_w(" ", font, False) + track) * size
|
| 234 |
+
lines, x = 1, 0.0
|
| 235 |
+
for word, bold in words:
|
| 236 |
+
ww = sum((_char_w(c, font, bold) + track) * size for c in word)
|
| 237 |
+
if ww > width: # a word longer than the line breaks anywhere
|
| 238 |
+
extra = x + (space if x else 0) + ww
|
| 239 |
+
lines += int(extra // width)
|
| 240 |
+
x = extra % width
|
| 241 |
+
continue
|
| 242 |
+
need = ww if x == 0 else x + space + ww
|
| 243 |
+
if need <= width:
|
| 244 |
+
x = need
|
| 245 |
+
else:
|
| 246 |
+
lines += 1
|
| 247 |
+
x = ww
|
| 248 |
+
return lines
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# --- placed elements ------------------------------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
@dataclass
|
| 254 |
+
class El:
|
| 255 |
+
kind: str # text | rect | circle | line
|
| 256 |
+
role: str
|
| 257 |
+
x: float
|
| 258 |
+
y: float
|
| 259 |
+
w: float
|
| 260 |
+
h: float
|
| 261 |
+
paras: list[list[Run]] = field(default_factory=list)
|
| 262 |
+
size: float = 0
|
| 263 |
+
lh: float = 1.3
|
| 264 |
+
font: str = "body" # display | body | mono
|
| 265 |
+
align: str = "left"
|
| 266 |
+
valign: str = "top"
|
| 267 |
+
upper: bool = False
|
| 268 |
+
track: float = 0.0 # letter spacing in em
|
| 269 |
+
gap: float = 0.0 # space between paragraphs, in px
|
| 270 |
+
fill: str | None = None # palette key
|
| 271 |
+
stroke: str | None = None
|
| 272 |
+
stroke_w: float = 0
|
| 273 |
+
radius: float = 0
|
| 274 |
+
opacity: float = 1.0
|
| 275 |
+
|
| 276 |
+
def text_height(self) -> float:
|
| 277 |
+
return text_height(self.paras, self.w, self.size, self.lh, self.font, self.upper, self.track, self.gap)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def text_height(paras: list[list[Run]], width: float, size: float, lh: float, font: str, upper: bool = False,
|
| 281 |
+
track: float = 0.0, gap: float = 0.0) -> float:
|
| 282 |
+
n = [_lines(p, width, size, font, upper, track) for p in paras]
|
| 283 |
+
used = [k for k in n if k]
|
| 284 |
+
return sum(k * size * lh for k in used) + max(len(used) - 1, 0) * gap
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
SCALES = (1.0, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55)
|
| 288 |
+
GROW = (1.4, 1.28, 1.18, 1.08) # sparse content is drawn larger, so a slide with little on it is not half empty
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _scales(start: int, grow: bool = False) -> tuple[float, ...]:
|
| 292 |
+
"""Scales to try, largest first: from `start` down, with the growing ones first when allowed and not shrunk."""
|
| 293 |
+
return (GROW + SCALES) if grow and start == 0 else SCALES[start:]
|
| 294 |
+
|
| 295 |
+
# Base font sizes in px, by format (both themes). Presenter slides carry fewer words, so they are drawn larger.
|
| 296 |
+
TYPE = {
|
| 297 |
+
"detailed": {"kicker": 24, "heading": 66, "title": 110, "section": 88, "lead": 33, "title_lead": 37, "point": 31,
|
| 298 |
+
"card_term": 33, "card_text": 28, "value": 242, "label": 37, "quote": 57, "by": 26, "take": 31,
|
| 299 |
+
"closing": 51, "closing_big": 73, "agenda": 35, "col_label": 31, "col_item": 30, "num": 24,
|
| 300 |
+
"footer": 20},
|
| 301 |
+
"presenter": {"kicker": 26, "heading": 79, "title": 123, "section": 101, "lead": 40, "title_lead": 42,
|
| 302 |
+
"point": 44, "card_term": 37, "card_text": 37, "value": 286, "label": 44, "quote": 66, "by": 29,
|
| 303 |
+
"take": 40, "closing": 62, "closing_big": 86, "agenda": 40, "col_label": 33, "col_item": 40,
|
| 304 |
+
"num": 26, "footer": 20},
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _fit_size(paras: list[list[Run]], width: float, height: float, base: float, lh: float, font: str,
|
| 309 |
+
start: int, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, grow: bool = False) -> float | None:
|
| 310 |
+
"""The largest size (base times a scale, from `start` down) at which the paragraphs fit the box, or None."""
|
| 311 |
+
for scale in _scales(start, grow):
|
| 312 |
+
size = round(base * scale, 1)
|
| 313 |
+
if text_height(paras, width, size, lh, font, upper, track, size * gap_em) <= height:
|
| 314 |
+
return size
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
class NoFit(Exception):
|
| 319 |
+
"""Text that does not fit even at the smallest size: the caller shortens it and tries again."""
|
| 320 |
+
|
| 321 |
+
def __init__(self, field_name: str, index: int | None = None):
|
| 322 |
+
super().__init__(field_name)
|
| 323 |
+
self.field_name = field_name
|
| 324 |
+
self.index = index
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
@dataclass
|
| 328 |
+
class Composed:
|
| 329 |
+
slide: dict # the slide as drawn (text shortened when it had to be)
|
| 330 |
+
elements: list[El]
|
| 331 |
+
seed: int
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _text(role: str, rect: tuple, paras: list[list[Run]], base: float, start: int, *, font: str = "body",
|
| 335 |
+
lh: float = 1.35, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, field_name: str = "",
|
| 336 |
+
index: int | None = None, grow: bool = False) -> El:
|
| 337 |
+
x, y, w, h, align, valign = rect[:6]
|
| 338 |
+
size = _fit_size(paras, w, h, base, lh, font, start, upper, track, gap_em, grow)
|
| 339 |
+
if size is None:
|
| 340 |
+
raise NoFit(field_name or role, index)
|
| 341 |
+
return El("text", role, x, y, w, h, paras=paras, size=size, lh=lh, font=font, align=align, valign=valign,
|
| 342 |
+
upper=upper, track=track, gap=size * gap_em)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def _point_runs(p: dict, i: int, fmt: str) -> list[Run]:
|
| 346 |
+
if p["term"] and fmt == "detailed":
|
| 347 |
+
return [Run(p["term"], True, "ink", f"points.{i}.term"), Run(". ", True, "ink"),
|
| 348 |
+
Run(p["text"], False, "mute", f"points.{i}.text")]
|
| 349 |
+
return [Run(p["text"], False, "ink", f"points.{i}.text")]
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, start: int, dark: bool) -> None:
|
| 353 |
+
"""The slide's main content: points, agenda items or takeaways, drawn as `kind`."""
|
| 354 |
+
x, y, w, h, _align, valign, _ = rect
|
| 355 |
+
pts = s["points"]
|
| 356 |
+
|
| 357 |
+
if kind in ("list", "list2", "agenda_list", "agenda_grid", "take_stack"):
|
| 358 |
+
if kind in ("agenda_list", "agenda_grid"):
|
| 359 |
+
items = [[Run(p["text"], False, "ink", f"points.{i}.text")] for i, p in enumerate(pts)]
|
| 360 |
+
base, numbered = t["agenda"], True
|
| 361 |
+
elif kind == "take_stack":
|
| 362 |
+
items = [[Run(tx, False, "ink", f"takeaways.{i}")] for i, tx in enumerate(s["takeaways"])]
|
| 363 |
+
base, numbered = t["take"], True
|
| 364 |
+
else:
|
| 365 |
+
items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)]
|
| 366 |
+
base, numbered = t["point"], False
|
| 367 |
+
cols = 2 if kind in ("list2", "agenda_grid") and len(items) >= 3 else 1
|
| 368 |
+
per = math.ceil(len(items) / cols)
|
| 369 |
+
col_gap = 70
|
| 370 |
+
cw = (w - col_gap * (cols - 1)) / cols
|
| 371 |
+
indent = 78 if numbered else 44
|
| 372 |
+
tw = cw - indent
|
| 373 |
+
# One size for every item: the largest at which the tallest column fits with breathing room between items.
|
| 374 |
+
size = None
|
| 375 |
+
for scale in _scales(start, True):
|
| 376 |
+
sz = round(base * scale, 1)
|
| 377 |
+
gap = sz * 0.9
|
| 378 |
+
heights = [text_height([it], tw, sz, 1.38, "body") for it in items]
|
| 379 |
+
tallest = max(sum(heights[c * per:(c + 1) * per]) + gap * (len(heights[c * per:(c + 1) * per]) - 1)
|
| 380 |
+
for c in range(cols))
|
| 381 |
+
if tallest <= h:
|
| 382 |
+
size = sz
|
| 383 |
+
break
|
| 384 |
+
if size is None:
|
| 385 |
+
raise NoFit("takeaways" if kind == "take_stack" else "points")
|
| 386 |
+
heights = [text_height([it], tw, size, 1.38, "body") for it in items]
|
| 387 |
+
for c in range(cols):
|
| 388 |
+
chunk = list(range(c * per, min((c + 1) * per, len(items))))
|
| 389 |
+
total = sum(heights[i] for i in chunk)
|
| 390 |
+
free = h - total
|
| 391 |
+
gap = min(free / max(len(chunk) - 1, 1), size * 2.2) if len(chunk) > 1 else 0
|
| 392 |
+
block_h = total + gap * max(len(chunk) - 1, 0)
|
| 393 |
+
cy = y + (h - block_h) / 2 if valign == "middle" else y
|
| 394 |
+
cx = x + c * (cw + col_gap)
|
| 395 |
+
for i in chunk:
|
| 396 |
+
ih = heights[i]
|
| 397 |
+
if numbered:
|
| 398 |
+
els.append(El("text", "num", cx, cy + size * 0.12, 64, size * 1.3, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
|
| 399 |
+
size=round(size * 0.82, 1), lh=1.2, font="mono"))
|
| 400 |
+
else:
|
| 401 |
+
d = size * 0.34
|
| 402 |
+
els.append(El("circle", "dot", cx + 4, cy + size * 0.69 - d / 2, d, d, fill="accent"))
|
| 403 |
+
els.append(El("text", "item", cx + indent, cy, tw, ih + 2, paras=[items[i]], size=size, lh=1.38))
|
| 404 |
+
if numbered and kind != "take_stack" and i != chunk[-1]:
|
| 405 |
+
els.append(El("line", "rule", cx + indent, cy + ih + gap / 2, tw, 0, stroke="line", stroke_w=1.5))
|
| 406 |
+
cy += ih + gap
|
| 407 |
+
return
|
| 408 |
+
|
| 409 |
+
if kind in ("cards", "take_cols", "take_row", "take_arc"):
|
| 410 |
+
if kind == "cards":
|
| 411 |
+
cells = [(p["term"] if fmt == "detailed" else "", p["text"]) for p in pts]
|
| 412 |
+
else:
|
| 413 |
+
cells = [("", tx) for tx in s["takeaways"]]
|
| 414 |
+
n = max(len(cells), 1)
|
| 415 |
+
gap = 36 if kind != "take_row" else 60
|
| 416 |
+
cw = (w - gap * (n - 1)) / n
|
| 417 |
+
boxed = kind in ("cards", "take_cols")
|
| 418 |
+
pad = 40 if boxed else 0
|
| 419 |
+
top = 0.0
|
| 420 |
+
if kind == "take_arc":
|
| 421 |
+
top = 120 # the glowing marker above each takeaway
|
| 422 |
+
elif kind == "take_row":
|
| 423 |
+
top = 70
|
| 424 |
+
else:
|
| 425 |
+
top = 64 # number row inside the card
|
| 426 |
+
inner_w = cw - 2 * pad
|
| 427 |
+
inner_h = h - 2 * pad - top
|
| 428 |
+
term_base = t["card_term"]
|
| 429 |
+
text_base = t["card_text"] if kind == "cards" else t["take"]
|
| 430 |
+
size = None
|
| 431 |
+
for scale in _scales(start, True):
|
| 432 |
+
ts, xs = round(term_base * scale, 1), round(text_base * scale, 1)
|
| 433 |
+
ok = True
|
| 434 |
+
for term, text in cells:
|
| 435 |
+
hh = (text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
|
| 436 |
+
hh += text_height([[Run(text)]], inner_w, xs, 1.4, "body")
|
| 437 |
+
if hh > inner_h:
|
| 438 |
+
ok = False
|
| 439 |
+
break
|
| 440 |
+
if ok:
|
| 441 |
+
size = (ts, xs)
|
| 442 |
+
break
|
| 443 |
+
if size is None:
|
| 444 |
+
raise NoFit("takeaways" if kind != "cards" else "points")
|
| 445 |
+
ts, xs = size
|
| 446 |
+
# Cards as tall as the tallest content (not the whole box), so short text does not float in a tall card.
|
| 447 |
+
tallest = max((text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
|
| 448 |
+
+ text_height([[Run(text)]], inner_w, xs, 1.4, "body") for term, text in cells)
|
| 449 |
+
h = min(h, tallest + 2 * pad + top + 12)
|
| 450 |
+
for i, (term, text) in enumerate(cells):
|
| 451 |
+
cx = x + i * (cw + gap)
|
| 452 |
+
if boxed:
|
| 453 |
+
els.append(El("rect", "card", cx, y, cw, h, fill="card", stroke="cardline", stroke_w=2, radius=22))
|
| 454 |
+
if kind == "take_arc":
|
| 455 |
+
r = 46
|
| 456 |
+
els.append(El("circle", "halo", cx + cw / 2 - r * 1.5, y + 2 - r * 0.5, r * 3, r * 3, fill="accent2", opacity=0.18))
|
| 457 |
+
els.append(El("circle", "marker", cx + cw / 2 - r, y + 2, r * 2, r * 2, fill="accent2"))
|
| 458 |
+
els.append(El("text", "num", cx + cw / 2 - r, y + 2, r * 2, r * 2, paras=[[Run(f"{i + 1}", True, "white")]],
|
| 459 |
+
size=34, lh=1.0, font="display", align="center", valign="middle"))
|
| 460 |
+
elif kind == "take_row":
|
| 461 |
+
els.append(El("rect", "bar", cx, y, cw, 5, fill="accent"))
|
| 462 |
+
els.append(El("text", "num", cx, y + 20, cw, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
|
| 463 |
+
size=24, lh=1.2, font="mono"))
|
| 464 |
+
else:
|
| 465 |
+
els.append(El("text", "num", cx + pad, y + pad, inner_w, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
|
| 466 |
+
size=t["num"], lh=1.2, font="mono"))
|
| 467 |
+
cy = y + pad + top
|
| 468 |
+
align = "center" if kind == "take_arc" else "left"
|
| 469 |
+
if term:
|
| 470 |
+
th = text_height([[Run(term, True)]], inner_w, ts, 1.15, "display")
|
| 471 |
+
els.append(El("text", "term", cx + pad, cy, inner_w, th + 2, paras=[[Run(term, True, "ink", f"points.{i}.term")]], size=ts,
|
| 472 |
+
lh=1.15, font="display", align=align))
|
| 473 |
+
cy += th + ts * 0.5
|
| 474 |
+
where = f"points.{i}.text" if kind == "cards" else f"takeaways.{i}"
|
| 475 |
+
els.append(El("text", "item", cx + pad, cy, inner_w, y + h - pad - cy,
|
| 476 |
+
paras=[[Run(text, False, "mute" if term else "ink", where)]], size=xs, lh=1.4, align=align))
|
| 477 |
+
return
|
| 478 |
+
|
| 479 |
+
if kind in ("timeline_v", "timeline_h"):
|
| 480 |
+
items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)]
|
| 481 |
+
n = len(items)
|
| 482 |
+
r = 28
|
| 483 |
+
if kind == "timeline_v":
|
| 484 |
+
tw = w - 96
|
| 485 |
+
size = None
|
| 486 |
+
for scale in _scales(start, True):
|
| 487 |
+
sz = round(t["point"] * scale, 1)
|
| 488 |
+
heights = [max(text_height([it], tw, sz, 1.38, "body"), r * 2) for it in items]
|
| 489 |
+
if sum(heights) + sz * 1.2 * (n - 1) <= h:
|
| 490 |
+
size = sz
|
| 491 |
+
break
|
| 492 |
+
if size is None:
|
| 493 |
+
raise NoFit("points")
|
| 494 |
+
heights = [max(text_height([it], tw, size, 1.38, "body"), r * 2) for it in items]
|
| 495 |
+
gap = min((h - sum(heights)) / max(n - 1, 1), size * 2.4)
|
| 496 |
+
total = sum(heights) + gap * (n - 1)
|
| 497 |
+
cy = y + (h - total) / 2 if valign == "middle" else y
|
| 498 |
+
els.append(El("line", "spine", x + r, cy + r, 0, total - 2 * r if total > 2 * r else 0, stroke="line", stroke_w=3))
|
| 499 |
+
for i, it in enumerate(items):
|
| 500 |
+
els.append(El("circle", "marker", x, cy, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
|
| 501 |
+
els.append(El("text", "num", x, cy, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
|
| 502 |
+
font="display", align="center", valign="middle"))
|
| 503 |
+
els.append(El("text", "item", x + 96, cy + max((r * 2 - size * 1.38) / 2, 0) if heights[i] <= r * 2 else cy,
|
| 504 |
+
tw, heights[i] + 2, paras=[it], size=size, lh=1.38))
|
| 505 |
+
cy += heights[i] + gap
|
| 506 |
+
else:
|
| 507 |
+
gap = 40
|
| 508 |
+
cw = (w - gap * (n - 1)) / n
|
| 509 |
+
th = h - r * 2 - 40
|
| 510 |
+
size = _fit_all([[it] for it in items], cw, th, t["point"] * 0.9, 1.38, start)
|
| 511 |
+
if size is None:
|
| 512 |
+
raise NoFit("points")
|
| 513 |
+
els.append(El("line", "spine", x + r, y + r, w - 2 * r, 0, stroke="line", stroke_w=3))
|
| 514 |
+
for i, it in enumerate(items):
|
| 515 |
+
cx = x + i * (cw + gap)
|
| 516 |
+
els.append(El("circle", "marker", cx, y, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
|
| 517 |
+
els.append(El("text", "num", cx, y, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
|
| 518 |
+
font="display", align="center", valign="middle"))
|
| 519 |
+
els.append(El("text", "item", cx, y + r * 2 + 40, cw, th, paras=[it], size=size, lh=1.38))
|
| 520 |
+
return
|
| 521 |
+
raise ValueError(f"unknown body kind {kind}")
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _fit_all(blocks: list[list[list[Run]]], width: float, height: float, base: float, lh: float, start: int) -> float | None:
|
| 525 |
+
for scale in _scales(start, True):
|
| 526 |
+
size = round(base * scale, 1)
|
| 527 |
+
if all(text_height(b, width, size, lh, "body") <= height for b in blocks):
|
| 528 |
+
return size
|
| 529 |
+
return None
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start: int) -> None:
|
| 533 |
+
"""The two sides of a comparison, at one font size, each panel as tall as the taller side's content."""
|
| 534 |
+
kind = rects[0][6]
|
| 535 |
+
pad = 44 if kind == "panel" else 0
|
| 536 |
+
top = 0 if kind == "panel" else 28
|
| 537 |
+
sides = [s["left"], s["right"]]
|
| 538 |
+
|
| 539 |
+
def measure(col: dict, iw: float, ls: float, xs: float) -> tuple[float, list[float]]:
|
| 540 |
+
label = [[Run(col["label"], True, "accent")]] if col["label"] else []
|
| 541 |
+
lab = text_height(label, iw, ls, 1.2, "display") + (xs * 0.9 if label else 0)
|
| 542 |
+
heights = [text_height([[Run(i)]], iw - 40, xs, 1.38, "body") for i in col["items"]]
|
| 543 |
+
return lab + sum(heights) + xs * 0.75 * (len(heights) - 1), heights
|
| 544 |
+
|
| 545 |
+
for scale in _scales(start, True):
|
| 546 |
+
ls, xs = round(t["col_label"] * scale, 1), round(t["col_item"] * scale, 1)
|
| 547 |
+
need = [measure(col, r[2] - 2 * pad, ls, xs)[0] for col, r in zip(sides, rects, strict=True)]
|
| 548 |
+
bad = [i for i, (n, r) in enumerate(zip(need, rects, strict=True)) if n > r[3] - 2 * pad - top]
|
| 549 |
+
if not bad:
|
| 550 |
+
break
|
| 551 |
+
else:
|
| 552 |
+
raise NoFit(("left", "right")[bad[0]] + "_items")
|
| 553 |
+
panel_h = max(need) + 2 * pad + top + 8
|
| 554 |
+
for side, col, (x, y, w, h, *_) in zip(("left", "right"), sides, rects, strict=True):
|
| 555 |
+
if kind == "panel":
|
| 556 |
+
els.append(El("rect", "panel", x, y, w, min(h, panel_h), fill="card", stroke="cardline", stroke_w=2, radius=22))
|
| 557 |
+
else:
|
| 558 |
+
els.append(El("rect", "bar", x, y, 72, 5, fill="accent"))
|
| 559 |
+
ix, iw = x + pad, w - 2 * pad
|
| 560 |
+
cy = y + pad + top
|
| 561 |
+
_, heights = measure(col, iw, ls, xs)
|
| 562 |
+
if col["label"]:
|
| 563 |
+
lab_h = text_height([[Run(col["label"], True)]], iw, ls, 1.2, "display")
|
| 564 |
+
els.append(El("text", "col_label", ix, cy, iw, lab_h + 2, paras=[[Run(col["label"], True, "accent", f"{side}.label")]],
|
| 565 |
+
size=ls, lh=1.2, font="display"))
|
| 566 |
+
cy += lab_h + xs * 0.9
|
| 567 |
+
for n, (item, hh) in enumerate(zip(col["items"], heights, strict=True)):
|
| 568 |
+
d = xs * 0.32
|
| 569 |
+
els.append(El("circle", "dot", ix + 2, cy + xs * 0.69 - d / 2, d, d, fill="accent"))
|
| 570 |
+
els.append(El("text", "item", ix + 40, cy, iw - 40, hh + 2, paras=[[Run(item, False, "ink", f"{side}.items.{n}")]],
|
| 571 |
+
size=xs, lh=1.38))
|
| 572 |
+
cy += hh + xs * 0.75
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start: int) -> list[El]:
|
| 576 |
+
from app.slides.themes import THEMES
|
| 577 |
+
|
| 578 |
+
t = TYPE[fmt]
|
| 579 |
+
dark = THEMES[theme].dark
|
| 580 |
+
layout, v = s["layout"], s["variant"]
|
| 581 |
+
els: list[El] = []
|
| 582 |
+
|
| 583 |
+
lead_slot = block(theme, layout, v, "lead")
|
| 584 |
+
|
| 585 |
+
def rect(name: str) -> tuple | None:
|
| 586 |
+
r = block(theme, layout, v, name)
|
| 587 |
+
# No lead: what sits right under the lead's slot (in the same column) moves up into it.
|
| 588 |
+
if r and name in ("body", "left", "right") and lead_slot and not s["lead"]:
|
| 589 |
+
lx, ly, lw, lh_ = lead_slot[:4]
|
| 590 |
+
if r[1] >= ly + lh_ - 2 and r[1] - (ly + lh_) < 80 and r[0] < lx + lw and lx < r[0] + r[2]:
|
| 591 |
+
return (r[0], ly, r[2], r[3] + r[1] - ly, *r[4:])
|
| 592 |
+
return r
|
| 593 |
+
|
| 594 |
+
k = rect("kicker")
|
| 595 |
+
if k and s["kicker"]:
|
| 596 |
+
x, y, w, h, align, *_ = k
|
| 597 |
+
bar_x = x + (w - 64) / 2 if align == "center" else (x + w - 64 if align == "right" else x)
|
| 598 |
+
els.append(El("rect", "bar", bar_x, y - 18, 64, 5, fill="accent"))
|
| 599 |
+
els.append(_text("kicker", k, [[Run(s["kicker"], True, "accent", "kicker")]], t["kicker"], 0, font="mono", lh=1.2,
|
| 600 |
+
upper=True, track=0.16, field_name="kicker"))
|
| 601 |
+
|
| 602 |
+
heading_base = {"title": t["title"], "section": t["section"]}.get(layout, t["heading"])
|
| 603 |
+
hr = rect("heading")
|
| 604 |
+
if hr and s["heading"]:
|
| 605 |
+
els.append(_text("heading", hr, [[Run(s["heading"], True, "ink", "heading")]], heading_base, start, font="display",
|
| 606 |
+
lh=1.08, field_name="heading"))
|
| 607 |
+
|
| 608 |
+
lr = rect("lead")
|
| 609 |
+
if lr and s["lead"]:
|
| 610 |
+
base = t["title_lead"] if layout == "title" else t["lead"]
|
| 611 |
+
head = next((e for e in els if e.role == "heading"), None)
|
| 612 |
+
if head and head.valign == "top" and abs(head.x - lr[0]) < 1 and head.y < lr[1]:
|
| 613 |
+
# Under a top aligned heading in the same column: follow the heading instead of waiting at the box's top.
|
| 614 |
+
top = head.y + head.text_height() + head.size * 0.55
|
| 615 |
+
lr = (lr[0], top, lr[2], lr[1] + lr[3] - top, *lr[4:])
|
| 616 |
+
els.append(_text("lead", lr, [[Run(s["lead"], False, "mute", "lead")]], base, start, lh=1.4, field_name="lead"))
|
| 617 |
+
|
| 618 |
+
if layout == "stat":
|
| 619 |
+
els.append(_text("value", rect("value"), [[Run(s["stat"]["value"], True, "accent", "stat.value")]], t["value"], start,
|
| 620 |
+
font="display", lh=1.0, field_name="stat_value"))
|
| 621 |
+
els.append(_text("label", rect("label"), [[Run(s["stat"]["label"], False, "ink", "stat.label")]], t["label"], start, lh=1.35,
|
| 622 |
+
field_name="stat_label", grow=True))
|
| 623 |
+
if layout == "quote":
|
| 624 |
+
q = rect("quote")
|
| 625 |
+
els.append(_text("quote", q, [[Run("“", True, "accent"), Run(s["quote"]["text"], False, "ink", "quote.text"),
|
| 626 |
+
Run("”", True, "accent")]], t["quote"], start, font="display", lh=1.22,
|
| 627 |
+
field_name="quote", grow=True))
|
| 628 |
+
if s["quote"]["by"] and (b := rect("by")):
|
| 629 |
+
els.append(_text("by", b, [[Run(s["quote"]["by"], False, "mute", "quote.by")]], t["by"], 0, font="mono", lh=1.3,
|
| 630 |
+
upper=True, track=0.08, field_name="by"))
|
| 631 |
+
if layout == "two_column":
|
| 632 |
+
_columns(els, s, (rect("left"), rect("right")), t, start)
|
| 633 |
+
br = rect("body")
|
| 634 |
+
if br:
|
| 635 |
+
_body(els, s, br, br[6], fmt, t, start, dark)
|
| 636 |
+
cr = rect("closing")
|
| 637 |
+
if cr and s["closing"]:
|
| 638 |
+
kind = cr[6]
|
| 639 |
+
if kind == "band":
|
| 640 |
+
x, y, w, h, *_ = cr
|
| 641 |
+
els.append(El("rect", "band", x, y, w, h, fill="band", radius=24))
|
| 642 |
+
inner = (x + 60, y, w - 120, h, "left", "middle")
|
| 643 |
+
els.append(_text("closing", inner, [[Run(s["closing"], True, "bandink", "closing")]], t["closing"], start,
|
| 644 |
+
font="display", lh=1.15, field_name="closing"))
|
| 645 |
+
else:
|
| 646 |
+
base = t["closing_big"] if kind == "big" else t["closing"]
|
| 647 |
+
els.append(_text("closing", cr, [[Run(s["closing"], True, "ink", "closing")]], base, start, font="display", lh=1.12,
|
| 648 |
+
field_name="closing", grow=kind == "big"))
|
| 649 |
+
|
| 650 |
+
if layout != "title":
|
| 651 |
+
total = len(deck["slides"])
|
| 652 |
+
foot = [[Run(clip(deck.get("title"), 70), False, "mute")]]
|
| 653 |
+
els.append(El("text", "footer", 140, 1018, 1200, 30, paras=foot, size=t["footer"], lh=1.2, font="mono"))
|
| 654 |
+
els.append(El("text", "footer", 1480, 1018, 300, 30, paras=[[Run(f"{index + 1:02d} / {total:02d}", False, "mute")]],
|
| 655 |
+
size=t["footer"], lh=1.2, font="mono", align="right"))
|
| 656 |
+
return els
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def _shorten(s: dict, err: NoFit, fmt: str) -> bool:
|
| 660 |
+
"""Make the text that did not fit shorter (or drop a point). False when there is nothing left to shorten."""
|
| 661 |
+
lo = LIMITS[fmt]["points"][0]
|
| 662 |
+
name = err.field_name
|
| 663 |
+
|
| 664 |
+
def cut(text: str) -> str:
|
| 665 |
+
return clip(text, max(int(len(text) * 0.82), 12)) if len(text) > 14 else text
|
| 666 |
+
|
| 667 |
+
if name == "points":
|
| 668 |
+
pts = s["points"]
|
| 669 |
+
if s["layout"] == "agenda" or len(pts) > max(lo, 3):
|
| 670 |
+
if len(pts) > 2:
|
| 671 |
+
pts.pop()
|
| 672 |
+
return True
|
| 673 |
+
longest = max(range(len(pts)), key=lambda i: len(pts[i]["text"]) + len(pts[i]["term"]), default=None)
|
| 674 |
+
if longest is None or len(pts[longest]["text"]) <= 14:
|
| 675 |
+
return False
|
| 676 |
+
pts[longest]["text"] = cut(pts[longest]["text"])
|
| 677 |
+
return True
|
| 678 |
+
if name == "takeaways":
|
| 679 |
+
tk = s["takeaways"]
|
| 680 |
+
i = max(range(len(tk)), key=lambda j: len(tk[j]), default=None)
|
| 681 |
+
if i is None or len(tk[i]) <= 14:
|
| 682 |
+
return False
|
| 683 |
+
tk[i] = cut(tk[i])
|
| 684 |
+
return True
|
| 685 |
+
if name in ("left_items", "right_items"):
|
| 686 |
+
col = s[name.split("_")[0]]
|
| 687 |
+
if len(col["items"]) > 2:
|
| 688 |
+
col["items"].pop()
|
| 689 |
+
return True
|
| 690 |
+
i = max(range(len(col["items"])), key=lambda j: len(col["items"][j]), default=None)
|
| 691 |
+
if i is None or len(col["items"][i]) <= 14:
|
| 692 |
+
return False
|
| 693 |
+
col["items"][i] = cut(col["items"][i])
|
| 694 |
+
return True
|
| 695 |
+
path = {"stat_value": ("stat", "value"), "stat_label": ("stat", "label"), "quote": ("quote", "text"),
|
| 696 |
+
"by": ("quote", "by")}.get(name)
|
| 697 |
+
if path:
|
| 698 |
+
holder, key = s[path[0]], path[1]
|
| 699 |
+
elif name in s and isinstance(s[name], str):
|
| 700 |
+
holder, key = s, name
|
| 701 |
+
else:
|
| 702 |
+
return False
|
| 703 |
+
if len(holder[key]) <= 14:
|
| 704 |
+
if name in ("lead", "by", "kicker"):
|
| 705 |
+
holder[key] = ""
|
| 706 |
+
return True
|
| 707 |
+
return False
|
| 708 |
+
holder[key] = cut(holder[key])
|
| 709 |
+
return True
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def compose(deck: dict, index: int, theme: str, fmt: str, seed: int = 0) -> Composed:
|
| 713 |
+
"""Slide `index` of a clamped deck, placed. Text that does not fit is shortened until it does; a slide that
|
| 714 |
+
still cannot be drawn becomes a plain section slide with its heading."""
|
| 715 |
+
theme, fmt = theme_id(theme), fmt if fmt in TYPE else "detailed"
|
| 716 |
+
s = copy.deepcopy(deck["slides"][index])
|
| 717 |
+
if s["variant"] not in VARIANTS[s["layout"]]:
|
| 718 |
+
s["variant"] = VARIANTS[s["layout"]][0]
|
| 719 |
+
start = min(int(s.get("shrink") or 0), len(SCALES) - 1)
|
| 720 |
+
for _ in range(60):
|
| 721 |
+
try:
|
| 722 |
+
return Composed(s, _compose_once(s, deck, index, theme, fmt, start), seed)
|
| 723 |
+
except NoFit as err:
|
| 724 |
+
if not _shorten(s, err, fmt):
|
| 725 |
+
break
|
| 726 |
+
plain = {**copy.deepcopy(s), "layout": "section", "variant": VARIANTS["section"][0], "lead": "", "points": [],
|
| 727 |
+
"heading": clip(s["heading"] or deck.get("title"), 60)}
|
| 728 |
+
return Composed(plain, _compose_once(plain, deck, index, theme, fmt, 0), seed)
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
def seed_of(value: object) -> int:
|
| 732 |
+
"""A stable integer seed from a deck id."""
|
| 733 |
+
digits = re.sub(r"[^0-9a-f]", "", str(value or "").lower())
|
| 734 |
+
return int(digits[:12] or "0", 16)
|
backend/app/slides/metrics.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Advance widths (in em) of the characters slides use most, measured in Chrome with the fonts the slides load
|
| 2 |
+
(Space Grotesk bold for headings, Inter regular and bold for text). Text is measured with these before it is placed,
|
| 3 |
+
so a line breaks where the browser will break it. Regenerate if the fonts change: draw each character ten times
|
| 4 |
+
between two x's at 100px in headless Chrome and subtract the width of "xx"."""
|
| 5 |
+
|
| 6 |
+
CHARS = "".join(chr(c) for c in range(32, 127)) + "\u2026\u201c\u201d\u2018\u2019\u2013\u2014\u00e9"
|
| 7 |
+
|
| 8 |
+
_DISPLAY_BOLD = (
|
| 9 |
+
0.254, 0.298, 0.514, 0.636, 0.606, 0.758, 0.591, 0.294, 0.398, 0.390, 0.540, 0.620, 0.294, 0.425, 0.298, 0.388,
|
| 10 |
+
0.648, 0.452, 0.594, 0.608, 0.636, 0.600, 0.618, 0.554, 0.600, 0.618, 0.298, 0.298, 0.620, 0.620, 0.620, 0.578,
|
| 11 |
+
1.014, 0.634, 0.664, 0.644, 0.646, 0.554, 0.534, 0.662, 0.656, 0.264, 0.578, 0.626, 0.542, 0.882, 0.670, 0.676,
|
| 12 |
+
0.604, 0.676, 0.632, 0.606, 0.580, 0.672, 0.616, 0.898, 0.644, 0.617, 0.576, 0.358, 0.388, 0.358, 0.620, 0.620,
|
| 13 |
+
0.296, 0.577, 0.635, 0.580, 0.635, 0.571, 0.337, 0.635, 0.616, 0.266, 0.268, 0.564, 0.266, 0.854, 0.616, 0.606,
|
| 14 |
+
0.635, 0.635, 0.396, 0.522, 0.391, 0.616, 0.548, 0.784, 0.592, 0.616, 0.518, 0.466, 0.258, 0.466, 0.620, 0.798,
|
| 15 |
+
0.514, 0.514, 0.294, 0.294, 0.577, 0.881, 0.571,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
_BODY = (
|
| 19 |
+
0.281, 0.288, 0.466, 0.633, 0.642, 0.982, 0.644, 0.300, 0.365, 0.365, 0.501, 0.657, 0.288, 0.455, 0.288, 0.360,
|
| 20 |
+
0.631, 0.407, 0.610, 0.618, 0.646, 0.593, 0.620, 0.584, 0.619, 0.620, 0.288, 0.302, 0.662, 0.655, 0.662, 0.511,
|
| 21 |
+
0.966, 0.703, 0.654, 0.730, 0.722, 0.601, 0.590, 0.746, 0.743, 0.269, 0.571, 0.672, 0.565, 0.903, 0.753, 0.765,
|
| 22 |
+
0.639, 0.765, 0.644, 0.642, 0.633, 0.744, 0.703, 1.007, 0.682, 0.679, 0.629, 0.365, 0.316, 0.365, 0.471, 0.456,
|
| 23 |
+
0.323, 0.562, 0.610, 0.567, 0.610, 0.579, 0.337, 0.611, 0.591, 0.242, 0.242, 0.549, 0.242, 0.876, 0.591, 0.595,
|
| 24 |
+
0.610, 0.610, 0.392, 0.528, 0.329, 0.591, 0.562, 0.818, 0.546, 0.562, 0.552, 0.426, 0.333, 0.426, 0.657, 0.864,
|
| 25 |
+
0.440, 0.440, 0.261, 0.261, 0.500, 1.000, 0.579,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
_BODY_BOLD = (
|
| 29 |
+
0.237, 0.338, 0.551, 0.649, 0.655, 1.015, 0.672, 0.339, 0.377, 0.377, 0.559, 0.674, 0.334, 0.463, 0.334, 0.388,
|
| 30 |
+
0.674, 0.431, 0.630, 0.645, 0.676, 0.622, 0.650, 0.599, 0.651, 0.650, 0.334, 0.343, 0.679, 0.672, 0.679, 0.559,
|
| 31 |
+
1.016, 0.760, 0.662, 0.740, 0.722, 0.607, 0.587, 0.751, 0.747, 0.281, 0.584, 0.719, 0.565, 0.932, 0.762, 0.770,
|
| 32 |
+
0.648, 0.777, 0.657, 0.655, 0.661, 0.732, 0.760, 1.057, 0.738, 0.731, 0.664, 0.377, 0.344, 0.377, 0.487, 0.476,
|
| 33 |
+
0.365, 0.580, 0.628, 0.584, 0.628, 0.592, 0.362, 0.630, 0.623, 0.271, 0.271, 0.580, 0.271, 0.912, 0.622, 0.609,
|
| 34 |
+
0.628, 0.628, 0.415, 0.560, 0.358, 0.623, 0.600, 0.850, 0.580, 0.602, 0.573, 0.469, 0.372, 0.469, 0.674, 1.002,
|
| 35 |
+
0.540, 0.532, 0.311, 0.311, 0.500, 1.000, 0.592,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
WIDTHS = {name: dict(zip(CHARS, table, strict=True)) for name, table in
|
| 39 |
+
(("display_bold", _DISPLAY_BOLD), ("body", _BODY), ("body_bold", _BODY_BOLD))}
|
backend/app/slides/render.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A deck as HTML: one standalone page per slide (shown in the app in a sandboxed frame, scaled to fit) and one page
|
| 2 |
+
with every slide (printed to PDF). Every word from the model is escaped; the markup is all ours."""
|
| 3 |
+
|
| 4 |
+
import math
|
| 5 |
+
|
| 6 |
+
from markupsafe import escape
|
| 7 |
+
|
| 8 |
+
from app.slides.content import field_limit
|
| 9 |
+
from app.slides.decor import Decor, Shape, build, ring_path
|
| 10 |
+
from app.slides.layout import Composed, El, Run, compose
|
| 11 |
+
from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
|
| 12 |
+
|
| 13 |
+
FONT_LINK = ("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;700"
|
| 14 |
+
"&family=JetBrains+Mono:wght@400;700&display=swap")
|
| 15 |
+
|
| 16 |
+
_FAMILY = {"display": f"'{FONTS['display']}','{FONTS['body']}',sans-serif", "body": f"'{FONTS['body']}',sans-serif",
|
| 17 |
+
"mono": f"'{FONTS['mono']}',monospace"}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def color(pal: Palette, key: str | None) -> str:
|
| 21 |
+
if not key:
|
| 22 |
+
return "transparent"
|
| 23 |
+
return "#ffffff" if key == "white" else getattr(pal, key)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
CSS = f"""
|
| 27 |
+
html,body{{margin:0;padding:0}}
|
| 28 |
+
.slide{{position:relative;width:{W}px;height:{H}px;overflow:hidden;font-family:{_FAMILY['body']};
|
| 29 |
+
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}}
|
| 30 |
+
.slide svg.bg{{position:absolute;inset:0}}
|
| 31 |
+
.e{{position:absolute;box-sizing:border-box;margin:0}}
|
| 32 |
+
.t{{display:flex;flex-direction:column;overflow:visible;overflow-wrap:anywhere}}
|
| 33 |
+
.t>.in{{display:block}}
|
| 34 |
+
.t.v-middle{{justify-content:center}}.t.v-bottom{{justify-content:flex-end}}
|
| 35 |
+
.t p{{margin:0}}
|
| 36 |
+
.t b{{font-weight:700}}
|
| 37 |
+
.r-heading,.r-quote,.r-closing,.r-term{{text-wrap:balance}}
|
| 38 |
+
.dark .r-heading.l-title,.dark .r-value{{text-shadow:0 0 48px rgba(33,124,255,.45)}}
|
| 39 |
+
.dark .r-marker{{box-shadow:0 0 36px rgba(33,124,255,.75)}}
|
| 40 |
+
.dark .r-bar{{box-shadow:0 0 18px rgba(77,148,255,.8)}}
|
| 41 |
+
.light .r-card,.light .r-panel{{box-shadow:0 18px 44px rgba(11,26,58,.14)}}
|
| 42 |
+
.light .r-band{{box-shadow:0 18px 44px rgba(11,26,58,.26)}}
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _style(**kw) -> str:
|
| 47 |
+
return ";".join(f"{k.replace('_', '-')}:{v}" for k, v in kw.items() if v is not None)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _el(e: El, pal: Palette, layout: str, fmt: str) -> str:
|
| 51 |
+
box = dict(left=f"{e.x:.1f}px", top=f"{e.y:.1f}px", width=f"{e.w:.1f}px", height=f"{e.h:.1f}px")
|
| 52 |
+
if e.kind == "text":
|
| 53 |
+
paras = []
|
| 54 |
+
for i, p in enumerate(e.paras):
|
| 55 |
+
runs = "".join(
|
| 56 |
+
f'<span{_editable(r, layout, fmt)} style="color:{color(pal, r.color)}{";font-weight:700" if r.bold else ""}">'
|
| 57 |
+
f'{escape(r.text)}</span>' for r in p)
|
| 58 |
+
gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else ""
|
| 59 |
+
paras.append(f"<p{gap}>{runs}</p>")
|
| 60 |
+
css = _style(**box, font_size=f"{e.size}px", line_height=str(e.lh), font_family=_FAMILY[e.font],
|
| 61 |
+
text_align=e.align, letter_spacing=f"{e.track}em" if e.track else None,
|
| 62 |
+
text_transform="uppercase" if e.upper else None,
|
| 63 |
+
font_weight="700" if e.font == "display" else None)
|
| 64 |
+
return f'<div class="e t r-{e.role} l-{layout} v-{e.valign}" style="{css}"><div class="in">{"".join(paras)}</div></div>'
|
| 65 |
+
if e.kind == "line":
|
| 66 |
+
horizontal = e.h == 0
|
| 67 |
+
css = _style(left=f"{e.x:.1f}px", top=f"{e.y - (e.stroke_w / 2 if horizontal else 0):.1f}px",
|
| 68 |
+
width=f"{e.w if horizontal else e.stroke_w:.1f}px", height=f"{e.stroke_w if horizontal else e.h:.1f}px",
|
| 69 |
+
background=color(pal, e.stroke), border_radius="2px")
|
| 70 |
+
return f'<div class="e r-{e.role}" style="{css}"></div>'
|
| 71 |
+
radius = "50%" if e.kind == "circle" else f"{e.radius}px"
|
| 72 |
+
border = f"{e.stroke_w}px solid {color(pal, e.stroke)}" if e.stroke and e.stroke_w else None
|
| 73 |
+
css = _style(**box, background=color(pal, e.fill), border=border, border_radius=radius,
|
| 74 |
+
opacity=str(e.opacity) if e.opacity < 1 else None)
|
| 75 |
+
return f'<div class="e r-{e.role}" style="{css}"></div>'
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _editable(r: Run, layout: str, fmt: str) -> str:
|
| 79 |
+
"""Where a run's text lives and how long it may be, for the page's editor (none on our own text: numbers, footers)."""
|
| 80 |
+
return f' data-f="{r.field}" data-max="{field_limit(layout, r.field, fmt)}"' if r.field else ""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _svg(d: Decor, pal: Palette, dark: bool, uid: str) -> str:
|
| 84 |
+
defs, out = [], []
|
| 85 |
+
for n, s in enumerate(d.shapes):
|
| 86 |
+
out.append(_shape(s, pal, dark, f"{uid}{n}", defs))
|
| 87 |
+
hx, hy = d.hot
|
| 88 |
+
sky = (f'<radialGradient id="{uid}sky" gradientUnits="userSpaceOnUse" cx="{hx:.0f}" cy="{hy:.0f}" r="{W * 0.85:.0f}">'
|
| 89 |
+
f'<stop offset="0" stop-color="{pal.bg2}"/><stop offset="1" stop-color="{pal.bg1}"/></radialGradient>')
|
| 90 |
+
return (f'<svg class="bg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{sky}{"".join(defs)}</defs>'
|
| 91 |
+
f'<rect width="{W}" height="{H}" fill="url(#{uid}sky)"/>{"".join(out)}</svg>')
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str:
|
| 95 |
+
c = color(pal, s.color)
|
| 96 |
+
if s.kind == "stars":
|
| 97 |
+
return "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o}"/>' for x, y, r, o in s.points)
|
| 98 |
+
if s.kind == "glow":
|
| 99 |
+
defs.append(f'<radialGradient id="{gid}"><stop offset="0" stop-color="{c}" stop-opacity="{s.opacity}"/>'
|
| 100 |
+
f'<stop offset="1" stop-color="{c}" stop-opacity="0"/></radialGradient>')
|
| 101 |
+
return f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
|
| 102 |
+
if s.kind == "planet":
|
| 103 |
+
hi, lo, rim = planet_colors(pal, dark)
|
| 104 |
+
defs.append(f'<radialGradient id="{gid}" cx="35%" cy="28%" r="80%"><stop offset="0" stop-color="{hi}"/>'
|
| 105 |
+
f'<stop offset="1" stop-color="{lo}"/></radialGradient>')
|
| 106 |
+
return (f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
|
| 107 |
+
f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="none" stroke="{rim}" stroke-width="3" opacity=".7"/>')
|
| 108 |
+
if s.kind == "band":
|
| 109 |
+
lo, hi = ring_shade(s)
|
| 110 |
+
defs.append(f'<linearGradient id="{gid}" gradientUnits="userSpaceOnUse" x1="{lo:.0f}" y1="0" x2="{hi:.0f}" y2="0">'
|
| 111 |
+
+ "".join(f'<stop offset="{o}" stop-color="{c}" stop-opacity="{a * s.opacity:.3f}"/>' for o, a in SHADE)
|
| 112 |
+
+ "</linearGradient>")
|
| 113 |
+
pts = ring_path(s) + ring_path(s, s.inner)[::-1]
|
| 114 |
+
return f'<path d="M{_pts(pts)}Z" fill="url(#{gid})"/>'
|
| 115 |
+
if s.kind == "ring" and s.arc:
|
| 116 |
+
return (f'<path d="M{_pts(ring_path(s))}" fill="none" stroke="{c}" stroke-width="{s.width}" '
|
| 117 |
+
f'stroke-linecap="round" opacity="{s.opacity}"/>')
|
| 118 |
+
if s.kind == "ring":
|
| 119 |
+
return (f'<ellipse cx="{s.cx:.0f}" cy="{s.cy:.0f}" rx="{s.rx:.0f}" ry="{s.ry:.0f}" fill="none" stroke="{c}" '
|
| 120 |
+
f'stroke-width="{s.width}" opacity="{s.opacity}" transform="rotate({s.rot:.1f} {s.cx:.0f} {s.cy:.0f})"/>')
|
| 121 |
+
if s.kind == "const":
|
| 122 |
+
lines = "".join(f'<line x1="{s.points[a][0]:.0f}" y1="{s.points[a][1]:.0f}" x2="{s.points[b][0]:.0f}" '
|
| 123 |
+
f'y2="{s.points[b][1]:.0f}" stroke="{c}" stroke-width="1.6" opacity="{s.opacity * 0.5:.2f}"/>'
|
| 124 |
+
for a, b in s.edges)
|
| 125 |
+
dots = "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{s.opacity}"/>' for x, y, r, _ in s.points)
|
| 126 |
+
return lines + dots
|
| 127 |
+
return ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# How brightly a ringed planet's band shows across its width, left to right: lit from the upper left, as the planet is.
|
| 131 |
+
SHADE = ((0, 1.0), (0.45, 0.8), (1, 0.3))
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def ring_shade(s: Shape) -> tuple[float, float]:
|
| 135 |
+
"""The band's left and right extent on the slide, which SHADE runs across."""
|
| 136 |
+
half = math.hypot(s.rx * math.cos(math.radians(s.rot)), s.ry * math.sin(math.radians(s.rot)))
|
| 137 |
+
return s.cx - half, s.cx + half
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _pts(pts: list[tuple[float, float]]) -> str:
|
| 141 |
+
return "L".join(f"{x:.1f} {y:.1f}" for x, y in pts)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def planet_colors(pal: Palette, dark: bool) -> tuple[str, str, str]:
|
| 145 |
+
"""Lit side, dark side and rim of a planet."""
|
| 146 |
+
return ("#1d4fb3", "#020713", pal.accent) if dark else ("#5d9bff", pal.accent2, pal.accent)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def slide_body(c: Composed, deck: dict, index: int, theme: str, fmt: str) -> str:
|
| 150 |
+
t = THEMES[theme]
|
| 151 |
+
s = c.slide
|
| 152 |
+
d = build(theme, s["layout"], s["variant"], c.seed, index)
|
| 153 |
+
els = "".join(_el(e, t.pal, s["layout"], fmt) for e in c.elements)
|
| 154 |
+
cls = f"slide {'dark' if t.dark else 'light'} l-{s['layout']} v-{s['variant']}"
|
| 155 |
+
return f'<div class="{cls}" style="background:{t.pal.bg1}">{_svg(d, t.pal, t.dark, f"s{index}g")}{els}</div>'
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _page(body: str, extra_css: str = "") -> str:
|
| 159 |
+
return (f'<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="{FONT_LINK}">'
|
| 160 |
+
f"<style>{CSS}{extra_css}</style></head><body>{body}</body></html>")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str:
|
| 164 |
+
"""One slide as a standalone page."""
|
| 165 |
+
theme = theme_id(theme)
|
| 166 |
+
return _page(slide_body(compose(deck, index, theme, fmt, seed), deck, index, theme, fmt),
|
| 167 |
+
f"body{{background:{THEMES[theme].pal.bg1}}}")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def slides_html(deck: dict, theme: str, fmt: str, seed: int, start: int, end: int) -> str:
|
| 171 |
+
"""Slides start..end-1 of the deck stacked on one page, each as it is in the deck (its number, its sky)."""
|
| 172 |
+
theme = theme_id(theme)
|
| 173 |
+
body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(start, end))
|
| 174 |
+
return _page(body, f"body{{background:{THEMES[theme].pal.bg1}}}")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
|
| 178 |
+
"""Every slide on one page, one per printed page."""
|
| 179 |
+
theme = theme_id(theme)
|
| 180 |
+
body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(len(deck["slides"])))
|
| 181 |
+
css = (f"@page{{size:{W}px {H}px;margin:0}}body{{background:{THEMES[theme].pal.bg1}}}"
|
| 182 |
+
".slide{break-after:page;page-break-after:always}.slide:last-child{break-after:auto;page-break-after:auto}"
|
| 183 |
+
"*{-webkit-print-color-adjust:exact;print-color-adjust:exact}")
|
| 184 |
+
return _page(body + script, css)
|
backend/app/slides/themes.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The two deck themes. Both are space themes in the app's own palette (black, #217cff, white): one on a night sky, one
|
| 2 |
+
on a pale daylight sky. Colours are plain hex so the HTML and the PowerPoint use exactly the same values."""
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
W, H = 1920, 1080 # every slide is drawn at this size
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass(frozen=True)
|
| 10 |
+
class Palette:
|
| 11 |
+
bg1: str # sky at the edges
|
| 12 |
+
bg2: str # sky at its brightest point
|
| 13 |
+
ink: str # headings and body text
|
| 14 |
+
mute: str # secondary text
|
| 15 |
+
accent: str # kickers, numbers, markers
|
| 16 |
+
accent2: str # second accent (planets, rings)
|
| 17 |
+
card: str # card and panel fill
|
| 18 |
+
cardline: str # card and panel border
|
| 19 |
+
line: str # rules and dividers
|
| 20 |
+
band: str # the closing band
|
| 21 |
+
bandink: str # text on the band
|
| 22 |
+
star: str # star colour
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True)
|
| 26 |
+
class Theme:
|
| 27 |
+
id: str
|
| 28 |
+
name: str
|
| 29 |
+
dark: bool
|
| 30 |
+
pal: Palette
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
THEMES: dict[str, Theme] = {t.id: t for t in (
|
| 34 |
+
Theme("dark-space", "Night stellar", True, Palette(
|
| 35 |
+
bg1="#000000", bg2="#0a1736", ink="#ffffff", mute="#b4bccc", accent="#4d94ff", accent2="#217cff",
|
| 36 |
+
card="#0b1326", cardline="#1f3156", line="#26385c", band="#0e1c3d", bandink="#ffffff", star="#ffffff")),
|
| 37 |
+
Theme("light-space", "Moon light", False, Palette(
|
| 38 |
+
bg1="#c3cddf", bg2="#dfe5ef", ink="#0b1a3a", mute="#36435f", accent="#1858c4", accent2="#0b3d91",
|
| 39 |
+
card="#e9eef6", cardline="#b3bfd5", line="#a7b4cc", band="#0b1a3a", bandink="#ffffff", star="#0b1a3a")),
|
| 40 |
+
)}
|
| 41 |
+
DEFAULT_THEME = "dark-space"
|
| 42 |
+
|
| 43 |
+
FONTS = {"display": "Space Grotesk", "body": "Inter", "mono": "JetBrains Mono"}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def theme_id(value: object) -> str:
|
| 47 |
+
return value if isinstance(value, str) and value in THEMES else DEFAULT_THEME
|
backend/app/worker.py
CHANGED
|
@@ -45,7 +45,7 @@ from app.models import (
|
|
| 45 |
Workspace,
|
| 46 |
WorkspaceMember,
|
| 47 |
)
|
| 48 |
-
from app.pipeline import flashcards, generate, infographic, launchkit, media, quiz, report
|
| 49 |
from app.pipeline.generate import NothingToDo
|
| 50 |
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 51 |
from app.pipeline.memory import learn_from_message
|
|
@@ -295,6 +295,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
|
|
| 295 |
"flashcards": _artifact_handler(flashcards.build_flashcards, "Flashcards ready"),
|
| 296 |
"report": _artifact_handler(report.build_report, "Report ready"),
|
| 297 |
"infographic": _artifact_handler(infographic.build_infographic, "Infographic ready"),
|
|
|
|
| 298 |
"report_block": report.handle_report_block,
|
| 299 |
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 300 |
"video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
|
|
@@ -338,9 +339,10 @@ def run_job(
|
|
| 338 |
mark_artifact_failed(db, job, str(exc))
|
| 339 |
return
|
| 340 |
except Exception as exc:
|
| 341 |
-
|
| 342 |
db.rollback()
|
| 343 |
job = db.get(Job, job_id)
|
|
|
|
| 344 |
if not retry_or_fail(db, job, f"{type(exc).__name__}: {exc}"):
|
| 345 |
mark_artifact_failed(db, job, f"{type(exc).__name__}: {exc}")
|
| 346 |
return
|
|
@@ -489,6 +491,15 @@ def run_periodic(now: float) -> None:
|
|
| 489 |
# Main loop
|
| 490 |
|
| 491 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
|
| 493 |
"""Claim and run jobs until `stop` is set. Used by the CLI and, in development, by the API."""
|
| 494 |
worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}"
|
|
@@ -509,7 +520,9 @@ def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
|
|
| 509 |
break
|
| 510 |
if not job_id:
|
| 511 |
break
|
| 512 |
-
|
|
|
|
|
|
|
| 513 |
claimed = True
|
| 514 |
if not claimed:
|
| 515 |
stop.wait(settings.worker_poll_seconds)
|
|
|
|
| 45 |
Workspace,
|
| 46 |
WorkspaceMember,
|
| 47 |
)
|
| 48 |
+
from app.pipeline import flashcards, generate, infographic, launchkit, media, quiz, report, slides
|
| 49 |
from app.pipeline.generate import NothingToDo
|
| 50 |
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 51 |
from app.pipeline.memory import learn_from_message
|
|
|
|
| 295 |
"flashcards": _artifact_handler(flashcards.build_flashcards, "Flashcards ready"),
|
| 296 |
"report": _artifact_handler(report.build_report, "Report ready"),
|
| 297 |
"infographic": _artifact_handler(infographic.build_infographic, "Infographic ready"),
|
| 298 |
+
"slide_deck": _artifact_handler(slides.build_slide_deck, "Slide deck ready"),
|
| 299 |
"report_block": report.handle_report_block,
|
| 300 |
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 301 |
"video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
|
|
|
|
| 339 |
mark_artifact_failed(db, job, str(exc))
|
| 340 |
return
|
| 341 |
except Exception as exc:
|
| 342 |
+
# Roll back first: after a failed flush, touching `job` raises PendingRollbackError.
|
| 343 |
db.rollback()
|
| 344 |
job = db.get(Job, job_id)
|
| 345 |
+
log.exception("job %s (%s) attempt %s failed", job_id, job.kind, job.attempts)
|
| 346 |
if not retry_or_fail(db, job, f"{type(exc).__name__}: {exc}"):
|
| 347 |
mark_artifact_failed(db, job, f"{type(exc).__name__}: {exc}")
|
| 348 |
return
|
|
|
|
| 491 |
# Main loop
|
| 492 |
|
| 493 |
|
| 494 |
+
def _log_crash(job_id: uuid.UUID) -> Callable[[Future], None]:
|
| 495 |
+
"""run_job handles job errors itself; anything escaping it would otherwise vanish inside the Future and leave
|
| 496 |
+
the job 'running' until recover_stale."""
|
| 497 |
+
def callback(future: Future) -> None:
|
| 498 |
+
if exc := future.exception():
|
| 499 |
+
log.error("job %s crashed outside its error handling", job_id, exc_info=exc)
|
| 500 |
+
return callback
|
| 501 |
+
|
| 502 |
+
|
| 503 |
def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
|
| 504 |
"""Claim and run jobs until `stop` is set. Used by the CLI and, in development, by the API."""
|
| 505 |
worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}"
|
|
|
|
| 520 |
break
|
| 521 |
if not job_id:
|
| 522 |
break
|
| 523 |
+
future = pool.submit(run_job, job_id)
|
| 524 |
+
future.add_done_callback(_log_crash(job_id))
|
| 525 |
+
running.add(future)
|
| 526 |
claimed = True
|
| 527 |
if not claimed:
|
| 528 |
stop.wait(settings.worker_poll_seconds)
|
backend/requirements.txt
CHANGED
|
@@ -19,6 +19,7 @@ rank-bm25==0.2.*
|
|
| 19 |
feedparser==6.*
|
| 20 |
beautifulsoup4==4.*
|
| 21 |
pypdf==5.*
|
|
|
|
| 22 |
stripe==13.*
|
| 23 |
jinja2==3.*
|
| 24 |
|
|
|
|
| 19 |
feedparser==6.*
|
| 20 |
beautifulsoup4==4.*
|
| 21 |
pypdf==5.*
|
| 22 |
+
python-pptx==1.*
|
| 23 |
stripe==13.*
|
| 24 |
jinja2==3.*
|
| 25 |
|
backend/scripts/render_slide_thumbnails.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Draws a sample opening slide in each slide deck theme with a local Chrome, for the theme picker's thumbnails.
|
| 2 |
+
|
| 3 |
+
python scripts/render_slide_thumbnails.py # writes ../frontend/public/slides/<theme>.png
|
| 4 |
+
Set CHROME to the browser binary if it is not found (see app.infographics.image.chrome)."""
|
| 5 |
+
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from PIL import Image # noqa: E402
|
| 14 |
+
|
| 15 |
+
from app.infographics.image import chrome # noqa: E402
|
| 16 |
+
from app.slides.content import clamp_deck # noqa: E402
|
| 17 |
+
from app.slides.render import slide_html # noqa: E402
|
| 18 |
+
from app.slides.themes import THEMES # noqa: E402
|
| 19 |
+
|
| 20 |
+
SAMPLE = {"title": "Your ideas, in orbit", "slides": [
|
| 21 |
+
{"layout": "title", "kicker": "Slide deck", "heading": "Your ideas, in orbit",
|
| 22 |
+
"lead": "Every post you have written, turned into a story worth presenting."},
|
| 23 |
+
]}
|
| 24 |
+
|
| 25 |
+
PICK = {"dark-space": "b", "light-space": "a"} # each theme's most telling opener
|
| 26 |
+
|
| 27 |
+
if __name__ == "__main__":
|
| 28 |
+
binary = chrome()
|
| 29 |
+
if not binary:
|
| 30 |
+
sys.exit("No Chrome found: set CHROME")
|
| 31 |
+
folder = Path(__file__).resolve().parents[2] / "frontend/public/slides"
|
| 32 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
deck = clamp_deck(SAMPLE, "presenter")
|
| 34 |
+
for tid in THEMES:
|
| 35 |
+
deck["slides"][0]["variant"] = PICK[tid]
|
| 36 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 37 |
+
src, shot = Path(tmp) / "s.html", Path(tmp) / "s.png"
|
| 38 |
+
src.write_text(slide_html(deck, 0, tid, "presenter", 2024), encoding="utf-8")
|
| 39 |
+
subprocess.run([binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
|
| 40 |
+
"--force-device-scale-factor=1", f"--user-data-dir={tmp}/p", "--window-size=1920,1080",
|
| 41 |
+
"--virtual-time-budget=8000", f"--screenshot={shot}", f"file://{src}"],
|
| 42 |
+
check=True, capture_output=True, timeout=120)
|
| 43 |
+
Image.open(shot).convert("RGB").resize((800, 450), Image.LANCZOS).save(folder / f"{tid}.png", optimize=True)
|
| 44 |
+
print(folder / f"{tid}.png")
|
backend/tests/test_buildout.py
CHANGED
|
@@ -447,7 +447,7 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
|
|
| 447 |
client.put("/api/voice", json={"host_voices": {"host_a": "voiceA", "host_b": "voiceB"},
|
| 448 |
"delivery": {"host_b": {"stability": 0.9, "similarity_boost": 0.7,
|
| 449 |
"style": 0.1, "speed": 1.1}}}, headers=auth)
|
| 450 |
-
llm["
|
| 451 |
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 452 |
{"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
|
| 453 |
"line_end": 13}]},
|
|
@@ -754,13 +754,14 @@ def _schedule(client, auth, artifact, platform, account=None):
|
|
| 754 |
def test_postable_lists_what_can_be_posted(client, auth, db_session):
|
| 755 |
ws = db_session.query(Workspace).first()
|
| 756 |
quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
|
| 757 |
-
_made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
|
| 758 |
_made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
|
| 759 |
done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
|
| 760 |
unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
|
| 761 |
_made(db_session, ws, "summary", summary="Raise prices once a year [1].")
|
| 762 |
got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
|
| 763 |
-
assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary"} # no
|
|
|
|
| 764 |
assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
|
| 765 |
# Made but not rendered: listed, flagged; the rendered one is not.
|
| 766 |
assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
|
|
@@ -1094,8 +1095,8 @@ def test_audio_overview_hosts_are_picked_from_the_voices(client, auth, run_jobs,
|
|
| 1094 |
used = []
|
| 1095 |
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
|
| 1096 |
b"\x00" * 16000)
|
| 1097 |
-
llm["
|
| 1098 |
-
|
| 1099 |
ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
|
| 1100 |
db_session.add_all([UserSavedVoice(workspace_id=ws_id, voice_id=v, name=v) for v in ("mine-1", "mine-2")])
|
| 1101 |
db_session.commit()
|
|
@@ -1116,6 +1117,59 @@ def test_audio_overview_hosts_are_picked_from_the_voices(client, auth, run_jobs,
|
|
| 1116 |
assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
|
| 1117 |
|
| 1118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1119 |
def test_tts_retries_rate_limits(monkeypatch):
|
| 1120 |
from app.config import settings
|
| 1121 |
from app.services import tts
|
|
|
|
| 447 |
client.put("/api/voice", json={"host_voices": {"host_a": "voiceA", "host_b": "voiceB"},
|
| 448 |
"delivery": {"host_b": {"stability": 0.9, "similarity_boost": 0.7,
|
| 449 |
"style": 0.1, "speed": 1.1}}}, headers=auth)
|
| 450 |
+
llm["WriteAudioScript"] = {"turns": [
|
| 451 |
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 452 |
{"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
|
| 453 |
"line_end": 13}]},
|
|
|
|
| 754 |
def test_postable_lists_what_can_be_posted(client, auth, db_session):
|
| 755 |
ws = db_session.query(Workspace).first()
|
| 756 |
quote = _made(db_session, ws, "quote_card", key="ws/q/quotecard.png", quote="Ship it", source="Ada")
|
| 757 |
+
audio = _made(db_session, ws, "audio_overview", key="ws/a/audio.mp3")
|
| 758 |
_made(db_session, ws, "video", status="processing", provider="blog2video") # not rendered yet
|
| 759 |
done = _made(db_session, ws, "video", status="processing", provider="blog2video", video_url="https://v/x.mp4")
|
| 760 |
unrendered = _made(db_session, ws, "video", status="ready", provider="blog2video", b2v_status="generated")
|
| 761 |
_made(db_session, ws, "summary", summary="Raise prices once a year [1].")
|
| 762 |
got = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
|
| 763 |
+
assert {a["type"] for a in got.values()} == {"quote_card", "video", "summary", "audio_overview"} # no unfinished video
|
| 764 |
+
assert got[str(audio.id)]["media"] == "audio" and got[str(audio.id)]["download_url"] # audio: to download, not post
|
| 765 |
assert str(done.id) in got and got[str(quote.id)]["thumb_url"] and got[str(quote.id)]["media"] == "image"
|
| 766 |
# Made but not rendered: listed, flagged; the rendered one is not.
|
| 767 |
assert got[str(unrendered.id)]["needs_render"] is True and got[str(done.id)]["needs_render"] is False
|
|
|
|
| 1095 |
used = []
|
| 1096 |
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
|
| 1097 |
b"\x00" * 16000)
|
| 1098 |
+
llm["WriteAudioScript"] = {"turns": [{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 1099 |
+
{"speaker": "host_b", "text": "Thanks.", "sources": []}]}
|
| 1100 |
ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
|
| 1101 |
db_session.add_all([UserSavedVoice(workspace_id=ws_id, voice_id=v, name=v) for v in ("mine-1", "mine-2")])
|
| 1102 |
db_session.commit()
|
|
|
|
| 1117 |
assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
|
| 1118 |
|
| 1119 |
|
| 1120 |
+
def test_audio_overview_alternates_two_hosts_or_uses_one(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
|
| 1121 |
+
from app.config import settings
|
| 1122 |
+
from app.services import tts
|
| 1123 |
+
|
| 1124 |
+
connect(client, auth, run_jobs)
|
| 1125 |
+
pricing = next(d for d in docs(client, auth) if d["title"] == "On Pricing")
|
| 1126 |
+
nb = client.post("/api/notebooks", json={"title": "Money", "document_ids": [pricing["id"]]}, headers=auth).json()
|
| 1127 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 1128 |
+
used = []
|
| 1129 |
+
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
|
| 1130 |
+
b"\x00" * 16000)
|
| 1131 |
+
seen = {}
|
| 1132 |
+
|
| 1133 |
+
def script(**kw):
|
| 1134 |
+
seen.update(kw)
|
| 1135 |
+
return {"turns": [ # host A twice in a row, **markup**, an empty turn: cleaned and made to alternate
|
| 1136 |
+
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 1137 |
+
{"speaker": "host_a", "text": "Today, **pricing**.", "sources": []},
|
| 1138 |
+
{"speaker": "host_b", "text": "", "sources": []},
|
| 1139 |
+
{"speaker": "host_b", "text": "Prices went up.", "sources": []},
|
| 1140 |
+
{"speaker": "host_b", "text": "And readers stayed.", "sources": []},
|
| 1141 |
+
{"speaker": "host_a", "text": "Exactly.", "sources": []},
|
| 1142 |
+
]}
|
| 1143 |
+
|
| 1144 |
+
llm["WriteAudioScript"] = script
|
| 1145 |
+
body = {"type": "audio_overview", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "minutes": 3,
|
| 1146 |
+
"format": "critique", "language": "Spanish", "instructions": "- Focus on raising prices"}
|
| 1147 |
+
art = client.post("/api/artifacts/generate", json=body, headers=auth).json()
|
| 1148 |
+
run_jobs()
|
| 1149 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 1150 |
+
assert done["status"] == "ready", done["content"]
|
| 1151 |
+
segs = done["content"]["segments"]
|
| 1152 |
+
assert [(s["speaker"], s["text"]) for s in segs] == [
|
| 1153 |
+
("host_a", "Welcome in. Today, pricing."), ("host_b", "Prices went up. And readers stayed."), ("host_a", "Exactly.")]
|
| 1154 |
+
assert seen["hosts"] == 2 and seen["target_words"] == 450 and seen["language"] == "Spanish"
|
| 1155 |
+
assert seen["focus"] == "- Focus on raising prices" and seen["style"].startswith("Critique")
|
| 1156 |
+
assert any(pricing["path"] in m for m in seen["material"]) # the picked post is what the script reads
|
| 1157 |
+
voices = done["content"]["voices"]
|
| 1158 |
+
assert used[1][1] == voices["host_b"] != voices["host_a"] == used[0][1]
|
| 1159 |
+
|
| 1160 |
+
used.clear()
|
| 1161 |
+
one = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "brief"}, headers=auth).json()
|
| 1162 |
+
run_jobs()
|
| 1163 |
+
done = client.get(f"/api/artifacts/{one['id']}", headers=auth).json()
|
| 1164 |
+
assert {s["speaker"] for s in done["content"]["segments"]} == {"host_a"} and seen["hosts"] == 1
|
| 1165 |
+
assert list(done["content"]["voices"]) == ["host_a"] and {v for _, v in used} == {done["content"]["voices"]["host_a"]}
|
| 1166 |
+
assert done["content"]["hosts"] == 1 and done["content"]["focus"] == "- Focus on raising prices"
|
| 1167 |
+
|
| 1168 |
+
r = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "debate"}, headers=auth)
|
| 1169 |
+
assert r.status_code == 400 and "two hosts" in r.json()["detail"]
|
| 1170 |
+
assert client.post("/api/artifacts/generate", json={**body, "document_ids": []}, headers=auth).status_code == 400
|
| 1171 |
+
|
| 1172 |
+
|
| 1173 |
def test_tts_retries_rate_limits(monkeypatch):
|
| 1174 |
from app.config import settings
|
| 1175 |
from app.services import tts
|
backend/tests/test_slides.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slide decks: what the model writes is clamped to a fixed shape, laid out by code in either theme, checked against
|
| 2 |
+
the sources, and drawn the same way as pages, a PDF and an editable PowerPoint. The LLM is faked at app.llm.run."""
|
| 3 |
+
|
| 4 |
+
import copy
|
| 5 |
+
import io
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from pptx import Presentation
|
| 10 |
+
|
| 11 |
+
from app.infographics.image import chrome
|
| 12 |
+
from app.slides import export
|
| 13 |
+
from app.slides.build import finish, view
|
| 14 |
+
from app.slides.content import LIMITS, clamp_deck, clamp_slide
|
| 15 |
+
from app.slides.layout import GEOMETRY, VARIANTS, assign_variants, compose
|
| 16 |
+
from app.slides.render import deck_print_html, slide_html
|
| 17 |
+
from tests.test_buildout import _notebook_with_pricing, auth, feed, llm # noqa: F401 (fixtures)
|
| 18 |
+
|
| 19 |
+
LONG = "Words that keep going and going " * 40
|
| 20 |
+
|
| 21 |
+
FULL = {
|
| 22 |
+
"title": {"heading": "Pricing that readers say yes to", "kicker": "From 4 posts", "lead": "What two years taught us."},
|
| 23 |
+
"agenda": {"heading": "What we will cover", "points": [{"text": f"Part {i}"} for i in range(9)]},
|
| 24 |
+
"section": {"heading": "The numbers behind trials", "kicker": "Part two", "lead": "Short trials converted twice as well."},
|
| 25 |
+
"points": {"heading": "Why tiers work", "kicker": "Tiers", "lead": "Readers self-select.",
|
| 26 |
+
"points": [{"term": "Anchoring", "text": "A higher tier makes the middle one feel fair."}] * 4},
|
| 27 |
+
"two_column": {"heading": "Monthly or annual", "left": {"label": "Monthly", "items": ["Low commitment", "Easy to leave"]},
|
| 28 |
+
"right": {"label": "Annual", "items": ["Two months free", "Lower churn"]}},
|
| 29 |
+
"stat": {"heading": "Short trials win", "stat": {"value": "2x", "label": "more paid signups"}, "lead": "A decision point."},
|
| 30 |
+
"quote": {"heading": "In their words", "quote": {"text": "Thursday would feel empty without it.", "by": "A reader"}},
|
| 31 |
+
"closing": {"heading": "Key takeaways", "takeaways": ["One.", "Two.", "Three."], "closing": "Price the habit."},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _stuff(slide: dict, text: str) -> dict:
|
| 36 |
+
"""The same slide with every text field set to `text` (the longest anything could be)."""
|
| 37 |
+
s = copy.deepcopy(slide)
|
| 38 |
+
for k in ("heading", "kicker", "lead", "closing"):
|
| 39 |
+
if k in s:
|
| 40 |
+
s[k] = text
|
| 41 |
+
for p in s.get("points") or []:
|
| 42 |
+
p["text"], p["term"] = text, text[:60]
|
| 43 |
+
for side in ("left", "right"):
|
| 44 |
+
if side in s:
|
| 45 |
+
s[side] = {"label": text, "items": [text] * 6}
|
| 46 |
+
if "stat" in s:
|
| 47 |
+
s["stat"] = {"value": "123456789012345678", "label": text}
|
| 48 |
+
if "quote" in s:
|
| 49 |
+
s["quote"] = {"text": text, "by": text}
|
| 50 |
+
if "takeaways" in s:
|
| 51 |
+
s["takeaways"] = [text] * 5
|
| 52 |
+
return s
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.mark.parametrize("theme", ["dark-space", "light-space"])
|
| 56 |
+
@pytest.mark.parametrize("fmt", ["detailed", "presenter"])
|
| 57 |
+
def test_every_variant_draws_inside_its_boxes_at_any_length(theme, fmt):
|
| 58 |
+
for layout, variants in VARIANTS.items():
|
| 59 |
+
for v in variants:
|
| 60 |
+
for text in ("Short text here", LONG, "長い日本語のテキスト" * 30, "Supercalifragilistic" * 12):
|
| 61 |
+
raw = {**_stuff(FULL[layout], text), "layout": layout, "variant": v}
|
| 62 |
+
deck = clamp_deck({"title": "Deck", "slides": [raw, raw]}, fmt)
|
| 63 |
+
for i in range(2):
|
| 64 |
+
c = compose(deck, i, theme, fmt, seed=7)
|
| 65 |
+
for e in c.elements:
|
| 66 |
+
assert 0 <= e.x and e.x + e.w <= 1920 + 1 and 0 <= e.y and e.y + e.h <= 1080 + 1, (layout, v, e.role)
|
| 67 |
+
if e.kind == "text":
|
| 68 |
+
assert e.text_height() <= e.h + 1, (layout, v, e.role, text[:20])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_clamp_never_lets_bad_input_through():
|
| 72 |
+
garbage = [None, 5, "text", [], {"layout": "nonsense", "heading": "<script>alert(1)</script>**Bold** [1]"},
|
| 73 |
+
{"layout": "stat", "heading": "No number", "stat": {"value": "", "label": "x"}},
|
| 74 |
+
{"layout": "quote", "heading": "No quote", "quote": None, "points": [{"text": "a"}, {"text": "b"}]},
|
| 75 |
+
{"layout": "two_column", "heading": "One side", "left": {"items": ["x"]}, "right": None},
|
| 76 |
+
{"layout": "points", "heading": "Nul\x00 byte— dash", "points": "not a list"},
|
| 77 |
+
{"layout": "closing", "heading": "", "takeaways": None, "closing": None},
|
| 78 |
+
{"layout": "points", "heading": "x" * 5000, "points": [{"term": None, "text": {"nested": 1}}] * 30}]
|
| 79 |
+
deck = clamp_deck({"title": None, "slides": garbage}, "detailed")
|
| 80 |
+
assert deck and deck["title"]
|
| 81 |
+
keys = set(clamp_slide({"heading": "x"}, "detailed"))
|
| 82 |
+
for s in deck["slides"]:
|
| 83 |
+
assert set(s) == keys # every slide has every key
|
| 84 |
+
for value in (s["heading"], s["lead"], s["kicker"]):
|
| 85 |
+
assert "<" not in value and "\x00" not in value and "—" not in value and "**" not in value
|
| 86 |
+
assert len(s["heading"]) <= LIMITS["detailed"]["heading"]
|
| 87 |
+
layouts = [s["layout"] for s in deck["slides"]]
|
| 88 |
+
assert layouts[0] == "points" or layouts[0] == "section" # unknown layout becomes a simpler one
|
| 89 |
+
assert "stat" not in layouts and "quote" not in layouts and "two_column" not in layouts
|
| 90 |
+
assert clamp_deck({"slides": []}, "detailed") is None and clamp_deck("nope", "presenter") is None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_pages_escape_everything_the_model_wrote():
|
| 94 |
+
raw = {"layout": "points", "heading": "Fine <b onmouseover=x>heading</b>", "lead": "a & b < c",
|
| 95 |
+
"points": [{"term": "<img src=x>", "text": "\"quoted\" <script>x</script> text"}] * 3}
|
| 96 |
+
deck = clamp_deck({"title": "<title>", "slides": [raw]}, "detailed")
|
| 97 |
+
deck["slides"][0]["variant"] = "a"
|
| 98 |
+
html = slide_html(deck, 0, "dark-space", "detailed", 1)
|
| 99 |
+
assert "<script" not in html and "<img" not in html and "onmouseover" not in html
|
| 100 |
+
assert "a & b < c" in html
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_variants_never_repeat_in_a_row_and_differ_between_decks():
|
| 104 |
+
slides = [clamp_slide({**FULL[k], "layout": k}, "detailed") for k in
|
| 105 |
+
("title", "agenda", "points", "points", "points", "stat", "points", "quote", "points", "closing")]
|
| 106 |
+
a, b = copy.deepcopy(slides), copy.deepcopy(slides)
|
| 107 |
+
assign_variants(a, "dark-space", seed=1)
|
| 108 |
+
pairs = [(s["layout"], s["variant"]) for s in a]
|
| 109 |
+
assert all(pairs[i] != pairs[i + 1] for i in range(len(pairs) - 1))
|
| 110 |
+
points = [s["variant"] for s in a if s["layout"] == "points"]
|
| 111 |
+
assert len(set(points)) == len(VARIANTS["points"]) # every points variant used before any repeats
|
| 112 |
+
assign_variants(b, "dark-space", seed=2, previous=pairs)
|
| 113 |
+
assert [s["variant"] for s in b] != [s["variant"] for s in a]
|
| 114 |
+
assert b[0]["variant"] != a[0]["variant"] and b[-1]["variant"] != a[-1]["variant"] # new opener and closer
|
| 115 |
+
again = copy.deepcopy(slides)
|
| 116 |
+
assign_variants(again, "dark-space", seed=1)
|
| 117 |
+
assert [s["variant"] for s in again] == [s["variant"] for s in a] # one deck always draws the same
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_the_two_themes_compose_differently():
|
| 121 |
+
for layout, variants in VARIANTS.items():
|
| 122 |
+
for v in variants:
|
| 123 |
+
assert GEOMETRY["dark-space"][layout][v] != GEOMETRY["light-space"][layout][v] or layout in ("points", "two_column"), \
|
| 124 |
+
(layout, v)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _deck(fmt: str = "detailed") -> dict:
|
| 128 |
+
src = [{"path": "p.md", "line_start": 1, "line_end": 2, "title": "On Pricing"}]
|
| 129 |
+
slides = [{**FULL[k], "layout": k, "notes": f"Say {k}", "sources": src}
|
| 130 |
+
for k in ("title", "agenda", "points", "two_column", "stat", "quote", "closing")]
|
| 131 |
+
deck = clamp_deck({"title": "Pricing", "slides": slides}, fmt)
|
| 132 |
+
assign_variants(deck["slides"], "light-space", seed=3)
|
| 133 |
+
return deck
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_powerpoint_has_every_slide_as_editable_text_with_notes():
|
| 137 |
+
deck = _deck()
|
| 138 |
+
data = export.build_pptx(deck, "light-space", "detailed", 3)
|
| 139 |
+
prs = Presentation(io.BytesIO(data))
|
| 140 |
+
assert len(prs.slides) == len(deck["slides"]) and prs.slide_width == 12192000
|
| 141 |
+
texts = [" ".join(sh.text_frame.text for sh in s.shapes if sh.has_text_frame) for s in prs.slides]
|
| 142 |
+
assert "Why tiers work" in texts[2] and "Anchoring" in texts[2] and "middle one feel fair" in texts[2]
|
| 143 |
+
notes = prs.slides[2].notes_slide.notes_text_frame.text
|
| 144 |
+
assert "Say points" in notes and "On Pricing (lines 1-2)" in notes
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@pytest.mark.skipif(not chrome(), reason="no Chrome to print with")
|
| 148 |
+
def test_pdf_has_one_page_per_slide_and_nothing_overflows():
|
| 149 |
+
from pypdf import PdfReader
|
| 150 |
+
|
| 151 |
+
deck = finish(_deck(), "dark-space", "detailed", 3)
|
| 152 |
+
assert export.overflowing(deck, "dark-space", "detailed", 3) == set()
|
| 153 |
+
pdf = PdfReader(io.BytesIO(export.render_pdf(deck, "dark-space", "detailed", 3)))
|
| 154 |
+
assert len(pdf.pages) == len(deck["slides"])
|
| 155 |
+
assert round(float(pdf.pages[0].mediabox.width) / float(pdf.pages[0].mediabox.height), 2) == round(1920 / 1080, 2)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_a_stored_deck_with_a_broken_slide_still_shows():
|
| 159 |
+
content = {"theme": "dark-space", "format": "detailed", "seed": "abc", "deck": _deck()}
|
| 160 |
+
content["deck"]["slides"][2]["variant"] = "zz" # unknown: drawn with the first variant
|
| 161 |
+
content["deck"]["slides"][3]["points"] = None
|
| 162 |
+
pages = view(content)
|
| 163 |
+
assert len(pages) == len(content["deck"]["slides"]) and all(p.startswith("<!doctype html>") for p in pages)
|
| 164 |
+
assert view({"deck": {"slides": []}}) == [] and view({}) == []
|
| 165 |
+
assert "Pricing" in deck_print_html(clamp_deck(content["deck"], "detailed"), "x", "detailed", 1)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# --- the whole flow, through the API ------------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
@pytest.fixture()
|
| 171 |
+
def no_chrome(monkeypatch):
|
| 172 |
+
"""Generation measures slides in Chrome when one is installed; the API tests run without it."""
|
| 173 |
+
monkeypatch.setattr(export, "chrome", lambda: None)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _fake_many(monkeypatch, pricing_path: str, log: dict):
|
| 177 |
+
from app.llm import run
|
| 178 |
+
|
| 179 |
+
def predict_many(signature, inputs_list, *, db, workspace_id, lm=None, workers=5):
|
| 180 |
+
name = signature.__name__
|
| 181 |
+
log.setdefault(name, []).extend(inputs_list)
|
| 182 |
+
out = []
|
| 183 |
+
for inp in inputs_list:
|
| 184 |
+
if name == "WriteSlide":
|
| 185 |
+
good = {"path": pricing_path, "line_start": 1, "line_end": 12}
|
| 186 |
+
fake = {"path": "sources/made-up.md", "line_start": 1, "line_end": 3}
|
| 187 |
+
slide = {"kicker": "Pricing", "heading": inp["heading"], "lead": "Charging more made readers take it seriously.",
|
| 188 |
+
"points": [{"term": "Price", "text": "The paid tier doubled after the price went up."},
|
| 189 |
+
{"term": "Invented", "text": "Ninety percent of readers loved it."},
|
| 190 |
+
{"term": "Seriousness", "text": "Readers took the work seriously."}],
|
| 191 |
+
"notes": "Talk about pricing.", "sources": [good, fake]}
|
| 192 |
+
if inp["layout"] == "stat":
|
| 193 |
+
slide["stat"] = {"value": "90%", "label": "of readers upgraded"} # not in the post: removed
|
| 194 |
+
if inp["layout"] == "quote":
|
| 195 |
+
slide["quote"] = {"text": "Charging more made my readers take the work seriously.", "by": "Ada"}
|
| 196 |
+
if inp["layout"] == "two_column":
|
| 197 |
+
slide["left"] = {"label": "Before", "items": ["Low price", "Few paid readers"]}
|
| 198 |
+
slide["right"] = {"label": "After", "items": ["Ten dollars", "Paid tier doubled"]}
|
| 199 |
+
out.append({"slide": slide})
|
| 200 |
+
elif name == "CheckSlide":
|
| 201 |
+
bad = [c for c in inp["claims"] if "Ninety percent" in c]
|
| 202 |
+
out.append({"checks": [{"n": int(c.split(".")[0]), "supported": False, "fix": ""} for c in bad]})
|
| 203 |
+
else:
|
| 204 |
+
out.append(None)
|
| 205 |
+
return out
|
| 206 |
+
|
| 207 |
+
monkeypatch.setattr(run, "predict_many", predict_many)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _outline(n: int, layouts=("points", "quote", "stat", "points", "section", "points", "points", "two_column", "points")):
|
| 211 |
+
def make(**inputs):
|
| 212 |
+
return {"outline": {"title": "Pricing for writers", "subtitle": "Why charging more worked",
|
| 213 |
+
"opening_kicker": "Pricing", "agenda_heading": "What we cover",
|
| 214 |
+
"slides": [{"layout": layouts[i % len(layouts)], "heading": f"Slide idea {i}",
|
| 215 |
+
"purpose": "Make a point", "sources": []} for i in range(n)]}}
|
| 216 |
+
return make
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
CLOSING = {"closing": {"kicker": "Conclusion", "heading": "Key takeaways",
|
| 220 |
+
"takeaways": ["Charge more.", "Readers take it seriously.", "The paid tier grows."],
|
| 221 |
+
"closing": "Price the work like it matters.", "notes": "Close."}}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_short_detailed_deck_is_grounded_checked_and_downloadable(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 225 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 226 |
+
log: dict = {}
|
| 227 |
+
_fake_many(monkeypatch, pricing["path"], log)
|
| 228 |
+
llm["OutlineDeck"] = _outline(9) # too many for a short deck: trimmed
|
| 229 |
+
llm["WriteClosing"] = CLOSING
|
| 230 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 231 |
+
"document_ids": [pricing["id"]], "deck_format": "detailed", "deck_length": "short",
|
| 232 |
+
"theme": "light-space", "instructions": "For new writers"}, headers=auth).json()
|
| 233 |
+
assert art["type_label"] == "Slide deck"
|
| 234 |
+
run_jobs()
|
| 235 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 236 |
+
assert done["status"] == "ready", done["content"].get("error")
|
| 237 |
+
c = done["content"]
|
| 238 |
+
slides = c["deck"]["slides"]
|
| 239 |
+
assert 6 <= len(slides) <= 8 and len(done["content"]["slides_html"]) == len(slides)
|
| 240 |
+
assert slides[0]["layout"] == "title" and slides[-1]["layout"] == "closing"
|
| 241 |
+
assert slides[-1]["closing"] == "Price the work like it matters." and len(slides[-1]["takeaways"]) == 3
|
| 242 |
+
assert not any(s["layout"] == "agenda" for s in slides) # short decks have no agenda
|
| 243 |
+
assert c["theme"] == "light-space" and c["format"] == "detailed"
|
| 244 |
+
body = [s for s in slides[1:-1]]
|
| 245 |
+
for s in body:
|
| 246 |
+
assert all(src["path"] == pricing["path"] for src in s["sources"]) # the invented path is gone
|
| 247 |
+
assert all("Ninety percent" not in p["text"] for p in s["points"]) # unsupported claim removed
|
| 248 |
+
assert s["layout"] != "stat" # its number was not in the post
|
| 249 |
+
assert any(s["layout"] == "quote" for s in body) # the quote is in the post word for word
|
| 250 |
+
pairs = [(s["layout"], s["variant"]) for s in slides]
|
| 251 |
+
assert all(pairs[i] != pairs[i + 1] for i in range(len(pairs) - 1))
|
| 252 |
+
assert all(log["WriteSlide"][0]["source_lines"]) and log["WriteSlide"][0]["deck_format"] == "detailed"
|
| 253 |
+
html = "".join(c["slides_html"])
|
| 254 |
+
assert "<script" not in html and "Pricing for writers" in html
|
| 255 |
+
|
| 256 |
+
pptx = client.get(f"/api/artifacts/{art['id']}/slides.pptx", headers=auth)
|
| 257 |
+
assert pptx.status_code == 200 and "Pricing for writers.pptx" in pptx.headers["content-disposition"]
|
| 258 |
+
assert len(Presentation(io.BytesIO(pptx.content)).slides) == len(slides)
|
| 259 |
+
assert client.get(f"/api/artifacts/{art['id']}/slides.pdf", headers=auth).status_code == 503 # no Chrome here
|
| 260 |
+
assert client.get(f"/api/artifacts/{art['id']}/slides.doc", headers=auth).status_code == 422
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_default_presenter_deck_has_an_agenda_and_stays_in_range(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 264 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 265 |
+
log: dict = {}
|
| 266 |
+
_fake_many(monkeypatch, pricing["path"], log)
|
| 267 |
+
llm["OutlineDeck"] = _outline(7)
|
| 268 |
+
llm["WriteClosing"] = CLOSING
|
| 269 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 270 |
+
"document_ids": [pricing["id"]], "deck_format": "presenter", "deck_length": "default",
|
| 271 |
+
"language": "Spanish"}, headers=auth).json()
|
| 272 |
+
run_jobs()
|
| 273 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 274 |
+
slides = done["content"]["deck"]["slides"]
|
| 275 |
+
assert done["status"] == "ready" and 9 <= len(slides) <= 11
|
| 276 |
+
assert slides[1]["layout"] == "agenda" and [p["text"] for p in slides[1]["points"]][:2] == ["Slide idea 0", "Slide idea 1"]
|
| 277 |
+
assert done["content"]["theme"] == "dark-space" # the default
|
| 278 |
+
assert log["WriteSlide"][0]["language"] == "Spanish" and log["WriteSlide"][0]["deck_format"] == "presenter"
|
| 279 |
+
for s in slides:
|
| 280 |
+
assert all(len(p["text"]) <= LIMITS["presenter"]["text"] for p in s["points"] if s["layout"] != "agenda")
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def test_too_little_material_fails_cleanly_and_sources_are_required(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 284 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 285 |
+
_fake_many(monkeypatch, pricing["path"], {})
|
| 286 |
+
llm["OutlineDeck"] = _outline(1)
|
| 287 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 288 |
+
"document_ids": [pricing["id"]], "deck_length": "default"}, headers=auth).json()
|
| 289 |
+
run_jobs()
|
| 290 |
+
failed = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 291 |
+
assert failed["status"] == "failed" and "Short" in failed["content"]["error"]
|
| 292 |
+
assert llm["_calls"].count("OutlineDeck") >= 2 # asked again before giving up
|
| 293 |
+
assert client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 294 |
+
"document_ids": []}, headers=auth).status_code == 400
|
| 295 |
+
assert client.get(f"/api/artifacts/{art['id']}/slides.pptx", headers=auth).status_code == 404
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_a_slide_the_model_fails_to_write_is_written_from_its_sources(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 299 |
+
from app.llm import run
|
| 300 |
+
|
| 301 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 302 |
+
log: dict = {}
|
| 303 |
+
_fake_many(monkeypatch, pricing["path"], log)
|
| 304 |
+
real = run.predict_many
|
| 305 |
+
|
| 306 |
+
def flaky(signature, inputs_list, **kw):
|
| 307 |
+
out = real(signature, inputs_list, **kw)
|
| 308 |
+
return [None] * len(out) if signature.__name__ == "WriteSlide" else out
|
| 309 |
+
|
| 310 |
+
monkeypatch.setattr(run, "predict_many", flaky)
|
| 311 |
+
llm["OutlineDeck"] = lambda **kw: {"outline": {"title": "Pricing", "subtitle": "", "opening_kicker": "", "agenda_heading": "",
|
| 312 |
+
"slides": [{"layout": "points", "heading": f"Idea {i}", "purpose": "A point",
|
| 313 |
+
"sources": [{"path": pricing["path"], "line_start": 1, "line_end": 14}]}
|
| 314 |
+
for i in range(4)]}}
|
| 315 |
+
llm["WriteClosing"] = lambda **kw: (_ for _ in ()).throw(RuntimeError("model down"))
|
| 316 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 317 |
+
"document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
|
| 318 |
+
run_jobs()
|
| 319 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 320 |
+
assert done["status"] == "ready", done["content"].get("error")
|
| 321 |
+
slides = done["content"]["deck"]["slides"]
|
| 322 |
+
assert [s["heading"] for s in slides[1:-1]] == [f"Idea {i}" for i in range(4)]
|
| 323 |
+
assert slides[-1]["layout"] == "closing" and slides[-1]["takeaways"]
|
| 324 |
+
assert len(log["WriteSlide"]) == 8 # each slide tried twice
|
| 325 |
+
assert re.search(r"Charging more|ten dollars|seriously", " ".join(str(s) for s in slides[1:-1]))
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def test_the_opening_and_closing_say_only_what_the_slides_say(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 329 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 330 |
+
_fake_many(monkeypatch, pricing["path"], {})
|
| 331 |
+
outline = _outline(4)
|
| 332 |
+
|
| 333 |
+
def mixed_up(**kw):
|
| 334 |
+
out = outline(**kw)
|
| 335 |
+
out["outline"]["subtitle"] = "Ada won a writing award for her pricing" # joins two things no slide says
|
| 336 |
+
return out
|
| 337 |
+
|
| 338 |
+
llm["OutlineDeck"] = mixed_up
|
| 339 |
+
takeaways = ["Charge more.", "Ada sold a million copies.", "The paid tier grows."]
|
| 340 |
+
llm["WriteClosing"] = {"closing": {**CLOSING["closing"], "takeaways": takeaways}}
|
| 341 |
+
|
| 342 |
+
def check(**kw):
|
| 343 |
+
claims = kw["claims"]
|
| 344 |
+
return {"checks": [{"n": int(c.split(".")[0]), "supported": False, "fix": ""} for c in claims
|
| 345 |
+
if "award" in c or "million" in c]}
|
| 346 |
+
|
| 347 |
+
llm["CheckSlide"] = check
|
| 348 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 349 |
+
"document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
|
| 350 |
+
run_jobs()
|
| 351 |
+
slides = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["deck"]["slides"]
|
| 352 |
+
assert "award" not in slides[0]["lead"] and slides[0]["heading"] == "Pricing for writers"
|
| 353 |
+
assert slides[-1]["takeaways"] == ["Charge more.", "The paid tier grows."]
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def test_long_deck_has_parts_and_the_request_reaches_every_step(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 357 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 358 |
+
log: dict = {}
|
| 359 |
+
_fake_many(monkeypatch, pricing["path"], log)
|
| 360 |
+
layouts = ("section", "points", "stat", "points", "section", "quote", "points", "two_column", "section", "points",
|
| 361 |
+
"points", "points")
|
| 362 |
+
llm["OutlineDeck"] = _outline(12, layouts)
|
| 363 |
+
llm["CheckRequest"] = {"asks": [{"ask": "For new writers", "covered": True}]}
|
| 364 |
+
seen: dict = {}
|
| 365 |
+
|
| 366 |
+
def closing(**kw):
|
| 367 |
+
seen["closing"] = kw["request"]
|
| 368 |
+
return CLOSING
|
| 369 |
+
|
| 370 |
+
llm["WriteClosing"] = closing
|
| 371 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 372 |
+
"document_ids": [pricing["id"]], "deck_length": "long", "instructions": "For new writers"},
|
| 373 |
+
headers=auth).json()
|
| 374 |
+
run_jobs()
|
| 375 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 376 |
+
assert done["status"] == "ready", done["content"].get("error")
|
| 377 |
+
slides = done["content"]["deck"]["slides"]
|
| 378 |
+
assert 12 <= len(slides) <= 16 and done["content"]["length"] == "long"
|
| 379 |
+
sections = [s["heading"] for s in slides if s["layout"] == "section"]
|
| 380 |
+
assert slides[1]["layout"] == "agenda" and [p["text"] for p in slides[1]["points"]] == sections # the parts
|
| 381 |
+
assert all(w["request"] == "For new writers" for w in log["WriteSlide"]) and seen["closing"] == "For new writers"
|
| 382 |
+
assert llm["_calls"].count("OutlineDeck") == 1 # nothing missed: no second plan
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def test_a_missed_ask_is_planned_again(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 386 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 387 |
+
_fake_many(monkeypatch, pricing["path"], {})
|
| 388 |
+
requests: list[str] = []
|
| 389 |
+
first, second = _outline(4), _outline(5)
|
| 390 |
+
|
| 391 |
+
def outline(**kw):
|
| 392 |
+
requests.append(kw["request"])
|
| 393 |
+
out = (first if len(requests) == 1 else second)(**kw)
|
| 394 |
+
if len(requests) > 1:
|
| 395 |
+
out["outline"]["slides"][-1]["heading"] = "Pricing for beginners"
|
| 396 |
+
return out
|
| 397 |
+
|
| 398 |
+
llm["OutlineDeck"] = outline
|
| 399 |
+
llm["CheckRequest"] = {"asks": [{"ask": "a slide for beginners", "covered": False}, {"ask": "pricing", "covered": True}]}
|
| 400 |
+
llm["WriteClosing"] = CLOSING
|
| 401 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 402 |
+
"document_ids": [pricing["id"]], "deck_length": "short",
|
| 403 |
+
"instructions": "Pricing, with a slide for beginners"}, headers=auth).json()
|
| 404 |
+
run_jobs()
|
| 405 |
+
slides = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["deck"]["slides"]
|
| 406 |
+
assert len(requests) == 2 and "a slide for beginners" in requests[1] and "pricing" not in requests[1].split("supports: ")[1]
|
| 407 |
+
assert any(s["heading"] == "Pricing for beginners" for s in slides)
|
| 408 |
+
assert client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 409 |
+
"document_ids": [pricing["id"]], "deck_length": "huge"}, headers=auth).status_code == 422
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_the_closing_is_tried_again_and_a_repeated_heading_goes(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 413 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 414 |
+
_fake_many(monkeypatch, pricing["path"], {})
|
| 415 |
+
outline = _outline(6, ("points", "section", "points", "quote", "points", "two_column"))
|
| 416 |
+
|
| 417 |
+
def with_repeat(**kw):
|
| 418 |
+
out = outline(**kw)
|
| 419 |
+
out["outline"]["slides"][1]["heading"] = out["outline"]["slides"][2]["heading"] # a part titled like its slide
|
| 420 |
+
return out
|
| 421 |
+
|
| 422 |
+
llm["OutlineDeck"] = with_repeat
|
| 423 |
+
tries = []
|
| 424 |
+
|
| 425 |
+
def closing(**kw):
|
| 426 |
+
tries.append(1)
|
| 427 |
+
if len(tries) == 1:
|
| 428 |
+
raise RuntimeError("model hiccup")
|
| 429 |
+
return CLOSING
|
| 430 |
+
|
| 431 |
+
llm["WriteClosing"] = closing
|
| 432 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 433 |
+
"document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
|
| 434 |
+
run_jobs()
|
| 435 |
+
slides = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["deck"]["slides"]
|
| 436 |
+
assert len(tries) == 2 and slides[-1]["closing"] == "Price the work like it matters."
|
| 437 |
+
headings = [s["heading"] for s in slides]
|
| 438 |
+
assert len(headings) == len(set(headings)) and 6 <= len(slides) <= 8
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
@pytest.mark.parametrize("key", [("title", "a"), ("title", "c"), ("section", "a"), ("section", "b")])
|
| 442 |
+
def test_light_planets_sit_on_their_ring(key):
|
| 443 |
+
"""A planet beside rings is drawn on one of them, tilt included, whatever the seed."""
|
| 444 |
+
import math
|
| 445 |
+
|
| 446 |
+
from app.slides.decor import build
|
| 447 |
+
|
| 448 |
+
for seed in range(40):
|
| 449 |
+
shapes = build("light-space", *key, seed, 0).shapes
|
| 450 |
+
rings = [s for s in shapes if s.kind == "ring"]
|
| 451 |
+
for p in (s for s in shapes if s.kind == "planet"):
|
| 452 |
+
def off(r, p=p): # how far the planet's centre is from the ring's line, in units of the ring
|
| 453 |
+
rot = math.radians(-r.rot)
|
| 454 |
+
dx, dy = p.cx - r.cx, p.cy - r.cy
|
| 455 |
+
x, y = dx * math.cos(rot) - dy * math.sin(rot), dx * math.sin(rot) + dy * math.cos(rot)
|
| 456 |
+
return abs(math.hypot(x / r.rx, y / r.ry) - 1)
|
| 457 |
+
assert min(off(r) for r in rings) < 1e-6, (key, seed)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
@pytest.mark.parametrize("key", [("title", "b"), ("points", "a")])
|
| 461 |
+
def test_dark_rings_wrap_round_their_planet(key):
|
| 462 |
+
"""A ringed planet's far half is drawn before the planet (hidden behind it) and the near half after (in front)."""
|
| 463 |
+
from app.slides.decor import FAR, NEAR, build
|
| 464 |
+
|
| 465 |
+
for seed in range(10):
|
| 466 |
+
shapes = build("dark-space", *key, seed, 0).shapes
|
| 467 |
+
at = next(i for i, s in enumerate(shapes) if s.kind == "planet")
|
| 468 |
+
rings = [(i, s) for i, s in enumerate(shapes) if s.kind in ("ring", "band")]
|
| 469 |
+
assert rings and all(s.arc for _, s in rings)
|
| 470 |
+
assert all(i < at for i, s in rings if s.arc == FAR) and all(i > at for i, s in rings if s.arc == NEAR)
|
| 471 |
+
assert all(s.rx * s.inner > shapes[at].r for _, s in rings if s.kind == "band") # the band clears the planet
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
@pytest.mark.parametrize("theme", ["dark-space", "light-space"])
|
| 475 |
+
def test_every_variant_marks_its_editable_text(theme):
|
| 476 |
+
"""Every piece of a slide's own text is drawn in a span the editor can find, with the field's real limit; numbers
|
| 477 |
+
and footers are not editable."""
|
| 478 |
+
from app.slides.content import field_limit
|
| 479 |
+
|
| 480 |
+
for fmt in ("detailed", "presenter"):
|
| 481 |
+
for layout, variants in GEOMETRY[theme].items():
|
| 482 |
+
for v in variants:
|
| 483 |
+
deck = clamp_deck({"title": "T", "slides": [{"layout": layout, **FULL[layout]}]}, fmt)
|
| 484 |
+
deck["slides"][0]["variant"] = v
|
| 485 |
+
html = slide_html(deck, 0, theme, fmt, 3)
|
| 486 |
+
found = dict(re.findall(r'data-f="([^"]+)" data-max="(\d+)"', html))
|
| 487 |
+
assert "heading" in found or layout in ("quote", "closing"), (layout, v)
|
| 488 |
+
assert all(int(n) == field_limit(layout, f, fmt) for f, n in found.items())
|
| 489 |
+
s = deck["slides"][0]
|
| 490 |
+
want = {"points": [f"points.{i}.text" for i in range(len(s["points"]))], "stat": ["stat.value", "stat.label"],
|
| 491 |
+
"quote": ["quote.text", "quote.by"], "two_column": ["left.label", "right.items.1"],
|
| 492 |
+
"closing": [f"takeaways.{i}" for i in range(len(s["takeaways"]))] + ["closing"]}.get(layout, [])
|
| 493 |
+
assert set(want) <= set(found), (fmt, layout, v, found)
|
| 494 |
+
assert not any(f.startswith("footer") for f in found)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _ready_deck(client, auth, run_jobs, monkeypatch, llm): # noqa: F811
|
| 498 |
+
pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
|
| 499 |
+
_fake_many(monkeypatch, pricing["path"], {})
|
| 500 |
+
llm["OutlineDeck"] = _outline(5)
|
| 501 |
+
llm["WriteClosing"] = CLOSING
|
| 502 |
+
art = client.post("/api/artifacts/generate", json={"type": "slide_deck", "notebook_id": nb["id"],
|
| 503 |
+
"document_ids": [pricing["id"]], "deck_length": "short"}, headers=auth).json()
|
| 504 |
+
run_jobs()
|
| 505 |
+
return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def test_edited_deck_is_saved_fitted_and_downloaded(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 509 |
+
done = _ready_deck(client, auth, run_jobs, monkeypatch, llm)
|
| 510 |
+
slides = copy.deepcopy(done["content"]["deck"]["slides"])
|
| 511 |
+
assert len(done["content"]["slide_slots"]) == len(slides) and "heading" in done["content"]["slide_slots"][0]
|
| 512 |
+
pi = next(i for i, s in enumerate(slides) if s["points"] and s["layout"] == "points")
|
| 513 |
+
slides[0]["heading"] = "A brand new title"
|
| 514 |
+
slides[0]["notes"] = "Say hello first."
|
| 515 |
+
slides[pi]["points"][0]["text"] = "Edited point " + "very long " * 60
|
| 516 |
+
slides[-1]["takeaways"][1] = " An <b>edited</b> takeaway "
|
| 517 |
+
url = f"/api/artifacts/{done['id']}/deck"
|
| 518 |
+
saved = client.put(url, json={"slides": slides}, headers=auth)
|
| 519 |
+
assert saved.status_code == 200, saved.text
|
| 520 |
+
after = saved.json()["content"]
|
| 521 |
+
s = after["deck"]["slides"]
|
| 522 |
+
assert s[0]["heading"] == "A brand new title" and s[0]["notes"] == "Say hello first."
|
| 523 |
+
assert s[pi]["points"][0]["text"].startswith("Edited point") and len(s[pi]["points"][0]["text"]) <= LIMITS["detailed"]["text"]
|
| 524 |
+
assert s[-1]["takeaways"][1] == "An edited takeaway" # plain text only
|
| 525 |
+
before = done["content"]["deck"]["slides"]
|
| 526 |
+
assert [(x["layout"], x["variant"]) for x in s] == [(x["layout"], x["variant"]) for x in before] # designs kept
|
| 527 |
+
assert "A brand new title" in after["slides_html"][0]
|
| 528 |
+
pptx = client.get(f"/api/artifacts/{done['id']}/slides.pptx", headers=auth)
|
| 529 |
+
texts = [sh.text_frame.text for sh in Presentation(io.BytesIO(pptx.content)).slides[0].shapes if sh.has_text_frame]
|
| 530 |
+
assert any("A brand new title" in t for t in texts)
|
| 531 |
+
|
| 532 |
+
assert client.put(url, json={"slides": []}, headers=auth).status_code == 422
|
| 533 |
+
assert client.put(url, json={"slides": [{"layout": "points", "heading": ""}]}, headers=auth).status_code == 400
|
| 534 |
+
assert client.put(url, json={"slides": [slides[0]] * 31}, headers=auth).status_code == 400
|
| 535 |
+
kept = client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]["deck"]["slides"]
|
| 536 |
+
assert kept[0]["heading"] == "A brand new title" and len(kept) == len(slides) # a refused save changes nothing
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def test_slides_can_be_added_removed_moved_and_change_layout(client, auth, run_jobs, feed, llm, monkeypatch, no_chrome): # noqa: F811
|
| 540 |
+
done = _ready_deck(client, auth, run_jobs, monkeypatch, llm)
|
| 541 |
+
slides = copy.deepcopy(done["content"]["deck"]["slides"])
|
| 542 |
+
n = len(slides)
|
| 543 |
+
pi = next(i for i, s in enumerate(slides) if s["layout"] == "points")
|
| 544 |
+
edited = copy.deepcopy(slides)
|
| 545 |
+
edited[pi]["points"].append({"term": "", "text": "A point added by hand"}) # a text box added
|
| 546 |
+
edited[pi]["lead"] = "" # and one removed
|
| 547 |
+
stat = {"layout": "stat", "variant": "", "heading": "A new stat slide", "stat": {"value": "42%", "label": "Readers who stayed"}}
|
| 548 |
+
edited.insert(1, stat) # a slide added
|
| 549 |
+
edited.insert(2, copy.deepcopy(edited[0]) | {"variant": ""}) # a slide duplicated
|
| 550 |
+
del edited[-2] # a slide removed
|
| 551 |
+
edited[-1], edited[-2] = edited[-2], edited[-1] # two moved
|
| 552 |
+
url = f"/api/artifacts/{done['id']}"
|
| 553 |
+
prev = client.post(f"{url}/deck-preview", json={"slides": edited}, headers=auth)
|
| 554 |
+
assert prev.status_code == 200, prev.text
|
| 555 |
+
got = prev.json()
|
| 556 |
+
out = got["slides"]
|
| 557 |
+
assert len(out) == n + 1 and len(got["slides_html"]) == n + 1 and len(got["slide_slots"]) == n + 1
|
| 558 |
+
assert out[1]["layout"] == "stat" and out[1]["variant"] and "42%" in got["slides_html"][1]
|
| 559 |
+
assert "value" in got["slide_slots"][1] and "label" in got["slide_slots"][1]
|
| 560 |
+
moved = next(s for s in out if s["heading"] == slides[pi]["heading"])
|
| 561 |
+
assert moved["points"][-1]["text"] == "A point added by hand" and moved["lead"] == ""
|
| 562 |
+
pairs = [(x["layout"], x["variant"]) for x in out]
|
| 563 |
+
assert all(a != b for a, b in zip(pairs, pairs[1:], strict=False)) # no two alike in a row
|
| 564 |
+
assert client.get(url, headers=auth).json()["content"]["deck"]["slides"] == done["content"]["deck"]["slides"] # preview saves nothing
|
| 565 |
+
|
| 566 |
+
# a layout change: the points slide becomes a quote slide
|
| 567 |
+
q = next(i for i, s in enumerate(edited) if s["layout"] == "points")
|
| 568 |
+
edited[q] = {**edited[q], "layout": "quote", "variant": "", "quote": {"text": "Price the work like it matters.", "by": "A writer"}}
|
| 569 |
+
saved = client.put(f"{url}/deck", json={"slides": edited}, headers=auth).json()["content"]
|
| 570 |
+
assert saved["deck"]["slides"][q]["layout"] == "quote" and saved["deck"]["slides"][q]["variant"]
|
| 571 |
+
assert len(saved["deck"]["slides"]) == n + 1
|
| 572 |
+
pptx = client.get(f"{url}/slides.pptx", headers=auth)
|
| 573 |
+
assert len(Presentation(io.BytesIO(pptx.content)).slides) == n + 1
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def test_fill_variants_keeps_good_variants_and_fixes_the_rest():
|
| 577 |
+
from app.slides.build import fill_variants
|
| 578 |
+
|
| 579 |
+
deck = clamp_deck({"title": "T", "slides": [{"layout": lay, **FULL[lay]} for lay in ("title", "points", "points", "stat", "closing")]},
|
| 580 |
+
"detailed")
|
| 581 |
+
assign_variants(deck["slides"], "dark-space", seed=3)
|
| 582 |
+
before = [s["variant"] for s in deck["slides"]]
|
| 583 |
+
fill_variants(deck["slides"], "dark-space", 3)
|
| 584 |
+
assert [s["variant"] for s in deck["slides"]] == before # nothing to fix: nothing changes
|
| 585 |
+
deck["slides"][3]["variant"] = "" # new
|
| 586 |
+
deck["slides"][2]["variant"] = deck["slides"][1]["variant"] # the same as the slide before it
|
| 587 |
+
fill_variants(deck["slides"], "dark-space", 3)
|
| 588 |
+
assert deck["slides"][3]["variant"] in VARIANTS["stat"]
|
| 589 |
+
assert deck["slides"][2]["variant"] != deck["slides"][1]["variant"]
|
| 590 |
+
assert [s["variant"] for s in deck["slides"]][:2] == before[:2] and deck["slides"][4]["variant"] == before[4]
|
backend/tests/test_social_posting.py
CHANGED
|
@@ -47,8 +47,8 @@ def account(db, ws, platform="x", scopes=None, expires_in=None, refresh=None, ex
|
|
| 47 |
return acct
|
| 48 |
|
| 49 |
|
| 50 |
-
def item(db, ws, acct, minutes=60, status="scheduled", **kw):
|
| 51 |
-
it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content=
|
| 52 |
scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
|
| 53 |
db.add(it)
|
| 54 |
db.commit()
|
|
@@ -552,3 +552,171 @@ def test_uploaded_files_carry_a_preview_link(client, auth, ws, db_session):
|
|
| 552 |
kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
|
| 553 |
listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
|
| 554 |
assert listed[str(kit.id)]["view_url"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
return acct
|
| 48 |
|
| 49 |
|
| 50 |
+
def item(db, ws, acct, minutes=60, status="scheduled", content="Hello", **kw):
|
| 51 |
+
it = CalendarItem(workspace_id=ws.id, platform=acct.platform, content=content, social_account_id=acct.id,
|
| 52 |
scheduled_at=datetime.now(UTC) + timedelta(minutes=minutes), status=status, **kw)
|
| 53 |
db.add(it)
|
| 54 |
db.commit()
|
|
|
|
| 552 |
kit = made(db_session, ws, "launch_kit", post_title="Kit", posts={"linkedin": ["Hi"]})
|
| 553 |
listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
|
| 554 |
assert listed[str(kit.id)]["view_url"] is None
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def test_a_slide_deck_is_listed_and_posts_its_slides_as_images(client, auth, ws, db_session, monkeypatch):
|
| 558 |
+
from app.services.social import media
|
| 559 |
+
from app.slides import export
|
| 560 |
+
from app.slides.build import fit
|
| 561 |
+
from app.slides.content import clamp_deck
|
| 562 |
+
|
| 563 |
+
raw = {"title": "Pricing for writers", "subtitle": "What two years taught us.", "slides": [
|
| 564 |
+
{"layout": "title", "heading": "Pricing for writers", "lead": "What two years taught us."},
|
| 565 |
+
*[{"layout": "section", "heading": f"Part {i}"} for i in range(5)],
|
| 566 |
+
{"layout": "closing", "heading": "Key takeaways", "takeaways": ["Charge more.", "Readers take it seriously."]}]}
|
| 567 |
+
deck = fit(clamp_deck(raw, "detailed"), "dark-space", "detailed", 3)
|
| 568 |
+
art = Artifact(workspace_id=ws.id, type="slide_deck", status="ready",
|
| 569 |
+
content_json={"title": "Slide deck: Pricing for writers", "theme": "dark-space", "format": "detailed",
|
| 570 |
+
"seed": "abc", "deck": deck})
|
| 571 |
+
db_session.add(art)
|
| 572 |
+
db_session.commit()
|
| 573 |
+
listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[str(art.id)]
|
| 574 |
+
assert listed["type"] == "slide_deck" and listed["media"] == "image" and listed["media_count"] == 7
|
| 575 |
+
assert listed["title"] == "Pricing for writers"
|
| 576 |
+
assert listed["prefill"]["linkedin"].startswith("Pricing for writers\n\nWhat two years taught us.")
|
| 577 |
+
assert "- Charge more." in listed["prefill"]["linkedin"]
|
| 578 |
+
|
| 579 |
+
drawn: list[int] = []
|
| 580 |
+
|
| 581 |
+
def chrome(html, args, out): # a screenshot of the size asked for: a batch of slides stacked
|
| 582 |
+
import io
|
| 583 |
+
|
| 584 |
+
from PIL import Image
|
| 585 |
+
|
| 586 |
+
w, h = (int(v) for v in next(a for a in args if a.startswith("--window-size=")).split("=")[1].split(","))
|
| 587 |
+
drawn.append(h // 1080)
|
| 588 |
+
buf = io.BytesIO()
|
| 589 |
+
Image.new("RGB", (w, h), "navy").save(buf, "PNG")
|
| 590 |
+
return buf.getvalue(), ""
|
| 591 |
+
|
| 592 |
+
monkeypatch.setattr(export, "_run_chrome", chrome)
|
| 593 |
+
with media.media_for(art, "x") as files: # X takes 4 images: one Chrome run draws them
|
| 594 |
+
assert [f.filename for f in files] == [f"slide-{i}.png" for i in range(1, 5)] and drawn == [4]
|
| 595 |
+
assert all(f.data.startswith(b"\x89PNG") for f in files)
|
| 596 |
+
with media.media_for(art, "linkedin") as files:
|
| 597 |
+
assert len(files) == 7 and all(f.content_type == "image/png" for f in files)
|
| 598 |
+
acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
|
| 599 |
+
assert schedule(client, auth, "linkedin", acct, artifact_id=str(art.id)).status_code == 200
|
| 600 |
+
r = schedule(client, auth, "bluesky", artifact_id=str(art.id))
|
| 601 |
+
assert r.status_code == 400 and "X and LinkedIn" in r.json()["detail"] # images go to X and LinkedIn only
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def test_a_slide_deck_that_cannot_be_drawn_is_retried(ws, db_session, monkeypatch):
|
| 605 |
+
from app.infographics.image import ImageUnavailable
|
| 606 |
+
from app.services.social import SocialError, media
|
| 607 |
+
from app.slides import export
|
| 608 |
+
|
| 609 |
+
art = Artifact(workspace_id=ws.id, type="slide_deck", status="ready", content_json={
|
| 610 |
+
"format": "detailed", "deck": {"title": "T", "slides": [{"layout": "section", "heading": "Hello"}]}})
|
| 611 |
+
db_session.add(art)
|
| 612 |
+
db_session.commit()
|
| 613 |
+
|
| 614 |
+
def no_chrome(*a, **k):
|
| 615 |
+
raise ImageUnavailable("No Chrome")
|
| 616 |
+
|
| 617 |
+
monkeypatch.setattr(export, "_run_chrome", no_chrome)
|
| 618 |
+
with pytest.raises(SocialError, match="could not be drawn") as err, media.media_for(art, "x"):
|
| 619 |
+
pass
|
| 620 |
+
assert not err.value.permanent
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def test_an_audio_overview_is_listed_to_download_but_never_posted(client, auth, ws, db_session):
|
| 624 |
+
audio = made(db_session, ws, "audio_overview", key="ws/a/overview.mp3", title="Audio overview: Pricing", duration_s=225)
|
| 625 |
+
listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}[str(audio.id)]
|
| 626 |
+
assert listed["media"] == "audio" and listed["media_count"] == 0 and listed["duration_s"] == 225
|
| 627 |
+
assert listed["download_url"]
|
| 628 |
+
acct = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
|
| 629 |
+
r = schedule(client, auth, "linkedin", acct, artifact_id=str(audio.id))
|
| 630 |
+
assert r.status_code == 400 and "Audio can't be posted" in r.json()["detail"]
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
def test_a_video_or_image_can_go_out_without_a_caption(client, auth, ws, db_session, monkeypatch):
|
| 634 |
+
"""An upload starts with no words (they are the writer's to add): a post that carries media may stay that way.
|
| 635 |
+
Without media, a post still needs words."""
|
| 636 |
+
from app.services import launchpad
|
| 637 |
+
from app.services.social import linkedin, x
|
| 638 |
+
|
| 639 |
+
li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
|
| 640 |
+
upload = made(db_session, ws, "upload", key="ws/u/clip.mp4", data=os.urandom(100_000), media="video",
|
| 641 |
+
content_type="video/mp4", filename="clip.mp4")
|
| 642 |
+
r = schedule(client, auth, "linkedin", li, content="", artifact_id=str(upload.id))
|
| 643 |
+
assert r.status_code == 200, r.text
|
| 644 |
+
r = schedule(client, auth, "linkedin", li, content=" ")
|
| 645 |
+
assert r.status_code == 422 and r.json()["detail"]["message"] == "Write something to post, or attach an image or video."
|
| 646 |
+
created = schedule(client, auth, "linkedin", li, content="Words", artifact_id=str(upload.id)).json()
|
| 647 |
+
r = client.patch(f"/api/calendar/{created['id']}", json={"content": "", "artifact_id": None}, headers=auth)
|
| 648 |
+
assert r.status_code == 422 and r.json()["detail"]["code"] == "no_text" # emptied and no media left
|
| 649 |
+
|
| 650 |
+
# Publishing: LinkedIn gets an empty caption, X a post with the media and no "text" at all.
|
| 651 |
+
sent: dict = {}
|
| 652 |
+
monkeypatch.setattr(linkedin, "_upload_video", lambda acct, m: "urn:li:video:1")
|
| 653 |
+
monkeypatch.setattr(linkedin, "_call", lambda method, url, **kw: sent.setdefault("li", kw["json"]) and httpx.Response(
|
| 654 |
+
201, headers={"x-restli-id": "urn:li:share:1"}, request=httpx.Request(method, url)))
|
| 655 |
+
li_item = item(db_session, ws, li, minutes=0, content="", artifact_id=upload.id)
|
| 656 |
+
launchpad.publish_item(db_session, li_item)
|
| 657 |
+
assert li_item.status == "posted" and sent["li"]["commentary"] == "" and sent["li"]["content"]["media"]["id"]
|
| 658 |
+
|
| 659 |
+
xa = account(db_session, ws, "x", scopes="tweet.write media.write")
|
| 660 |
+
monkeypatch.setattr(x, "can_post_media", lambda a: True)
|
| 661 |
+
monkeypatch.setattr(x, "_upload_video", lambda headers, m: "m1")
|
| 662 |
+
monkeypatch.setattr(x, "_access", lambda db, a: "token")
|
| 663 |
+
monkeypatch.setattr(x, "_call", lambda method, url, **kw: sent.setdefault("x", kw["json"]) and httpx.Response(
|
| 664 |
+
201, json={"data": {"id": "t1"}}, request=httpx.Request(method, url)))
|
| 665 |
+
x_item = item(db_session, ws, xa, minutes=0, content="", artifact_id=upload.id)
|
| 666 |
+
launchpad.publish_item(db_session, x_item)
|
| 667 |
+
assert x_item.status == "posted" and sent["x"] == {"media": {"media_ids": ["m1"]}}
|
| 668 |
+
|
| 669 |
+
text_only = item(db_session, ws, xa, minutes=0, content="")
|
| 670 |
+
launchpad.publish_item(db_session, text_only)
|
| 671 |
+
assert text_only.status == "failed" and text_only.error == "Nothing to post."
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def test_post_now_publishes_in_the_same_request_and_is_never_left_scheduled(client, auth, ws, db_session, monkeypatch):
|
| 675 |
+
from app.models import Job
|
| 676 |
+
from app.services.social import linkedin
|
| 677 |
+
|
| 678 |
+
li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
|
| 679 |
+
monkeypatch.setattr(linkedin, "publish", lambda db, a, posts, media=None: ("urn:li:share:9", "https://linkedin.com/x"))
|
| 680 |
+
now = lambda **kw: client.post("/api/calendar", json={ # noqa: E731 (no time sent: the server takes it)
|
| 681 |
+
"platform": "linkedin", "content": "Hello", "social_account_id": str(li.id), "publish_now": True, **kw}, headers=auth)
|
| 682 |
+
|
| 683 |
+
text = now()
|
| 684 |
+
assert text.status_code == 200, text.text
|
| 685 |
+
assert text.json()["status"] == "posted" and text.json()["external_url"] == "https://linkedin.com/x"
|
| 686 |
+
|
| 687 |
+
video = made(db_session, ws, "video", key="ws/v/now.mp4", data=os.urandom(100_000), duration_s=30)
|
| 688 |
+
media = now(artifact_id=str(video.id)).json()
|
| 689 |
+
assert media["status"] == "publishing" # uploading goes through a job: never "scheduled" in between
|
| 690 |
+
job = db_session.query(Job).filter(Job.kind == "publish_post").one()
|
| 691 |
+
assert job.params["item_id"] == media["id"]
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def test_saving_a_failed_or_draft_post_reschedules_it_at_a_time_still_ahead(client, auth, ws, db_session):
|
| 695 |
+
li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
|
| 696 |
+
year = datetime.now(UTC).year + 1
|
| 697 |
+
for status in ("failed", "draft", "reminded"):
|
| 698 |
+
it = item(db_session, ws, li, minutes=-90, status=status)
|
| 699 |
+
# Its old time has passed: rescheduled there, it would go out at the very next run.
|
| 700 |
+
r = client.patch(f"/api/calendar/{it.id}", json={"status": "scheduled"}, headers=auth)
|
| 701 |
+
assert r.status_code == 409 and r.json()["detail"]["code"] == "missed", status
|
| 702 |
+
r = client.patch(f"/api/calendar/{it.id}", json={"status": "scheduled", "local_time": f"{year}-01-05T10:30",
|
| 703 |
+
"timezone": "UTC"}, headers=auth)
|
| 704 |
+
assert r.status_code == 200, r.text
|
| 705 |
+
got = r.json()
|
| 706 |
+
assert got["status"] == "scheduled" and got["error"] is None
|
| 707 |
+
assert datetime.fromisoformat(got["scheduled_at"]) == datetime(year, 1, 5, 10, 30, tzinfo=UTC)
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def test_the_list_says_what_each_item_already_has_scheduled(client, auth, ws, db_session):
|
| 711 |
+
li = account(db_session, ws, "linkedin", scopes=LI_SCOPES)
|
| 712 |
+
deck = made(db_session, ws, "summary", summary="Raise prices once a year.")
|
| 713 |
+
video = made(db_session, ws, "video", key="ws/v/s.mp4", data=os.urandom(100_000), duration_s=30)
|
| 714 |
+
later = item(db_session, ws, li, minutes=600, artifact_id=deck.id)
|
| 715 |
+
sooner = item(db_session, ws, li, minutes=120, artifact_id=deck.id)
|
| 716 |
+
item(db_session, ws, li, minutes=-60, artifact_id=deck.id) # its time has passed
|
| 717 |
+
item(db_session, ws, li, minutes=300, status="posted", artifact_id=deck.id)
|
| 718 |
+
item(db_session, ws, li, minutes=300, status="failed", artifact_id=deck.id)
|
| 719 |
+
listed = {a["id"]: a for a in client.get("/api/launchpad/postable", headers=auth).json()}
|
| 720 |
+
assert [s["id"] for s in listed[str(deck.id)]["scheduled"]] == [str(sooner.id), str(later.id)]
|
| 721 |
+
assert listed[str(deck.id)]["scheduled"][0]["platform_label"] == "LinkedIn"
|
| 722 |
+
assert listed[str(video.id)]["scheduled"] == []
|
frontend/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { Navigate, Route, Routes
|
| 2 |
import AppShell from "./components/AppShell";
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
|
@@ -8,6 +8,7 @@ import AuthPage from "./pages/AuthPage";
|
|
| 8 |
import { Blog, BlogPostPage } from "./pages/Blog";
|
| 9 |
import Landing from "./pages/Landing";
|
| 10 |
import Archive from "./pages/Archive";
|
|
|
|
| 11 |
import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
|
| 12 |
import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
|
| 13 |
import MissionControl from "./pages/MissionControl";
|
|
@@ -64,7 +65,7 @@ export default function App() {
|
|
| 64 |
<Route path="reports/:id" element={<ReportPage />} />
|
| 65 |
<Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
|
| 66 |
<Route path="videos/new" element={<VideoCreate />} />
|
| 67 |
-
<Route path="videos/voices" element={<Navigate to="/app/
|
| 68 |
<Route path="videos/:id" element={<VideoEditor />} />
|
| 69 |
<Route path="sources" element={<Sources />} />
|
| 70 |
<Route path="map" element={<TopicMap />} />
|
|
@@ -76,6 +77,7 @@ export default function App() {
|
|
| 76 |
<Route path="launchpad/kits" element={<LaunchKits />} />
|
| 77 |
<Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
|
| 78 |
<Route path="archive" element={<Archive />} />
|
|
|
|
| 79 |
<Route path="resurface" element={<Resurface />} />
|
| 80 |
<Route path="settings" element={<Settings />} />
|
| 81 |
<Route path="*" element={<MissionControl />} />
|
|
@@ -87,9 +89,7 @@ export default function App() {
|
|
| 87 |
);
|
| 88 |
}
|
| 89 |
|
| 90 |
-
/** The old Voice page (/app/voice
|
| 91 |
function VoiceRedirect() {
|
| 92 |
-
|
| 93 |
-
const writing = params.get("step") === "writing";
|
| 94 |
-
return <Navigate to={`/app/archive?tab=voices${writing ? "&step=writing" : ""}`} replace />;
|
| 95 |
}
|
|
|
|
| 1 |
+
import { Navigate, Route, Routes } from "react-router-dom";
|
| 2 |
import AppShell from "./components/AppShell";
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
|
|
|
| 8 |
import { Blog, BlogPostPage } from "./pages/Blog";
|
| 9 |
import Landing from "./pages/Landing";
|
| 10 |
import Archive from "./pages/Archive";
|
| 11 |
+
import Voices from "./pages/Voices";
|
| 12 |
import Launchpad, { ScheduleLaunch } from "./pages/Launchpad";
|
| 13 |
import LaunchKitRedirect, { LaunchKitPage, LaunchKits } from "./pages/LaunchKit";
|
| 14 |
import MissionControl from "./pages/MissionControl";
|
|
|
|
| 65 |
<Route path="reports/:id" element={<ReportPage />} />
|
| 66 |
<Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
|
| 67 |
<Route path="videos/new" element={<VideoCreate />} />
|
| 68 |
+
<Route path="videos/voices" element={<Navigate to="/app/voices" replace />} />
|
| 69 |
<Route path="videos/:id" element={<VideoEditor />} />
|
| 70 |
<Route path="sources" element={<Sources />} />
|
| 71 |
<Route path="map" element={<TopicMap />} />
|
|
|
|
| 77 |
<Route path="launchpad/kits" element={<LaunchKits />} />
|
| 78 |
<Route path="launchpad/kits/:id" element={<LaunchKitPage />} />
|
| 79 |
<Route path="archive" element={<Archive />} />
|
| 80 |
+
<Route path="voices" element={<Voices />} />
|
| 81 |
<Route path="resurface" element={<Resurface />} />
|
| 82 |
<Route path="settings" element={<Settings />} />
|
| 83 |
<Route path="*" element={<MissionControl />} />
|
|
|
|
| 89 |
);
|
| 90 |
}
|
| 91 |
|
| 92 |
+
/** The old Voice page (/app/voice) is Manage voices (/app/voices) now. */
|
| 93 |
function VoiceRedirect() {
|
| 94 |
+
return <Navigate to="/app/voices" replace />;
|
|
|
|
|
|
|
| 95 |
}
|
frontend/src/api/client.ts
CHANGED
|
@@ -63,7 +63,12 @@ async function parseError(res: Response): Promise<ApiError> {
|
|
| 63 |
const body = await res.json();
|
| 64 |
const detail = body.detail;
|
| 65 |
if (typeof detail === "string") message = detail;
|
| 66 |
-
else if (detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
message = detail.message;
|
| 68 |
code = detail.code;
|
| 69 |
extra = detail;
|
|
|
|
| 63 |
const body = await res.json();
|
| 64 |
const detail = body.detail;
|
| 65 |
if (typeof detail === "string") message = detail;
|
| 66 |
+
else if (Array.isArray(detail) && detail.length) {
|
| 67 |
+
// A request the server's field checks refused (FastAPI's 422 list): say which field and why, not "Unprocessable Entity".
|
| 68 |
+
const first = detail[0] as { loc?: (string | number)[]; msg?: string };
|
| 69 |
+
const field = (first.loc ?? []).filter((p) => p !== "body").join(" ");
|
| 70 |
+
if (first.msg) message = field ? `${field}: ${first.msg}` : first.msg;
|
| 71 |
+
} else if (detail?.message) {
|
| 72 |
message = detail.message;
|
| 73 |
code = detail.code;
|
| 74 |
extra = detail;
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -116,7 +116,9 @@ export type GenerateBody = {
|
|
| 116 |
document_id?: string;
|
| 117 |
/** No notebook or post: use the "All posts" notebook. */
|
| 118 |
archive?: boolean;
|
| 119 |
-
format?: "deep_dive" | "brief" | "debate";
|
|
|
|
|
|
|
| 120 |
minutes?: number;
|
| 121 |
style?: "audiogram";
|
| 122 |
audio_artifact_id?: string;
|
|
@@ -139,8 +141,11 @@ export type GenerateBody = {
|
|
| 139 |
report_format?: "document" | "interactive";
|
| 140 |
template_id?: string;
|
| 141 |
instructions?: string;
|
| 142 |
-
/** infographic: one of the premade themes (see components/Infographic.tsx). */
|
| 143 |
theme?: string;
|
|
|
|
|
|
|
|
|
|
| 144 |
};
|
| 145 |
|
| 146 |
export const artifactsApi = {
|
|
@@ -154,6 +159,33 @@ export const artifactsApi = {
|
|
| 154 |
/** An infographic drawn as a PNG. */
|
| 155 |
image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`),
|
| 156 |
retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
};
|
| 158 |
|
| 159 |
export const jobsApi = {
|
|
@@ -217,6 +249,8 @@ export type ItemBody = {
|
|
| 217 |
social_account_id?: string | null;
|
| 218 |
remind_by_email?: boolean;
|
| 219 |
draft?: boolean;
|
|
|
|
|
|
|
| 220 |
};
|
| 221 |
|
| 222 |
export const launchpadApi = {
|
|
|
|
| 116 |
document_id?: string;
|
| 117 |
/** No notebook or post: use the "All posts" notebook. */
|
| 118 |
archive?: boolean;
|
| 119 |
+
format?: "deep_dive" | "brief" | "critique" | "debate";
|
| 120 |
+
/** audio_overview: a single narrator or a two host conversation. */
|
| 121 |
+
hosts?: 1 | 2;
|
| 122 |
minutes?: number;
|
| 123 |
style?: "audiogram";
|
| 124 |
audio_artifact_id?: string;
|
|
|
|
| 141 |
report_format?: "document" | "interactive";
|
| 142 |
template_id?: string;
|
| 143 |
instructions?: string;
|
| 144 |
+
/** infographic: one of the premade themes (see components/Infographic.tsx); slide_deck: dark-space or light-space. */
|
| 145 |
theme?: string;
|
| 146 |
+
/** slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides. */
|
| 147 |
+
deck_format?: "detailed" | "presenter";
|
| 148 |
+
deck_length?: "short" | "default" | "long";
|
| 149 |
};
|
| 150 |
|
| 151 |
export const artifactsApi = {
|
|
|
|
| 159 |
/** An infographic drawn as a PNG. */
|
| 160 |
image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`),
|
| 161 |
retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
|
| 162 |
+
/** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
|
| 163 |
+
slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
|
| 164 |
+
/** A slide deck's slides as the editor has them, drawn without saving (after a slide or a text box is added,
|
| 165 |
+
* removed, moved or changed to another layout). Returns them as they will be kept. */
|
| 166 |
+
previewDeck: (id: string, slides: EditSlide[]) =>
|
| 167 |
+
post<{ slides: EditSlide[]; slides_html: string[]; slide_slots: string[][] }>(`/api/artifacts/${id}/deck-preview`, { slides }),
|
| 168 |
+
/** The editor's slides kept: the server fits the deck again and returns it. */
|
| 169 |
+
saveDeck: (id: string, slides: EditSlide[]) => put<Artifact>(`/api/artifacts/${id}/deck`, { slides }),
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
/** One slide in the stored shape (backend app/slides/content.py, empty_slide). */
|
| 173 |
+
export type EditSlide = {
|
| 174 |
+
layout: string;
|
| 175 |
+
variant: string;
|
| 176 |
+
kicker: string;
|
| 177 |
+
heading: string;
|
| 178 |
+
lead: string;
|
| 179 |
+
points: { term: string; text: string }[];
|
| 180 |
+
left: { label: string; items: string[] };
|
| 181 |
+
right: { label: string; items: string[] };
|
| 182 |
+
stat: { value: string; label: string };
|
| 183 |
+
quote: { text: string; by: string };
|
| 184 |
+
takeaways: string[];
|
| 185 |
+
closing: string;
|
| 186 |
+
notes: string;
|
| 187 |
+
sources: { title?: string; path: string; line_start: number; line_end: number }[];
|
| 188 |
+
shrink?: number;
|
| 189 |
};
|
| 190 |
|
| 191 |
export const jobsApi = {
|
|
|
|
| 249 |
social_account_id?: string | null;
|
| 250 |
remind_by_email?: boolean;
|
| 251 |
draft?: boolean;
|
| 252 |
+
/** Post now: published (or reminded) in the same request, never left scheduled. */
|
| 253 |
+
publish_now?: boolean;
|
| 254 |
};
|
| 255 |
|
| 256 |
export const launchpadApi = {
|
frontend/src/api/types.ts
CHANGED
|
@@ -89,7 +89,7 @@ export type AnswerFeedback = { rating: "up" | "down"; reasons: string[]; comment
|
|
| 89 |
export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
|
| 90 |
export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
|
| 91 |
|
| 92 |
-
export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map" | "quiz" | "flashcards" | "report" | "infographic"| "upload";
|
| 93 |
|
| 94 |
export type QuizQuestionType = "multiple_choice" | "multiple_select" | "fill_blank" | "short_answer";
|
| 95 |
export type QuizQuestion = {
|
|
@@ -320,7 +320,8 @@ export type PostableArtifact = {
|
|
| 320 |
type: ArtifactType;
|
| 321 |
type_label: string;
|
| 322 |
title: string;
|
| 323 |
-
|
|
|
|
| 324 |
media_count: number;
|
| 325 |
duration_s: number | null;
|
| 326 |
thumb_url: string | null;
|
|
@@ -332,6 +333,10 @@ export type PostableArtifact = {
|
|
| 332 |
editable_video: boolean;
|
| 333 |
/** An uploaded file's link, to preview it (null for anything else). */
|
| 334 |
view_url: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
created_at: string | null;
|
| 336 |
/** A starting text: X as a thread (list of posts), LinkedIn as one post. */
|
| 337 |
prefill: { x: string[]; linkedin: string };
|
|
|
|
| 89 |
export type WorkspaceChat = ChatSummary & { notebook_id: string; notebook_title: string };
|
| 90 |
export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[]; feedback?: AnswerFeedback | null };
|
| 91 |
|
| 92 |
+
export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit" | "mind_map" | "quiz" | "flashcards" | "report" | "infographic" | "slide_deck" | "upload";
|
| 93 |
|
| 94 |
export type QuizQuestionType = "multiple_choice" | "multiple_select" | "fill_blank" | "short_answer";
|
| 95 |
export type QuizQuestion = {
|
|
|
|
| 320 |
type: ArtifactType;
|
| 321 |
type_label: string;
|
| 322 |
title: string;
|
| 323 |
+
/** "audio": an audio overview, listed to download only (X and LinkedIn take no audio files). */
|
| 324 |
+
media: "image" | "video" | "audio" | "text";
|
| 325 |
media_count: number;
|
| 326 |
duration_s: number | null;
|
| 327 |
thumb_url: string | null;
|
|
|
|
| 333 |
editable_video: boolean;
|
| 334 |
/** An uploaded file's link, to preview it (null for anything else). */
|
| 335 |
view_url: string | null;
|
| 336 |
+
/** An audio overview's file, saved as a download (null for anything else). */
|
| 337 |
+
download_url?: string | null;
|
| 338 |
+
/** Its posts still to go out (scheduled, ahead), soonest first: the Schedule a launch list offers to reschedule them. */
|
| 339 |
+
scheduled?: { id: string; scheduled_at: string; platform_label: string }[];
|
| 340 |
created_at: string | null;
|
| 341 |
/** A starting text: X as a thread (list of posts), LinkedIn as one post. */
|
| 342 |
prefill: { x: string[]; linkedin: string };
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import UpgradeModal from "./UpgradeModal";
|
|
| 9 |
import {
|
| 10 |
LaunchpadIcon,
|
| 11 |
LibraryIcon,
|
|
|
|
| 12 |
OrbitIcon,
|
| 13 |
PlanetIcon,
|
| 14 |
SatelliteDishIcon,
|
|
@@ -20,6 +21,7 @@ const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean;
|
|
| 20 |
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 21 |
{ to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
|
| 22 |
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
|
|
|
|
| 23 |
];
|
| 24 |
|
| 25 |
const COLLAPSED_KEY = "ns_sidebar_collapsed";
|
|
|
|
| 9 |
import {
|
| 10 |
LaunchpadIcon,
|
| 11 |
LibraryIcon,
|
| 12 |
+
MicIcon,
|
| 13 |
OrbitIcon,
|
| 14 |
PlanetIcon,
|
| 15 |
SatelliteDishIcon,
|
|
|
|
| 21 |
{ to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
|
| 22 |
{ to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
|
| 23 |
{ to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface", "/app/launch-kit"] },
|
| 24 |
+
{ to: "/app/voices", label: "Manage voices", icon: MicIcon },
|
| 25 |
];
|
| 26 |
|
| 27 |
const COLLAPSED_KEY = "ns_sidebar_collapsed";
|
frontend/src/components/ArtifactCard.tsx
CHANGED
|
@@ -68,7 +68,7 @@ export function CitationList({
|
|
| 68 |
);
|
| 69 |
}
|
| 70 |
|
| 71 |
-
function Transcript({ segments, onSeek }: { segments: Segment[]; onSeek: (t: number) => void }) {
|
| 72 |
return (
|
| 73 |
<ol className="transcript">
|
| 74 |
{segments.map((s, i) => (
|
|
|
|
| 68 |
);
|
| 69 |
}
|
| 70 |
|
| 71 |
+
export function Transcript({ segments, onSeek }: { segments: Segment[]; onSeek: (t: number) => void }) {
|
| 72 |
return (
|
| 73 |
<ol className="transcript">
|
| 74 |
{segments.map((s, i) => (
|
frontend/src/components/ArtifactRow.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { artifactsApi, videosApi } from "../api/endpoints";
|
|
| 4 |
import type { Artifact, Citation } from "../api/types";
|
| 5 |
import { useJob } from "../hooks/useJob";
|
| 6 |
import { Body } from "./ArtifactCard";
|
|
|
|
| 7 |
import { TrashIcon } from "./icons/Icons";
|
| 8 |
import { errorMessage, formatDate, formatDuration, JobProgress } from "./ui";
|
| 9 |
|
|
@@ -14,7 +15,8 @@ function statusOf(a: Artifact, running: boolean): { label: string; tone: "done"
|
|
| 14 |
}
|
| 15 |
|
| 16 |
/** One generated item as a full-width row (like the Videos list): its type, title, status and date. A video opens its
|
| 17 |
-
* editor
|
|
|
|
| 18 |
export function ArtifactRow({
|
| 19 |
artifact: initial,
|
| 20 |
onCite,
|
|
@@ -41,6 +43,8 @@ export function ArtifactRow({
|
|
| 41 |
const running = !!job && (job.status === "queued" || job.status === "running");
|
| 42 |
const st = statusOf(artifact, running);
|
| 43 |
const isEditorVideo = artifact.provider === "blog2video";
|
|
|
|
|
|
|
| 44 |
|
| 45 |
useEffect(() => {
|
| 46 |
if (!armed) return;
|
|
@@ -73,7 +77,8 @@ export function ArtifactRow({
|
|
| 73 |
<>
|
| 74 |
<span className="vw-list-title">
|
| 75 |
<span className={`type-chip ${artifact.type}`}>{artifact.type_label}</span>
|
| 76 |
-
|
|
|
|
| 77 |
<span className={`vw-dot ${st.tone}`} aria-hidden="true" />
|
| 78 |
<span className="muted">{st.label}</span>
|
| 79 |
</span>
|
|
@@ -90,12 +95,17 @@ export function ArtifactRow({
|
|
| 90 |
<div className="artifact-row-head">
|
| 91 |
{isEditorVideo ? (
|
| 92 |
<Link to={`/app/videos/${artifact.id}`} className="vw-list-main">{main}</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
) : (
|
| 94 |
<button type="button" className="vw-list-main artifact-row-toggle" aria-expanded={openRow}
|
| 95 |
onClick={() => setOpenRow((o) => !o)}>
|
| 96 |
{main}
|
| 97 |
</button>
|
| 98 |
)}
|
|
|
|
| 99 |
{artifact.status === "failed" && (
|
| 100 |
<button type="button" className="btn btn-small" onClick={retry}>Retry</button>
|
| 101 |
)}
|
|
@@ -109,7 +119,17 @@ export function ArtifactRow({
|
|
| 109 |
</div>
|
| 110 |
{running && job && <JobProgress job={job} compact />}
|
| 111 |
{error && <p className="error-text">{error}</p>}
|
| 112 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
<div className="artifact-row-body">
|
| 114 |
{artifact.status === "failed" && artifact.content.error && <p className="error-text">{artifact.content.error}</p>}
|
| 115 |
<Body artifact={artifact} onCite={onCite} />
|
|
|
|
| 4 |
import type { Artifact, Citation } from "../api/types";
|
| 5 |
import { useJob } from "../hooks/useJob";
|
| 6 |
import { Body } from "./ArtifactCard";
|
| 7 |
+
import { SlideDeckViewer } from "./SlideDeck";
|
| 8 |
import { TrashIcon } from "./icons/Icons";
|
| 9 |
import { errorMessage, formatDate, formatDuration, JobProgress } from "./ui";
|
| 10 |
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
/** One generated item as a full-width row (like the Videos list): its type, title, status and date. A video opens its
|
| 18 |
+
* editor, a finished slide deck its viewer (to read, edit, present or download it); anything else expands in place
|
| 19 |
+
* to its player or image. */
|
| 20 |
export function ArtifactRow({
|
| 21 |
artifact: initial,
|
| 22 |
onCite,
|
|
|
|
| 43 |
const running = !!job && (job.status === "queued" || job.status === "running");
|
| 44 |
const st = statusOf(artifact, running);
|
| 45 |
const isEditorVideo = artifact.provider === "blog2video";
|
| 46 |
+
const isDeck = artifact.type === "slide_deck" && artifact.status === "ready";
|
| 47 |
+
const [viewing, setViewing] = useState(false);
|
| 48 |
|
| 49 |
useEffect(() => {
|
| 50 |
if (!armed) return;
|
|
|
|
| 77 |
<>
|
| 78 |
<span className="vw-list-title">
|
| 79 |
<span className={`type-chip ${artifact.type}`}>{artifact.type_label}</span>
|
| 80 |
+
{/* A deck's title starts "Slide deck: ", which its chip already says */}
|
| 81 |
+
<strong>{artifact.type === "slide_deck" ? artifact.title.replace(/^Slide deck: /, "") : artifact.title}</strong>
|
| 82 |
<span className={`vw-dot ${st.tone}`} aria-hidden="true" />
|
| 83 |
<span className="muted">{st.label}</span>
|
| 84 |
</span>
|
|
|
|
| 95 |
<div className="artifact-row-head">
|
| 96 |
{isEditorVideo ? (
|
| 97 |
<Link to={`/app/videos/${artifact.id}`} className="vw-list-main">{main}</Link>
|
| 98 |
+
) : isDeck ? (
|
| 99 |
+
<button type="button" className="vw-list-main artifact-row-toggle" onClick={() => setViewing(true)}>
|
| 100 |
+
{main}
|
| 101 |
+
</button>
|
| 102 |
) : (
|
| 103 |
<button type="button" className="vw-list-main artifact-row-toggle" aria-expanded={openRow}
|
| 104 |
onClick={() => setOpenRow((o) => !o)}>
|
| 105 |
{main}
|
| 106 |
</button>
|
| 107 |
)}
|
| 108 |
+
{isDeck && actions?.(artifact)}
|
| 109 |
{artifact.status === "failed" && (
|
| 110 |
<button type="button" className="btn btn-small" onClick={retry}>Retry</button>
|
| 111 |
)}
|
|
|
|
| 119 |
</div>
|
| 120 |
{running && job && <JobProgress job={job} compact />}
|
| 121 |
{error && <p className="error-text">{error}</p>}
|
| 122 |
+
{viewing && (
|
| 123 |
+
<SlideDeckViewer artifact={artifact} onClose={() => setViewing(false)} onSaved={(a) => {
|
| 124 |
+
setArtifact(a);
|
| 125 |
+
onChanged?.(a);
|
| 126 |
+
}} onDelete={async () => {
|
| 127 |
+
await artifactsApi.remove(artifact.id);
|
| 128 |
+
setViewing(false);
|
| 129 |
+
onRemoved?.(artifact.id);
|
| 130 |
+
}} />
|
| 131 |
+
)}
|
| 132 |
+
{openRow && !isEditorVideo && !isDeck && (
|
| 133 |
<div className="artifact-row-body">
|
| 134 |
{artifact.status === "failed" && artifact.content.error && <p className="error-text">{artifact.content.error}</p>}
|
| 135 |
<Body artifact={artifact} onCite={onCite} />
|
frontend/src/components/AudioOverview.tsx
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState, type RefObject } from "react";
|
| 2 |
+
import { artifactsApi, videoVoicesApi, type GenerateBody } from "../api/endpoints";
|
| 3 |
+
import type { Artifact, ChatSummary, Segment, VideoSavedVoice } from "../api/types";
|
| 4 |
+
import { useJob } from "../hooks/useJob";
|
| 5 |
+
import { Transcript } from "./ArtifactCard";
|
| 6 |
+
import { Dropdown } from "./Dropdown";
|
| 7 |
+
import { CheckIcon, ChevronIcon, HeadphonesIcon } from "./icons/Icons";
|
| 8 |
+
import { LANGUAGES, Pills, SourceFocusFields, type SourceSelection } from "./SourceFocusFields";
|
| 9 |
+
import { ConfirmDeleteModal, errorMessage, JobProgress, Modal, StatusPill } from "./ui";
|
| 10 |
+
|
| 11 |
+
/** The audio styles. The server describes each to the script writer (backend/app/pipeline/media.py STYLES). */
|
| 12 |
+
const STYLES = [
|
| 13 |
+
{ id: "deep_dive", name: "Deep Dive", blurb: "A lively conversation that unpacks the ideas in your sources and connects them." },
|
| 14 |
+
{ id: "brief", name: "Brief", blurb: "A bite sized overview to help you grasp the core ideas from your sources quickly." },
|
| 15 |
+
{ id: "critique", name: "Critique", blurb: "An expert review of your sources, with constructive feedback to help you improve your material." },
|
| 16 |
+
{ id: "debate", name: "Debate", blurb: "A thoughtful debate between two hosts, bringing out different perspectives on your sources." },
|
| 17 |
+
] as const;
|
| 18 |
+
type Style = (typeof STYLES)[number]["id"];
|
| 19 |
+
|
| 20 |
+
const LENGTHS = [
|
| 21 |
+
{ value: "3", label: "Short", hint: "about 3 min" },
|
| 22 |
+
{ value: "6", label: "Default", hint: "about 6 min" },
|
| 23 |
+
{ value: "12", label: "Long", hint: "about 12 min" },
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
export type AudioOverviewRequest = Omit<GenerateBody, "type">;
|
| 27 |
+
|
| 28 |
+
/** The workspace's voices, asked for once (the list takes a few seconds): the notebook asks ahead of time, so the
|
| 29 |
+
* pickers are ready when the settings open. A failure is forgotten, so the next open asks again. */
|
| 30 |
+
let voicesAsked: Promise<VideoSavedVoice[]> | null = null;
|
| 31 |
+
export function prefetchVoices(): Promise<VideoSavedVoice[]> {
|
| 32 |
+
voicesAsked ??= videoVoicesApi.list().then((r) => r.saved).catch((e) => {
|
| 33 |
+
voicesAsked = null;
|
| 34 |
+
throw e;
|
| 35 |
+
});
|
| 36 |
+
return voicesAsked;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** The audio overview settings: a style, one or two hosts and their voices, length, language, the sources, and what
|
| 40 |
+
* the hosts should focus on. */
|
| 41 |
+
export function AudioOverviewDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
|
| 42 |
+
notebookId: string;
|
| 43 |
+
notebookTitle?: string;
|
| 44 |
+
chats: ChatSummary[];
|
| 45 |
+
currentChatId?: string | null;
|
| 46 |
+
onClose: () => void;
|
| 47 |
+
onCreate: (body: AudioOverviewRequest) => void;
|
| 48 |
+
}) {
|
| 49 |
+
const [sel, setSel] = useState<SourceSelection | null>(null);
|
| 50 |
+
const [style, setStyle] = useState<Style>("deep_dive");
|
| 51 |
+
const [hosts, setHosts] = useState<"1" | "2">("2");
|
| 52 |
+
const [minutes, setMinutes] = useState("6");
|
| 53 |
+
const [language, setLanguage] = useState("English");
|
| 54 |
+
const [focus, setFocus] = useState("");
|
| 55 |
+
// The hosts' voices, from the workspace's voices (none to pick when they cannot load: the default voices are used).
|
| 56 |
+
const [voices, setVoices] = useState<VideoSavedVoice[] | null>(null);
|
| 57 |
+
const [voice, setVoice] = useState<[string, string]>(["", ""]);
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
let live = true;
|
| 61 |
+
prefetchVoices().then((saved) => {
|
| 62 |
+
if (!live) return;
|
| 63 |
+
setVoices(saved);
|
| 64 |
+
setVoice([saved[0]?.voice_id ?? "", (saved[1] ?? saved[0])?.voice_id ?? ""]);
|
| 65 |
+
}).catch(() => live && setVoices([]));
|
| 66 |
+
return () => {
|
| 67 |
+
live = false;
|
| 68 |
+
};
|
| 69 |
+
}, []);
|
| 70 |
+
|
| 71 |
+
const two = hosts === "2";
|
| 72 |
+
// A debate needs two hosts: with one, the style moves to Deep Dive.
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
if (!two && style === "debate") setStyle("deep_dive");
|
| 75 |
+
}, [two, style]);
|
| 76 |
+
|
| 77 |
+
/** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
|
| 78 |
+
const pickVoice = (i: 0 | 1, voiceId: string) =>
|
| 79 |
+
setVoice((cur) => {
|
| 80 |
+
const next: [string, string] = [...cur];
|
| 81 |
+
next[i] = voiceId;
|
| 82 |
+
const other = i === 0 ? 1 : 0;
|
| 83 |
+
if (two && next[other] === voiceId) next[other] = voices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
|
| 84 |
+
return next;
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
const voiceOptions = (voices ?? []).map((v) => ({ value: v.voice_id, label: v.name }));
|
| 89 |
+
const create = () => {
|
| 90 |
+
if (!sel) return;
|
| 91 |
+
onCreate({
|
| 92 |
+
...sel.request, format: style, hosts: two ? 2 : 1, minutes: Number(minutes), language, instructions: focus.trim(),
|
| 93 |
+
...(voice[0] ? { host_a: voice[0], ...(two ? { host_b: voice[1] || voice[0] } : {}) } : {}),
|
| 94 |
+
});
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<Modal title="Customize audio overview" onClose={onClose} wide>
|
| 99 |
+
<div className="stack vw-in-modal ao-dialog">
|
| 100 |
+
<section className="field">
|
| 101 |
+
<span className="vw-label">Style</span>
|
| 102 |
+
<div className="ao-styles" role="radiogroup" aria-label="Style">
|
| 103 |
+
{STYLES.map((s) => {
|
| 104 |
+
const off = s.id === "debate" && !two;
|
| 105 |
+
return (
|
| 106 |
+
<button key={s.id} type="button" role="radio" aria-checked={style === s.id} disabled={off}
|
| 107 |
+
className={`ao-style${style === s.id ? " on" : ""}`} onClick={() => setStyle(s.id)}>
|
| 108 |
+
<span className="ao-style-head">
|
| 109 |
+
<strong>{s.name}</strong>
|
| 110 |
+
<span className="ao-radio" aria-hidden="true">{style === s.id && <CheckIcon size={12} />}</span>
|
| 111 |
+
</span>
|
| 112 |
+
<span className="ao-style-blurb">{off ? "Needs two hosts." : s.blurb}</span>
|
| 113 |
+
</button>
|
| 114 |
+
);
|
| 115 |
+
})}
|
| 116 |
+
</div>
|
| 117 |
+
</section>
|
| 118 |
+
|
| 119 |
+
<section className="sd-opts ao-opts">
|
| 120 |
+
<div className="field">
|
| 121 |
+
<span className="vw-label">Hosts</span>
|
| 122 |
+
<Pills<"1" | "2"> label="Hosts" value={hosts} onChange={setHosts}
|
| 123 |
+
options={[{ id: "1", label: "One host" }, { id: "2", label: "Two hosts" }]} />
|
| 124 |
+
</div>
|
| 125 |
+
<div className="field">
|
| 126 |
+
<span className="vw-label">Length</span>
|
| 127 |
+
<Dropdown<string> label="Length" value={minutes} onChange={setMinutes} options={LENGTHS} />
|
| 128 |
+
</div>
|
| 129 |
+
<div className="field">
|
| 130 |
+
<span className="vw-label">Language</span>
|
| 131 |
+
<Dropdown<string> label="Language" value={language} onChange={setLanguage}
|
| 132 |
+
options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
|
| 133 |
+
</div>
|
| 134 |
+
</section>
|
| 135 |
+
|
| 136 |
+
<section className="ao-voices">
|
| 137 |
+
{voices === null && <p className="muted small">Loading your voices...</p>}
|
| 138 |
+
{voices !== null && voices.length === 0 && (
|
| 139 |
+
<p className="muted small">Your voices could not load, so the default {two ? "voices are" : "voice is"} used.</p>
|
| 140 |
+
)}
|
| 141 |
+
{voices !== null && voices.length > 0 && ([0, 1] as const).filter((i) => i === 0 || two).map((i) => (
|
| 142 |
+
<div key={i} className="field">
|
| 143 |
+
<span className="vw-label">{two ? `Host ${i + 1} voice` : "Host voice"}</span>
|
| 144 |
+
<Dropdown<string> label={two ? `Host ${i + 1} voice` : "Host voice"} value={voice[i]} onChange={(v) => pickVoice(i, v)}
|
| 145 |
+
options={voiceOptions} />
|
| 146 |
+
</div>
|
| 147 |
+
))}
|
| 148 |
+
</section>
|
| 149 |
+
|
| 150 |
+
<SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 151 |
+
what="audio overview" noFocus onChange={setSel} />
|
| 152 |
+
|
| 153 |
+
<label className="field">
|
| 154 |
+
<span className="vw-label">What should the AI {two ? "hosts" : "host"} focus on in this episode? <span className="muted">(optional)</span></span>
|
| 155 |
+
<textarea className="input sd-prompt" rows={4} maxLength={4000} value={focus} onChange={(e) => setFocus(e.target.value)}
|
| 156 |
+
placeholder={"- List the core ideas worth remembering\n- Explain how the pieces connect"} />
|
| 157 |
+
</label>
|
| 158 |
+
{!sel?.ready && <p className="muted small">Select at least one post or chat to make an audio overview.</p>}
|
| 159 |
+
<div className="vw-nav">
|
| 160 |
+
<button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready} onClick={create}>
|
| 161 |
+
Generate audio overview
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</Modal>
|
| 166 |
+
);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75];
|
| 170 |
+
|
| 171 |
+
function clock(seconds: number): string {
|
| 172 |
+
const s = Math.max(0, Math.floor(seconds || 0));
|
| 173 |
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/** The audio's own player: play or pause, where it is, a bar to move through it, and the playback speed. */
|
| 177 |
+
function AudioPlayer({ src, duration: known, audioRef }: { src: string; duration?: number; audioRef: RefObject<HTMLAudioElement> }) {
|
| 178 |
+
const [playing, setPlaying] = useState(false);
|
| 179 |
+
const [time, setTime] = useState(0);
|
| 180 |
+
const [duration, setDuration] = useState(known ?? 0);
|
| 181 |
+
const [speed, setSpeed] = useState(1);
|
| 182 |
+
useEffect(() => {
|
| 183 |
+
const a = audioRef.current;
|
| 184 |
+
if (!a) return;
|
| 185 |
+
const sync = () => {
|
| 186 |
+
setTime(a.currentTime);
|
| 187 |
+
if (Number.isFinite(a.duration) && a.duration > 0) setDuration(a.duration);
|
| 188 |
+
setPlaying(!a.paused && !a.ended);
|
| 189 |
+
};
|
| 190 |
+
const events = ["timeupdate", "loadedmetadata", "durationchange", "play", "pause", "ended"];
|
| 191 |
+
events.forEach((e) => a.addEventListener(e, sync));
|
| 192 |
+
return () => events.forEach((e) => a.removeEventListener(e, sync));
|
| 193 |
+
}, [audioRef]);
|
| 194 |
+
const toggle = () => {
|
| 195 |
+
const a = audioRef.current;
|
| 196 |
+
if (!a) return;
|
| 197 |
+
if (a.paused) void a.play();
|
| 198 |
+
else a.pause();
|
| 199 |
+
};
|
| 200 |
+
const nextSpeed = () => {
|
| 201 |
+
const next = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length];
|
| 202 |
+
setSpeed(next);
|
| 203 |
+
if (audioRef.current) audioRef.current.playbackRate = next;
|
| 204 |
+
};
|
| 205 |
+
const pct = duration ? Math.min(100, (time / duration) * 100) : 0;
|
| 206 |
+
return (
|
| 207 |
+
<div className="ao-player">
|
| 208 |
+
<audio ref={audioRef} src={src} preload="metadata" />
|
| 209 |
+
<button type="button" className="ao-play" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
|
| 210 |
+
{playing ? (
|
| 211 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
|
| 212 |
+
) : (
|
| 213 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z" /></svg>
|
| 214 |
+
)}
|
| 215 |
+
</button>
|
| 216 |
+
<span className="ao-time mono">{clock(time)} / {clock(duration)}</span>
|
| 217 |
+
<input className="ao-seek" type="range" min={0} max={duration || 0} step={0.1} value={Math.min(time, duration || 0)}
|
| 218 |
+
aria-label="Position" style={{ ["--ao-fill" as string]: `${pct}%` }}
|
| 219 |
+
onChange={(e) => {
|
| 220 |
+
const a = audioRef.current;
|
| 221 |
+
if (a) a.currentTime = Number(e.target.value);
|
| 222 |
+
setTime(Number(e.target.value));
|
| 223 |
+
}} />
|
| 224 |
+
<button type="button" className="ao-speed mono" onClick={nextSpeed} aria-label={`Playback speed ${speed}x`} title="Playback speed">
|
| 225 |
+
{speed}x
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const STYLE_NAMES: Record<string, string> = Object.fromEntries(STYLES.map((s) => [s.id, s.name]));
|
| 232 |
+
|
| 233 |
+
/** An audio overview in the notebook's list: a row with its title and length that opens (chevron) to its player,
|
| 234 |
+
* transcript, Download and Delete. Shows its progress while it is being made. */
|
| 235 |
+
export function AudioOverviewCard({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
|
| 236 |
+
const [artifact, setArtifact] = useState(initial);
|
| 237 |
+
const [open, setOpen] = useState(false);
|
| 238 |
+
const [transcript, setTranscript] = useState(false);
|
| 239 |
+
const [confirming, setConfirming] = useState(false);
|
| 240 |
+
const [error, setError] = useState<string | null>(null);
|
| 241 |
+
const audio = useRef<HTMLAudioElement>(null);
|
| 242 |
+
const job = useJob(artifact.job, () => {
|
| 243 |
+
artifactsApi.get(artifact.id).then(setArtifact);
|
| 244 |
+
});
|
| 245 |
+
const running = job && (job.status === "queued" || job.status === "running");
|
| 246 |
+
const ready = artifact.status === "ready";
|
| 247 |
+
const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this audio." : null;
|
| 248 |
+
const c = artifact.content;
|
| 249 |
+
const segments = (c.segments as Segment[] | undefined) ?? [];
|
| 250 |
+
const meta = [
|
| 251 |
+
c.duration_s ? clock(c.duration_s as number) : "",
|
| 252 |
+
c.hosts === 1 ? "1 host" : c.hosts === 2 ? "2 hosts" : "",
|
| 253 |
+
STYLE_NAMES[c.format as string] ?? "",
|
| 254 |
+
].filter(Boolean).join(" · ") || (running ? "Making the audio..." : "");
|
| 255 |
+
|
| 256 |
+
const remove = async () => {
|
| 257 |
+
await artifactsApi.remove(artifact.id);
|
| 258 |
+
onRemoved(artifact.id);
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
return (
|
| 262 |
+
<article className={`ao-card${open ? " open" : ""}`}>
|
| 263 |
+
<div className="ao-card-head" role={ready ? "button" : undefined} tabIndex={ready ? 0 : undefined} aria-expanded={ready ? open : undefined}
|
| 264 |
+
onClick={() => ready && setOpen((o) => !o)}
|
| 265 |
+
onKeyDown={(e) => ready && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen((o) => !o))}>
|
| 266 |
+
<span className="ao-card-icon" aria-hidden="true">{running ? <span className="nbv-send-spinner" /> : <HeadphonesIcon size={20} />}</span>
|
| 267 |
+
<span className="ao-card-text">
|
| 268 |
+
<strong title={artifact.title}>{artifact.title}</strong>
|
| 269 |
+
<span className="ao-card-meta">{meta}</span>
|
| 270 |
+
</span>
|
| 271 |
+
{!ready && !failed && <StatusPill status={running ? (job!.status === "queued" ? "queued" : "working") : artifact.status} />}
|
| 272 |
+
{ready && (
|
| 273 |
+
<span className="ao-chevron" aria-hidden="true"><ChevronIcon size={18} className={`chevron${open ? " open" : ""}`} /></span>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
{running && job && <div className="ao-card-progress"><JobProgress job={job} compact /></div>}
|
| 277 |
+
{failed && (
|
| 278 |
+
<div className="ao-card-progress sd-item-failed">
|
| 279 |
+
<p className="error-text small">{failed}</p>
|
| 280 |
+
<button type="button" className="link-btn" onClick={async () => {
|
| 281 |
+
setError(null);
|
| 282 |
+
try {
|
| 283 |
+
setArtifact(await artifactsApi.retry(artifact.id));
|
| 284 |
+
} catch (e) {
|
| 285 |
+
setError(errorMessage(e));
|
| 286 |
+
}
|
| 287 |
+
}}>Retry</button>
|
| 288 |
+
<button type="button" className="link-btn" onClick={() => setConfirming(true)}>Remove</button>
|
| 289 |
+
</div>
|
| 290 |
+
)}
|
| 291 |
+
{ready && open && (
|
| 292 |
+
<div className="ao-card-body">
|
| 293 |
+
{artifact.url && <AudioPlayer src={artifact.url} duration={c.duration_s as number | undefined} audioRef={audio} />}
|
| 294 |
+
{segments.length > 0 && (
|
| 295 |
+
<button type="button" className="link-btn ao-transcript-toggle" aria-expanded={transcript} onClick={() => setTranscript((t) => !t)}>
|
| 296 |
+
{transcript ? "Hide transcript" : `Show transcript (${segments.length} lines)`}
|
| 297 |
+
</button>
|
| 298 |
+
)}
|
| 299 |
+
{transcript && (
|
| 300 |
+
<Transcript segments={segments} onSeek={(t) => {
|
| 301 |
+
const a = audio.current;
|
| 302 |
+
if (!a) return;
|
| 303 |
+
a.currentTime = t;
|
| 304 |
+
void a.play();
|
| 305 |
+
}} />
|
| 306 |
+
)}
|
| 307 |
+
<div className="row ao-card-actions">
|
| 308 |
+
{artifact.download_url && <a className="btn btn-small" href={artifact.download_url}>Download</a>}
|
| 309 |
+
<button type="button" className="btn btn-small ao-delete" onClick={() => setConfirming(true)}>Delete</button>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
)}
|
| 313 |
+
{error && <p className="error-text small ao-card-progress">{error}</p>}
|
| 314 |
+
{confirming && <ConfirmDeleteModal heading="Delete audio overview?" name={artifact.title} onCancel={() => setConfirming(false)}
|
| 315 |
+
onConfirm={remove} />}
|
| 316 |
+
</article>
|
| 317 |
+
);
|
| 318 |
+
}
|
frontend/src/components/PaneResizer.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
|
| 2 |
+
|
| 3 |
+
/** The notebook's side panels, resized by dragging the line between a panel and the chat. Widths are kept per browser;
|
| 4 |
+
* nothing set means the stylesheet's default width. A panel never goes under its minimum, and the chat always keeps
|
| 5 |
+
* room, so a narrower window simply takes the extra width back. */
|
| 6 |
+
|
| 7 |
+
export const PANE_MIN = { left: 220, right: 300 } as const;
|
| 8 |
+
const CHAT_MIN = 440;
|
| 9 |
+
const GUTTER = 16; // the space between the chat and the right panel (.nbv-side's margin)
|
| 10 |
+
const KEYS = { left: "ns_nbv_left", right: "ns_nbv_right" } as const;
|
| 11 |
+
|
| 12 |
+
type Side = "left" | "right";
|
| 13 |
+
type Widths = { left: number | null; right: number | null };
|
| 14 |
+
|
| 15 |
+
function read(side: Side): number | null {
|
| 16 |
+
try {
|
| 17 |
+
const v = Number(localStorage.getItem(KEYS[side]));
|
| 18 |
+
return Number.isFinite(v) && v > 0 ? v : null;
|
| 19 |
+
} catch {
|
| 20 |
+
return null;
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function write(side: Side, value: number | null) {
|
| 25 |
+
try {
|
| 26 |
+
if (value === null) localStorage.removeItem(KEYS[side]);
|
| 27 |
+
else localStorage.setItem(KEYS[side], String(Math.round(value)));
|
| 28 |
+
} catch {
|
| 29 |
+
/* Private browsing can prevent persistence; resizing still works for this visit. */
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function useMedia(query: string): boolean {
|
| 34 |
+
const [on, setOn] = useState(() => typeof window !== "undefined" && window.matchMedia(query).matches);
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
const m = window.matchMedia(query);
|
| 37 |
+
const change = () => setOn(m.matches);
|
| 38 |
+
change();
|
| 39 |
+
m.addEventListener("change", change);
|
| 40 |
+
return () => m.removeEventListener("change", change);
|
| 41 |
+
}, [query]);
|
| 42 |
+
return on;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Where the panes are now: the container's width and the two boundaries (px from its left edge). */
|
| 46 |
+
type Frame = { width: number; leftEdge: number | null; rightEdge: number | null };
|
| 47 |
+
|
| 48 |
+
export function usePaneWidths(container: RefObject<HTMLElement | null>, opts: { leftOpen: boolean }) {
|
| 49 |
+
const [saved, setSaved] = useState<Widths>(() => ({ left: read("left"), right: read("right") }));
|
| 50 |
+
const [frame, setFrame] = useState<Frame>({ width: 0, leftEdge: null, rightEdge: null });
|
| 51 |
+
// The left panel sits beside the chat from 900px; the right one from 1350px (below that it stacks under the chat).
|
| 52 |
+
const leftActive = useMedia("(min-width: 900px)") && opts.leftOpen;
|
| 53 |
+
const rightActive = useMedia("(min-width: 1350px)");
|
| 54 |
+
|
| 55 |
+
// Measure the real boundaries after layout changes, so the handles sit exactly on them (state only changes when a
|
| 56 |
+
// number does, so this cannot loop).
|
| 57 |
+
const measure = useCallback(() => {
|
| 58 |
+
const el = container.current;
|
| 59 |
+
if (!el) return;
|
| 60 |
+
const box = el.getBoundingClientRect();
|
| 61 |
+
const history = el.querySelector<HTMLElement>(":scope > .nbv-history");
|
| 62 |
+
const side = el.querySelector<HTMLElement>(":scope > .nbv-side");
|
| 63 |
+
const next: Frame = {
|
| 64 |
+
width: Math.round(box.width),
|
| 65 |
+
leftEdge: history ? Math.round(history.getBoundingClientRect().right - box.left) : null,
|
| 66 |
+
rightEdge: side ? Math.round(side.getBoundingClientRect().left - box.left - GUTTER / 2) : null,
|
| 67 |
+
};
|
| 68 |
+
setFrame((cur) => (cur.width === next.width && cur.leftEdge === next.leftEdge && cur.rightEdge === next.rightEdge ? cur : next));
|
| 69 |
+
}, [container]);
|
| 70 |
+
useLayoutEffect(() => {
|
| 71 |
+
measure();
|
| 72 |
+
});
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
const el = container.current;
|
| 75 |
+
if (!el || typeof ResizeObserver === "undefined") return;
|
| 76 |
+
const ro = new ResizeObserver(measure);
|
| 77 |
+
ro.observe(el);
|
| 78 |
+
el.querySelectorAll(":scope > .nbv-history, :scope > .nbv-side").forEach((p) => ro.observe(p));
|
| 79 |
+
window.addEventListener("resize", measure);
|
| 80 |
+
return () => {
|
| 81 |
+
ro.disconnect();
|
| 82 |
+
window.removeEventListener("resize", measure);
|
| 83 |
+
};
|
| 84 |
+
}, [container, measure, leftActive, rightActive]);
|
| 85 |
+
|
| 86 |
+
const { width } = frame;
|
| 87 |
+
// What each panel measures by default (the stylesheet's width), when nothing is saved for it.
|
| 88 |
+
const measuredLeft = frame.leftEdge ?? 0;
|
| 89 |
+
const measuredRight = frame.rightEdge !== null ? width - frame.rightEdge - GUTTER / 2 : 0;
|
| 90 |
+
const wantLeft = leftActive ? (saved.left ?? measuredLeft) : 0;
|
| 91 |
+
const wantRight = rightActive ? (saved.right ?? measuredRight) : 0;
|
| 92 |
+
// Each panel: at least its minimum, and never so wide that the chat gets less than CHAT_MIN.
|
| 93 |
+
const maxLeft = Math.max(PANE_MIN.left, width - CHAT_MIN - (rightActive ? wantRight + GUTTER : 0));
|
| 94 |
+
const maxRight = Math.max(PANE_MIN.right, width - CHAT_MIN - GUTTER - (leftActive ? wantLeft : 0));
|
| 95 |
+
const fit = (v: number, max: number, min: number) => Math.max(min, Math.min(v, max));
|
| 96 |
+
const left = leftActive && saved.left !== null && width ? fit(saved.left, maxLeft, PANE_MIN.left) : null;
|
| 97 |
+
const right = rightActive && saved.right !== null && width ? fit(saved.right, maxRight, PANE_MIN.right) : null;
|
| 98 |
+
|
| 99 |
+
const set = useCallback((side: Side, value: number | null, persist = false) => {
|
| 100 |
+
setSaved((cur) => ({ ...cur, [side]: value }));
|
| 101 |
+
if (persist) write(side, value);
|
| 102 |
+
}, []);
|
| 103 |
+
|
| 104 |
+
const style: CSSProperties = {
|
| 105 |
+
...(left !== null ? { ["--nbv-left" as string]: `${Math.round(left)}px` } : {}),
|
| 106 |
+
...(right !== null ? { ["--nbv-right" as string]: `${Math.round(right + GUTTER)}px` } : {}),
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
style,
|
| 111 |
+
frame,
|
| 112 |
+
active: { left: leftActive && frame.leftEdge !== null, right: rightActive && frame.rightEdge !== null },
|
| 113 |
+
current: { left: left ?? measuredLeft, right: right ?? measuredRight },
|
| 114 |
+
bounds: { left: { min: PANE_MIN.left, max: maxLeft }, right: { min: PANE_MIN.right, max: maxRight } },
|
| 115 |
+
set,
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
type Panes = ReturnType<typeof usePaneWidths>;
|
| 120 |
+
|
| 121 |
+
/** The line between a side panel and the chat: drag it (or focus it and use the arrow keys) to resize the panel;
|
| 122 |
+
* double click puts the panel back to its default width. */
|
| 123 |
+
export function PaneResizer({ side, panes, container, label }: {
|
| 124 |
+
side: Side;
|
| 125 |
+
panes: Panes;
|
| 126 |
+
container: RefObject<HTMLElement | null>;
|
| 127 |
+
label: string;
|
| 128 |
+
}) {
|
| 129 |
+
const [dragging, setDragging] = useState(false);
|
| 130 |
+
const frameReq = useRef(0);
|
| 131 |
+
const { min, max } = panes.bounds[side];
|
| 132 |
+
const value = Math.round(panes.current[side]);
|
| 133 |
+
const clamp = (v: number) => Math.max(min, Math.min(max, v));
|
| 134 |
+
const at = side === "left" ? panes.frame.leftEdge : panes.frame.rightEdge;
|
| 135 |
+
|
| 136 |
+
const fromPointer = (clientX: number) => {
|
| 137 |
+
const box = container.current?.getBoundingClientRect();
|
| 138 |
+
if (!box) return value;
|
| 139 |
+
return clamp(side === "left" ? clientX - box.left : box.right - clientX - GUTTER / 2);
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
| 143 |
+
if (e.button !== 0) return;
|
| 144 |
+
e.preventDefault();
|
| 145 |
+
e.currentTarget.setPointerCapture(e.pointerId);
|
| 146 |
+
setDragging(true);
|
| 147 |
+
document.body.classList.add("nbv-resizing");
|
| 148 |
+
};
|
| 149 |
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
| 150 |
+
if (!dragging) return;
|
| 151 |
+
const x = e.clientX;
|
| 152 |
+
cancelAnimationFrame(frameReq.current);
|
| 153 |
+
frameReq.current = requestAnimationFrame(() => panes.set(side, fromPointer(x)));
|
| 154 |
+
};
|
| 155 |
+
const end = (e: React.PointerEvent<HTMLDivElement>) => {
|
| 156 |
+
if (!dragging) return;
|
| 157 |
+
cancelAnimationFrame(frameReq.current);
|
| 158 |
+
setDragging(false);
|
| 159 |
+
document.body.classList.remove("nbv-resizing");
|
| 160 |
+
panes.set(side, fromPointer(e.clientX), true);
|
| 161 |
+
};
|
| 162 |
+
useEffect(() => () => document.body.classList.remove("nbv-resizing"), []);
|
| 163 |
+
|
| 164 |
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
| 165 |
+
const step = e.shiftKey ? 64 : 16;
|
| 166 |
+
// The left panel grows to the right; the right panel grows to the left.
|
| 167 |
+
const grow = side === "left" ? "ArrowRight" : "ArrowLeft";
|
| 168 |
+
const shrink = side === "left" ? "ArrowLeft" : "ArrowRight";
|
| 169 |
+
let next: number | null = null;
|
| 170 |
+
if (e.key === grow) next = value + step;
|
| 171 |
+
else if (e.key === shrink) next = value - step;
|
| 172 |
+
else if (e.key === "Home") next = min;
|
| 173 |
+
else if (e.key === "End") next = max;
|
| 174 |
+
else return;
|
| 175 |
+
e.preventDefault();
|
| 176 |
+
panes.set(side, clamp(next), true);
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
if (!panes.active[side] || at === null) return null;
|
| 180 |
+
return (
|
| 181 |
+
<div className={`nbv-resizer nbv-resizer-${side}${dragging ? " dragging" : ""}`} style={{ left: at }}
|
| 182 |
+
role="separator" aria-orientation="vertical" aria-label={label} tabIndex={0}
|
| 183 |
+
aria-valuenow={value} aria-valuemin={min} aria-valuemax={Math.round(max)}
|
| 184 |
+
title="Drag to resize. Double click to reset."
|
| 185 |
+
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={end} onPointerCancel={end}
|
| 186 |
+
onDoubleClick={() => panes.set(side, null, true)} onKeyDown={onKeyDown} />
|
| 187 |
+
);
|
| 188 |
+
}
|
frontend/src/components/ScheduleModal.tsx
CHANGED
|
@@ -79,7 +79,8 @@ export function ScheduleModal({
|
|
| 79 |
if (artifactId && !artifact) {
|
| 80 |
launchpadApi.postable().then((list) => {
|
| 81 |
const found = list.find((a) => a.id === artifactId);
|
| 82 |
-
|
|
|
|
| 83 |
}, () => undefined);
|
| 84 |
}
|
| 85 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -151,8 +152,9 @@ export function ScheduleModal({
|
|
| 151 |
const account = meta.auto ? accountFor(p) : null;
|
| 152 |
const item = await launchpadApi.create({
|
| 153 |
platform: p,
|
| 154 |
-
// Post now: this
|
| 155 |
-
|
|
|
|
| 156 |
content: merged[0] ?? "",
|
| 157 |
thread: merged.slice(1),
|
| 158 |
// Images and videos only go to X and LinkedIn; text attachments go anywhere.
|
|
@@ -162,8 +164,7 @@ export function ScheduleModal({
|
|
| 162 |
social_account_id: account?.id ?? null,
|
| 163 |
remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
|
| 164 |
});
|
| 165 |
-
|
| 166 |
-
onScheduled?.(out);
|
| 167 |
done.push(meta.label);
|
| 168 |
}
|
| 169 |
onClose();
|
|
|
|
| 79 |
if (artifactId && !artifact) {
|
| 80 |
launchpadApi.postable().then((list) => {
|
| 81 |
const found = list.find((a) => a.id === artifactId);
|
| 82 |
+
// a kit is never an attachment, and audio can't be posted (X and LinkedIn take no audio files)
|
| 83 |
+
if (found && found.type !== "launch_kit" && found.media !== "audio") attach(found);
|
| 84 |
}, () => undefined);
|
| 85 |
}
|
| 86 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
| 152 |
const account = meta.auto ? accountFor(p) : null;
|
| 153 |
const item = await launchpadApi.create({
|
| 154 |
platform: p,
|
| 155 |
+
// Post now: published by this very request (the server takes the time). Scheduled: the half hour picked, in
|
| 156 |
+
// the user's zone (the backend makes it UTC).
|
| 157 |
+
...(now ? { publish_now: true } : { local_time: when, timezone: userTimeZone() }),
|
| 158 |
content: merged[0] ?? "",
|
| 159 |
thread: merged.slice(1),
|
| 160 |
// Images and videos only go to X and LinkedIn; text attachments go anywhere.
|
|
|
|
| 164 |
social_account_id: account?.id ?? null,
|
| 165 |
remind_by_email: CONNECTED_ONLY.includes(p) ? false : remind || !account,
|
| 166 |
});
|
| 167 |
+
onScheduled?.(item);
|
|
|
|
| 168 |
done.push(meta.label);
|
| 169 |
}
|
| 170 |
onClose();
|
frontend/src/components/SlideDeck.tsx
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
| 2 |
+
import { createPortal } from "react-dom";
|
| 3 |
+
import { artifactsApi, type EditSlide, type GenerateBody } from "../api/endpoints";
|
| 4 |
+
import { addActions, convert, type DeckFmt, LAYOUTS, MAX_SLIDES, newSlide, problem, removable, removeField, setText } from "./slideDeckEdit";
|
| 5 |
+
import type { Artifact, ChatSummary } from "../api/types";
|
| 6 |
+
import { Dropdown } from "./Dropdown";
|
| 7 |
+
import { useJob } from "../hooks/useJob";
|
| 8 |
+
import { CheckIcon, CollapseIcon, DownloadIcon, ExpandIcon, MoreIcon, PdfIcon, PlayIcon, PptxIcon, SlidesIcon, TrashIcon } from "./icons/Icons";
|
| 9 |
+
import { LANGUAGES, Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
|
| 10 |
+
import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from "./ui";
|
| 11 |
+
|
| 12 |
+
/** Bumped when the thumbnails are drawn again (backend/scripts/render_slide_thumbnails.py), so browsers fetch the new
|
| 13 |
+
* images instead of the ones they cached. */
|
| 14 |
+
const THUMBS_VERSION = 2;
|
| 15 |
+
|
| 16 |
+
/** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
|
| 17 |
+
* them (backend/scripts/render_slide_thumbnails.py). */
|
| 18 |
+
export const SLIDE_THEMES = [
|
| 19 |
+
{ id: "dark-space", name: "Night stellar", blurb: "Night sky, planets and a blue glow" },
|
| 20 |
+
{ id: "light-space", name: "Moon light", blurb: "Moonlit sky, orbits and constellations" },
|
| 21 |
+
] as const;
|
| 22 |
+
|
| 23 |
+
const FORMATS = [
|
| 24 |
+
{ id: "detailed", label: "Detailed deck", blurb: "Full text and details, perfect for emailing or reading on its own." },
|
| 25 |
+
{ id: "presenter", label: "Presenter slides", blurb: "Clean slides with key talking points to support you while you speak." },
|
| 26 |
+
] as const;
|
| 27 |
+
|
| 28 |
+
const LENGTHS = [
|
| 29 |
+
{ id: "short", label: "Short", range: "6 to 8 slides" },
|
| 30 |
+
{ id: "default", label: "Default", range: "9 to 11 slides" },
|
| 31 |
+
{ id: "long", label: "Long", range: "12 to 16 slides" },
|
| 32 |
+
] as const;
|
| 33 |
+
|
| 34 |
+
const SLIDE_W = 1920;
|
| 35 |
+
const SLIDE_H = 1080;
|
| 36 |
+
|
| 37 |
+
export type SlideDeckRequest = Omit<GenerateBody, "type">;
|
| 38 |
+
type DeckFormat = "detailed" | "presenter";
|
| 39 |
+
type DeckLength = "short" | "default" | "long";
|
| 40 |
+
|
| 41 |
+
/** The slide deck settings: a visual style on top, then the format, language, length, sources and what it should say. */
|
| 42 |
+
export function SlideDeckDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
|
| 43 |
+
notebookId: string;
|
| 44 |
+
notebookTitle?: string;
|
| 45 |
+
chats: ChatSummary[];
|
| 46 |
+
currentChatId?: string | null;
|
| 47 |
+
onClose: () => void;
|
| 48 |
+
onCreate: (body: SlideDeckRequest) => void;
|
| 49 |
+
}) {
|
| 50 |
+
const [sel, setSel] = useState<SourceSelection | null>(null);
|
| 51 |
+
const [theme, setTheme] = useState<string>("dark-space");
|
| 52 |
+
const [format, setFormat] = useState<DeckFormat>("detailed");
|
| 53 |
+
const [length, setLength] = useState<DeckLength>("default");
|
| 54 |
+
const [language, setLanguage] = useState("English");
|
| 55 |
+
const [prompt, setPrompt] = useState("");
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<Modal title="Customize slide deck" onClose={onClose} wide>
|
| 59 |
+
<div className="stack vw-in-modal sd-dialog">
|
| 60 |
+
<section className="field">
|
| 61 |
+
<span className="vw-label">Visual style</span>
|
| 62 |
+
<div className="sd-themes" role="radiogroup" aria-label="Visual style">
|
| 63 |
+
{SLIDE_THEMES.map((t) => (
|
| 64 |
+
<button key={t.id} type="button" role="radio" aria-checked={theme === t.id} title={t.blurb}
|
| 65 |
+
className={`sd-theme${theme === t.id ? " on" : ""}`} onClick={() => setTheme(t.id)}>
|
| 66 |
+
<span className="sd-theme-img">
|
| 67 |
+
<img src={`/slides/${t.id}.png?v=${THUMBS_VERSION}`} alt="" loading="lazy" />
|
| 68 |
+
{theme === t.id && <span className="sd-check" aria-hidden="true"><CheckIcon size={12} /></span>}
|
| 69 |
+
</span>
|
| 70 |
+
<span className="sd-theme-name">{t.name}</span>
|
| 71 |
+
</button>
|
| 72 |
+
))}
|
| 73 |
+
</div>
|
| 74 |
+
</section>
|
| 75 |
+
|
| 76 |
+
<section className="sd-opts">
|
| 77 |
+
<div className="field">
|
| 78 |
+
<span className="vw-label">Format</span>
|
| 79 |
+
<Pills<DeckFormat> label="Format" value={format} onChange={setFormat} options={FORMATS} />
|
| 80 |
+
<small className="muted">{FORMATS.find((f) => f.id === format)?.blurb}</small>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="field">
|
| 83 |
+
<span className="vw-label">Length</span>
|
| 84 |
+
<Dropdown<DeckLength> label="Length" value={length} onChange={setLength}
|
| 85 |
+
options={LENGTHS.map((l) => ({ value: l.id, label: l.label, hint: l.range }))} />
|
| 86 |
+
</div>
|
| 87 |
+
<div className="field">
|
| 88 |
+
<span className="vw-label">Language</span>
|
| 89 |
+
<Dropdown<string> label="Language" value={language} onChange={setLanguage}
|
| 90 |
+
options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
|
| 91 |
+
</div>
|
| 92 |
+
</section>
|
| 93 |
+
|
| 94 |
+
<SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 95 |
+
what="slide deck" noFocus onChange={setSel} />
|
| 96 |
+
|
| 97 |
+
<label className="field">
|
| 98 |
+
<span className="vw-label">Describe the slide deck you want to create <span className="muted">(optional)</span></span>
|
| 99 |
+
<textarea className="input sd-prompt" rows={3} maxLength={4000} value={prompt} onChange={(e) => setPrompt(e.target.value)}
|
| 100 |
+
placeholder={'Add a high-level outline, or guide the audience, style, and focus: "Create a deck for beginners with a focus on step-by-step instructions."'} />
|
| 101 |
+
</label>
|
| 102 |
+
|
| 103 |
+
{!sel?.ready && <p className="muted small">Select at least one post or chat to make a slide deck.</p>}
|
| 104 |
+
<div className="vw-nav">
|
| 105 |
+
<button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready}
|
| 106 |
+
onClick={() => sel && onCreate({ ...sel.request, theme, deck_format: format, deck_length: length,
|
| 107 |
+
language, instructions: prompt.trim() })}>
|
| 108 |
+
Generate slide deck
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</Modal>
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/** The editor that runs inside an editable slide's frame. The frame is sandboxed with allow-scripts but not
|
| 117 |
+
* allow-same-origin, so this script (and anything else in the slide) can never reach this page: the two only talk by
|
| 118 |
+
* postMessage. The page says when edit mode is on and which text boxes may be removed ({type: "sd-mode", editing,
|
| 119 |
+
* removable}); the frame reports each edit ({type: "sd-edit", field, text}), a box's remove button
|
| 120 |
+
* ({type: "sd-remove", field}) and that it is ready ({type: "sd-ready"}). Self-contained: it is sent as source.
|
| 121 |
+
*
|
| 122 |
+
* In edit mode the spans the server marks with data-f (and data-max) are editable, single line, plain text, no longer
|
| 123 |
+
* than the field holds; every text box is framed (dashed, solid on hover, lit while typing), and a click anywhere in a
|
| 124 |
+
* box types into it. Sizes are slide pixels: the slide is drawn 1920 wide and scaled down. */
|
| 125 |
+
function slideEditor() {
|
| 126 |
+
const css = `.sd-box{outline:3px dashed rgba(33,124,255,.8);outline-offset:10px;border-radius:6px;cursor:text;
|
| 127 |
+
background-color:rgba(33,124,255,.07);transition:outline-color .12s,box-shadow .12s}
|
| 128 |
+
.sd-box:hover{outline-style:solid}
|
| 129 |
+
.sd-box:focus-within{outline:4px solid #217cff;box-shadow:0 0 0 14px rgba(33,124,255,.22)}
|
| 130 |
+
[data-f]{outline:none;caret-color:#217cff}
|
| 131 |
+
[data-f]:empty{display:inline-block;min-width:1.2em;min-height:1em;background:rgba(255,93,93,.25)}
|
| 132 |
+
.sd-x{position:absolute;top:-34px;right:-34px;z-index:2;width:48px;height:48px;padding:0;border:3px solid #fff;border-radius:50%;
|
| 133 |
+
background:#e5484d;color:#fff;font:700 30px/1 Inter,system-ui,sans-serif;cursor:pointer;opacity:.45;transition:opacity .12s;
|
| 134 |
+
box-shadow:0 4px 14px rgba(0,0,0,.35)}
|
| 135 |
+
.sd-box:hover .sd-x,.sd-box:focus-within .sd-x{opacity:1}
|
| 136 |
+
.sd-x:hover{background:#c9353a}`;
|
| 137 |
+
const spans = Array.from(document.querySelectorAll<HTMLElement>("[data-f]"));
|
| 138 |
+
const boxes = Array.from(new Set(spans.map((el) => el.closest<HTMLElement>(".e")).filter((b): b is HTMLElement => !!b)));
|
| 139 |
+
const style = document.createElement("style");
|
| 140 |
+
style.textContent = css;
|
| 141 |
+
let on = false;
|
| 142 |
+
const field = (t: EventTarget | null) => {
|
| 143 |
+
const el = t as HTMLElement | null;
|
| 144 |
+
return on && el && el.dataset && el.dataset.f ? el : null;
|
| 145 |
+
};
|
| 146 |
+
const room = (el: HTMLElement) => {
|
| 147 |
+
const sel = document.getSelection();
|
| 148 |
+
const picked = sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.toString().length : 0;
|
| 149 |
+
return Number(el.dataset.max || 9999) - ((el.textContent || "").length - picked);
|
| 150 |
+
};
|
| 151 |
+
let removers: HTMLElement[] = [];
|
| 152 |
+
const setMode = (editing: boolean, removable: string[]) => {
|
| 153 |
+
on = editing;
|
| 154 |
+
removers.forEach((x) => x.remove());
|
| 155 |
+
removers = [];
|
| 156 |
+
for (const el of spans) {
|
| 157 |
+
if (!editing) {
|
| 158 |
+
el.removeAttribute("contenteditable");
|
| 159 |
+
continue;
|
| 160 |
+
}
|
| 161 |
+
try {
|
| 162 |
+
el.contentEditable = "plaintext-only";
|
| 163 |
+
} catch {
|
| 164 |
+
el.contentEditable = "true"; // no plaintext-only: paste is made plain below
|
| 165 |
+
}
|
| 166 |
+
el.spellcheck = true;
|
| 167 |
+
}
|
| 168 |
+
boxes.forEach((b) => b.classList.toggle("sd-box", editing));
|
| 169 |
+
// One remove button per box that may go: for a point's box, the point itself (its text) before its title.
|
| 170 |
+
if (editing) {
|
| 171 |
+
for (const b of boxes) {
|
| 172 |
+
const fields = Array.from(b.querySelectorAll<HTMLElement>("[data-f]")).map((el) => el.dataset.f || "");
|
| 173 |
+
const f = fields.find((x) => x.endsWith(".text") && removable.includes(x)) || fields.find((x) => removable.includes(x));
|
| 174 |
+
if (!f) continue;
|
| 175 |
+
const x = document.createElement("button");
|
| 176 |
+
x.className = "sd-x";
|
| 177 |
+
x.type = "button";
|
| 178 |
+
x.textContent = "×";
|
| 179 |
+
x.title = "Remove this text box";
|
| 180 |
+
x.setAttribute("aria-label", "Remove this text box");
|
| 181 |
+
x.contentEditable = "false";
|
| 182 |
+
x.dataset.remove = f;
|
| 183 |
+
b.appendChild(x);
|
| 184 |
+
removers.push(x);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
if (editing) document.head.appendChild(style);
|
| 188 |
+
else {
|
| 189 |
+
style.remove();
|
| 190 |
+
const sel = document.getSelection();
|
| 191 |
+
if (sel) sel.removeAllRanges();
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
// A click in a box but not on its text (the box is often larger than the words): type at the end of the nearest text.
|
| 195 |
+
document.addEventListener("mousedown", (e) => {
|
| 196 |
+
const t = e.target as HTMLElement | null;
|
| 197 |
+
if (!on || !t || field(t) || !t.closest) return;
|
| 198 |
+
const x = t.closest<HTMLElement>(".sd-x");
|
| 199 |
+
if (x) {
|
| 200 |
+
e.preventDefault();
|
| 201 |
+
parent.postMessage({ type: "sd-remove", field: x.dataset.remove }, "*");
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
const box = t.closest<HTMLElement>(".sd-box");
|
| 205 |
+
if (!box) return;
|
| 206 |
+
let near: HTMLElement | null = null;
|
| 207 |
+
let best = Infinity;
|
| 208 |
+
box.querySelectorAll<HTMLElement>("[data-f]").forEach((el) => {
|
| 209 |
+
const r = el.getBoundingClientRect();
|
| 210 |
+
const d = Math.hypot(Math.max(r.left - e.clientX, 0, e.clientX - r.right), Math.max(r.top - e.clientY, 0, e.clientY - r.bottom));
|
| 211 |
+
if (d < best) [near, best] = [el, d];
|
| 212 |
+
});
|
| 213 |
+
if (!near) return;
|
| 214 |
+
e.preventDefault();
|
| 215 |
+
const el: HTMLElement = near;
|
| 216 |
+
el.focus();
|
| 217 |
+
const range = document.createRange();
|
| 218 |
+
range.selectNodeContents(el);
|
| 219 |
+
range.collapse(false);
|
| 220 |
+
const sel = document.getSelection();
|
| 221 |
+
if (sel) {
|
| 222 |
+
sel.removeAllRanges();
|
| 223 |
+
sel.addRange(range);
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
document.addEventListener("beforeinput", (e) => {
|
| 227 |
+
const el = field(e.target);
|
| 228 |
+
if (!el) return;
|
| 229 |
+
if (e.inputType === "insertParagraph" || e.inputType === "insertLineBreak") {
|
| 230 |
+
e.preventDefault(); // one line: Enter is done
|
| 231 |
+
el.blur();
|
| 232 |
+
} else if (e.inputType === "insertText" && e.data && e.data.length > room(el)) {
|
| 233 |
+
e.preventDefault();
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
document.addEventListener("paste", (e) => {
|
| 237 |
+
const el = field(e.target);
|
| 238 |
+
if (!el) return;
|
| 239 |
+
e.preventDefault();
|
| 240 |
+
const text = ((e.clipboardData && e.clipboardData.getData("text/plain")) || "").replace(/\s+/g, " ").slice(0, Math.max(room(el), 0));
|
| 241 |
+
if (text) document.execCommand("insertText", false, text);
|
| 242 |
+
});
|
| 243 |
+
document.addEventListener("drop", (e) => {
|
| 244 |
+
if (field(e.target)) e.preventDefault(); // dragged text could bring markup or overflow
|
| 245 |
+
});
|
| 246 |
+
document.addEventListener("input", (e) => {
|
| 247 |
+
const el = field(e.target);
|
| 248 |
+
if (el) parent.postMessage({ type: "sd-edit", field: el.dataset.f, text: el.textContent || "" }, "*");
|
| 249 |
+
});
|
| 250 |
+
document.addEventListener("keydown", (e) => {
|
| 251 |
+
const el = field(e.target);
|
| 252 |
+
if (el && e.key === "Escape") el.blur();
|
| 253 |
+
});
|
| 254 |
+
window.addEventListener("message", (e) => {
|
| 255 |
+
if (e.source === parent && e.data && e.data.type === "sd-mode") {
|
| 256 |
+
setMode(!!e.data.editing, Array.isArray(e.data.removable) ? e.data.removable : []);
|
| 257 |
+
}
|
| 258 |
+
});
|
| 259 |
+
parent.postMessage({ type: "sd-ready" }, "*");
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
const EDITOR_TAG = `<script>(${slideEditor.toString()})()</script>`;
|
| 263 |
+
|
| 264 |
+
/** One slide (HTML from our own layout, every word escaped) in a sandboxed frame, scaled to fit the box it is given.
|
| 265 |
+
* With `onEdit` the frame also runs the slide editor (still isolated from this page: an opaque origin), and while
|
| 266 |
+
* `editing` its text is edited in place. */
|
| 267 |
+
export function SlideFrame({ html, title, fit = "width", hidden = false, editing = false, onEdit, removable, onRemove }: {
|
| 268 |
+
html: string;
|
| 269 |
+
title: string;
|
| 270 |
+
/** "width": as wide as the box, 16:9. "contain": as large as fits in the box both ways (presenting). */
|
| 271 |
+
fit?: "width" | "contain";
|
| 272 |
+
hidden?: boolean;
|
| 273 |
+
editing?: boolean;
|
| 274 |
+
/** Text was edited: where it lives on the slide ("heading", "points.2.text") and what it says now. */
|
| 275 |
+
onEdit?: (field: string, text: string) => void;
|
| 276 |
+
/** The text boxes that get a remove button in edit mode, and what a click on one does. */
|
| 277 |
+
removable?: string[];
|
| 278 |
+
onRemove?: (field: string) => void;
|
| 279 |
+
}) {
|
| 280 |
+
const box = useRef<HTMLDivElement>(null);
|
| 281 |
+
const frame = useRef<HTMLIFrameElement>(null);
|
| 282 |
+
const [scale, setScale] = useState(0.2);
|
| 283 |
+
const editRef = useRef(onEdit);
|
| 284 |
+
editRef.current = onEdit;
|
| 285 |
+
const editingRef = useRef(editing);
|
| 286 |
+
editingRef.current = editing;
|
| 287 |
+
const removeRef = useRef(onRemove);
|
| 288 |
+
removeRef.current = onRemove;
|
| 289 |
+
const removableKey = (removable ?? []).join(",");
|
| 290 |
+
const removableRef = useRef(removable ?? []);
|
| 291 |
+
removableRef.current = removable ?? [];
|
| 292 |
+
const withEditor = !!onEdit;
|
| 293 |
+
const doc = withEditor ? html.replace("</body>", `${EDITOR_TAG}</body>`) : html;
|
| 294 |
+
const tell = (on: boolean) =>
|
| 295 |
+
frame.current?.contentWindow?.postMessage({ type: "sd-mode", editing: on, removable: removableRef.current }, "*");
|
| 296 |
+
useEffect(() => {
|
| 297 |
+
if (!withEditor) return;
|
| 298 |
+
const onMessage = (e: MessageEvent) => {
|
| 299 |
+
if (e.source !== frame.current?.contentWindow || !e.data || typeof e.data !== "object") return;
|
| 300 |
+
if (e.data.type === "sd-ready") tell(editingRef.current); // loaded (again): in the mode the page is in
|
| 301 |
+
else if (e.data.type === "sd-edit" && typeof e.data.field === "string" && typeof e.data.text === "string") {
|
| 302 |
+
if (editingRef.current) editRef.current?.(e.data.field, e.data.text);
|
| 303 |
+
} else if (e.data.type === "sd-remove" && typeof e.data.field === "string" && removableRef.current.includes(e.data.field)) {
|
| 304 |
+
if (editingRef.current) removeRef.current?.(e.data.field);
|
| 305 |
+
}
|
| 306 |
+
};
|
| 307 |
+
window.addEventListener("message", onMessage);
|
| 308 |
+
return () => window.removeEventListener("message", onMessage);
|
| 309 |
+
}, [withEditor]);
|
| 310 |
+
useEffect(() => {
|
| 311 |
+
if (withEditor) tell(editing);
|
| 312 |
+
}, [editing, withEditor, removableKey]);
|
| 313 |
+
useEffect(() => {
|
| 314 |
+
const el = box.current;
|
| 315 |
+
if (!el) return;
|
| 316 |
+
const measure = () => {
|
| 317 |
+
const s = fit === "contain" ? Math.min(el.clientWidth / SLIDE_W, el.clientHeight / SLIDE_H) : el.clientWidth / SLIDE_W;
|
| 318 |
+
if (s > 0) setScale(s);
|
| 319 |
+
};
|
| 320 |
+
measure();
|
| 321 |
+
if (typeof ResizeObserver === "undefined") return;
|
| 322 |
+
const ro = new ResizeObserver(measure);
|
| 323 |
+
ro.observe(el);
|
| 324 |
+
return () => ro.disconnect();
|
| 325 |
+
}, [fit]);
|
| 326 |
+
return (
|
| 327 |
+
<div ref={box} className={`sd-frame sd-frame-${fit}${editing ? " sd-frame-edit" : ""}`}
|
| 328 |
+
style={hidden ? { visibility: "hidden" } : undefined} aria-hidden={hidden}>
|
| 329 |
+
<iframe ref={frame} title={title} srcDoc={doc} sandbox={withEditor ? "allow-scripts" : ""} tabIndex={editing ? undefined : -1}
|
| 330 |
+
width={SLIDE_W} height={SLIDE_H} style={{ transform: `translate(-50%, -50%) scale(${scale})` }} />
|
| 331 |
+
</div>
|
| 332 |
+
);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
type DeckSlide = { layout: string; heading: string; notes?: string; sources?: { title?: string; path: string; line_start: number; line_end: number }[] };
|
| 336 |
+
|
| 337 |
+
function deckOf(artifact: Artifact): { pages: string[]; slides: DeckSlide[] } {
|
| 338 |
+
const c = artifact.content;
|
| 339 |
+
const pages = Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [];
|
| 340 |
+
const slides = Array.isArray(c.deck?.slides) ? (c.deck.slides as DeckSlide[]) : [];
|
| 341 |
+
return { pages, slides };
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/** Playing the deck: full screen, one slide at a time. Arrows, Space, Page Up/Down, Home and End move; a click goes on;
|
| 345 |
+
* Escape (or leaving full screen) stops. Every slide is loaded at once and only the current one shown, so moving between
|
| 346 |
+
* them never flashes. */
|
| 347 |
+
export function SlidePresenter({ artifact, start = 0, onClose }: { artifact: Artifact; start?: number; onClose: () => void }) {
|
| 348 |
+
const { pages } = deckOf(artifact);
|
| 349 |
+
const [i, setI] = useState(Math.min(start, Math.max(pages.length - 1, 0)));
|
| 350 |
+
const [idle, setIdle] = useState(false);
|
| 351 |
+
const root = useRef<HTMLDivElement>(null);
|
| 352 |
+
const timer = useRef<number | undefined>(undefined);
|
| 353 |
+
const dark = artifact.content.theme !== "light-space";
|
| 354 |
+
|
| 355 |
+
const go = useCallback((to: number) => setI(Math.max(0, Math.min(pages.length - 1, to))), [pages.length]);
|
| 356 |
+
|
| 357 |
+
useEffect(() => {
|
| 358 |
+
const el = root.current;
|
| 359 |
+
// Full screen when the browser allows it (not iOS Safari): the overlay already fills the window either way.
|
| 360 |
+
if (el?.requestFullscreen) el.requestFullscreen().catch(() => undefined);
|
| 361 |
+
const onChange = () => {
|
| 362 |
+
if (!document.fullscreenElement) onClose();
|
| 363 |
+
};
|
| 364 |
+
document.addEventListener("fullscreenchange", onChange);
|
| 365 |
+
return () => {
|
| 366 |
+
document.removeEventListener("fullscreenchange", onChange);
|
| 367 |
+
if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
|
| 368 |
+
};
|
| 369 |
+
}, [onClose]);
|
| 370 |
+
|
| 371 |
+
useEffect(() => {
|
| 372 |
+
const onKey = (e: KeyboardEvent) => {
|
| 373 |
+
if (["ArrowRight", "ArrowDown", "PageDown", " ", "Enter"].includes(e.key)) go(i + 1);
|
| 374 |
+
else if (["ArrowLeft", "ArrowUp", "PageUp", "Backspace"].includes(e.key)) go(i - 1);
|
| 375 |
+
else if (e.key === "Home") go(0);
|
| 376 |
+
else if (e.key === "End") go(pages.length - 1);
|
| 377 |
+
else if (e.key === "Escape") onClose();
|
| 378 |
+
else return;
|
| 379 |
+
// Ours alone: the viewer (and its modal) underneath must not move or close too.
|
| 380 |
+
e.preventDefault();
|
| 381 |
+
e.stopImmediatePropagation();
|
| 382 |
+
};
|
| 383 |
+
document.addEventListener("keydown", onKey, true);
|
| 384 |
+
return () => document.removeEventListener("keydown", onKey, true);
|
| 385 |
+
}, [go, i, pages.length, onClose]);
|
| 386 |
+
|
| 387 |
+
const wake = () => {
|
| 388 |
+
setIdle(false);
|
| 389 |
+
window.clearTimeout(timer.current);
|
| 390 |
+
timer.current = window.setTimeout(() => setIdle(true), 2200);
|
| 391 |
+
};
|
| 392 |
+
useEffect(() => {
|
| 393 |
+
wake();
|
| 394 |
+
return () => window.clearTimeout(timer.current);
|
| 395 |
+
}, []);
|
| 396 |
+
|
| 397 |
+
return createPortal(
|
| 398 |
+
<div ref={root} className={`sd-present${dark ? "" : " light"}${idle ? " idle" : ""}`} role="dialog" aria-modal="true"
|
| 399 |
+
aria-label={`${artifact.title}, slide ${i + 1} of ${pages.length}`} onMouseMove={wake}
|
| 400 |
+
onClick={(e) => (e.target as HTMLElement).closest(".sd-present-bar") ? undefined : go(i + 1)}>
|
| 401 |
+
<div className="sd-present-stage">
|
| 402 |
+
{pages.map((html, k) => (
|
| 403 |
+
<SlideFrame key={k} html={html} title={`Slide ${k + 1}`} fit="contain" hidden={k !== i} />
|
| 404 |
+
))}
|
| 405 |
+
</div>
|
| 406 |
+
<div className="sd-present-bar" role="toolbar" aria-label="Presenting">
|
| 407 |
+
<button type="button" className="btn btn-small" onClick={() => go(i - 1)} disabled={i === 0} aria-label="Previous slide">Prev</button>
|
| 408 |
+
<span className="sd-count" aria-live="polite">{i + 1} / {pages.length}</span>
|
| 409 |
+
<button type="button" className="btn btn-small" onClick={() => go(i + 1)} disabled={i === pages.length - 1} aria-label="Next slide">Next</button>
|
| 410 |
+
<button type="button" className="btn btn-small" onClick={onClose}>Exit</button>
|
| 411 |
+
</div>
|
| 412 |
+
</div>,
|
| 413 |
+
document.body,
|
| 414 |
+
);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/** A small menu under an icon button. Closes on a click outside or Escape (an Escape that closes the menu does not
|
| 418 |
+
* also close the popup it sits in). */
|
| 419 |
+
function useMenu() {
|
| 420 |
+
const [open, setOpen] = useState(false);
|
| 421 |
+
const ref = useRef<HTMLDivElement>(null); // the button's wrapper
|
| 422 |
+
const menuRef = useRef<HTMLDivElement>(null); // the floating menu (rendered in <body>)
|
| 423 |
+
useEffect(() => {
|
| 424 |
+
if (!open) return;
|
| 425 |
+
const inside = (t: EventTarget | null) => !!t && (ref.current?.contains(t as Node) || menuRef.current?.contains(t as Node));
|
| 426 |
+
const onDown = (e: MouseEvent) => {
|
| 427 |
+
if (!inside(e.target)) setOpen(false);
|
| 428 |
+
};
|
| 429 |
+
const onKey = (e: KeyboardEvent) => {
|
| 430 |
+
if (e.key !== "Escape") return;
|
| 431 |
+
e.stopImmediatePropagation();
|
| 432 |
+
setOpen(false);
|
| 433 |
+
};
|
| 434 |
+
// The menu floats at a fixed place: scrolling what is under it (not the menu itself) closes it.
|
| 435 |
+
const onScroll = (e: Event) => {
|
| 436 |
+
if (!inside(e.target)) setOpen(false);
|
| 437 |
+
};
|
| 438 |
+
const onResize = () => setOpen(false);
|
| 439 |
+
document.addEventListener("mousedown", onDown);
|
| 440 |
+
document.addEventListener("keydown", onKey, true);
|
| 441 |
+
window.addEventListener("scroll", onScroll, true);
|
| 442 |
+
window.addEventListener("resize", onResize);
|
| 443 |
+
return () => {
|
| 444 |
+
document.removeEventListener("mousedown", onDown);
|
| 445 |
+
document.removeEventListener("keydown", onKey, true);
|
| 446 |
+
window.removeEventListener("scroll", onScroll, true);
|
| 447 |
+
window.removeEventListener("resize", onResize);
|
| 448 |
+
};
|
| 449 |
+
}, [open]);
|
| 450 |
+
return { open, setOpen, ref, menuRef };
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
/** A menu under its button, drawn in <body> so a scrolling list or a popup can never clip it. Opens below and to the
|
| 454 |
+
* left of the button's right edge, and above it when there is no room below. */
|
| 455 |
+
function FloatingMenu({ menu, children }: { menu: ReturnType<typeof useMenu>; children: ReactNode }) {
|
| 456 |
+
const [pos, setPos] = useState<CSSProperties>({ visibility: "hidden" });
|
| 457 |
+
useLayoutEffect(() => {
|
| 458 |
+
const button = menu.ref.current?.getBoundingClientRect();
|
| 459 |
+
const box = menu.menuRef.current?.getBoundingClientRect();
|
| 460 |
+
if (!button || !box) return;
|
| 461 |
+
const below = button.bottom + 6 + box.height <= window.innerHeight - 8;
|
| 462 |
+
setPos({
|
| 463 |
+
top: below ? button.bottom + 6 : Math.max(8, button.top - 6 - box.height),
|
| 464 |
+
left: Math.max(8, Math.min(button.right - box.width, window.innerWidth - box.width - 8)),
|
| 465 |
+
});
|
| 466 |
+
}, [menu.ref, menu.menuRef]);
|
| 467 |
+
return createPortal(
|
| 468 |
+
<div ref={menu.menuRef} className="sd-menu sd-menu-floating" role="menu" style={pos} onClick={(e) => e.stopPropagation()}>
|
| 469 |
+
{children}
|
| 470 |
+
</div>,
|
| 471 |
+
document.body,
|
| 472 |
+
);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/** Saves the deck as a PDF (exactly as shown) or an editable PowerPoint, drawn by the server. `prepare` runs first
|
| 476 |
+
* (the viewer saves unsaved edits, so the file has them) and stops the download when it returns false. */
|
| 477 |
+
function useDeckDownload(artifact: Artifact, prepare?: () => Promise<boolean>) {
|
| 478 |
+
const [busy, setBusy] = useState<"pdf" | "pptx" | null>(null);
|
| 479 |
+
const [error, setError] = useState<string | null>(null);
|
| 480 |
+
const name = deckTitle(artifact).replace(/[^\w\- ]+/g, "").trim().slice(0, 80) || "Slide deck";
|
| 481 |
+
const save = async (ext: "pdf" | "pptx") => {
|
| 482 |
+
setBusy(ext);
|
| 483 |
+
setError(null);
|
| 484 |
+
try {
|
| 485 |
+
if (prepare && !(await prepare())) return;
|
| 486 |
+
const blob = await artifactsApi.slidesFile(artifact.id, ext);
|
| 487 |
+
const url = URL.createObjectURL(blob);
|
| 488 |
+
const a = document.createElement("a");
|
| 489 |
+
a.href = url;
|
| 490 |
+
a.download = `${name}.${ext}`;
|
| 491 |
+
document.body.appendChild(a);
|
| 492 |
+
a.click();
|
| 493 |
+
a.remove();
|
| 494 |
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
| 495 |
+
} catch (e) {
|
| 496 |
+
setError(errorMessage(e));
|
| 497 |
+
} finally {
|
| 498 |
+
setBusy(null);
|
| 499 |
+
}
|
| 500 |
+
};
|
| 501 |
+
return { busy, error, save };
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
function deckTitle(artifact: Artifact): string {
|
| 505 |
+
return artifact.title.replace(/^Slide deck: /, "") || (artifact.content.deck?.title as string | undefined) || "Slide deck";
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
/** "5d ago", "3h ago", "just now". */
|
| 509 |
+
function ago(iso: string | null): string {
|
| 510 |
+
if (!iso) return "";
|
| 511 |
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
| 512 |
+
if (s < 60) return "just now";
|
| 513 |
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
| 514 |
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
| 515 |
+
if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
|
| 516 |
+
return formatDate(iso);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/** Download, in the popup's header: a menu of PDF or PowerPoint. */
|
| 520 |
+
function DownloadMenu({ artifact, prepare }: { artifact: Artifact; prepare?: () => Promise<boolean> }) {
|
| 521 |
+
const menu = useMenu();
|
| 522 |
+
const dl = useDeckDownload(artifact, prepare);
|
| 523 |
+
return (
|
| 524 |
+
<div className="sd-menu-wrap" ref={menu.ref}>
|
| 525 |
+
<button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} disabled={dl.busy !== null}
|
| 526 |
+
title={dl.busy ? "Preparing the file..." : "Download"} aria-label={dl.busy ? "Preparing the file" : "Download"}
|
| 527 |
+
onClick={() => menu.setOpen((o) => !o)}>
|
| 528 |
+
{dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <DownloadIcon size={20} />}
|
| 529 |
+
</button>
|
| 530 |
+
{menu.open && (
|
| 531 |
+
<FloatingMenu menu={menu}>
|
| 532 |
+
<button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pdf"); }}>
|
| 533 |
+
<PdfIcon size={20} /> Download PDF Document (.pdf)
|
| 534 |
+
</button>
|
| 535 |
+
<button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pptx"); }}>
|
| 536 |
+
<PptxIcon size={20} /> Download PowerPoint (.pptx)
|
| 537 |
+
</button>
|
| 538 |
+
</FloatingMenu>
|
| 539 |
+
)}
|
| 540 |
+
{dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
|
| 541 |
+
</div>
|
| 542 |
+
);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
/** Everything a deck can do, behind its three dots (the row's and the popup's): rename, download, present, speaker
|
| 546 |
+
* notes (in the popup) and delete (asks once more first). */
|
| 547 |
+
function DeckMenu({ artifact, onRename, onPresent, onDelete, notes, onNotes, prepare }: {
|
| 548 |
+
artifact: Artifact;
|
| 549 |
+
prepare?: () => Promise<boolean>;
|
| 550 |
+
onRename?: () => void;
|
| 551 |
+
onPresent: () => void;
|
| 552 |
+
onDelete: () => Promise<void>;
|
| 553 |
+
notes?: boolean;
|
| 554 |
+
onNotes?: () => void;
|
| 555 |
+
}) {
|
| 556 |
+
const menu = useMenu();
|
| 557 |
+
const dl = useDeckDownload(artifact, prepare);
|
| 558 |
+
const [confirming, setConfirming] = useState(false);
|
| 559 |
+
const pick = (fn: () => void) => () => {
|
| 560 |
+
menu.setOpen(false);
|
| 561 |
+
fn();
|
| 562 |
+
};
|
| 563 |
+
return (
|
| 564 |
+
<div className="sd-menu-wrap" ref={menu.ref} onClick={(e) => e.stopPropagation()}>
|
| 565 |
+
<button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} title="More" aria-label="More"
|
| 566 |
+
onClick={() => menu.setOpen((o) => !o)}>
|
| 567 |
+
{dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <MoreIcon size={20} />}
|
| 568 |
+
</button>
|
| 569 |
+
{menu.open && (
|
| 570 |
+
<FloatingMenu menu={menu}>
|
| 571 |
+
{onRename && <button type="button" role="menuitem" onClick={pick(onRename)}><PencilIcon /> Rename</button>}
|
| 572 |
+
<button type="button" role="menuitem" onClick={pick(() => void dl.save("pdf"))}><PdfIcon size={20} /> Download PDF Document (.pdf)</button>
|
| 573 |
+
<button type="button" role="menuitem" onClick={pick(() => void dl.save("pptx"))}><PptxIcon size={20} /> Download PowerPoint (.pptx)</button>
|
| 574 |
+
<button type="button" role="menuitem" onClick={pick(onPresent)}><PlayIcon size={20} /> Start slideshow</button>
|
| 575 |
+
{onNotes && (
|
| 576 |
+
<button type="button" role="menuitemcheckbox" aria-checked={notes} onClick={pick(onNotes)}>
|
| 577 |
+
<SpeakerNotesIcon /> {notes ? "Hide speaker notes" : "Show speaker notes"}
|
| 578 |
+
</button>
|
| 579 |
+
)}
|
| 580 |
+
<button type="button" role="menuitem" className="danger" onClick={pick(() => setConfirming(true))}>
|
| 581 |
+
<TrashIcon size={20} /> Delete
|
| 582 |
+
</button>
|
| 583 |
+
</FloatingMenu>
|
| 584 |
+
)}
|
| 585 |
+
{confirming && <ConfirmDeleteModal heading="Delete slide deck?" name={deckTitle(artifact)} onCancel={() => setConfirming(false)}
|
| 586 |
+
onConfirm={onDelete} />}
|
| 587 |
+
{dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
|
| 588 |
+
</div>
|
| 589 |
+
);
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
function SvgIcon({ children }: { children: ReactNode }) {
|
| 593 |
+
return (
|
| 594 |
+
<svg className="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}
|
| 595 |
+
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 596 |
+
{children}
|
| 597 |
+
</svg>
|
| 598 |
+
);
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
const SpeakerNotesIcon = () => (
|
| 602 |
+
<SvgIcon>
|
| 603 |
+
<path d="M5 4h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-4 3.5V17H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
|
| 604 |
+
<path d="M7.5 9h9M7.5 12.5h6" />
|
| 605 |
+
</SvgIcon>
|
| 606 |
+
);
|
| 607 |
+
const PencilIcon = () => (
|
| 608 |
+
<SvgIcon>
|
| 609 |
+
<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zM13.5 6.5l4 4" />
|
| 610 |
+
</SvgIcon>
|
| 611 |
+
);
|
| 612 |
+
|
| 613 |
+
const ArrowUpIcon = () => <SvgIcon><path d="M12 19V5M6 11l6-6 6 6" /></SvgIcon>;
|
| 614 |
+
const ArrowDownIcon = () => <SvgIcon><path d="M12 5v14M6 13l6 6 6-6" /></SvgIcon>;
|
| 615 |
+
const DuplicateIcon = () => (
|
| 616 |
+
<SvgIcon>
|
| 617 |
+
<rect x="8" y="8" width="12" height="12" rx="2" />
|
| 618 |
+
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
| 619 |
+
</SvgIcon>
|
| 620 |
+
);
|
| 621 |
+
|
| 622 |
+
/** The working copy edit mode changes: the slides as the editor has them, drawn, and what each slide's design has a
|
| 623 |
+
* place for. Typing changes the slides only (the frame already shows it); anything else is drawn again by the server. */
|
| 624 |
+
type Draft = { slides: EditSlide[]; pages: string[]; slots: string[][] };
|
| 625 |
+
|
| 626 |
+
function draftOf(artifact: Artifact): Draft {
|
| 627 |
+
const c = artifact.content;
|
| 628 |
+
return {
|
| 629 |
+
slides: JSON.parse(JSON.stringify(Array.isArray(c.deck?.slides) ? c.deck.slides : [])) as EditSlide[],
|
| 630 |
+
pages: Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [],
|
| 631 |
+
slots: Array.isArray(c.slide_slots) ? (c.slide_slots as string[][]) : [],
|
| 632 |
+
};
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
/** One slide's controls in edit mode: its layout, the text boxes it can gain, and moving, duplicating or deleting it. */
|
| 636 |
+
function SlideTools({ k, count, slide, slots, fmt, busy, onChange, onMove, onDuplicate, onDelete }: {
|
| 637 |
+
k: number;
|
| 638 |
+
count: number;
|
| 639 |
+
slide: EditSlide;
|
| 640 |
+
slots: string[];
|
| 641 |
+
fmt: DeckFmt;
|
| 642 |
+
busy: boolean;
|
| 643 |
+
onChange: (next: EditSlide) => void;
|
| 644 |
+
onMove: (by: -1 | 1) => void;
|
| 645 |
+
onDuplicate: () => void;
|
| 646 |
+
onDelete: () => void;
|
| 647 |
+
}) {
|
| 648 |
+
return (
|
| 649 |
+
<div className="sd-tools" role="toolbar" aria-label={`Slide ${k + 1}`}>
|
| 650 |
+
<span className="sd-tools-no">Slide {k + 1}</span>
|
| 651 |
+
<label className="sd-tools-layout">
|
| 652 |
+
<span className="sr-only">Layout of slide {k + 1}</span>
|
| 653 |
+
<select className="input" value={slide.layout} disabled={busy} onChange={(e) => onChange(convert(slide, e.target.value, fmt))}>
|
| 654 |
+
{LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
| 655 |
+
</select>
|
| 656 |
+
</label>
|
| 657 |
+
{addActions(slide, slots, fmt).map((a) => (
|
| 658 |
+
<button key={a.id} type="button" className="sd-tool" disabled={busy} onClick={() => onChange(a.apply(slide))}>+ {a.label}</button>
|
| 659 |
+
))}
|
| 660 |
+
<span className="sd-tools-gap" />
|
| 661 |
+
<button type="button" className="sd-icon-btn sd-tool-icon" title="Move up" aria-label={`Move slide ${k + 1} up`}
|
| 662 |
+
disabled={busy || k === 0} onClick={() => onMove(-1)}><ArrowUpIcon /></button>
|
| 663 |
+
<button type="button" className="sd-icon-btn sd-tool-icon" title="Move down" aria-label={`Move slide ${k + 1} down`}
|
| 664 |
+
disabled={busy || k === count - 1} onClick={() => onMove(1)}><ArrowDownIcon /></button>
|
| 665 |
+
<button type="button" className="sd-icon-btn sd-tool-icon" title="Duplicate slide" aria-label={`Duplicate slide ${k + 1}`}
|
| 666 |
+
disabled={busy || count >= MAX_SLIDES} onClick={onDuplicate}><DuplicateIcon /></button>
|
| 667 |
+
<button type="button" className="sd-icon-btn sd-tool-icon sd-tool-danger" title="Delete slide" aria-label={`Delete slide ${k + 1}`}
|
| 668 |
+
disabled={busy || count <= 1} onClick={onDelete}><TrashIcon size={20} /></button>
|
| 669 |
+
</div>
|
| 670 |
+
);
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
/** "+ Add slide" after a slide in edit mode: a new slide of the layout picked. */
|
| 674 |
+
function AddSlide({ after, busy, full, onAdd }: { after: number; busy: boolean; full: boolean; onAdd: (layout: string) => void }) {
|
| 675 |
+
return (
|
| 676 |
+
<div className="sd-add-slide">
|
| 677 |
+
<label>
|
| 678 |
+
<span className="sr-only">Add a slide after slide {after + 1}</span>
|
| 679 |
+
<select className="input" value="" disabled={busy || full} onChange={(e) => e.target.value && onAdd(e.target.value)}>
|
| 680 |
+
<option value="">{full ? `At most ${MAX_SLIDES} slides` : "+ Add slide here"}</option>
|
| 681 |
+
{LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
| 682 |
+
</select>
|
| 683 |
+
</label>
|
| 684 |
+
</div>
|
| 685 |
+
);
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
/** The deck in a popup: its actions along the top (edit, present, a larger preview, download, more) and every slide
|
| 689 |
+
* below, one after another. A click on a slide presents from it. The pencil opens edit mode: every text on the slides
|
| 690 |
+
* is a framed text box to type in (with a remove button where the slide can do without it), each slide has its
|
| 691 |
+
* controls (layout, text boxes to add, move, duplicate, delete), slides can be added between them, and the speaker
|
| 692 |
+
* notes are editable too. Save keeps it all; leaving edit mode or closing with unsaved changes asks first. Presenting
|
| 693 |
+
* or downloading saves first, so both show the edited deck. */
|
| 694 |
+
export function SlideDeckViewer({ artifact, onClose, onDelete, onSaved }: {
|
| 695 |
+
artifact: Artifact;
|
| 696 |
+
onClose: () => void;
|
| 697 |
+
onDelete: () => Promise<void>;
|
| 698 |
+
/** The deck as the server has it now (saved, or fetched afresh). */
|
| 699 |
+
onSaved: (artifact: Artifact) => void;
|
| 700 |
+
}) {
|
| 701 |
+
const stored = deckOf(artifact);
|
| 702 |
+
const [big, setBig] = useState(false);
|
| 703 |
+
const [notes, setNotes] = useState(false);
|
| 704 |
+
const [presenting, setPresenting] = useState<number | null>(null);
|
| 705 |
+
const [inView, setInView] = useState(0);
|
| 706 |
+
const [draft, setDraft] = useState<Draft | null>(null); // edit mode when set
|
| 707 |
+
const [dirty, setDirty] = useState(false);
|
| 708 |
+
const [busy, setBusy] = useState(false); // the server is drawing the slides again
|
| 709 |
+
const [saving, setSaving] = useState(false);
|
| 710 |
+
const [saveError, setSaveError] = useState<string | null>(null);
|
| 711 |
+
// Unsaved changes and the viewer is closing, or edit mode is being left: Save, Don't save or Cancel.
|
| 712 |
+
const [asking, setAsking] = useState<"close" | "done" | null>(null);
|
| 713 |
+
const [opening, setOpening] = useState(false); // fetching the deck afresh before edit mode
|
| 714 |
+
const [version, setVersion] = useState(0); // bumped to draw the slides again from the stored deck (changes dropped)
|
| 715 |
+
const previewSeq = useRef(0);
|
| 716 |
+
const stack = useRef<HTMLOListElement>(null);
|
| 717 |
+
const c = artifact.content;
|
| 718 |
+
const fmt: DeckFmt = c.format === "presenter" ? "presenter" : "detailed";
|
| 719 |
+
const theme = SLIDE_THEMES.find((t) => t.id === c.theme)?.name ?? "Night stellar";
|
| 720 |
+
const editing = draft !== null;
|
| 721 |
+
const pages = draft ? draft.pages : stored.pages;
|
| 722 |
+
const slides: DeckSlide[] = draft ? draft.slides : stored.slides;
|
| 723 |
+
|
| 724 |
+
const save = async (): Promise<boolean> => {
|
| 725 |
+
if (!draft || !dirty) return true;
|
| 726 |
+
const wrong = problem(draft.slides);
|
| 727 |
+
if (wrong) {
|
| 728 |
+
setSaveError(wrong);
|
| 729 |
+
return false;
|
| 730 |
+
}
|
| 731 |
+
setSaving(true);
|
| 732 |
+
setSaveError(null);
|
| 733 |
+
try {
|
| 734 |
+
const next = await artifactsApi.saveDeck(artifact.id, draft.slides);
|
| 735 |
+
onSaved(next);
|
| 736 |
+
setDraft(draftOf(next));
|
| 737 |
+
setDirty(false);
|
| 738 |
+
return true;
|
| 739 |
+
} catch (e) {
|
| 740 |
+
setSaveError(errorMessage(e));
|
| 741 |
+
return false;
|
| 742 |
+
} finally {
|
| 743 |
+
setSaving(false);
|
| 744 |
+
}
|
| 745 |
+
};
|
| 746 |
+
// A change to the deck's structure (a slide or a text box added, removed, moved, another layout): drawn by the server.
|
| 747 |
+
const restructure = async (next: EditSlide[]) => {
|
| 748 |
+
if (!draft) return;
|
| 749 |
+
setDraft({ ...draft, slides: next });
|
| 750 |
+
setDirty(true);
|
| 751 |
+
setBusy(true);
|
| 752 |
+
setSaveError(null);
|
| 753 |
+
const seq = ++previewSeq.current;
|
| 754 |
+
try {
|
| 755 |
+
const got = await artifactsApi.previewDeck(artifact.id, next);
|
| 756 |
+
if (seq === previewSeq.current) setDraft({ slides: got.slides, pages: got.slides_html, slots: got.slide_slots });
|
| 757 |
+
} catch (e) {
|
| 758 |
+
if (seq === previewSeq.current) setSaveError(errorMessage(e));
|
| 759 |
+
} finally {
|
| 760 |
+
if (seq === previewSeq.current) setBusy(false);
|
| 761 |
+
}
|
| 762 |
+
};
|
| 763 |
+
const change = (k: number, slide: EditSlide) => draft && void restructure(draft.slides.map((x, i) => (i === k ? slide : x)));
|
| 764 |
+
const move = (k: number, by: -1 | 1) => {
|
| 765 |
+
if (!draft) return;
|
| 766 |
+
const next = [...draft.slides];
|
| 767 |
+
[next[k], next[k + by]] = [next[k + by], next[k]];
|
| 768 |
+
void restructure(next);
|
| 769 |
+
};
|
| 770 |
+
const insert = (at: number, slide: EditSlide) => draft && void restructure([...draft.slides.slice(0, at), slide, ...draft.slides.slice(at)]);
|
| 771 |
+
const typed = (k: number, field: string, text: string) => {
|
| 772 |
+
setDraft((d) => (d ? { ...d, slides: d.slides.map((x, i) => (i === k ? setText(x, field, text) : x)) } : d));
|
| 773 |
+
setDirty(true);
|
| 774 |
+
};
|
| 775 |
+
const requestClose = () => (dirty ? setAsking("close") : onClose());
|
| 776 |
+
const leave = () => {
|
| 777 |
+
setDraft(null);
|
| 778 |
+
setDirty(false);
|
| 779 |
+
setBusy(false);
|
| 780 |
+
setSaveError(null);
|
| 781 |
+
previewSeq.current++;
|
| 782 |
+
setVersion((v) => v + 1);
|
| 783 |
+
};
|
| 784 |
+
const stopEditing = () => (dirty ? setAsking("done") : leave());
|
| 785 |
+
// Edit mode works on the deck as the server has it now (a deck loaded before an update has no editable text).
|
| 786 |
+
const startEditing = async () => {
|
| 787 |
+
setOpening(true);
|
| 788 |
+
setSaveError(null);
|
| 789 |
+
try {
|
| 790 |
+
const fresh = await artifactsApi.get(artifact.id);
|
| 791 |
+
onSaved(fresh);
|
| 792 |
+
if (!deckOf(fresh).pages.some((html) => html.includes("data-f="))) {
|
| 793 |
+
setSaveError("This deck can't be edited. Generate it again to edit its text.");
|
| 794 |
+
return;
|
| 795 |
+
}
|
| 796 |
+
setDraft(draftOf(fresh));
|
| 797 |
+
} catch (e) {
|
| 798 |
+
setSaveError(errorMessage(e));
|
| 799 |
+
} finally {
|
| 800 |
+
setOpening(false);
|
| 801 |
+
}
|
| 802 |
+
};
|
| 803 |
+
const present = async (k: number) => {
|
| 804 |
+
if (await save()) setPresenting(k);
|
| 805 |
+
};
|
| 806 |
+
|
| 807 |
+
// Leaving the page (closing the tab, reloading) with unsaved changes: the browser asks too.
|
| 808 |
+
useEffect(() => {
|
| 809 |
+
if (!dirty) return;
|
| 810 |
+
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
| 811 |
+
window.addEventListener("beforeunload", warn);
|
| 812 |
+
return () => window.removeEventListener("beforeunload", warn);
|
| 813 |
+
}, [dirty]);
|
| 814 |
+
|
| 815 |
+
// The slide most in view: Present starts there.
|
| 816 |
+
useEffect(() => {
|
| 817 |
+
const root = stack.current;
|
| 818 |
+
if (!root || typeof IntersectionObserver === "undefined") return;
|
| 819 |
+
const seen = new Map<number, number>();
|
| 820 |
+
const io = new IntersectionObserver((entries) => {
|
| 821 |
+
for (const e of entries) seen.set(Number((e.target as HTMLElement).dataset.i), e.intersectionRatio);
|
| 822 |
+
let best = 0;
|
| 823 |
+
let ratio = -1;
|
| 824 |
+
seen.forEach((r, i) => {
|
| 825 |
+
if (r > ratio) [best, ratio] = [i, r];
|
| 826 |
+
});
|
| 827 |
+
setInView(best);
|
| 828 |
+
}, { root, threshold: [0, 0.25, 0.5, 0.75, 1] });
|
| 829 |
+
root.querySelectorAll("[data-i]").forEach((el) => io.observe(el));
|
| 830 |
+
return () => io.disconnect();
|
| 831 |
+
}, [pages.length, notes, editing]);
|
| 832 |
+
|
| 833 |
+
const actions = (
|
| 834 |
+
<div className="sd-head-actions" role="toolbar" aria-label="Slide deck">
|
| 835 |
+
{editing ? (
|
| 836 |
+
<>
|
| 837 |
+
<button type="button" className="btn btn-primary btn-small sd-save" disabled={saving || busy || !dirty} onClick={() => void save()}>
|
| 838 |
+
{saving ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Saving...</>) : "Save"}
|
| 839 |
+
</button>
|
| 840 |
+
<button type="button" className="btn btn-small sd-save" disabled={saving} onClick={stopEditing}>Done</button>
|
| 841 |
+
</>
|
| 842 |
+
) : (
|
| 843 |
+
<button type="button" className="sd-icon-btn" title="Edit slides" aria-label="Edit slides" disabled={!pages.length || opening}
|
| 844 |
+
onClick={() => void startEditing()}>
|
| 845 |
+
{opening ? <span className="nbv-send-spinner" aria-hidden="true" /> : <PencilIcon />}
|
| 846 |
+
</button>
|
| 847 |
+
)}
|
| 848 |
+
<button type="button" className="sd-icon-btn" title="Start slideshow" aria-label="Start slideshow"
|
| 849 |
+
disabled={!pages.length || saving || busy} onClick={() => void present(inView)}>
|
| 850 |
+
<PlayIcon size={20} />
|
| 851 |
+
</button>
|
| 852 |
+
<button type="button" className="sd-icon-btn" title={big ? "Smaller preview" : "Larger preview"} aria-pressed={big}
|
| 853 |
+
aria-label={big ? "Smaller preview" : "Larger preview"} onClick={() => setBig((b) => !b)}>
|
| 854 |
+
{big ? <CollapseIcon size={20} /> : <ExpandIcon size={20} />}
|
| 855 |
+
</button>
|
| 856 |
+
{pages.length > 0 && <DownloadMenu artifact={artifact} prepare={save} />}
|
| 857 |
+
<DeckMenu artifact={artifact} onPresent={() => void present(inView)} onDelete={onDelete} prepare={save}
|
| 858 |
+
notes={notes} onNotes={() => setNotes((n) => !n)} />
|
| 859 |
+
<button type="button" className="sd-icon-btn" onClick={requestClose} title="Close" aria-label="Close">
|
| 860 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
| 861 |
+
<path d="M6 6l12 12M18 6L6 18" />
|
| 862 |
+
</svg>
|
| 863 |
+
</button>
|
| 864 |
+
</div>
|
| 865 |
+
);
|
| 866 |
+
|
| 867 |
+
return (
|
| 868 |
+
<Modal title={deckTitle(artifact)} onClose={requestClose} wide actions={actions}>
|
| 869 |
+
<div className={`sd-view${big ? " big" : ""}`}>
|
| 870 |
+
<p className="muted small sd-meta">
|
| 871 |
+
{pages.length} slides · {fmt === "presenter" ? "Presenter" : "Detailed"} · {theme}
|
| 872 |
+
{c.source ? ` · from ${sourceSummary(c.source)}` : ""}
|
| 873 |
+
<span className="sd-edit-hint">
|
| 874 |
+
{editing ? ` · Editing: click a text box to type${busy ? " · Updating..." : dirty ? " · Unsaved changes" : ""}` : ""}
|
| 875 |
+
</span>
|
| 876 |
+
</p>
|
| 877 |
+
{saveError && !asking && <p className="error-text small sd-edit-error" role="alert">{saveError}</p>}
|
| 878 |
+
{pages.length === 0 ? (
|
| 879 |
+
<p className="error-text">This deck has no slides to show. Try generating it again.</p>
|
| 880 |
+
) : (
|
| 881 |
+
<ol className={`sd-stack${busy ? " sd-busy" : ""}`} ref={stack} aria-label="Slides" aria-busy={busy}>
|
| 882 |
+
{pages.map((html, k) => {
|
| 883 |
+
const s = slides[k];
|
| 884 |
+
const sources = [...new Map((s?.sources ?? []).map((x) => [x.title || x.path, x])).values()];
|
| 885 |
+
const e = draft?.slides[k];
|
| 886 |
+
return (
|
| 887 |
+
<li key={k} data-i={k}>
|
| 888 |
+
{draft && e && (
|
| 889 |
+
<SlideTools k={k} count={draft.slides.length} slide={e} slots={draft.slots[k] ?? []} fmt={fmt} busy={busy || saving}
|
| 890 |
+
onChange={(next) => change(k, next)} onMove={(by) => move(k, by)}
|
| 891 |
+
onDuplicate={() => insert(k + 1, { ...JSON.parse(JSON.stringify(e)), variant: "" })}
|
| 892 |
+
onDelete={() => void restructure(draft.slides.filter((_, i) => i !== k))} />
|
| 893 |
+
)}
|
| 894 |
+
<div className={`sd-slide${editing ? " sd-slide-editing" : ""}`} role={editing ? undefined : "button"}
|
| 895 |
+
tabIndex={editing ? undefined : 0} aria-label={editing ? undefined : `Present from slide ${k + 1}: ${s?.heading ?? ""}`}
|
| 896 |
+
onClick={editing ? undefined : () => void present(k)}
|
| 897 |
+
onKeyDown={editing ? undefined : (ev) => (ev.key === "Enter" || ev.key === " ") && (ev.preventDefault(), void present(k))}>
|
| 898 |
+
<SlideFrame key={version} html={html} title={`Slide ${k + 1}: ${s?.heading ?? ""}`} editing={editing && !busy}
|
| 899 |
+
onEdit={(field, text) => typed(k, field, text)}
|
| 900 |
+
removable={e ? removable(e) : []} onRemove={(field) => e && change(k, removeField(e, field))} />
|
| 901 |
+
</div>
|
| 902 |
+
{notes && (
|
| 903 |
+
<div className="sd-notes">
|
| 904 |
+
{e ? (
|
| 905 |
+
<>
|
| 906 |
+
<label className="vw-label" htmlFor={`sd-notes-${artifact.id}-${k}`}>Slide {k + 1} notes</label>
|
| 907 |
+
<textarea id={`sd-notes-${artifact.id}-${k}`} className="textarea sd-notes-edit" rows={3} maxLength={1500}
|
| 908 |
+
placeholder="No notes for this slide yet." value={e.notes}
|
| 909 |
+
onChange={(ev) => typed(k, "notes", ev.target.value)} />
|
| 910 |
+
</>
|
| 911 |
+
) : (
|
| 912 |
+
<>
|
| 913 |
+
<span className="vw-label">Slide {k + 1}</span>
|
| 914 |
+
<p>{s?.notes || <span className="muted">No notes for this slide.</span>}</p>
|
| 915 |
+
</>
|
| 916 |
+
)}
|
| 917 |
+
{sources.length > 0 && (
|
| 918 |
+
<p className="muted small">
|
| 919 |
+
From {sources.map((x) => `${x.title || x.path} (lines ${x.line_start} to ${x.line_end})`).join("; ")}
|
| 920 |
+
</p>
|
| 921 |
+
)}
|
| 922 |
+
</div>
|
| 923 |
+
)}
|
| 924 |
+
{draft && (
|
| 925 |
+
<AddSlide after={k} busy={busy || saving} full={draft.slides.length >= MAX_SLIDES}
|
| 926 |
+
onAdd={(layout) => insert(k + 1, newSlide(layout, fmt))} />
|
| 927 |
+
)}
|
| 928 |
+
</li>
|
| 929 |
+
);
|
| 930 |
+
})}
|
| 931 |
+
</ol>
|
| 932 |
+
)}
|
| 933 |
+
</div>
|
| 934 |
+
{presenting !== null && <SlidePresenter artifact={artifact} start={presenting} onClose={() => setPresenting(null)} />}
|
| 935 |
+
{asking && (
|
| 936 |
+
<Modal title="Save changes?" onClose={() => !saving && setAsking(null)}>
|
| 937 |
+
<div className="stack sd-confirm">
|
| 938 |
+
<p>You edited this slide deck. Do you want to save your changes{asking === "close" ? " before closing" : ""}?</p>
|
| 939 |
+
{saveError && <p className="error-text" role="alert">{saveError}</p>}
|
| 940 |
+
<div className="row sd-confirm-actions">
|
| 941 |
+
<button type="button" className="btn" onClick={() => setAsking(null)} disabled={saving}>Cancel</button>
|
| 942 |
+
<button type="button" className="btn" disabled={saving} onClick={() => {
|
| 943 |
+
if (asking === "close") return onClose();
|
| 944 |
+
leave();
|
| 945 |
+
setAsking(null);
|
| 946 |
+
}}>Don't save</button>
|
| 947 |
+
<button type="button" className="btn btn-primary" disabled={saving || busy} autoFocus onClick={async () => {
|
| 948 |
+
if (!(await save())) return;
|
| 949 |
+
if (asking === "close") return onClose();
|
| 950 |
+
setAsking(null);
|
| 951 |
+
leave();
|
| 952 |
+
}}>
|
| 953 |
+
{saving ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Saving...</>) : "Save"}
|
| 954 |
+
</button>
|
| 955 |
+
</div>
|
| 956 |
+
</div>
|
| 957 |
+
</Modal>
|
| 958 |
+
)}
|
| 959 |
+
</Modal>
|
| 960 |
+
);
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
/** A deck in the notebook's list, as one quiet row: its icon, its title, "2 sources · 5d ago", and its menu. A click
|
| 964 |
+
* opens it. While it is being made the row says so and shows the progress. */
|
| 965 |
+
export function SlideDeckRow({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
|
| 966 |
+
const [artifact, setArtifact] = useState(initial);
|
| 967 |
+
const [open, setOpen] = useState(false);
|
| 968 |
+
const [presenting, setPresenting] = useState(false);
|
| 969 |
+
const [renaming, setRenaming] = useState(false);
|
| 970 |
+
const [name, setName] = useState("");
|
| 971 |
+
const [error, setError] = useState<string | null>(null);
|
| 972 |
+
const job = useJob(artifact.job, () => {
|
| 973 |
+
artifactsApi.get(artifact.id).then(setArtifact);
|
| 974 |
+
});
|
| 975 |
+
const running = job && (job.status === "queued" || job.status === "running");
|
| 976 |
+
const ready = artifact.status === "ready";
|
| 977 |
+
const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this deck." : null;
|
| 978 |
+
const title = deckTitle(artifact);
|
| 979 |
+
const count = (artifact.content.source?.count as number | undefined) ?? 0;
|
| 980 |
+
const meta = running || !ready
|
| 981 |
+
? (failed ? "Slide deck · could not be made" : `Generating slide deck... based on ${count || "your"} source${count === 1 ? "" : "s"}`)
|
| 982 |
+
: `${count} source${count === 1 ? "" : "s"} · ${ago(artifact.created_at)}`;
|
| 983 |
+
|
| 984 |
+
const remove = async () => {
|
| 985 |
+
await artifactsApi.remove(artifact.id);
|
| 986 |
+
setOpen(false);
|
| 987 |
+
onRemoved(artifact.id);
|
| 988 |
+
};
|
| 989 |
+
const rename = async () => {
|
| 990 |
+
const next = name.trim();
|
| 991 |
+
setRenaming(false);
|
| 992 |
+
if (!next || next === title) return;
|
| 993 |
+
setError(null);
|
| 994 |
+
try {
|
| 995 |
+
const deck = artifact.content.deck ? { ...artifact.content.deck, title: next } : undefined;
|
| 996 |
+
setArtifact(await artifactsApi.update(artifact.id, { title: next, ...(deck ? { content: { deck } } : {}) }));
|
| 997 |
+
} catch (e) {
|
| 998 |
+
setError(errorMessage(e));
|
| 999 |
+
}
|
| 1000 |
+
};
|
| 1001 |
+
|
| 1002 |
+
return (
|
| 1003 |
+
<div className={`sd-item${ready ? " is-ready" : ""}${running ? " is-running" : ""}`}>
|
| 1004 |
+
<div className="sd-item-main" role={ready && !renaming ? "button" : undefined} tabIndex={ready && !renaming ? 0 : undefined}
|
| 1005 |
+
aria-label={ready && !renaming ? `Open slide deck: ${title}` : undefined}
|
| 1006 |
+
onClick={() => ready && !renaming && setOpen(true)}
|
| 1007 |
+
onKeyDown={(e) => ready && !renaming && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen(true))}>
|
| 1008 |
+
<span className="sd-item-icon" aria-hidden="true">
|
| 1009 |
+
{running ? <span className="nbv-send-spinner" /> : <SlidesIcon size={22} />}
|
| 1010 |
+
</span>
|
| 1011 |
+
<span className="sd-item-text">
|
| 1012 |
+
{renaming ? (
|
| 1013 |
+
<input className="input sd-rename" autoFocus value={name} maxLength={140} aria-label="Slide deck name"
|
| 1014 |
+
onChange={(e) => setName(e.target.value)} onBlur={rename} onClick={(e) => e.stopPropagation()}
|
| 1015 |
+
onKeyDown={(e) => {
|
| 1016 |
+
e.stopPropagation();
|
| 1017 |
+
if (e.key === "Enter") void rename();
|
| 1018 |
+
if (e.key === "Escape") setRenaming(false);
|
| 1019 |
+
}} />
|
| 1020 |
+
) : (
|
| 1021 |
+
<strong title={title}>{running ? "Generating slide deck..." : title}</strong>
|
| 1022 |
+
)}
|
| 1023 |
+
<span className="sd-item-meta">{meta}</span>
|
| 1024 |
+
</span>
|
| 1025 |
+
</div>
|
| 1026 |
+
{ready && (
|
| 1027 |
+
<DeckMenu artifact={artifact} onRename={() => { setName(title); setRenaming(true); }} onPresent={() => setPresenting(true)}
|
| 1028 |
+
onDelete={remove} />
|
| 1029 |
+
)}
|
| 1030 |
+
{running && job && (
|
| 1031 |
+
<div className="sd-item-progress">
|
| 1032 |
+
<JobProgress job={job} compact />
|
| 1033 |
+
</div>
|
| 1034 |
+
)}
|
| 1035 |
+
{failed && (
|
| 1036 |
+
<div className="sd-item-progress sd-item-failed">
|
| 1037 |
+
<p className="error-text small">{failed}</p>
|
| 1038 |
+
<button type="button" className="link-btn" onClick={async () => {
|
| 1039 |
+
setError(null);
|
| 1040 |
+
try {
|
| 1041 |
+
setArtifact(await artifactsApi.retry(artifact.id));
|
| 1042 |
+
} catch (e) {
|
| 1043 |
+
setError(errorMessage(e));
|
| 1044 |
+
}
|
| 1045 |
+
}}>Retry</button>
|
| 1046 |
+
<button type="button" className="link-btn" onClick={() => void remove()}>Remove</button>
|
| 1047 |
+
</div>
|
| 1048 |
+
)}
|
| 1049 |
+
{error && <p className="error-text small sd-item-progress">{error}</p>}
|
| 1050 |
+
{open && <SlideDeckViewer artifact={artifact} onClose={() => setOpen(false)} onDelete={remove} onSaved={setArtifact} />}
|
| 1051 |
+
{presenting && <SlidePresenter artifact={artifact} onClose={() => setPresenting(false)} />}
|
| 1052 |
+
</div>
|
| 1053 |
+
);
|
| 1054 |
+
}
|
frontend/src/components/icons/Icons.tsx
CHANGED
|
@@ -239,6 +239,62 @@ export const InfographicIcon = (p: P) => (
|
|
| 239 |
</Icon>
|
| 240 |
);
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
/** A question mark in a circle: the quiz. */
|
| 243 |
export const QuizIcon = (p: P) => (
|
| 244 |
<Icon {...p}>
|
|
|
|
| 239 |
</Icon>
|
| 240 |
);
|
| 241 |
|
| 242 |
+
/** A slide on a stand, a second one behind it: a slide deck. */
|
| 243 |
+
export const SlidesIcon = (p: P) => (
|
| 244 |
+
<Icon {...p}>
|
| 245 |
+
<rect x="3" y="4" width="15" height="10.5" rx="1.8" />
|
| 246 |
+
<path d="M6.5 1.9h12.7A1.8 1.8 0 0 1 21 3.7v8.6" />
|
| 247 |
+
<path d="M10.5 14.5V19M7 21h7M6.5 8h5M6.5 11h8" />
|
| 248 |
+
</Icon>
|
| 249 |
+
);
|
| 250 |
+
|
| 251 |
+
/** A play triangle: present a deck. */
|
| 252 |
+
export const PlayIcon = (p: P) => (
|
| 253 |
+
<Icon {...p}>
|
| 254 |
+
<path d="M7.5 5.2v13.6a.8.8 0 0 0 1.2.7l10.6-6.8a.8.8 0 0 0 0-1.4L8.7 4.5a.8.8 0 0 0-1.2.7z" />
|
| 255 |
+
</Icon>
|
| 256 |
+
);
|
| 257 |
+
|
| 258 |
+
/** Four corners pointing out: show larger. */
|
| 259 |
+
export const ExpandIcon = (p: P) => (
|
| 260 |
+
<Icon {...p}>
|
| 261 |
+
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
| 262 |
+
</Icon>
|
| 263 |
+
);
|
| 264 |
+
|
| 265 |
+
/** Four corners pointing in: back to the normal size. */
|
| 266 |
+
export const CollapseIcon = (p: P) => (
|
| 267 |
+
<Icon {...p}>
|
| 268 |
+
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
|
| 269 |
+
</Icon>
|
| 270 |
+
);
|
| 271 |
+
|
| 272 |
+
/** Three dots, stacked: more actions. */
|
| 273 |
+
export const MoreIcon = (p: P) => (
|
| 274 |
+
<Icon {...p}>
|
| 275 |
+
<circle cx="12" cy="5.5" r="1.1" fill="currentColor" />
|
| 276 |
+
<circle cx="12" cy="12" r="1.1" fill="currentColor" />
|
| 277 |
+
<circle cx="12" cy="18.5" r="1.1" fill="currentColor" />
|
| 278 |
+
</Icon>
|
| 279 |
+
);
|
| 280 |
+
|
| 281 |
+
/** A framed PDF mark: a PDF file. */
|
| 282 |
+
export const PdfIcon = (p: P) => (
|
| 283 |
+
<Icon {...p}>
|
| 284 |
+
<rect x="3" y="4" width="18" height="16" rx="2.5" />
|
| 285 |
+
<text x="12" y="15" textAnchor="middle" fontSize="6.4" fontWeight="700" fill="currentColor" stroke="none"
|
| 286 |
+
fontFamily="Inter, sans-serif">PDF</text>
|
| 287 |
+
</Icon>
|
| 288 |
+
);
|
| 289 |
+
|
| 290 |
+
/** A framed P: a PowerPoint file. */
|
| 291 |
+
export const PptxIcon = (p: P) => (
|
| 292 |
+
<Icon {...p}>
|
| 293 |
+
<rect x="3" y="4" width="18" height="16" rx="2.5" />
|
| 294 |
+
<path d="M10 16V8h2.8a2.3 2.3 0 0 1 0 4.6H10" />
|
| 295 |
+
</Icon>
|
| 296 |
+
);
|
| 297 |
+
|
| 298 |
/** A question mark in a circle: the quiz. */
|
| 299 |
export const QuizIcon = (p: P) => (
|
| 300 |
<Icon {...p}>
|
frontend/src/components/launchpad/Attachment.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|
| 4 |
import { ApiError } from "../../api/client";
|
| 5 |
import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
|
| 6 |
import type { PostableArtifact } from "../../api/types";
|
| 7 |
-
import { ArrowRightIcon, FilmIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
|
| 8 |
import { errorMessage, formatDate, Loading, Modal } from "../ui";
|
| 9 |
import { finished, jobPercent } from "../video/jobState";
|
| 10 |
|
|
@@ -18,9 +18,16 @@ export function mediaLabel(a: PostableArtifact): string {
|
|
| 18 |
if (a.type === "upload") {
|
| 19 |
return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
|
| 20 |
}
|
|
|
|
| 21 |
if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
|
| 22 |
if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
|
| 23 |
if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
|
| 25 |
return "Text";
|
| 26 |
}
|
|
@@ -29,7 +36,8 @@ function Thumb({ a }: { a: PostableArtifact }) {
|
|
| 29 |
if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
|
| 30 |
return (
|
| 31 |
<span className="lp-thumb lp-thumb-icon" aria-hidden="true">
|
| 32 |
-
{a.media === "video" ? <FilmIcon size={20} /> : <
|
|
|
|
| 33 |
</span>
|
| 34 |
);
|
| 35 |
}
|
|
@@ -55,6 +63,32 @@ export function MediaPreviewModal({ a, onClose }: { a: PostableArtifact; onClose
|
|
| 55 |
);
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
/** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
|
| 59 |
* preview. */
|
| 60 |
export function AttachmentChip({ a, onChange, onRemove }: {
|
|
@@ -307,16 +341,29 @@ function deleteWording(a: PostableArtifact): { title: string; body: string } {
|
|
| 307 |
return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
|
| 308 |
}
|
| 309 |
|
| 310 |
-
type Kind = "all" | "kits" | "video" | "uploads";
|
| 311 |
const KINDS: { id: Kind; label: string }[] = [
|
| 312 |
{ id: "all", label: "All" },
|
| 313 |
{ id: "kits", label: "Launch kits" },
|
| 314 |
{ id: "video", label: "Videos" },
|
|
|
|
|
|
|
| 315 |
{ id: "uploads", label: "Uploads" },
|
| 316 |
];
|
| 317 |
/** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
|
| 318 |
const matches = (a: PostableArtifact, kind: Kind) =>
|
| 319 |
-
kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
/** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
|
| 322 |
const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
|
|
@@ -366,11 +413,20 @@ export function AttachPicker({ onPick, onCreateKit, onClose }: {
|
|
| 366 |
* searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
|
| 367 |
* Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
|
| 368 |
* with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
|
| 369 |
-
export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }: {
|
| 370 |
onPick: (a: PostableArtifact) => void;
|
| 371 |
onPickKit?: (a: PostableArtifact) => void;
|
| 372 |
onCreateKit?: () => void;
|
| 373 |
fill?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
}) {
|
| 375 |
const [items, setItems] = useState<PostableArtifact[] | null>(null);
|
| 376 |
const [kind, setKind] = useState<Kind>("all");
|
|
@@ -382,6 +438,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
|
|
| 382 |
const [dragging, setDragging] = useState(false); // a file is held over the drop area
|
| 383 |
const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
|
| 384 |
const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
|
|
|
|
| 385 |
const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
|
| 386 |
const [deleteBusy, setDeleteBusy] = useState(false);
|
| 387 |
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
@@ -441,7 +498,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
|
|
| 441 |
const load = useCallback(() => launchpadApi.postable(q).then((list) => {
|
| 442 |
setItems(list);
|
| 443 |
return list;
|
| 444 |
-
}), [q]);
|
| 445 |
|
| 446 |
useEffect(() => {
|
| 447 |
const t = setTimeout(() => {
|
|
@@ -540,12 +597,18 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
|
|
| 540 |
)}
|
| 541 |
{items && shown.length === 0 && (
|
| 542 |
<p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
|
| 543 |
-
: kind === "uploads" ? "Nothing uploaded yet." : "
|
|
|
|
|
|
|
| 544 |
)}
|
| 545 |
<ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
|
| 546 |
{shown.map((a) => {
|
| 547 |
const kit = a.type === "launch_kit";
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
if (renaming?.id === a.id) {
|
| 550 |
return (
|
| 551 |
<li key={a.id}>
|
|
@@ -570,7 +633,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
|
|
| 570 |
}
|
| 571 |
return (
|
| 572 |
<li key={a.id} className="lp-pick-item">
|
| 573 |
-
<button type="button" className="lk-row lp-pick-row" onClick={go}
|
| 574 |
title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
|
| 575 |
: a.needs_render ? "Render it, then post it" : undefined}>
|
| 576 |
<Thumb a={a} />
|
|
@@ -578,22 +641,33 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
|
|
| 578 |
<strong>{a.title}</strong>
|
| 579 |
<span className="muted small">
|
| 580 |
{kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
|
| 581 |
-
{
|
|
|
|
| 582 |
</span>
|
| 583 |
</span>
|
| 584 |
-
<span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : ""}`}>
|
| 585 |
-
{a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : kit ? "Launch Kit"
|
|
|
|
| 586 |
</span>
|
| 587 |
<span className="lp-pick-go">
|
| 588 |
-
{
|
|
|
|
|
|
|
| 589 |
<><span className="vw-spin small" aria-hidden="true" /> Rendering</>
|
| 590 |
) : (
|
| 591 |
-
<>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : "
|
|
|
|
| 592 |
)}
|
| 593 |
</span>
|
| 594 |
</button>
|
| 595 |
<RowMenu label={a.title} items={[
|
| 596 |
...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
|
| 598 |
...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
|
| 599 |
: a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
|
|
@@ -608,6 +682,7 @@ export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false }:
|
|
| 608 |
</ul>
|
| 609 |
{rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
|
| 610 |
{previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
|
|
|
|
| 611 |
{deleting && (
|
| 612 |
<Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
|
| 613 |
<div className="stack">
|
|
|
|
| 4 |
import { ApiError } from "../../api/client";
|
| 5 |
import { artifactsApi, launchpadApi, videoEditApi, videosApi } from "../../api/endpoints";
|
| 6 |
import type { PostableArtifact } from "../../api/types";
|
| 7 |
+
import { ArrowRightIcon, DownloadIcon, FilmIcon, HeadphonesIcon, SlidesIcon, SparkleIcon, UploadIcon } from "../icons/Icons";
|
| 8 |
import { errorMessage, formatDate, Loading, Modal } from "../ui";
|
| 9 |
import { finished, jobPercent } from "../video/jobState";
|
| 10 |
|
|
|
|
| 18 |
if (a.type === "upload") {
|
| 19 |
return a.media === "video" ? `Your video${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""}` : "Your image";
|
| 20 |
}
|
| 21 |
+
if (a.media === "audio") return `Audio${a.duration_s ? ` ${fmtDuration(a.duration_s)}` : ""} · download only`;
|
| 22 |
if (a.media === "video" && a.rendering) return "Rendering: wait for it to finish";
|
| 23 |
if (a.media === "video" && a.needs_render) return "Not rendered yet: render it first";
|
| 24 |
if (a.media === "video") return a.duration_s ? `Video ${fmtDuration(a.duration_s)}` : "Video";
|
| 25 |
+
if (a.type === "slide_deck") {
|
| 26 |
+
// X takes 4 images and LinkedIn 20 (backend app/services/social/media.py MAX_IMAGES): longer decks are cut there.
|
| 27 |
+
const n = a.media_count;
|
| 28 |
+
const cut = n > 20 ? " · X posts the first 4, LinkedIn the first 20" : n > 4 ? " · X posts the first 4" : "";
|
| 29 |
+
return `${n} slide${n === 1 ? "" : "s"} as images${cut}`;
|
| 30 |
+
}
|
| 31 |
if (a.media === "image") return a.media_count === 1 ? "Image" : `${a.media_count} images`;
|
| 32 |
return "Text";
|
| 33 |
}
|
|
|
|
| 36 |
if (a.thumb_url) return <img className="lp-thumb" src={a.thumb_url} alt="" />;
|
| 37 |
return (
|
| 38 |
<span className="lp-thumb lp-thumb-icon" aria-hidden="true">
|
| 39 |
+
{a.media === "video" ? <FilmIcon size={20} /> : a.media === "audio" ? <HeadphonesIcon size={20} />
|
| 40 |
+
: a.type === "slide_deck" ? <SlidesIcon size={20} /> : <SparkleIcon size={20} />}
|
| 41 |
</span>
|
| 42 |
);
|
| 43 |
}
|
|
|
|
| 63 |
);
|
| 64 |
}
|
| 65 |
|
| 66 |
+
/** An audio overview someone tried to schedule: X and LinkedIn take no audio files, so it is offered to download
|
| 67 |
+
* instead. Used by the Launchpad's list and the Library. */
|
| 68 |
+
export function AudioOnlyModal({ title, downloadUrl, onClose }: { title: string; downloadUrl: string | null | undefined; onClose: () => void }) {
|
| 69 |
+
return (
|
| 70 |
+
<Modal title="Audio can't be posted" onClose={onClose}>
|
| 71 |
+
<div className="stack lp-audio-only">
|
| 72 |
+
<span className="lp-audio-only-icon" aria-hidden="true"><HeadphonesIcon size={28} /></span>
|
| 73 |
+
<p>
|
| 74 |
+
LinkedIn does not support posting audio files, so <strong>{title}</strong> can't be scheduled. Download it now
|
| 75 |
+
to use it elsewhere, like a podcast app, your newsletter or a website.
|
| 76 |
+
</p>
|
| 77 |
+
<div className="row lp-audio-only-actions">
|
| 78 |
+
<button type="button" className="btn" onClick={onClose}>Close</button>
|
| 79 |
+
{downloadUrl ? (
|
| 80 |
+
<a className="btn btn-primary" href={downloadUrl} download onClick={onClose} autoFocus>
|
| 81 |
+
<DownloadIcon size={18} /> Download
|
| 82 |
+
</a>
|
| 83 |
+
) : (
|
| 84 |
+
<span className="muted small">The file isn't available right now. Try again later.</span>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</Modal>
|
| 89 |
+
);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
/** The attached item: thumbnail, title, what it carries, and Change / Remove. An uploaded file's thumbnail opens its
|
| 93 |
* preview. */
|
| 94 |
export function AttachmentChip({ a, onChange, onRemove }: {
|
|
|
|
| 341 |
return { title: "Delete this item?", body: "It's deleted for good. Posts on your calendar that carry it are removed too." };
|
| 342 |
}
|
| 343 |
|
| 344 |
+
type Kind = "all" | "kits" | "video" | "decks" | "audio" | "uploads";
|
| 345 |
const KINDS: { id: Kind; label: string }[] = [
|
| 346 |
{ id: "all", label: "All" },
|
| 347 |
{ id: "kits", label: "Launch kits" },
|
| 348 |
{ id: "video", label: "Videos" },
|
| 349 |
+
{ id: "decks", label: "Slide decks" },
|
| 350 |
+
{ id: "audio", label: "Audio" },
|
| 351 |
{ id: "uploads", label: "Uploads" },
|
| 352 |
];
|
| 353 |
/** Which rows a tab shows: Videos is only the ones the video service made (uploaded videos are under Uploads). */
|
| 354 |
const matches = (a: PostableArtifact, kind: Kind) =>
|
| 355 |
+
kind === "all" || (kind === "kits" ? a.type === "launch_kit" : kind === "uploads" ? a.type === "upload"
|
| 356 |
+
: kind === "decks" ? a.type === "slide_deck" : kind === "audio" ? a.type === "audio_overview" : a.editable_video);
|
| 357 |
+
|
| 358 |
+
/** Saves a file from its (presigned, attachment) link. */
|
| 359 |
+
function download(url: string) {
|
| 360 |
+
const link = document.createElement("a");
|
| 361 |
+
link.href = url;
|
| 362 |
+
link.download = "";
|
| 363 |
+
document.body.appendChild(link);
|
| 364 |
+
link.click();
|
| 365 |
+
link.remove();
|
| 366 |
+
}
|
| 367 |
|
| 368 |
/** What can be uploaded to post, and LinkedIn's size limits for it (checked again by the backend). */
|
| 369 |
const UPLOAD_ACCEPT = "image/png,image/jpeg,image/gif,video/mp4";
|
|
|
|
| 413 |
* searchable; with a link to create a new launch kit when onCreateKit is given. onPickKit, when given, takes the
|
| 414 |
* Launch Kits instead of onPick (the Schedule a launch page opens the kit, to schedule its posts from there). A video
|
| 415 |
* with no MP4 yet (or one rendering) opens the render pop-up instead. fill: the rows take the screen's height. */
|
| 416 |
+
export function LibraryPicker({ onPick, onPickKit, onCreateKit, fill = false, onReschedule, onDuplicate, refreshKey = 0, openingId }: {
|
| 417 |
onPick: (a: PostableArtifact) => void;
|
| 418 |
onPickKit?: (a: PostableArtifact) => void;
|
| 419 |
onCreateKit?: () => void;
|
| 420 |
fill?: boolean;
|
| 421 |
+
/** With these (the Schedule a launch page), an item already scheduled says Reschedule and opens that post
|
| 422 |
+
* (`itemId`), and its menu can schedule it again (a copy of that post). Without them (the composer's picker), every
|
| 423 |
+
* item is simply picked. */
|
| 424 |
+
onReschedule?: (a: PostableArtifact, itemId: string) => void;
|
| 425 |
+
onDuplicate?: (a: PostableArtifact, itemId: string) => void;
|
| 426 |
+
/** Changed by the page to load the list again (a post was rescheduled, moved or deleted). */
|
| 427 |
+
refreshKey?: number;
|
| 428 |
+
/** The item whose scheduled post is being opened: its row shows a spinner and takes no clicks meanwhile. */
|
| 429 |
+
openingId?: string | null;
|
| 430 |
}) {
|
| 431 |
const [items, setItems] = useState<PostableArtifact[] | null>(null);
|
| 432 |
const [kind, setKind] = useState<Kind>("all");
|
|
|
|
| 438 |
const [dragging, setDragging] = useState(false); // a file is held over the drop area
|
| 439 |
const [renaming, setRenaming] = useState<{ id: string; title: string; busy?: boolean } | null>(null);
|
| 440 |
const [previewing, setPreviewing] = useState<PostableArtifact | null>(null); // the upload being looked at
|
| 441 |
+
const [audio, setAudio] = useState<PostableArtifact | null>(null); // an audio overview: offered to download, not posted
|
| 442 |
const [deleting, setDeleting] = useState<PostableArtifact | null>(null); // the item in the delete pop-up
|
| 443 |
const [deleteBusy, setDeleteBusy] = useState(false);
|
| 444 |
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
|
|
| 498 |
const load = useCallback(() => launchpadApi.postable(q).then((list) => {
|
| 499 |
setItems(list);
|
| 500 |
return list;
|
| 501 |
+
}), [q, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 502 |
|
| 503 |
useEffect(() => {
|
| 504 |
const t = setTimeout(() => {
|
|
|
|
| 597 |
)}
|
| 598 |
{items && shown.length === 0 && (
|
| 599 |
<p className="muted lp-pick-empty">{kind === "kits" ? "No launch kits yet: create a new one."
|
| 600 |
+
: kind === "uploads" ? "Nothing uploaded yet." : kind === "decks" ? "No slide decks yet: make one from a notebook."
|
| 601 |
+
: kind === "audio" ? "No audio overviews yet: make one from a notebook."
|
| 602 |
+
: "Nothing to attach here yet."}</p>
|
| 603 |
)}
|
| 604 |
<ul className={`lk-rows lp-pick-rows${fill ? " lp-pick-rows-fill" : ""}`}>
|
| 605 |
{shown.map((a) => {
|
| 606 |
const kit = a.type === "launch_kit";
|
| 607 |
+
// Already on the calendar (and this list offers it): open that post rather than start another one.
|
| 608 |
+
const next = onReschedule && !kit && a.media !== "audio" ? a.scheduled?.[0] : undefined;
|
| 609 |
+
const opening = openingId === a.id;
|
| 610 |
+
const go = () => opening ? undefined : (a.media === "audio" ? setAudio(a) : a.needs_render || a.rendering ? setRendering(a)
|
| 611 |
+
: next ? onReschedule!(a, next.id) : kit && onPickKit ? onPickKit(a) : onPick(a));
|
| 612 |
if (renaming?.id === a.id) {
|
| 613 |
return (
|
| 614 |
<li key={a.id}>
|
|
|
|
| 633 |
}
|
| 634 |
return (
|
| 635 |
<li key={a.id} className="lp-pick-item">
|
| 636 |
+
<button type="button" className="lk-row lp-pick-row" onClick={go} aria-busy={opening}
|
| 637 |
title={a.rendering ? "Rendering: it can be scheduled once the MP4 is made"
|
| 638 |
: a.needs_render ? "Render it, then post it" : undefined}>
|
| 639 |
<Thumb a={a} />
|
|
|
|
| 641 |
<strong>{a.title}</strong>
|
| 642 |
<span className="muted small">
|
| 643 |
{kit ? "LinkedIn post and hooks ready to schedule" : mediaLabel(a)}
|
| 644 |
+
{next ? ` · Scheduled ${formatDate(next.scheduled_at, true)}${a.scheduled!.length > 1
|
| 645 |
+
? ` +${a.scheduled!.length - 1} more` : ""}` : a.created_at ? ` · ${formatDate(a.created_at)}` : ""}
|
| 646 |
</span>
|
| 647 |
</span>
|
| 648 |
+
<span className={`lp-tag${a.media === "video" ? " video" : kit ? " kit" : a.type === "slide_deck" ? " deck" : ""}`}>
|
| 649 |
+
{a.type === "upload" ? "Upload" : a.media === "video" ? "Video" : a.media === "audio" ? "Audio" : kit ? "Launch Kit"
|
| 650 |
+
: a.type_label}
|
| 651 |
</span>
|
| 652 |
<span className="lp-pick-go">
|
| 653 |
+
{opening ? (
|
| 654 |
+
<><span className="vw-spin small" aria-hidden="true" /> Opening...</>
|
| 655 |
+
) : a.rendering ? (
|
| 656 |
<><span className="vw-spin small" aria-hidden="true" /> Rendering</>
|
| 657 |
) : (
|
| 658 |
+
<>{a.needs_render ? "Render it" : kit && onPickKit ? "Open kit" : next ? "Reschedule" : "Schedule"}
|
| 659 |
+
{" "}<ArrowRightIcon size={14} /></>
|
| 660 |
)}
|
| 661 |
</span>
|
| 662 |
</button>
|
| 663 |
<RowMenu label={a.title} items={[
|
| 664 |
...(a.view_url ? [{ label: "Preview", onSelect: () => setPreviewing(a) }] : []),
|
| 665 |
+
...(a.download_url ? [{ label: "Download", onSelect: () => download(a.download_url!) }] : []),
|
| 666 |
+
...(next && onDuplicate ? [{ label: "Duplicate schedule", onSelect: () => onDuplicate(a, next.id) }] : []),
|
| 667 |
+
// Several posts of it coming up: each can be opened.
|
| 668 |
+
...(next && a.scheduled!.length > 1 ? a.scheduled!.map((sc) => ({
|
| 669 |
+
label: `Edit schedule: ${formatDate(sc.scheduled_at, true)}`, onSelect: () => onReschedule!(a, sc.id),
|
| 670 |
+
})) : []),
|
| 671 |
...(a.type === "upload" ? [{ label: "Rename", onSelect: () => setRenaming({ id: a.id, title: a.title }) }] : []),
|
| 672 |
...(kit ? [{ label: "Open kit", onSelect: () => (onPickKit ? onPickKit(a) : navigate(`/app/launchpad/kits/${a.id}`)) }]
|
| 673 |
: a.editable_video ? [{ label: "Open", onSelect: () => navigate(`/app/videos/${a.id}`) }] : []),
|
|
|
|
| 682 |
</ul>
|
| 683 |
{rendering && <RenderVideoModal video={rendering} onClose={() => setRendering(null)} onRendered={rendered} />}
|
| 684 |
{previewing && <MediaPreviewModal a={previewing} onClose={() => setPreviewing(null)} />}
|
| 685 |
+
{audio && <AudioOnlyModal title={audio.title} downloadUrl={audio.download_url} onClose={() => setAudio(null)} />}
|
| 686 |
{deleting && (
|
| 687 |
<Modal title={deleteWording(deleting).title} onClose={() => !deleteBusy && setDeleting(null)}>
|
| 688 |
<div className="stack">
|
frontend/src/components/launchpad/PostButton.tsx
CHANGED
|
@@ -2,8 +2,9 @@ import { useState } from "react";
|
|
| 2 |
import type { Artifact } from "../../api/types";
|
| 3 |
import { ScheduleModal } from "../ScheduleModal";
|
| 4 |
|
| 5 |
-
/** What can go out on X / LinkedIn: images, videos and text. Not audio (neither platform
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
export function canPost(a: Artifact): boolean {
|
| 9 |
if (!POSTABLE.has(a.type)) return false;
|
|
|
|
| 2 |
import type { Artifact } from "../../api/types";
|
| 3 |
import { ScheduleModal } from "../ScheduleModal";
|
| 4 |
|
| 5 |
+
/** What can go out on X / LinkedIn: images (a slide deck's slides too), videos and text. Not audio (neither platform
|
| 6 |
+
* takes audio files). */
|
| 7 |
+
const POSTABLE = new Set(["video", "quote_card", "carousel", "summary", "launch_kit", "mind_map", "slide_deck"]);
|
| 8 |
|
| 9 |
export function canPost(a: Artifact): boolean {
|
| 10 |
if (!POSTABLE.has(a.type)) return false;
|
frontend/src/components/slideDeckEdit.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** The slide deck editor's rules: what a slide can gain or lose, how it changes to another layout, and what must not be
|
| 2 |
+
* left empty. They mirror the server (backend/app/slides/content.py: LIMITS and clamp_slide), which coerces whatever
|
| 3 |
+
* comes back anyway; these only keep the editor from offering what the server would undo. */
|
| 4 |
+
import type { EditSlide } from "../api/endpoints";
|
| 5 |
+
|
| 6 |
+
export type DeckFmt = "detailed" | "presenter";
|
| 7 |
+
|
| 8 |
+
/** The layouts a slide can have, in the order the editor lists them. */
|
| 9 |
+
export const LAYOUTS: { id: string; name: string }[] = [
|
| 10 |
+
{ id: "title", name: "Title" },
|
| 11 |
+
{ id: "section", name: "Section" },
|
| 12 |
+
{ id: "points", name: "Points" },
|
| 13 |
+
{ id: "two_column", name: "Two columns" },
|
| 14 |
+
{ id: "stat", name: "Big number" },
|
| 15 |
+
{ id: "quote", name: "Quote" },
|
| 16 |
+
{ id: "agenda", name: "Agenda" },
|
| 17 |
+
{ id: "closing", name: "Closing" },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
export const MAX_SLIDES = 30;
|
| 21 |
+
|
| 22 |
+
const max = (fmt: DeckFmt) => ({ points: fmt === "presenter" ? 4 : 5, agenda: 14, takeaways: 3, items: 4 });
|
| 23 |
+
|
| 24 |
+
const clone = (s: EditSlide): EditSlide => JSON.parse(JSON.stringify(s)) as EditSlide;
|
| 25 |
+
|
| 26 |
+
/** A new slide of `layout`, with placeholder text to type over. */
|
| 27 |
+
export function newSlide(layout: string, fmt: DeckFmt): EditSlide {
|
| 28 |
+
const blank: EditSlide = {
|
| 29 |
+
layout: "section", variant: "", kicker: "", heading: "New slide", lead: "", points: [],
|
| 30 |
+
left: { label: "", items: [] }, right: { label: "", items: [] }, stat: { value: "", label: "" },
|
| 31 |
+
quote: { text: "", by: "" }, takeaways: [], closing: "", notes: "", sources: [],
|
| 32 |
+
};
|
| 33 |
+
return convert(blank, layout, fmt);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** The slide changed to another layout. Its words carry over (points become a column's items, a closing's
|
| 37 |
+
* takeaways...); what the new layout needs and the slide lacks is filled with placeholder text. The old layout's
|
| 38 |
+
* content stays in the slide, so changing back brings it back. */
|
| 39 |
+
export function convert(s: EditSlide, layout: string, fmt: DeckFmt): EditSlide {
|
| 40 |
+
const out = { ...clone(s), layout, variant: "" };
|
| 41 |
+
const m = max(fmt);
|
| 42 |
+
const words = [...s.points.map((p) => p.text), ...s.takeaways, ...s.left.items, ...s.right.items].filter(Boolean);
|
| 43 |
+
const some = (n: number, fallback: string[]) => (words.length >= 2 ? words : fallback).slice(0, n);
|
| 44 |
+
if (layout === "points" && out.points.length < 2) {
|
| 45 |
+
out.points = some(m.points, ["First point", "Second point", "Third point"]).map((text) => ({ term: "", text }));
|
| 46 |
+
} else if (layout === "agenda" && out.points.length < 2) {
|
| 47 |
+
out.points = some(m.agenda, ["First topic", "Second topic", "Third topic"]).map((text) => ({ term: "", text }));
|
| 48 |
+
} else if (layout === "two_column" && !(out.left.items.length && out.right.items.length)) {
|
| 49 |
+
const items = some(2 * m.items, ["A point on this side", "Another point", "A point on that side", "Another point"]);
|
| 50 |
+
const half = Math.ceil(items.length / 2);
|
| 51 |
+
out.left = { label: out.left.label || "One side", items: items.slice(0, half).slice(0, m.items) };
|
| 52 |
+
out.right = { label: out.right.label || "The other side", items: items.slice(half).slice(0, m.items) };
|
| 53 |
+
} else if (layout === "stat" && !(out.stat.value && out.stat.label)) {
|
| 54 |
+
out.stat = { value: out.stat.value || "42%", label: out.stat.label || words[0] || "What this number means" };
|
| 55 |
+
} else if (layout === "quote" && !out.quote.text) {
|
| 56 |
+
out.quote = { text: words[0] || out.lead || "A line worth quoting.", by: out.quote.by };
|
| 57 |
+
} else if (layout === "closing" && !out.takeaways.length && !out.closing) {
|
| 58 |
+
out.takeaways = some(m.takeaways, ["First takeaway", "Second takeaway", "Third takeaway"]);
|
| 59 |
+
}
|
| 60 |
+
return out;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export type AddAction = { id: string; label: string; apply: (s: EditSlide) => EditSlide };
|
| 64 |
+
|
| 65 |
+
/** The text boxes a slide can gain: only those its design has a place for (`slots`, from the server) and that it
|
| 66 |
+
* does not have yet, within the format's limits. */
|
| 67 |
+
export function addActions(s: EditSlide, slots: string[], fmt: DeckFmt): AddAction[] {
|
| 68 |
+
const m = max(fmt);
|
| 69 |
+
const has = (slot: string) => slots.includes(slot);
|
| 70 |
+
const act = (id: string, label: string, change: (c: EditSlide) => void): AddAction => ({
|
| 71 |
+
id, label, apply: (from) => {
|
| 72 |
+
const c = clone(from);
|
| 73 |
+
change(c);
|
| 74 |
+
return c;
|
| 75 |
+
},
|
| 76 |
+
});
|
| 77 |
+
const out: AddAction[] = [];
|
| 78 |
+
if (has("kicker") && !s.kicker) out.push(act("kicker", "Kicker", (c) => (c.kicker = "Kicker")));
|
| 79 |
+
if (has("lead") && !s.lead) out.push(act("lead", "Subtitle", (c) => (c.lead = "A sentence that adds context.")));
|
| 80 |
+
if (s.layout === "points" && has("body") && s.points.length < m.points) {
|
| 81 |
+
out.push(act("point", "Bullet", (c) => c.points.push({ term: "", text: "New point" })));
|
| 82 |
+
}
|
| 83 |
+
if (s.layout === "agenda" && has("body") && s.points.length < m.agenda) {
|
| 84 |
+
out.push(act("point", "Topic", (c) => c.points.push({ term: "", text: "New topic" })));
|
| 85 |
+
}
|
| 86 |
+
if (s.layout === "two_column") {
|
| 87 |
+
for (const side of ["left", "right"] as const) {
|
| 88 |
+
const name = side === "left" ? "Left" : "Right";
|
| 89 |
+
if (!s[side].label) out.push(act(`${side}-label`, `${name} heading`, (c) => (c[side].label = "Heading")));
|
| 90 |
+
if (s[side].items.length < m.items) out.push(act(`${side}-item`, `${name} item`, (c) => c[side].items.push("New item")));
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
if (s.layout === "quote" && has("by") && !s.quote.by) out.push(act("by", "Author", (c) => (c.quote.by = "Name")));
|
| 94 |
+
if (s.layout === "closing") {
|
| 95 |
+
if (has("body") && s.takeaways.length < m.takeaways) out.push(act("takeaway", "Takeaway", (c) => c.takeaways.push("New takeaway")));
|
| 96 |
+
if (has("closing") && !s.closing) out.push(act("closing", "Closing line", (c) => (c.closing = "One line to end on.")));
|
| 97 |
+
}
|
| 98 |
+
return out;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/** The text boxes on a slide that can be removed (a point only while the slide keeps enough of them; the heading, a
|
| 102 |
+
* stat or a quote never: the layout needs them). Each is the `data-f` of the text drawn in that box. */
|
| 103 |
+
export function removable(s: EditSlide): string[] {
|
| 104 |
+
const out: string[] = [];
|
| 105 |
+
if (s.kicker) out.push("kicker");
|
| 106 |
+
if (s.lead) out.push("lead");
|
| 107 |
+
if (s.quote.by) out.push("quote.by");
|
| 108 |
+
if (s.closing && s.takeaways.length) out.push("closing");
|
| 109 |
+
const minPoints = 2;
|
| 110 |
+
s.points.forEach((p, i) => {
|
| 111 |
+
if (p.term) out.push(`points.${i}.term`);
|
| 112 |
+
if (s.points.length > minPoints) out.push(`points.${i}.text`);
|
| 113 |
+
});
|
| 114 |
+
if (s.takeaways.length > 1 || s.closing) s.takeaways.forEach((_, i) => out.push(`takeaways.${i}`));
|
| 115 |
+
for (const side of ["left", "right"] as const) {
|
| 116 |
+
if (s[side].label) out.push(`${side}.label`);
|
| 117 |
+
if (s[side].items.length > 1) s[side].items.forEach((_, i) => out.push(`${side}.items.${i}`));
|
| 118 |
+
}
|
| 119 |
+
return out;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/** The slide without the text box at `field`: a point, takeaway or column item goes; other text is emptied. */
|
| 123 |
+
export function removeField(s: EditSlide, field: string): EditSlide {
|
| 124 |
+
const c = clone(s);
|
| 125 |
+
const [a, b, d] = field.split(".");
|
| 126 |
+
if (a === "points" && d === "text") c.points.splice(Number(b), 1);
|
| 127 |
+
else if (a === "points" && d === "term") c.points[Number(b)].term = "";
|
| 128 |
+
else if (a === "takeaways") c.takeaways.splice(Number(b), 1);
|
| 129 |
+
else if ((a === "left" || a === "right") && b === "items") c[a].items.splice(Number(d), 1);
|
| 130 |
+
else if ((a === "left" || a === "right") && b === "label") c[a].label = "";
|
| 131 |
+
else if (a === "quote" && b === "by") c.quote.by = "";
|
| 132 |
+
else if (a === "kicker" || a === "lead" || a === "closing") c[a] = "";
|
| 133 |
+
return c;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/** The slide with the text at `field` ("points.2.text", "left.items.0", "heading") set to `text`. */
|
| 137 |
+
export function setText(s: EditSlide, field: string, text: string): EditSlide {
|
| 138 |
+
const c = clone(s);
|
| 139 |
+
const path = field.split(".");
|
| 140 |
+
let at: Record<string, unknown> | unknown[] = c as unknown as Record<string, unknown>;
|
| 141 |
+
for (const k of path.slice(0, -1)) at = (at as Record<string, unknown>)[k] as Record<string, unknown>;
|
| 142 |
+
const last = path[path.length - 1];
|
| 143 |
+
if (Array.isArray(at)) at[Number(last)] = text;
|
| 144 |
+
else if (at && typeof at === "object") at[last] = text;
|
| 145 |
+
return c;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const blank = (t: string) => !t.replace(/\s+/g, "");
|
| 149 |
+
|
| 150 |
+
/** What stops the deck from being saved as it is ("Slide 3: the heading can't be empty."), or null. */
|
| 151 |
+
export function problem(slides: EditSlide[]): string | null {
|
| 152 |
+
for (const [i, s] of slides.entries()) {
|
| 153 |
+
const at = (what: string) => `Slide ${i + 1}: ${what} can't be empty.`;
|
| 154 |
+
if (blank(s.heading) && s.layout !== "quote" && s.layout !== "closing") return at("the heading");
|
| 155 |
+
if (s.layout === "points" || s.layout === "agenda") {
|
| 156 |
+
const n = s.points.findIndex((p) => blank(p.text));
|
| 157 |
+
if (n >= 0) return at(`${s.layout === "agenda" ? "topic" : "point"} ${n + 1}`);
|
| 158 |
+
}
|
| 159 |
+
if (s.layout === "two_column") {
|
| 160 |
+
for (const side of ["left", "right"] as const) {
|
| 161 |
+
const n = s[side].items.findIndex(blank);
|
| 162 |
+
if (n >= 0) return at(`the ${side} column's item ${n + 1}`);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
if (s.layout === "stat" && (blank(s.stat.value) || blank(s.stat.label))) return at(blank(s.stat.value) ? "the number" : "the number's label");
|
| 166 |
+
if (s.layout === "quote" && blank(s.quote.text)) return at("the quote");
|
| 167 |
+
if (s.layout === "closing") {
|
| 168 |
+
const n = s.takeaways.findIndex(blank);
|
| 169 |
+
if (n >= 0) return at(`takeaway ${n + 1}`);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
return null;
|
| 173 |
+
}
|
frontend/src/components/ui.tsx
CHANGED
|
@@ -69,17 +69,21 @@ export function Modal({
|
|
| 69 |
actions?: ReactNode;
|
| 70 |
}) {
|
| 71 |
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
useEffect(() => {
|
| 73 |
const onKey = (e: KeyboardEvent) => {
|
| 74 |
if (e.key !== "Escape") return;
|
| 75 |
// A modal opened from a modal (a picker over the composer): Escape closes only the top one.
|
| 76 |
const open = document.querySelectorAll(".modal-backdrop");
|
| 77 |
-
if (open[open.length - 1] === ref.current?.parentElement)
|
| 78 |
};
|
| 79 |
document.addEventListener("keydown", onKey);
|
| 80 |
-
ref.current?.focus();
|
| 81 |
return () => document.removeEventListener("keydown", onKey);
|
| 82 |
-
}, [
|
| 83 |
// Rendered into <body>: a parent with backdrop-filter or transform (cards have one) would otherwise become the
|
| 84 |
// containing block of this fixed backdrop and trap the modal inside that card.
|
| 85 |
return createPortal(
|
|
@@ -171,6 +175,44 @@ export function CopyButton({ text, label = "Copy" }: { text: string; label?: str
|
|
| 171 |
}
|
| 172 |
|
| 173 |
/** Two step destructive button: first click arms it, second confirms. No browser dialogs. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
export function ConfirmButton({
|
| 175 |
onConfirm,
|
| 176 |
children,
|
|
|
|
| 69 |
actions?: ReactNode;
|
| 70 |
}) {
|
| 71 |
const ref = useRef<HTMLDivElement>(null);
|
| 72 |
+
// The latest onClose, so a parent passing a new function each render neither re-binds Escape nor (worse) pulls focus
|
| 73 |
+
// back to the dialog, away from whatever is being typed in.
|
| 74 |
+
const close = useRef(onClose);
|
| 75 |
+
close.current = onClose;
|
| 76 |
useEffect(() => {
|
| 77 |
const onKey = (e: KeyboardEvent) => {
|
| 78 |
if (e.key !== "Escape") return;
|
| 79 |
// A modal opened from a modal (a picker over the composer): Escape closes only the top one.
|
| 80 |
const open = document.querySelectorAll(".modal-backdrop");
|
| 81 |
+
if (open[open.length - 1] === ref.current?.parentElement) close.current();
|
| 82 |
};
|
| 83 |
document.addEventListener("keydown", onKey);
|
| 84 |
+
ref.current?.focus(); // once, on opening
|
| 85 |
return () => document.removeEventListener("keydown", onKey);
|
| 86 |
+
}, []);
|
| 87 |
// Rendered into <body>: a parent with backdrop-filter or transform (cards have one) would otherwise become the
|
| 88 |
// containing block of this fixed backdrop and trap the modal inside that card.
|
| 89 |
return createPortal(
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
/** Two step destructive button: first click arms it, second confirms. No browser dialogs. */
|
| 178 |
+
/** "Delete this?": deletes only on confirm, shows what went wrong if it fails, and closes once it is gone. */
|
| 179 |
+
export function ConfirmDeleteModal({ heading, name, onCancel, onConfirm }: {
|
| 180 |
+
heading: string;
|
| 181 |
+
name: string;
|
| 182 |
+
onCancel: () => void;
|
| 183 |
+
onConfirm: () => Promise<void>;
|
| 184 |
+
}) {
|
| 185 |
+
const [busy, setBusy] = useState(false);
|
| 186 |
+
const [error, setError] = useState<string | null>(null);
|
| 187 |
+
const confirm = async () => {
|
| 188 |
+
setBusy(true);
|
| 189 |
+
setError(null);
|
| 190 |
+
try {
|
| 191 |
+
await onConfirm();
|
| 192 |
+
onCancel(); // gone: close the warning
|
| 193 |
+
} catch (e) {
|
| 194 |
+
setError(errorMessage(e));
|
| 195 |
+
setBusy(false);
|
| 196 |
+
}
|
| 197 |
+
};
|
| 198 |
+
return (
|
| 199 |
+
<Modal title={heading} onClose={busy ? () => undefined : onCancel}>
|
| 200 |
+
<div className="stack sd-confirm">
|
| 201 |
+
<p>
|
| 202 |
+
<strong>{name}</strong> will be deleted for good. This cannot be undone.
|
| 203 |
+
</p>
|
| 204 |
+
{error && <p className="error-text" role="alert">{error}</p>}
|
| 205 |
+
<div className="row sd-confirm-actions">
|
| 206 |
+
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
| 207 |
+
<button type="button" className="btn btn-danger" onClick={confirm} disabled={busy} autoFocus>
|
| 208 |
+
{busy ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Deleting...</>) : "Delete"}
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</Modal>
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
export function ConfirmButton({
|
| 217 |
onConfirm,
|
| 218 |
children,
|
frontend/src/pages/Archive.tsx
CHANGED
|
@@ -1,17 +1,18 @@
|
|
| 1 |
-
import { useCallback, useEffect, useState } from "react";
|
| 2 |
-
import { Link, useSearchParams } from "react-router-dom";
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact } from "../api/types";
|
| 5 |
import { ArtifactRow } from "../components/ArtifactRow";
|
| 6 |
import { Reader } from "../components/Reader";
|
| 7 |
import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
|
| 8 |
import Videos from "./Videos";
|
| 9 |
-
import VoiceProfile from "./VoiceProfile";
|
| 10 |
import { canPost, PostButton } from "../components/launchpad/PostButton";
|
|
|
|
| 11 |
|
| 12 |
-
type Filter = "all" | "report" | "summary" | "quiz" | "flashcards" | "mind_map" | "infographic";
|
| 13 |
-
/** The Library's tabs: kinds of things made (a filter on one list), plus your videos
|
| 14 |
-
|
|
|
|
| 15 |
const TABS: { id: Tab; label: string }[] = [
|
| 16 |
{ id: "all", label: "All" },
|
| 17 |
{ id: "report", label: "Reports" },
|
|
@@ -20,13 +21,15 @@ const TABS: { id: Tab; label: string }[] = [
|
|
| 20 |
{ id: "flashcards", label: "Flashcards" },
|
| 21 |
{ id: "mind_map", label: "Mind Constellations" },
|
| 22 |
{ id: "infographic", label: "Infographics" },
|
|
|
|
|
|
|
| 23 |
{ id: "videos", label: "Videos" },
|
| 24 |
-
{ id: "voices", label: "Voices" },
|
| 25 |
];
|
| 26 |
const PAGE = 30;
|
| 27 |
|
| 28 |
export default function Archive() {
|
| 29 |
const [params, setParams] = useSearchParams();
|
|
|
|
| 30 |
const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
|
| 31 |
// The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
|
| 32 |
const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
|
|
@@ -35,11 +38,22 @@ export default function Archive() {
|
|
| 35 |
<div className="page-wrap">
|
| 36 |
<PageHeader eyebrow="Library" title="Everything you have made" />
|
| 37 |
<Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
|
| 38 |
-
{tab === "videos" ? <Videos /> :
|
| 39 |
</div>
|
| 40 |
);
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
/** What was made, of one kind (or all), searchable. */
|
| 44 |
function MadeList({ filter }: { filter: Filter }) {
|
| 45 |
const [q, setQ] = useState("");
|
|
@@ -48,19 +62,31 @@ function MadeList({ filter }: { filter: Filter }) {
|
|
| 48 |
const [failed, setFailed] = useState(false);
|
| 49 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 50 |
|
|
|
|
|
|
|
|
|
|
| 51 |
const load = useCallback(
|
| 52 |
-
(offset = 0) =>
|
| 53 |
-
|
|
|
|
| 54 |
.list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
|
| 55 |
.then((p) => {
|
|
|
|
| 56 |
setFailed(false);
|
| 57 |
setTotal(p.total);
|
| 58 |
setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
|
| 59 |
})
|
| 60 |
-
.catch(() => setFailed(true))
|
|
|
|
| 61 |
[filter, q],
|
| 62 |
);
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
useEffect(() => {
|
| 65 |
const t = setTimeout(() => load(0), 200);
|
| 66 |
return () => clearTimeout(t);
|
|
@@ -76,7 +102,7 @@ function MadeList({ filter }: { filter: Filter }) {
|
|
| 76 |
<EmptyState title="Could not load your library" body="Something went wrong reaching the server."
|
| 77 |
action={<button className="btn btn-primary" onClick={() => load(0)}>Try again</button>} />
|
| 78 |
)}
|
| 79 |
-
{items?.length === 0 && <EmptyState title="Nothing here yet" body="Reports, summaries, quizzes, flashcards, Mind Constellations, audio and more that you create show up here."
|
| 80 |
action={
|
| 81 |
<Link className="btn btn-primary" to="/app/studio">
|
| 82 |
Create something
|
|
@@ -88,7 +114,8 @@ function MadeList({ filter }: { filter: Filter }) {
|
|
| 88 |
<ArtifactRow
|
| 89 |
key={a.id}
|
| 90 |
artifact={a}
|
| 91 |
-
actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} />
|
|
|
|
| 92 |
onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
|
| 93 |
onRemoved={(id) => {
|
| 94 |
setItems((list) => (list ?? []).filter((x) => x.id !== id));
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Link, Navigate, useSearchParams } from "react-router-dom";
|
| 3 |
import { artifactsApi } from "../api/endpoints";
|
| 4 |
import type { Artifact } from "../api/types";
|
| 5 |
import { ArtifactRow } from "../components/ArtifactRow";
|
| 6 |
import { Reader } from "../components/Reader";
|
| 7 |
import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
|
| 8 |
import Videos from "./Videos";
|
|
|
|
| 9 |
import { canPost, PostButton } from "../components/launchpad/PostButton";
|
| 10 |
+
import { AudioOnlyModal } from "../components/launchpad/Attachment";
|
| 11 |
|
| 12 |
+
type Filter = "all" | "report" | "summary" | "quiz" | "flashcards" | "mind_map" | "infographic" | "slide_deck" | "audio_overview";
|
| 13 |
+
/** The Library's tabs: kinds of things made (a filter on one list), plus your videos (their own page). Voices moved to
|
| 14 |
+
* the sidebar (Manage voices, /app/voices); an old ?tab=voices link goes there. */
|
| 15 |
+
type Tab = Filter | "videos";
|
| 16 |
const TABS: { id: Tab; label: string }[] = [
|
| 17 |
{ id: "all", label: "All" },
|
| 18 |
{ id: "report", label: "Reports" },
|
|
|
|
| 21 |
{ id: "flashcards", label: "Flashcards" },
|
| 22 |
{ id: "mind_map", label: "Mind Constellations" },
|
| 23 |
{ id: "infographic", label: "Infographics" },
|
| 24 |
+
{ id: "slide_deck", label: "Slide decks" },
|
| 25 |
+
{ id: "audio_overview", label: "Audio overviews" },
|
| 26 |
{ id: "videos", label: "Videos" },
|
|
|
|
| 27 |
];
|
| 28 |
const PAGE = 30;
|
| 29 |
|
| 30 |
export default function Archive() {
|
| 31 |
const [params, setParams] = useSearchParams();
|
| 32 |
+
if (params.get("tab") === "voices") return <Navigate to="/app/voices" replace />;
|
| 33 |
const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
|
| 34 |
// The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
|
| 35 |
const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
|
|
|
|
| 38 |
<div className="page-wrap">
|
| 39 |
<PageHeader eyebrow="Library" title="Everything you have made" />
|
| 40 |
<Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
|
| 41 |
+
{tab === "videos" ? <Videos /> : <MadeList filter={tab} />}
|
| 42 |
</div>
|
| 43 |
);
|
| 44 |
}
|
| 45 |
|
| 46 |
+
/** "Post" on an audio overview: X and LinkedIn take no audio files, so it explains that and offers the download. */
|
| 47 |
+
function AudioPostButton({ artifact }: { artifact: Artifact }) {
|
| 48 |
+
const [open, setOpen] = useState(false);
|
| 49 |
+
return (
|
| 50 |
+
<>
|
| 51 |
+
<button type="button" className="btn btn-small" onClick={() => setOpen(true)}>Post</button>
|
| 52 |
+
{open && <AudioOnlyModal title={artifact.title} downloadUrl={artifact.download_url} onClose={() => setOpen(false)} />}
|
| 53 |
+
</>
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
/** What was made, of one kind (or all), searchable. */
|
| 58 |
function MadeList({ filter }: { filter: Filter }) {
|
| 59 |
const [q, setQ] = useState("");
|
|
|
|
| 62 |
const [failed, setFailed] = useState(false);
|
| 63 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 64 |
|
| 65 |
+
// The tab and search the list is showing: an answer for any other (a tab just left) is dropped when it arrives late.
|
| 66 |
+
const showing = useRef("");
|
| 67 |
+
showing.current = `${filter}|${q}`;
|
| 68 |
const load = useCallback(
|
| 69 |
+
(offset = 0) => {
|
| 70 |
+
const asked = `${filter}|${q}`;
|
| 71 |
+
return artifactsApi
|
| 72 |
.list({ type: filter === "all" ? undefined : filter, q, limit: PAGE, offset })
|
| 73 |
.then((p) => {
|
| 74 |
+
if (showing.current !== asked) return;
|
| 75 |
setFailed(false);
|
| 76 |
setTotal(p.total);
|
| 77 |
setItems((cur) => (offset ? [...(cur ?? []), ...p.items] : p.items));
|
| 78 |
})
|
| 79 |
+
.catch(() => showing.current === asked && setFailed(true));
|
| 80 |
+
},
|
| 81 |
[filter, q],
|
| 82 |
);
|
| 83 |
|
| 84 |
+
// Another tab: the loader until its items are fetched, never the last tab's items meanwhile.
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
setItems(null);
|
| 87 |
+
setFailed(false);
|
| 88 |
+
}, [filter]);
|
| 89 |
+
|
| 90 |
useEffect(() => {
|
| 91 |
const t = setTimeout(() => load(0), 200);
|
| 92 |
return () => clearTimeout(t);
|
|
|
|
| 102 |
<EmptyState title="Could not load your library" body="Something went wrong reaching the server."
|
| 103 |
action={<button className="btn btn-primary" onClick={() => load(0)}>Try again</button>} />
|
| 104 |
)}
|
| 105 |
+
{items?.length === 0 && <EmptyState title="Nothing here yet" body="Reports, summaries, quizzes, flashcards, Mind Constellations, slide decks, audio and more that you create show up here."
|
| 106 |
action={
|
| 107 |
<Link className="btn btn-primary" to="/app/studio">
|
| 108 |
Create something
|
|
|
|
| 114 |
<ArtifactRow
|
| 115 |
key={a.id}
|
| 116 |
artifact={a}
|
| 117 |
+
actions={(art) => (canPost(art) ? <PostButton artifactId={art.id} />
|
| 118 |
+
: art.type === "audio_overview" && art.status === "ready" ? <AudioPostButton artifact={art} /> : null)}
|
| 119 |
onCite={(c) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end })}
|
| 120 |
onRemoved={(id) => {
|
| 121 |
setItems((list) => (list ?? []).filter((x) => x.id !== id));
|
frontend/src/pages/Launchpad.tsx
CHANGED
|
@@ -52,8 +52,10 @@ function ItemModal({
|
|
| 52 |
const locked = item.status === "posted" || item.status === "publishing";
|
| 53 |
const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
|
| 54 |
|
|
|
|
|
|
|
| 55 |
const save = async () => {
|
| 56 |
-
if (moved && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
|
| 57 |
setBusy(true);
|
| 58 |
setError(null);
|
| 59 |
try {
|
|
@@ -62,7 +64,8 @@ function ItemModal({
|
|
| 62 |
await launchpadApi.update(item.id, {
|
| 63 |
content: clean[0],
|
| 64 |
thread: clean.slice(1),
|
| 65 |
-
...(moved ? { local_time: when, timezone: userTimeZone() } : {}),
|
|
|
|
| 66 |
social_account_id: accountId || null,
|
| 67 |
...((attached?.id ?? null) !== item.artifact_id ? { artifact_id: attached?.id ?? null } : {}),
|
| 68 |
}),
|
|
@@ -143,7 +146,7 @@ function ItemModal({
|
|
| 143 |
</label>
|
| 144 |
))}
|
| 145 |
{!locked && (
|
| 146 |
-
<div className="
|
| 147 |
<div className="field">
|
| 148 |
<span>When</span>
|
| 149 |
<SlotPicker value={when} onChange={setWhen} />
|
|
@@ -164,7 +167,7 @@ function ItemModal({
|
|
| 164 |
</div>
|
| 165 |
)}
|
| 166 |
{error && <p className="error-text">{error}</p>}
|
| 167 |
-
<div className="row between">
|
| 168 |
<div className="row">
|
| 169 |
<CopyButton text={posts.join("\n\n")} />
|
| 170 |
<ConfirmButton
|
|
@@ -201,8 +204,9 @@ function ItemModal({
|
|
| 201 |
>
|
| 202 |
{item.auto_post ? "Publish now" : "Send reminder now"}
|
| 203 |
</button>
|
| 204 |
-
<button className="btn btn-primary" disabled={busy} onClick={save}
|
| 205 |
-
|
|
|
|
| 206 |
</button>
|
| 207 |
</div>
|
| 208 |
)}
|
|
@@ -408,7 +412,6 @@ export default function Launchpad() {
|
|
| 408 |
</button>
|
| 409 |
</PageHeader>
|
| 410 |
{notice && <p className="notice">{notice}</p>}
|
| 411 |
-
{publishing && <p className="notice">Publishing... uploading a video can take a few minutes.</p>}
|
| 412 |
{error && <p className="error-text">{error}</p>}
|
| 413 |
|
| 414 |
|
|
@@ -478,7 +481,14 @@ export default function Launchpad() {
|
|
| 478 |
{items.length === 0 && <li className="muted">Nothing scheduled. Quiet moon tonight.</li>}
|
| 479 |
</ul>
|
| 480 |
)}
|
| 481 |
-
{items &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
</section>
|
| 483 |
|
| 484 |
<ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
|
|
@@ -495,22 +505,75 @@ export default function Launchpad() {
|
|
| 495 |
|
| 496 |
/** /app/launchpad/new: what to launch. Something from the Library opens the composer with it attached; Create a
|
| 497 |
* launch kit goes to the kit pages (and is scheduled from the kit). */
|
|
|
|
|
|
|
| 498 |
export function ScheduleLaunch() {
|
| 499 |
const navigate = useNavigate();
|
| 500 |
const [picked, setPicked] = useState<PostableArtifact | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
return (
|
| 502 |
<div className="page-wrap">
|
| 503 |
<BackArrow fallback="/app/launchpad" />
|
| 504 |
<PageHeader eyebrow="Launchpad" title="Schedule a launch" />
|
|
|
|
| 505 |
<section className="card">
|
| 506 |
<LibraryPicker fill onPick={setPicked} onPickKit={(k) => navigate(`/app/launchpad/kits/${k.id}`)}
|
| 507 |
-
onCreateKit={() => navigate("/app/launchpad/kits")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
</section>
|
| 509 |
{picked && (
|
| 510 |
<ScheduleModal platform="linkedin" posts={[]} artifact={picked} artifactId={picked.id}
|
| 511 |
onClose={() => setPicked(null)}
|
| 512 |
onScheduled={() => navigate("/app/launchpad")} />
|
| 513 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
</div>
|
| 515 |
);
|
| 516 |
}
|
|
|
|
| 52 |
const locked = item.status === "posted" || item.status === "publishing";
|
| 53 |
const options = (accounts?.accounts ?? []).filter((a) => a.platform === item.platform);
|
| 54 |
|
| 55 |
+
// Not on the calendar to go out (a draft, failed, reminded or paused post): Save puts it back, at the time shown.
|
| 56 |
+
const reschedules = item.status !== "scheduled";
|
| 57 |
const save = async () => {
|
| 58 |
+
if ((moved || reschedules) && wallToDate(when) <= new Date()) return setError("Pick a time that is still ahead.");
|
| 59 |
setBusy(true);
|
| 60 |
setError(null);
|
| 61 |
try {
|
|
|
|
| 64 |
await launchpadApi.update(item.id, {
|
| 65 |
content: clean[0],
|
| 66 |
thread: clean.slice(1),
|
| 67 |
+
...(moved || reschedules ? { local_time: when, timezone: userTimeZone() } : {}),
|
| 68 |
+
...(reschedules ? { status: "scheduled" as const } : {}),
|
| 69 |
social_account_id: accountId || null,
|
| 70 |
...((attached?.id ?? null) !== item.artifact_id ? { artifact_id: attached?.id ?? null } : {}),
|
| 71 |
}),
|
|
|
|
| 146 |
</label>
|
| 147 |
))}
|
| 148 |
{!locked && (
|
| 149 |
+
<div className="lp-edit-when">
|
| 150 |
<div className="field">
|
| 151 |
<span>When</span>
|
| 152 |
<SlotPicker value={when} onChange={setWhen} />
|
|
|
|
| 167 |
</div>
|
| 168 |
)}
|
| 169 |
{error && <p className="error-text">{error}</p>}
|
| 170 |
+
<div className="row between lp-edit-actions">
|
| 171 |
<div className="row">
|
| 172 |
<CopyButton text={posts.join("\n\n")} />
|
| 173 |
<ConfirmButton
|
|
|
|
| 204 |
>
|
| 205 |
{item.auto_post ? "Publish now" : "Send reminder now"}
|
| 206 |
</button>
|
| 207 |
+
<button className="btn btn-primary" disabled={busy} onClick={save}
|
| 208 |
+
title={reschedules ? "Save it and put it back on the calendar at this time" : undefined}>
|
| 209 |
+
{reschedules ? "Reschedule" : "Save"}
|
| 210 |
</button>
|
| 211 |
</div>
|
| 212 |
)}
|
|
|
|
| 412 |
</button>
|
| 413 |
</PageHeader>
|
| 414 |
{notice && <p className="notice">{notice}</p>}
|
|
|
|
| 415 |
{error && <p className="error-text">{error}</p>}
|
| 416 |
|
| 417 |
|
|
|
|
| 481 |
{items.length === 0 && <li className="muted">Nothing scheduled. Quiet moon tonight.</li>}
|
| 482 |
</ul>
|
| 483 |
)}
|
| 484 |
+
{items && (
|
| 485 |
+
<div className="lp-cal-foot">
|
| 486 |
+
<p className="mono muted small">{upcoming.length} upcoming in view</p>
|
| 487 |
+
<p className="muted small lp-cal-hint">
|
| 488 |
+
Click a post on the calendar to edit it: change its words or time, reschedule it, or publish it now.
|
| 489 |
+
</p>
|
| 490 |
+
</div>
|
| 491 |
+
)}
|
| 492 |
</section>
|
| 493 |
|
| 494 |
<ConnectionsPanel accounts={accounts} onConnect={connect} onChanged={async () => {
|
|
|
|
| 505 |
|
| 506 |
/** /app/launchpad/new: what to launch. Something from the Library opens the composer with it attached; Create a
|
| 507 |
* launch kit goes to the kit pages (and is scheduled from the kit). */
|
| 508 |
+
/** Schedule a launch: pick something from the Library to post. Something already scheduled says Reschedule and opens
|
| 509 |
+
* that post (the calendar's editor), and its menu can schedule it once more, starting from a copy of that post. */
|
| 510 |
export function ScheduleLaunch() {
|
| 511 |
const navigate = useNavigate();
|
| 512 |
const [picked, setPicked] = useState<PostableArtifact | null>(null);
|
| 513 |
+
// A post already on the calendar: opened to edit (rescheduled), or copied into a new one (a duplicate schedule).
|
| 514 |
+
const [editing, setEditing] = useState<CalendarItem | null>(null);
|
| 515 |
+
const [copying, setCopying] = useState<{ a: PostableArtifact; item: CalendarItem } | null>(null);
|
| 516 |
+
const [accounts, setAccounts] = useState<SocialAccounts | null>(null);
|
| 517 |
+
const [refreshKey, setRefreshKey] = useState(0);
|
| 518 |
+
const [error, setError] = useState<string | null>(null);
|
| 519 |
+
const [openingId, setOpeningId] = useState<string | null>(null); // the row whose post is loading
|
| 520 |
+
// The accounts the editor needs, loaded once with the page rather than on every click.
|
| 521 |
+
useEffect(() => {
|
| 522 |
+
launchpadApi.accounts().then(setAccounts, () => undefined);
|
| 523 |
+
}, []);
|
| 524 |
+
const scheduledItem = async (artifactId: string, id: string) => {
|
| 525 |
+
setError(null);
|
| 526 |
+
setOpeningId(artifactId);
|
| 527 |
+
try {
|
| 528 |
+
const [all, acc] = await Promise.all([launchpadApi.items({ status: "scheduled" }),
|
| 529 |
+
accounts ? Promise.resolve(accounts) : launchpadApi.accounts()]);
|
| 530 |
+
setAccounts(acc);
|
| 531 |
+
const found = all.find((i) => i.id === id);
|
| 532 |
+
if (!found) {
|
| 533 |
+
setError("That post isn't scheduled anymore.");
|
| 534 |
+
setRefreshKey((k) => k + 1);
|
| 535 |
+
}
|
| 536 |
+
return found ?? null;
|
| 537 |
+
} catch (e) {
|
| 538 |
+
setError(errorMessage(e));
|
| 539 |
+
return null;
|
| 540 |
+
} finally {
|
| 541 |
+
setOpeningId(null);
|
| 542 |
+
}
|
| 543 |
+
};
|
| 544 |
return (
|
| 545 |
<div className="page-wrap">
|
| 546 |
<BackArrow fallback="/app/launchpad" />
|
| 547 |
<PageHeader eyebrow="Launchpad" title="Schedule a launch" />
|
| 548 |
+
{error && <p className="error-text">{error}</p>}
|
| 549 |
<section className="card">
|
| 550 |
<LibraryPicker fill onPick={setPicked} onPickKit={(k) => navigate(`/app/launchpad/kits/${k.id}`)}
|
| 551 |
+
onCreateKit={() => navigate("/app/launchpad/kits")} refreshKey={refreshKey} openingId={openingId}
|
| 552 |
+
onReschedule={async (a, id) => setEditing(await scheduledItem(a.id, id))}
|
| 553 |
+
onDuplicate={async (a, id) => {
|
| 554 |
+
const item = await scheduledItem(a.id, id);
|
| 555 |
+
if (item) setCopying({ a, item });
|
| 556 |
+
}} />
|
| 557 |
</section>
|
| 558 |
{picked && (
|
| 559 |
<ScheduleModal platform="linkedin" posts={[]} artifact={picked} artifactId={picked.id}
|
| 560 |
onClose={() => setPicked(null)}
|
| 561 |
onScheduled={() => navigate("/app/launchpad")} />
|
| 562 |
)}
|
| 563 |
+
{copying && (
|
| 564 |
+
<ScheduleModal platform={copying.item.platform} posts={[copying.item.content, ...copying.item.thread]}
|
| 565 |
+
artifact={copying.a} artifactId={copying.a.id}
|
| 566 |
+
onClose={() => setCopying(null)}
|
| 567 |
+
onScheduled={() => {
|
| 568 |
+
setCopying(null);
|
| 569 |
+
setRefreshKey((k) => k + 1);
|
| 570 |
+
}} />
|
| 571 |
+
)}
|
| 572 |
+
{editing && (
|
| 573 |
+
<ItemModal item={editing} accounts={accounts} onClose={() => setEditing(null)}
|
| 574 |
+
onChanged={() => setRefreshKey((k) => k + 1)}
|
| 575 |
+
onReconnect={() => navigate("/app/launchpad")} />
|
| 576 |
+
)}
|
| 577 |
</div>
|
| 578 |
);
|
| 579 |
}
|
frontend/src/pages/NotebookView.tsx
CHANGED
|
@@ -1,20 +1,22 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useParams, useSearchParams } from "react-router-dom";
|
| 3 |
-
import { artifactsApi, notebooksApi
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
-
import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook
|
| 6 |
import { AnswerFeedback } from "../components/AnswerFeedback";
|
| 7 |
import { ArtifactCard, CitationList } from "../components/ArtifactCard";
|
| 8 |
import { Markdown } from "../components/Markdown";
|
| 9 |
import { DocPicker } from "../components/DocPicker";
|
| 10 |
-
import {
|
| 11 |
-
import { ChevronIcon, FlashcardsIcon, HeadphonesIcon, InfographicIcon, QuizIcon, ReportIcon, TelescopeIcon, VideoIcon } from "../components/icons/Icons";
|
| 12 |
import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap";
|
| 13 |
import { FlashcardsDialog, type FlashcardsRequest } from "../components/Flashcards";
|
| 14 |
import { InfographicDialog, type InfographicRequest } from "../components/Infographic";
|
|
|
|
|
|
|
| 15 |
import { QuizDialog, type QuizRequest } from "../components/Quiz";
|
| 16 |
import { MAX_STUDY_POSTS, prefetchFocus } from "../components/SourceFocusFields";
|
| 17 |
import { ReportDialog, type ReportRequest } from "../components/ReportDialog";
|
|
|
|
| 18 |
import { Reader } from "../components/Reader";
|
| 19 |
import { seedDocs } from "../components/video/sourceCache";
|
| 20 |
import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
|
|
@@ -45,7 +47,7 @@ function readChatRailOpen(): boolean {
|
|
| 45 |
}
|
| 46 |
|
| 47 |
/** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */
|
| 48 |
-
function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, disabled, quizOpen, onQuizClose, onCreateQuiz, cardsOpen, onCardsClose, onCreateCards, infographicOpen, onInfographicClose, onCreateInfographic, reportOpen, onReportClose, onCreateReport, onCreateVideo }: {
|
| 49 |
notebookId: string;
|
| 50 |
notebookTitle: string;
|
| 51 |
docs: { id: string; title: string; locked?: boolean }[];
|
|
@@ -61,67 +63,41 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
|
|
| 61 |
infographicOpen: boolean;
|
| 62 |
onInfographicClose: () => void;
|
| 63 |
onCreateInfographic: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
reportOpen: boolean;
|
| 65 |
onReportClose: () => void;
|
| 66 |
onCreateReport: () => void;
|
| 67 |
onCreateVideo: () => void;
|
| 68 |
}) {
|
| 69 |
const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
|
| 70 |
-
const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive");
|
| 71 |
-
const [minutes, setMinutes] = useState(6);
|
| 72 |
const [error, setError] = useState<string | null>(null);
|
| 73 |
-
const [busy, setBusy] = useState(false);
|
| 74 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
| 75 |
-
const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
|
| 76 |
-
// The two hosts' voices, picked from the workspace's voices (loaded when the options first open).
|
| 77 |
-
const [hostVoices, setHostVoices] = useState<VideoSavedVoice[] | null>(null);
|
| 78 |
-
const [hosts, setHosts] = useState<[string, string]>(["", ""]);
|
| 79 |
const [mapDialog, setMapDialog] = useState(false);
|
| 80 |
const [waitingMap, setWaitingMap] = useState<string | null>(null); // a map the user just asked for: open it when ready
|
| 81 |
const [exploring, setExploring] = useState<Artifact | null>(null);
|
| 82 |
|
| 83 |
const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
|
| 84 |
|
| 85 |
-
useEffect(() => {
|
| 86 |
-
if (!audioOpen || hostVoices) return;
|
| 87 |
-
videoVoicesApi.list().then((r) => {
|
| 88 |
-
setHostVoices(r.saved);
|
| 89 |
-
// The first two voices by default; with just one, it reads both parts.
|
| 90 |
-
setHosts([r.saved[0]?.voice_id ?? "", (r.saved[1] ?? r.saved[0])?.voice_id ?? ""]);
|
| 91 |
-
}).catch(() => setHostVoices([])); // none to pick: the server's default voices
|
| 92 |
-
}, [audioOpen, hostVoices]);
|
| 93 |
-
|
| 94 |
-
/** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
|
| 95 |
-
const pickHost = (i: 0 | 1, voiceId: string) =>
|
| 96 |
-
setHosts((cur) => {
|
| 97 |
-
const next: [string, string] = [...cur];
|
| 98 |
-
next[i] = voiceId;
|
| 99 |
-
const other = i === 0 ? 1 : 0;
|
| 100 |
-
if (next[other] === voiceId) next[other] = hostVoices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
|
| 101 |
-
return next;
|
| 102 |
-
});
|
| 103 |
useEffect(() => {
|
| 104 |
load();
|
| 105 |
}, [load]);
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
// The focus suggestions the study dialogs show, made now so they are waiting when a dialog opens.
|
| 108 |
const startIds = docs.filter((d) => !d.locked).slice(0, MAX_STUDY_POSTS).map((d) => d.id).join(",");
|
| 109 |
useEffect(() => {
|
| 110 |
if (startIds) prefetchFocus(notebookId, startIds.split(","));
|
| 111 |
}, [notebookId, startIds]);
|
| 112 |
|
| 113 |
-
const make = async (body: Omit<GenerateBody, "notebook_id">) => {
|
| 114 |
-
setBusy(true);
|
| 115 |
-
setError(null);
|
| 116 |
-
try {
|
| 117 |
-
const a = await artifactsApi.generate({ ...body, notebook_id: notebookId });
|
| 118 |
-
setArtifacts((list) => [a, ...(list ?? [])]);
|
| 119 |
-
} catch (e) {
|
| 120 |
-
setError(errorMessage(e));
|
| 121 |
-
} finally {
|
| 122 |
-
setBusy(false);
|
| 123 |
-
}
|
| 124 |
-
};
|
| 125 |
|
| 126 |
// Every "make" from a dialog works the same way: the dialog closes at once, a card in the panel says it is starting, the
|
| 127 |
// artifact replaces it as soon as the server has it (and shows its own progress), and a failure is shown in the panel.
|
|
@@ -156,6 +132,10 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
|
|
| 156 |
launch("report", onReportClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "report" }));
|
| 157 |
const makeInfographic = (body: InfographicRequest) =>
|
| 158 |
launch("infographic", onInfographicClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "infographic" }));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
const makeCards = (body: FlashcardsRequest) =>
|
| 160 |
launch("flashcards", onCardsClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "flashcards" }));
|
| 161 |
|
|
@@ -163,7 +143,7 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
|
|
| 163 |
const others = artifacts?.filter((a) => a.type !== "mind_map");
|
| 164 |
|
| 165 |
const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
|
| 166 |
-
const off = disabled
|
| 167 |
|
| 168 |
return (
|
| 169 |
<div className="stack">
|
|
@@ -183,61 +163,26 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
|
|
| 183 |
<circle cx="58" cy="18" r="2" fill="currentColor" />
|
| 184 |
</svg>
|
| 185 |
</button>
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
<span className="nbv-make-
|
| 191 |
-
<ChevronIcon size={16} className={`chevron${audioOpen ? " open" : ""}`} />
|
| 192 |
-
</span>
|
| 193 |
</button>
|
| 194 |
-
{audioOpen && (
|
| 195 |
-
<div className="nbv-audio-opts">
|
| 196 |
-
<div className="nbv-audio-row">
|
| 197 |
-
<div className="field">
|
| 198 |
-
<span className="small muted">Format</span>
|
| 199 |
-
<Dropdown<typeof format> label="Audio format" value={format} onChange={setFormat}
|
| 200 |
-
options={[
|
| 201 |
-
{ value: "deep_dive", label: "Deep dive" },
|
| 202 |
-
{ value: "brief", label: "Brief" },
|
| 203 |
-
{ value: "debate", label: "Debate" },
|
| 204 |
-
]} />
|
| 205 |
-
</div>
|
| 206 |
-
<div className="field">
|
| 207 |
-
<span className="small muted">Length</span>
|
| 208 |
-
<Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
|
| 209 |
-
options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
|
| 210 |
-
</div>
|
| 211 |
-
{hostVoices && hostVoices.length > 0 && ([0, 1] as const).map((i) => (
|
| 212 |
-
<div key={i} className="field">
|
| 213 |
-
<span className="small muted">Host {i + 1}</span>
|
| 214 |
-
<Dropdown<string> label={`Host ${i + 1} voice`} value={hosts[i]} onChange={(v) => pickHost(i, v)}
|
| 215 |
-
options={hostVoices.map((v) => ({ value: v.voice_id, label: v.name }))} />
|
| 216 |
-
</div>
|
| 217 |
-
))}
|
| 218 |
-
</div>
|
| 219 |
-
<button className="btn btn-primary btn-small" disabled={off || (audioOpen && hostVoices === null)}
|
| 220 |
-
onClick={() => make({ type: "audio_overview", format, minutes,
|
| 221 |
-
...(hosts[0] ? { host_a: hosts[0], host_b: hosts[1] || hosts[0] } : {}) })
|
| 222 |
-
.then(() => setAudioOpen(false))}>
|
| 223 |
-
{busy ? "Starting..." : "Create audio overview"}
|
| 224 |
-
</button>
|
| 225 |
-
</div>
|
| 226 |
-
)}
|
| 227 |
<button disabled={off} onClick={onCreateVideo}>
|
| 228 |
-
<VideoIcon size={18} /> Video
|
| 229 |
</button>
|
| 230 |
<button disabled={off} onClick={onCreateReport}>
|
| 231 |
-
<ReportIcon size={18} /> Report
|
| 232 |
</button>
|
| 233 |
<button disabled={off} onClick={onCreateInfographic}>
|
| 234 |
-
<InfographicIcon size={18} /> Infographic
|
| 235 |
</button>
|
| 236 |
<button disabled={off} onClick={onCreateQuiz}>
|
| 237 |
-
<QuizIcon size={18} /> Quiz
|
| 238 |
</button>
|
| 239 |
<button className="nbv-make-wide" disabled={off} onClick={onCreateCards}>
|
| 240 |
-
<FlashcardsIcon size={18} /> Flashcards
|
| 241 |
</button>
|
| 242 |
</div>
|
| 243 |
{disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
|
|
@@ -274,19 +219,17 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
|
|
| 274 |
</span>
|
| 275 |
</div>
|
| 276 |
))}
|
| 277 |
-
{others?.map((a) => (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
<ArtifactCard
|
| 279 |
key={a.id}
|
| 280 |
artifact={a}
|
| 281 |
onCite={cite}
|
| 282 |
onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))}
|
| 283 |
-
actions={(art) => canPost(art) ? <PostButton artifactId={art.id} /> :
|
| 284 |
-
art.type === "audio_overview" && art.status === "ready" ? (
|
| 285 |
-
<button className="btn btn-small" onClick={() => make({ type: "video", style: "audiogram", audio_artifact_id: art.id })}>
|
| 286 |
-
Audiogram
|
| 287 |
-
</button>
|
| 288 |
-
) : null
|
| 289 |
-
}
|
| 290 |
/>
|
| 291 |
))}
|
| 292 |
</div>}
|
|
@@ -297,6 +240,10 @@ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, di
|
|
| 297 |
error={null} onClose={onReportClose} onCreate={makeReport} />}
|
| 298 |
{infographicOpen && <InfographicDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 299 |
busy={false} error={null} onClose={onInfographicClose} onCreate={makeInfographic} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
{cardsOpen && <FlashcardsDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId} busy={false}
|
| 301 |
error={null} onClose={onCardsClose} onCreate={makeCards} />}
|
| 302 |
{mapDialog && <MindMapDialog docs={docs} busy={false} error={null} onClose={() => setMapDialog(false)} onCreate={makeMap} />}
|
|
@@ -319,6 +266,8 @@ export default function NotebookView() {
|
|
| 319 |
const [makingQuiz, setMakingQuiz] = useState(false); // the quiz settings are open in a modal
|
| 320 |
const [makingInfographic, setMakingInfographic] = useState(false); // the infographic settings are open in a modal
|
| 321 |
const [makingCards, setMakingCards] = useState(false); // the flashcard settings are open in a modal
|
|
|
|
|
|
|
| 322 |
const [makingReport, setMakingReport] = useState(false); // the report dialog is open in a modal
|
| 323 |
const [toAdd, setToAdd] = useState<string[]>([]);
|
| 324 |
const [addError, setAddError] = useState<string | null>(null);
|
|
@@ -335,6 +284,8 @@ export default function NotebookView() {
|
|
| 335 |
const chatLoad = useRef(0);
|
| 336 |
const asked = useRef(false);
|
| 337 |
const bottom = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
| 338 |
|
| 339 |
// Auto-save the title shortly after the user stops typing.
|
| 340 |
useEffect(() => {
|
|
@@ -527,6 +478,16 @@ export default function NotebookView() {
|
|
| 527 |
setMakingInfographic(true);
|
| 528 |
};
|
| 529 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
const openCards = () => {
|
| 531 |
seedDocs(nb.id, nb.documents);
|
| 532 |
setMakingCards(true);
|
|
@@ -544,7 +505,9 @@ export default function NotebookView() {
|
|
| 544 |
};
|
| 545 |
|
| 546 |
return (
|
| 547 |
-
<div className={`nbv${historyOpen ? "" : " nbv-history-closed"}`}>
|
|
|
|
|
|
|
| 548 |
{historyOpen && <aside className="nbv-pane nbv-history" aria-label="Notebook chats">
|
| 549 |
<Link to="/app/notebooks" className="nbv-history-back">
|
| 550 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
@@ -753,6 +716,8 @@ export default function NotebookView() {
|
|
| 753 |
onQuizClose={() => setMakingQuiz(false)} onCreateQuiz={openQuiz}
|
| 754 |
reportOpen={makingReport} onReportClose={() => setMakingReport(false)} onCreateReport={openReport}
|
| 755 |
infographicOpen={makingInfographic} onInfographicClose={() => setMakingInfographic(false)} onCreateInfographic={openInfographic}
|
|
|
|
|
|
|
| 756 |
cardsOpen={makingCards} onCardsClose={() => setMakingCards(false)} onCreateCards={openCards}
|
| 757 |
disabled={nb.documents.length === 0}
|
| 758 |
onCreateVideo={() => {
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useParams, useSearchParams } from "react-router-dom";
|
| 3 |
+
import { artifactsApi, notebooksApi } from "../api/endpoints";
|
| 4 |
import { streamSSE } from "../api/stream";
|
| 5 |
+
import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook } from "../api/types";
|
| 6 |
import { AnswerFeedback } from "../components/AnswerFeedback";
|
| 7 |
import { ArtifactCard, CitationList } from "../components/ArtifactCard";
|
| 8 |
import { Markdown } from "../components/Markdown";
|
| 9 |
import { DocPicker } from "../components/DocPicker";
|
| 10 |
+
import { FlashcardsIcon, HeadphonesIcon, InfographicIcon, QuizIcon, ReportIcon, SlidesIcon, TelescopeIcon, VideoIcon } from "../components/icons/Icons";
|
|
|
|
| 11 |
import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap";
|
| 12 |
import { FlashcardsDialog, type FlashcardsRequest } from "../components/Flashcards";
|
| 13 |
import { InfographicDialog, type InfographicRequest } from "../components/Infographic";
|
| 14 |
+
import { SlideDeckDialog, SlideDeckRow, type SlideDeckRequest } from "../components/SlideDeck";
|
| 15 |
+
import { AudioOverviewCard, AudioOverviewDialog, prefetchVoices, type AudioOverviewRequest } from "../components/AudioOverview";
|
| 16 |
import { QuizDialog, type QuizRequest } from "../components/Quiz";
|
| 17 |
import { MAX_STUDY_POSTS, prefetchFocus } from "../components/SourceFocusFields";
|
| 18 |
import { ReportDialog, type ReportRequest } from "../components/ReportDialog";
|
| 19 |
+
import { PaneResizer, usePaneWidths } from "../components/PaneResizer";
|
| 20 |
import { Reader } from "../components/Reader";
|
| 21 |
import { seedDocs } from "../components/video/sourceCache";
|
| 22 |
import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui";
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
/** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */
|
| 50 |
+
function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, disabled, quizOpen, onQuizClose, onCreateQuiz, cardsOpen, onCardsClose, onCreateCards, infographicOpen, onInfographicClose, onCreateInfographic, audioOpen, onAudioClose, onCreateAudio, slidesOpen, onSlidesClose, onCreateSlides, reportOpen, onReportClose, onCreateReport, onCreateVideo }: {
|
| 51 |
notebookId: string;
|
| 52 |
notebookTitle: string;
|
| 53 |
docs: { id: string; title: string; locked?: boolean }[];
|
|
|
|
| 63 |
infographicOpen: boolean;
|
| 64 |
onInfographicClose: () => void;
|
| 65 |
onCreateInfographic: () => void;
|
| 66 |
+
audioOpen: boolean;
|
| 67 |
+
onAudioClose: () => void;
|
| 68 |
+
onCreateAudio: () => void;
|
| 69 |
+
slidesOpen: boolean;
|
| 70 |
+
onSlidesClose: () => void;
|
| 71 |
+
onCreateSlides: () => void;
|
| 72 |
reportOpen: boolean;
|
| 73 |
onReportClose: () => void;
|
| 74 |
onCreateReport: () => void;
|
| 75 |
onCreateVideo: () => void;
|
| 76 |
}) {
|
| 77 |
const [artifacts, setArtifacts] = useState<Artifact[] | null>(null);
|
|
|
|
|
|
|
| 78 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 79 |
const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
const [mapDialog, setMapDialog] = useState(false);
|
| 81 |
const [waitingMap, setWaitingMap] = useState<string | null>(null); // a map the user just asked for: open it when ready
|
| 82 |
const [exploring, setExploring] = useState<Artifact | null>(null);
|
| 83 |
|
| 84 |
const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
useEffect(() => {
|
| 87 |
load();
|
| 88 |
}, [load]);
|
| 89 |
|
| 90 |
+
// The voices for the audio overview settings take a few seconds to list: ask now so they are ready when it opens.
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
prefetchVoices().catch(() => undefined);
|
| 93 |
+
}, []);
|
| 94 |
+
|
| 95 |
// The focus suggestions the study dialogs show, made now so they are waiting when a dialog opens.
|
| 96 |
const startIds = docs.filter((d) => !d.locked).slice(0, MAX_STUDY_POSTS).map((d) => d.id).join(",");
|
| 97 |
useEffect(() => {
|
| 98 |
if (startIds) prefetchFocus(notebookId, startIds.split(","));
|
| 99 |
}, [notebookId, startIds]);
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
// Every "make" from a dialog works the same way: the dialog closes at once, a card in the panel says it is starting, the
|
| 103 |
// artifact replaces it as soon as the server has it (and shows its own progress), and a failure is shown in the panel.
|
|
|
|
| 132 |
launch("report", onReportClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "report" }));
|
| 133 |
const makeInfographic = (body: InfographicRequest) =>
|
| 134 |
launch("infographic", onInfographicClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "infographic" }));
|
| 135 |
+
const makeAudio = (body: AudioOverviewRequest) =>
|
| 136 |
+
launch("audio overview", onAudioClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "audio_overview" }));
|
| 137 |
+
const makeSlides = (body: SlideDeckRequest) =>
|
| 138 |
+
launch("slide deck", onSlidesClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "slide_deck" }));
|
| 139 |
const makeCards = (body: FlashcardsRequest) =>
|
| 140 |
launch("flashcards", onCardsClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "flashcards" }));
|
| 141 |
|
|
|
|
| 143 |
const others = artifacts?.filter((a) => a.type !== "mind_map");
|
| 144 |
|
| 145 |
const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });
|
| 146 |
+
const off = disabled;
|
| 147 |
|
| 148 |
return (
|
| 149 |
<div className="stack">
|
|
|
|
| 163 |
<circle cx="58" cy="18" r="2" fill="currentColor" />
|
| 164 |
</svg>
|
| 165 |
</button>
|
| 166 |
+
<button className="nbv-make-audio" disabled={off} onClick={onCreateAudio}>
|
| 167 |
+
<HeadphonesIcon size={18} /> <span className="nbv-make-text">Audio overview</span>
|
| 168 |
+
</button>
|
| 169 |
+
<button className="nbv-make-slides" disabled={off} onClick={onCreateSlides}>
|
| 170 |
+
<SlidesIcon size={18} /> <span className="nbv-make-text">Slide deck</span>
|
|
|
|
|
|
|
| 171 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
<button disabled={off} onClick={onCreateVideo}>
|
| 173 |
+
<VideoIcon size={18} /> <span className="nbv-make-text">Video</span>
|
| 174 |
</button>
|
| 175 |
<button disabled={off} onClick={onCreateReport}>
|
| 176 |
+
<ReportIcon size={18} /> <span className="nbv-make-text">Report</span>
|
| 177 |
</button>
|
| 178 |
<button disabled={off} onClick={onCreateInfographic}>
|
| 179 |
+
<InfographicIcon size={18} /> <span className="nbv-make-text">Infographic</span>
|
| 180 |
</button>
|
| 181 |
<button disabled={off} onClick={onCreateQuiz}>
|
| 182 |
+
<QuizIcon size={18} /> <span className="nbv-make-text">Quiz</span>
|
| 183 |
</button>
|
| 184 |
<button className="nbv-make-wide" disabled={off} onClick={onCreateCards}>
|
| 185 |
+
<FlashcardsIcon size={18} /> <span className="nbv-make-text">Flashcards</span>
|
| 186 |
</button>
|
| 187 |
</div>
|
| 188 |
{disabled && <p className="muted small">Add posts to this notebook to start creating.</p>}
|
|
|
|
| 219 |
</span>
|
| 220 |
</div>
|
| 221 |
))}
|
| 222 |
+
{others?.map((a) => a.type === "slide_deck" ? (
|
| 223 |
+
<SlideDeckRow key={a.id} artifact={a} onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} />
|
| 224 |
+
) : a.type === "audio_overview" ? (
|
| 225 |
+
<AudioOverviewCard key={a.id} artifact={a} onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} />
|
| 226 |
+
) : (
|
| 227 |
<ArtifactCard
|
| 228 |
key={a.id}
|
| 229 |
artifact={a}
|
| 230 |
onCite={cite}
|
| 231 |
onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))}
|
| 232 |
+
actions={(art) => canPost(art) ? <PostButton artifactId={art.id} /> : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
/>
|
| 234 |
))}
|
| 235 |
</div>}
|
|
|
|
| 240 |
error={null} onClose={onReportClose} onCreate={makeReport} />}
|
| 241 |
{infographicOpen && <InfographicDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 242 |
busy={false} error={null} onClose={onInfographicClose} onCreate={makeInfographic} />}
|
| 243 |
+
{audioOpen && <AudioOverviewDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 244 |
+
onClose={onAudioClose} onCreate={makeAudio} />}
|
| 245 |
+
{slidesOpen && <SlideDeckDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 246 |
+
onClose={onSlidesClose} onCreate={makeSlides} />}
|
| 247 |
{cardsOpen && <FlashcardsDialog notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId} busy={false}
|
| 248 |
error={null} onClose={onCardsClose} onCreate={makeCards} />}
|
| 249 |
{mapDialog && <MindMapDialog docs={docs} busy={false} error={null} onClose={() => setMapDialog(false)} onCreate={makeMap} />}
|
|
|
|
| 266 |
const [makingQuiz, setMakingQuiz] = useState(false); // the quiz settings are open in a modal
|
| 267 |
const [makingInfographic, setMakingInfographic] = useState(false); // the infographic settings are open in a modal
|
| 268 |
const [makingCards, setMakingCards] = useState(false); // the flashcard settings are open in a modal
|
| 269 |
+
const [makingSlides, setMakingSlides] = useState(false); // the slide deck settings are open in a modal
|
| 270 |
+
const [makingAudio, setMakingAudio] = useState(false); // the audio overview settings are open in a modal
|
| 271 |
const [makingReport, setMakingReport] = useState(false); // the report dialog is open in a modal
|
| 272 |
const [toAdd, setToAdd] = useState<string[]>([]);
|
| 273 |
const [addError, setAddError] = useState<string | null>(null);
|
|
|
|
| 284 |
const chatLoad = useRef(0);
|
| 285 |
const asked = useRef(false);
|
| 286 |
const bottom = useRef<HTMLDivElement>(null);
|
| 287 |
+
const layout = useRef<HTMLDivElement>(null);
|
| 288 |
+
const panes = usePaneWidths(layout, { leftOpen: historyOpen }); // the side panels' dragged widths
|
| 289 |
|
| 290 |
// Auto-save the title shortly after the user stops typing.
|
| 291 |
useEffect(() => {
|
|
|
|
| 478 |
setMakingInfographic(true);
|
| 479 |
};
|
| 480 |
|
| 481 |
+
const openAudio = () => {
|
| 482 |
+
seedDocs(nb.id, nb.documents);
|
| 483 |
+
setMakingAudio(true);
|
| 484 |
+
};
|
| 485 |
+
|
| 486 |
+
const openSlides = () => {
|
| 487 |
+
seedDocs(nb.id, nb.documents);
|
| 488 |
+
setMakingSlides(true);
|
| 489 |
+
};
|
| 490 |
+
|
| 491 |
const openCards = () => {
|
| 492 |
seedDocs(nb.id, nb.documents);
|
| 493 |
setMakingCards(true);
|
|
|
|
| 505 |
};
|
| 506 |
|
| 507 |
return (
|
| 508 |
+
<div ref={layout} className={`nbv${historyOpen ? "" : " nbv-history-closed"}`} style={panes.style}>
|
| 509 |
+
<PaneResizer side="left" panes={panes} container={layout} label="Resize the chats panel" />
|
| 510 |
+
<PaneResizer side="right" panes={panes} container={layout} label="Resize the create panel" />
|
| 511 |
{historyOpen && <aside className="nbv-pane nbv-history" aria-label="Notebook chats">
|
| 512 |
<Link to="/app/notebooks" className="nbv-history-back">
|
| 513 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
|
|
| 716 |
onQuizClose={() => setMakingQuiz(false)} onCreateQuiz={openQuiz}
|
| 717 |
reportOpen={makingReport} onReportClose={() => setMakingReport(false)} onCreateReport={openReport}
|
| 718 |
infographicOpen={makingInfographic} onInfographicClose={() => setMakingInfographic(false)} onCreateInfographic={openInfographic}
|
| 719 |
+
audioOpen={makingAudio} onAudioClose={() => setMakingAudio(false)} onCreateAudio={openAudio}
|
| 720 |
+
slidesOpen={makingSlides} onSlidesClose={() => setMakingSlides(false)} onCreateSlides={openSlides}
|
| 721 |
cardsOpen={makingCards} onCardsClose={() => setMakingCards(false)} onCreateCards={openCards}
|
| 722 |
disabled={nb.documents.length === 0}
|
| 723 |
onCreateVideo={() => {
|
frontend/src/pages/VideoCreate.tsx
CHANGED
|
@@ -465,7 +465,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
|
|
| 465 |
{voices === null ? (
|
| 466 |
<Loading label="Loading voices" />
|
| 467 |
) : saved.length === 0 ? (
|
| 468 |
-
<p className="muted">No voices yet. Add some
|
| 469 |
) : (
|
| 470 |
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
|
| 471 |
{saved.map((v) => {
|
|
|
|
| 465 |
{voices === null ? (
|
| 466 |
<Loading label="Loading voices" />
|
| 467 |
) : saved.length === 0 ? (
|
| 468 |
+
<p className="muted">No voices yet. Add some in <Link to="/app/voices">Manage voices</Link>.</p>
|
| 469 |
) : (
|
| 470 |
<div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
|
| 471 |
{saved.map((v) => {
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -1,21 +1,11 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
-
import { useSearchParams } from "react-router-dom";
|
| 3 |
import { voiceApi } from "../api/endpoints";
|
| 4 |
-
import type { Job, VoiceDesignInput, VoiceDesignPreview,
|
| 5 |
-
import { DocPicker } from "../components/DocPicker";
|
| 6 |
import { YourVoices } from "../components/voice/YourVoices";
|
| 7 |
-
import { ConfirmButton, errorMessage,
|
| 8 |
import { useJob } from "../hooks/useJob";
|
| 9 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 10 |
|
| 11 |
-
const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
|
| 12 |
-
{ key: "tone", label: "Tone", hint: "warm, wry, direct" },
|
| 13 |
-
{ key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
|
| 14 |
-
{ key: "structure_habits", label: "Structure habits", hint: "short paragraphs, one idea each" },
|
| 15 |
-
{ key: "openings", label: "How pieces open", hint: "a number, a small scene" },
|
| 16 |
-
{ key: "avoid", label: "Never does", hint: "jargon, hashtags" },
|
| 17 |
-
];
|
| 18 |
-
|
| 19 |
/** Plays one URL at a time across the page. */
|
| 20 |
function usePlayer() {
|
| 21 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
@@ -416,216 +406,79 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
|
|
| 416 |
);
|
| 417 |
}
|
| 418 |
|
| 419 |
-
// The page:
|
| 420 |
-
//
|
| 421 |
-
|
| 422 |
-
type Step = "writing" | "hosts";
|
| 423 |
-
|
| 424 |
-
/** The underlined link between the two views (Speaking voices <-> Writing voice), its arrow pointing the way. */
|
| 425 |
-
function ViewLink({ to, label, onGo }: { to: Step; label: string; onGo: (s: Step) => void }) {
|
| 426 |
-
const back = to === "hosts";
|
| 427 |
-
return (
|
| 428 |
-
<button type="button" className={`vp-switch${back ? " back" : ""}`} onClick={() => onGo(to)}>
|
| 429 |
-
{back && <span aria-hidden="true">←</span>}
|
| 430 |
-
<span className="vp-switch-text">{label}</span>
|
| 431 |
-
{!back && <span aria-hidden="true">→</span>}
|
| 432 |
-
</button>
|
| 433 |
-
);
|
| 434 |
-
}
|
| 435 |
|
| 436 |
export default function VoiceProfile() {
|
| 437 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 438 |
-
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 439 |
-
const [params] = useSearchParams();
|
| 440 |
-
// Opens on Speaking voices; ?step=writing opens the writing voice (links between the two switch views).
|
| 441 |
-
const [step, setStep] = useState<Step>(params.get("step") === "writing" ? "writing" : "hosts");
|
| 442 |
-
const [picking, setPicking] = useState(false);
|
| 443 |
-
const [samples, setSamples] = useState<string[]>([]);
|
| 444 |
-
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
| 445 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 446 |
const [cloning, setCloning] = useState(false);
|
| 447 |
const [adding, setAdding] = useState(false); // the Add voices modal
|
| 448 |
const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
|
| 449 |
const { openUpgrade } = useUpgrade();
|
| 450 |
-
const [saved, setSaved] = useState(false);
|
| 451 |
const [error, setError] = useState<string | null>(null);
|
| 452 |
-
const autoBuilt = useRef(false);
|
| 453 |
const { play, playing } = usePlayer();
|
| 454 |
|
| 455 |
-
const load = () =>
|
| 456 |
-
voiceApi.get().then((s) => {
|
| 457 |
-
setState(s);
|
| 458 |
-
setDraft(s.profile);
|
| 459 |
-
setSamples(s.sample_doc_ids);
|
| 460 |
-
return s;
|
| 461 |
-
});
|
| 462 |
const loadVoices = () => setVoicesKey((k) => k + 1);
|
| 463 |
|
| 464 |
-
const startBuild = async (ids: string[]) => {
|
| 465 |
-
setPicking(false);
|
| 466 |
-
setError(null);
|
| 467 |
-
try {
|
| 468 |
-
setBuildJob(await voiceApi.build(ids));
|
| 469 |
-
} catch (e) {
|
| 470 |
-
setError(errorMessage(e));
|
| 471 |
-
}
|
| 472 |
-
};
|
| 473 |
-
|
| 474 |
useEffect(() => {
|
| 475 |
-
|
| 476 |
-
load().then((s) => {
|
| 477 |
-
if (!s.profile && !autoBuilt.current) {
|
| 478 |
-
autoBuilt.current = true;
|
| 479 |
-
void startBuild([]);
|
| 480 |
-
}
|
| 481 |
-
});
|
| 482 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 483 |
}, []);
|
| 484 |
|
| 485 |
-
const build = useJob(buildJob, () => load());
|
| 486 |
const clone = useJob(cloneJob, () => {
|
| 487 |
load();
|
| 488 |
loadVoices(); // a finished clone is saved to Your voices as "My voice"
|
| 489 |
});
|
| 490 |
|
| 491 |
if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
|
| 492 |
-
const building = build && (build.status === "queued" || build.status === "running");
|
| 493 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 494 |
const showClone = state.clone.status !== "none" || Boolean(clone);
|
| 495 |
|
| 496 |
-
const saveProfile = async () => {
|
| 497 |
-
setError(null);
|
| 498 |
-
try {
|
| 499 |
-
setState(await voiceApi.update({ profile: draft! }));
|
| 500 |
-
setSaved(true);
|
| 501 |
-
setTimeout(() => setSaved(false), 1500);
|
| 502 |
-
} catch (e) {
|
| 503 |
-
setError(errorMessage(e));
|
| 504 |
-
}
|
| 505 |
-
};
|
| 506 |
-
|
| 507 |
return (
|
| 508 |
-
//
|
| 509 |
<div className="stack vp">
|
| 510 |
-
|
| 511 |
-
<div className="vp-switch-row">
|
| 512 |
-
{step === "hosts"
|
| 513 |
-
? <ViewLink to="writing" label="Writing voice" onGo={setStep} />
|
| 514 |
-
: <ViewLink to="hosts" label="Speaking voices" onGo={setStep} />}
|
| 515 |
-
</div>
|
| 516 |
{error && <p className="error-text">{error}</p>}
|
| 517 |
|
| 518 |
-
|
| 519 |
-
<
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
<div className="row">
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
<button className="link-btn small" onClick={() => setPicking(true)}>
|
| 529 |
-
or pick the posts yourself
|
| 530 |
-
</button>
|
| 531 |
-
</div>
|
| 532 |
-
)}
|
| 533 |
-
|
| 534 |
-
{draft && (
|
| 535 |
-
<>
|
| 536 |
-
<label className="field">
|
| 537 |
-
<span>In a sentence, you sound like this</span>
|
| 538 |
-
<textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
|
| 539 |
-
</label>
|
| 540 |
-
<details className="delivery">
|
| 541 |
-
<summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
|
| 542 |
-
<label className="field">
|
| 543 |
-
<span>Sentence length</span>
|
| 544 |
-
<select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
|
| 545 |
-
{["short", "medium", "long", "varied"].map((v) => (
|
| 546 |
-
<option key={v}>{v}</option>
|
| 547 |
-
))}
|
| 548 |
-
</select>
|
| 549 |
-
</label>
|
| 550 |
-
{LIST_FIELDS.map((f) => (
|
| 551 |
-
<label key={f.key} className="field">
|
| 552 |
-
<span>{f.label}</span>
|
| 553 |
-
<input
|
| 554 |
-
className="input input-sm"
|
| 555 |
-
placeholder={f.hint}
|
| 556 |
-
value={(draft[f.key] as string[]).join(", ")}
|
| 557 |
-
onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
|
| 558 |
-
/>
|
| 559 |
-
</label>
|
| 560 |
-
))}
|
| 561 |
-
</details>
|
| 562 |
-
<p className="mono muted small">
|
| 563 |
-
Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
|
| 564 |
-
<button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
|
| 565 |
-
Rebuild from other posts
|
| 566 |
</button>
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
<button className="btn btn-small" onClick={
|
| 570 |
-
{
|
| 571 |
</button>
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
{showClone && (
|
| 585 |
-
<div className="vp-clone">
|
| 586 |
-
<strong>Your cloned voice</strong>
|
| 587 |
-
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
|
| 588 |
-
{state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
|
| 589 |
-
{state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
|
| 590 |
-
<div className="row">
|
| 591 |
-
{state.clone.status === "ready" && state.clone.preview_url && (
|
| 592 |
-
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 593 |
-
{playing === "clone" ? "Stop" : "Hear my voice"}
|
| 594 |
-
</button>
|
| 595 |
-
)}
|
| 596 |
-
{!cloneRunning && state.clone.allowed && (
|
| 597 |
-
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 598 |
-
{state.clone.status === "failed" ? "Try again" : "Record again"}
|
| 599 |
-
</button>
|
| 600 |
-
)}
|
| 601 |
-
{state.clone.status !== "none" && (
|
| 602 |
-
<ConfirmButton
|
| 603 |
-
confirmLabel="Revoke and delete my voice?"
|
| 604 |
-
onConfirm={async () => {
|
| 605 |
-
setState(await voiceApi.revoke());
|
| 606 |
-
loadVoices();
|
| 607 |
-
}}
|
| 608 |
-
>
|
| 609 |
-
Revoke consent
|
| 610 |
-
</ConfirmButton>
|
| 611 |
-
)}
|
| 612 |
-
</div>
|
| 613 |
</div>
|
| 614 |
-
)}
|
| 615 |
-
</section>
|
| 616 |
-
)}
|
| 617 |
-
|
| 618 |
-
{picking && (
|
| 619 |
-
<Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
|
| 620 |
-
<DocPicker selected={samples} onChange={setSamples} max={10} />
|
| 621 |
-
<p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
|
| 622 |
-
<div className="row end">
|
| 623 |
-
<button className="btn btn-primary" onClick={() => startBuild(samples)}>
|
| 624 |
-
Build profile
|
| 625 |
-
</button>
|
| 626 |
</div>
|
| 627 |
-
|
| 628 |
-
|
|
|
|
| 629 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 630 |
{adding && (
|
| 631 |
<Modal title="Add voices" onClose={() => setAdding(false)} wide>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
+
import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceState } from "../api/types";
|
|
|
|
| 4 |
import { YourVoices } from "../components/voice/YourVoices";
|
| 5 |
+
import { ConfirmButton, errorMessage, JobProgress, Loading, Modal, Tabs } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
/** Plays one URL at a time across the page. */
|
| 10 |
function usePlayer() {
|
| 11 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
|
|
| 406 |
);
|
| 407 |
}
|
| 408 |
|
| 409 |
+
// The page: the speaking voices (who reads audio and video aloud). Cloning lives in the "+ Add a voice" menu; a
|
| 410 |
+
// clone's status shows under the voice cards. The writing voice is built by the server on import and not shown here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
|
| 412 |
export default function VoiceProfile() {
|
| 413 |
const [state, setState] = useState<VoiceState | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 415 |
const [cloning, setCloning] = useState(false);
|
| 416 |
const [adding, setAdding] = useState(false); // the Add voices modal
|
| 417 |
const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
|
| 418 |
const { openUpgrade } = useUpgrade();
|
|
|
|
| 419 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 420 |
const { play, playing } = usePlayer();
|
| 421 |
|
| 422 |
+
const load = () => voiceApi.get().then(setState, (e) => setError(errorMessage(e)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
const loadVoices = () => setVoicesKey((k) => k + 1);
|
| 424 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
useEffect(() => {
|
| 426 |
+
void load();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 428 |
}, []);
|
| 429 |
|
|
|
|
| 430 |
const clone = useJob(cloneJob, () => {
|
| 431 |
load();
|
| 432 |
loadVoices(); // a finished clone is saved to Your voices as "My voice"
|
| 433 |
});
|
| 434 |
|
| 435 |
if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
|
|
|
|
| 436 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 437 |
const showClone = state.clone.status !== "none" || Boolean(clone);
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
return (
|
| 440 |
+
// Manage voices (/app/voices): the speaking voices.
|
| 441 |
<div className="stack vp">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
{error && <p className="error-text">{error}</p>}
|
| 443 |
|
| 444 |
+
<section className="card stack vp-card">
|
| 445 |
+
<h2>Who reads your work aloud</h2>
|
| 446 |
+
{!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
|
| 447 |
+
<YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
|
| 448 |
+
|
| 449 |
+
{showClone && (
|
| 450 |
+
<div className="vp-clone">
|
| 451 |
+
<strong>Your cloned voice</strong>
|
| 452 |
+
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
|
| 453 |
+
{state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
|
| 454 |
+
{state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
|
| 455 |
<div className="row">
|
| 456 |
+
{state.clone.status === "ready" && state.clone.preview_url && (
|
| 457 |
+
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 458 |
+
{playing === "clone" ? "Stop" : "Hear my voice"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
</button>
|
| 460 |
+
)}
|
| 461 |
+
{!cloneRunning && state.clone.allowed && (
|
| 462 |
+
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 463 |
+
{state.clone.status === "failed" ? "Try again" : "Record again"}
|
| 464 |
</button>
|
| 465 |
+
)}
|
| 466 |
+
{state.clone.status !== "none" && (
|
| 467 |
+
<ConfirmButton
|
| 468 |
+
confirmLabel="Revoke and delete my voice?"
|
| 469 |
+
onConfirm={async () => {
|
| 470 |
+
setState(await voiceApi.revoke());
|
| 471 |
+
loadVoices();
|
| 472 |
+
}}
|
| 473 |
+
>
|
| 474 |
+
Revoke consent
|
| 475 |
+
</ConfirmButton>
|
| 476 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
</div>
|
| 479 |
+
)}
|
| 480 |
+
</section>
|
| 481 |
+
|
| 482 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 483 |
{adding && (
|
| 484 |
<Modal title="Add voices" onClose={() => setAdding(false)} wide>
|
frontend/src/pages/Voices.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PageHeader } from "../components/ui";
|
| 2 |
+
import VoiceProfile from "./VoiceProfile";
|
| 3 |
+
|
| 4 |
+
/** Manage voices (in the sidebar): the speaking voices for audio and video, and the writing voice. */
|
| 5 |
+
export default function Voices() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="page-wrap">
|
| 8 |
+
<PageHeader eyebrow="Voices" title="Manage voices" />
|
| 9 |
+
<VoiceProfile />
|
| 10 |
+
</div>
|
| 11 |
+
);
|
| 12 |
+
}
|
frontend/src/styles/pages.css
CHANGED
|
@@ -2277,54 +2277,6 @@ select.input option {
|
|
| 2277 |
animation: welcome-in 0.35s ease both;
|
| 2278 |
}
|
| 2279 |
|
| 2280 |
-
/* The link between the two views (Speaking voices / Writing voice): underlined text and an arrow */
|
| 2281 |
-
.vp-switch-row {
|
| 2282 |
-
display: flex;
|
| 2283 |
-
justify-content: flex-end; /* at the right end */
|
| 2284 |
-
margin: -6px 0 4px;
|
| 2285 |
-
}
|
| 2286 |
-
|
| 2287 |
-
.vp-switch {
|
| 2288 |
-
display: inline-flex;
|
| 2289 |
-
align-items: center;
|
| 2290 |
-
gap: 8px;
|
| 2291 |
-
padding: 4px 0;
|
| 2292 |
-
border: 0;
|
| 2293 |
-
background: none;
|
| 2294 |
-
color: var(--text-2);
|
| 2295 |
-
font: inherit;
|
| 2296 |
-
font-size: 0.95rem;
|
| 2297 |
-
cursor: pointer;
|
| 2298 |
-
transition: color 0.15s;
|
| 2299 |
-
}
|
| 2300 |
-
|
| 2301 |
-
.vp-switch-text {
|
| 2302 |
-
text-decoration: underline;
|
| 2303 |
-
text-decoration-color: var(--line-strong);
|
| 2304 |
-
text-underline-offset: 4px;
|
| 2305 |
-
transition: text-decoration-color 0.15s;
|
| 2306 |
-
}
|
| 2307 |
-
|
| 2308 |
-
.vp-switch:hover {
|
| 2309 |
-
color: var(--text);
|
| 2310 |
-
}
|
| 2311 |
-
|
| 2312 |
-
.vp-switch:hover .vp-switch-text {
|
| 2313 |
-
text-decoration-color: var(--blue);
|
| 2314 |
-
}
|
| 2315 |
-
|
| 2316 |
-
.vp-switch > span[aria-hidden] {
|
| 2317 |
-
transition: transform 0.15s;
|
| 2318 |
-
}
|
| 2319 |
-
|
| 2320 |
-
.vp-switch:hover > span[aria-hidden] {
|
| 2321 |
-
transform: translateX(3px);
|
| 2322 |
-
}
|
| 2323 |
-
|
| 2324 |
-
.vp-switch.back:hover > span[aria-hidden] {
|
| 2325 |
-
transform: translateX(-3px);
|
| 2326 |
-
}
|
| 2327 |
-
|
| 2328 |
.voice-grid {
|
| 2329 |
list-style: none;
|
| 2330 |
margin: 0;
|
|
@@ -3415,7 +3367,8 @@ select.input,
|
|
| 3415 |
}
|
| 3416 |
|
| 3417 |
.nbv {
|
| 3418 |
-
|
|
|
|
| 3419 |
grid-template-areas:
|
| 3420 |
"history chat"
|
| 3421 |
"history side";
|
|
@@ -3483,12 +3436,12 @@ select.input,
|
|
| 3483 |
}
|
| 3484 |
|
| 3485 |
.nbv {
|
| 3486 |
-
grid-template-columns: clamp(16rem, 20vw, 20rem) minmax(0, 1fr) clamp(18rem, 24vw, 24rem);
|
| 3487 |
grid-template-areas: "history chat side";
|
| 3488 |
}
|
| 3489 |
|
| 3490 |
.nbv.nbv-history-closed {
|
| 3491 |
-
grid-template-columns: minmax(0, 1fr) clamp(18rem, 24vw, 24rem);
|
| 3492 |
grid-template-areas: "chat side";
|
| 3493 |
}
|
| 3494 |
|
|
@@ -4402,6 +4355,87 @@ select.input,
|
|
| 4402 |
color: var(--white);
|
| 4403 |
}
|
| 4404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4405 |
.lp-pick-go {
|
| 4406 |
display: inline-flex;
|
| 4407 |
flex: none;
|
|
@@ -4612,52 +4646,6 @@ details.map-legend[open] summary {
|
|
| 4612 |
border-color: var(--blue);
|
| 4613 |
}
|
| 4614 |
|
| 4615 |
-
.nbv-audio-opts {
|
| 4616 |
-
display: grid;
|
| 4617 |
-
gap: 12px;
|
| 4618 |
-
padding: 14px;
|
| 4619 |
-
border: 1px solid var(--blue-35);
|
| 4620 |
-
border-radius: var(--radius-sm);
|
| 4621 |
-
background: var(--blue-05);
|
| 4622 |
-
}
|
| 4623 |
-
|
| 4624 |
-
.nbv-audio-row {
|
| 4625 |
-
display: grid;
|
| 4626 |
-
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); /* Format needs a little more room than Length */
|
| 4627 |
-
gap: 10px;
|
| 4628 |
-
}
|
| 4629 |
-
|
| 4630 |
-
/* Each dropdown fills its column and can shrink, instead of spilling into its neighbour */
|
| 4631 |
-
.nbv-audio-row > .field {
|
| 4632 |
-
min-width: 0;
|
| 4633 |
-
}
|
| 4634 |
-
|
| 4635 |
-
.nbv-audio-row > .field > * {
|
| 4636 |
-
width: 100%;
|
| 4637 |
-
min-width: 0;
|
| 4638 |
-
}
|
| 4639 |
-
|
| 4640 |
-
.nbv-audio-opts .dropdown-btn {
|
| 4641 |
-
padding: 0 12px;
|
| 4642 |
-
font-size: 0.9rem;
|
| 4643 |
-
}
|
| 4644 |
-
|
| 4645 |
-
.nbv-audio-opts .dropdown-btn {
|
| 4646 |
-
height: 40px;
|
| 4647 |
-
border-radius: 12px;
|
| 4648 |
-
}
|
| 4649 |
-
|
| 4650 |
-
.nbv-audio-opts > .btn {
|
| 4651 |
-
justify-self: end;
|
| 4652 |
-
}
|
| 4653 |
-
|
| 4654 |
-
.nbv-audio-opts .dropdown-value {
|
| 4655 |
-
min-width: 0;
|
| 4656 |
-
overflow: hidden;
|
| 4657 |
-
text-overflow: ellipsis;
|
| 4658 |
-
white-space: nowrap;
|
| 4659 |
-
}
|
| 4660 |
-
|
| 4661 |
/* ---------- Mind map: the right-rail tile and list, the dialog, and the galaxy explorer ---------- */
|
| 4662 |
.nbv-make .nbv-make-map {
|
| 4663 |
grid-column: 1 / -1;
|
|
@@ -7559,13 +7547,12 @@ details.map-legend[open] summary {
|
|
| 7559 |
border-top-color: var(--black);
|
| 7560 |
}
|
| 7561 |
|
| 7562 |
-
/* Create buttons: two to a row. Audio overview
|
|
|
|
| 7563 |
.nbv-make {
|
| 7564 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 7565 |
}
|
| 7566 |
|
| 7567 |
-
.nbv-make .nbv-make-main,
|
| 7568 |
-
.nbv-make .nbv-audio-opts,
|
| 7569 |
.nbv-make .nbv-make-stars {
|
| 7570 |
grid-column: 1 / -1;
|
| 7571 |
}
|
|
@@ -8221,3 +8208,1074 @@ details.map-legend[open] summary {
|
|
| 8221 |
.nbv-starting-text strong {
|
| 8222 |
font: 500 0.92rem var(--font-body);
|
| 8223 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2277 |
animation: welcome-in 0.35s ease both;
|
| 2278 |
}
|
| 2279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2280 |
.voice-grid {
|
| 2281 |
list-style: none;
|
| 2282 |
margin: 0;
|
|
|
|
| 3367 |
}
|
| 3368 |
|
| 3369 |
.nbv {
|
| 3370 |
+
position: relative; /* the resize handles are placed over the boundaries */
|
| 3371 |
+
grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr);
|
| 3372 |
grid-template-areas:
|
| 3373 |
"history chat"
|
| 3374 |
"history side";
|
|
|
|
| 3436 |
}
|
| 3437 |
|
| 3438 |
.nbv {
|
| 3439 |
+
grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
|
| 3440 |
grid-template-areas: "history chat side";
|
| 3441 |
}
|
| 3442 |
|
| 3443 |
.nbv.nbv-history-closed {
|
| 3444 |
+
grid-template-columns: minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
|
| 3445 |
grid-template-areas: "chat side";
|
| 3446 |
}
|
| 3447 |
|
|
|
|
| 4355 |
color: var(--white);
|
| 4356 |
}
|
| 4357 |
|
| 4358 |
+
/* Under the calendar: how many posts are coming up, and how to change one */
|
| 4359 |
+
.lp-cal-foot {
|
| 4360 |
+
display: flex;
|
| 4361 |
+
flex-wrap: wrap;
|
| 4362 |
+
align-items: baseline;
|
| 4363 |
+
justify-content: space-between;
|
| 4364 |
+
gap: 6px 16px;
|
| 4365 |
+
}
|
| 4366 |
+
|
| 4367 |
+
.lp-cal-foot p {
|
| 4368 |
+
margin: 0;
|
| 4369 |
+
}
|
| 4370 |
+
|
| 4371 |
+
.lp-cal-hint {
|
| 4372 |
+
color: var(--text-2);
|
| 4373 |
+
}
|
| 4374 |
+
|
| 4375 |
+
/* Editing a scheduled post: When and Account side by side, their labels and boxes on one line (the time zone note
|
| 4376 |
+
sits under When without pushing Account down), and the actions on their own line below a rule */
|
| 4377 |
+
.lp-edit-when {
|
| 4378 |
+
display: grid;
|
| 4379 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 4380 |
+
align-items: start;
|
| 4381 |
+
gap: 16px;
|
| 4382 |
+
}
|
| 4383 |
+
|
| 4384 |
+
.lp-edit-when .field {
|
| 4385 |
+
margin: 0;
|
| 4386 |
+
}
|
| 4387 |
+
|
| 4388 |
+
.lp-edit-when select.input {
|
| 4389 |
+
width: 100%;
|
| 4390 |
+
}
|
| 4391 |
+
|
| 4392 |
+
@media (max-width: 640px) {
|
| 4393 |
+
.lp-edit-when {
|
| 4394 |
+
grid-template-columns: minmax(0, 1fr);
|
| 4395 |
+
}
|
| 4396 |
+
}
|
| 4397 |
+
|
| 4398 |
+
.lp-edit-actions {
|
| 4399 |
+
flex-wrap: wrap;
|
| 4400 |
+
gap: 12px;
|
| 4401 |
+
margin-top: 4px;
|
| 4402 |
+
padding-top: 16px;
|
| 4403 |
+
border-top: 1px solid var(--line);
|
| 4404 |
+
}
|
| 4405 |
+
|
| 4406 |
+
/* "Audio can't be posted": the headphones, why, and the download */
|
| 4407 |
+
.lp-audio-only {
|
| 4408 |
+
max-width: 460px;
|
| 4409 |
+
}
|
| 4410 |
+
|
| 4411 |
+
.lp-audio-only p {
|
| 4412 |
+
margin: 0;
|
| 4413 |
+
line-height: 1.6;
|
| 4414 |
+
}
|
| 4415 |
+
|
| 4416 |
+
.lp-audio-only-icon {
|
| 4417 |
+
display: grid;
|
| 4418 |
+
place-items: center;
|
| 4419 |
+
width: 52px;
|
| 4420 |
+
height: 52px;
|
| 4421 |
+
border-radius: 50%;
|
| 4422 |
+
background: var(--blue-10);
|
| 4423 |
+
color: var(--blue);
|
| 4424 |
+
}
|
| 4425 |
+
|
| 4426 |
+
.lp-audio-only-actions {
|
| 4427 |
+
justify-content: flex-end;
|
| 4428 |
+
align-items: center;
|
| 4429 |
+
gap: 10px;
|
| 4430 |
+
}
|
| 4431 |
+
|
| 4432 |
+
/* A slide deck: outlined like a video, dashed (its slides go out as images) */
|
| 4433 |
+
.lp-tag.deck {
|
| 4434 |
+
border-color: var(--blue-35);
|
| 4435 |
+
border-style: dashed;
|
| 4436 |
+
color: var(--white);
|
| 4437 |
+
}
|
| 4438 |
+
|
| 4439 |
.lp-pick-go {
|
| 4440 |
display: inline-flex;
|
| 4441 |
flex: none;
|
|
|
|
| 4646 |
border-color: var(--blue);
|
| 4647 |
}
|
| 4648 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4649 |
/* ---------- Mind map: the right-rail tile and list, the dialog, and the galaxy explorer ---------- */
|
| 4650 |
.nbv-make .nbv-make-map {
|
| 4651 |
grid-column: 1 / -1;
|
|
|
|
| 7547 |
border-top-color: var(--black);
|
| 7548 |
}
|
| 7549 |
|
| 7550 |
+
/* Create buttons: two to a row. Audio overview shares its row with Slide deck; its options and Mind Constellation take
|
| 7551 |
+
a whole row. */
|
| 7552 |
.nbv-make {
|
| 7553 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 7554 |
}
|
| 7555 |
|
|
|
|
|
|
|
| 7556 |
.nbv-make .nbv-make-stars {
|
| 7557 |
grid-column: 1 / -1;
|
| 7558 |
}
|
|
|
|
| 8208 |
.nbv-starting-text strong {
|
| 8209 |
font: 500 0.92rem var(--font-body);
|
| 8210 |
}
|
| 8211 |
+
|
| 8212 |
+
|
| 8213 |
+
/* ---- Slide decks ------------------------------------------------------------------------------------------------ */
|
| 8214 |
+
|
| 8215 |
+
.sd-dialog {
|
| 8216 |
+
gap: 22px;
|
| 8217 |
+
}
|
| 8218 |
+
|
| 8219 |
+
/* The settings: the two styles across one row, then format, length and language side by side */
|
| 8220 |
+
.sd-themes {
|
| 8221 |
+
display: grid;
|
| 8222 |
+
grid-template-columns: repeat(2, minmax(0, 220px));
|
| 8223 |
+
justify-content: center;
|
| 8224 |
+
gap: 16px;
|
| 8225 |
+
}
|
| 8226 |
+
|
| 8227 |
+
.sd-theme {
|
| 8228 |
+
display: grid;
|
| 8229 |
+
gap: 6px;
|
| 8230 |
+
padding: 5px 5px 8px;
|
| 8231 |
+
text-align: left;
|
| 8232 |
+
border: 1px solid var(--line);
|
| 8233 |
+
border-radius: 12px;
|
| 8234 |
+
background: transparent;
|
| 8235 |
+
color: inherit;
|
| 8236 |
+
cursor: pointer;
|
| 8237 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 8238 |
+
}
|
| 8239 |
+
|
| 8240 |
+
.sd-theme:hover {
|
| 8241 |
+
border-color: var(--line-strong);
|
| 8242 |
+
}
|
| 8243 |
+
|
| 8244 |
+
.sd-theme.on {
|
| 8245 |
+
border-color: var(--blue);
|
| 8246 |
+
box-shadow: 0 0 0 1px var(--blue), var(--glow-sm);
|
| 8247 |
+
}
|
| 8248 |
+
|
| 8249 |
+
.sd-theme-img {
|
| 8250 |
+
position: relative;
|
| 8251 |
+
display: block;
|
| 8252 |
+
}
|
| 8253 |
+
|
| 8254 |
+
.sd-theme-img img {
|
| 8255 |
+
display: block;
|
| 8256 |
+
width: 100%;
|
| 8257 |
+
aspect-ratio: 16 / 9; /* the whole slide, small */
|
| 8258 |
+
object-fit: cover;
|
| 8259 |
+
border-radius: 8px;
|
| 8260 |
+
}
|
| 8261 |
+
|
| 8262 |
+
.sd-check {
|
| 8263 |
+
position: absolute;
|
| 8264 |
+
top: 6px;
|
| 8265 |
+
right: 6px;
|
| 8266 |
+
display: grid;
|
| 8267 |
+
place-items: center;
|
| 8268 |
+
width: 22px;
|
| 8269 |
+
height: 22px;
|
| 8270 |
+
border-radius: 50%;
|
| 8271 |
+
background: var(--black);
|
| 8272 |
+
color: var(--white);
|
| 8273 |
+
box-shadow: 0 0 0 2px var(--blue);
|
| 8274 |
+
}
|
| 8275 |
+
|
| 8276 |
+
.sd-theme-name {
|
| 8277 |
+
padding: 0 4px;
|
| 8278 |
+
text-align: center;
|
| 8279 |
+
font-size: 0.85rem;
|
| 8280 |
+
font-weight: 600;
|
| 8281 |
+
}
|
| 8282 |
+
|
| 8283 |
+
.sd-opts {
|
| 8284 |
+
display: grid;
|
| 8285 |
+
grid-template-columns: auto minmax(210px, 1fr) minmax(140px, 0.7fr); /* format as wide as its pills */
|
| 8286 |
+
column-gap: 28px;
|
| 8287 |
+
row-gap: 14px;
|
| 8288 |
+
align-items: start;
|
| 8289 |
+
}
|
| 8290 |
+
|
| 8291 |
+
.sd-opts .field {
|
| 8292 |
+
gap: 8px;
|
| 8293 |
+
min-width: 0;
|
| 8294 |
+
}
|
| 8295 |
+
|
| 8296 |
+
/* The dropdowns stay inside their column: a long value shortens with an ellipsis */
|
| 8297 |
+
.sd-opts .dropdown,
|
| 8298 |
+
.sd-opts .dropdown-btn {
|
| 8299 |
+
min-width: 0;
|
| 8300 |
+
max-width: 100%;
|
| 8301 |
+
}
|
| 8302 |
+
|
| 8303 |
+
.sd-opts .dropdown-value {
|
| 8304 |
+
overflow: hidden;
|
| 8305 |
+
text-overflow: ellipsis;
|
| 8306 |
+
white-space: nowrap;
|
| 8307 |
+
}
|
| 8308 |
+
|
| 8309 |
+
/* The length's slide range, inside its dropdown: the same small grey text as the hints under the other fields */
|
| 8310 |
+
.sd-opts .dropdown-value .muted {
|
| 8311 |
+
font-size: 0.83em;
|
| 8312 |
+
}
|
| 8313 |
+
|
| 8314 |
+
.sd-opts .dropdown-btn {
|
| 8315 |
+
padding-left: 16px;
|
| 8316 |
+
padding-right: 14px;
|
| 8317 |
+
}
|
| 8318 |
+
|
| 8319 |
+
/* Format pills look exactly like the dropdowns beside them: same height, text, border and fill */
|
| 8320 |
+
.sd-opts .vw-chips {
|
| 8321 |
+
flex-wrap: nowrap;
|
| 8322 |
+
gap: 8px;
|
| 8323 |
+
}
|
| 8324 |
+
|
| 8325 |
+
.sd-opts .vw-chip {
|
| 8326 |
+
display: inline-flex;
|
| 8327 |
+
align-items: center;
|
| 8328 |
+
height: 48px;
|
| 8329 |
+
padding: 0 18px;
|
| 8330 |
+
border: 1px solid var(--line-strong);
|
| 8331 |
+
border-radius: 999px;
|
| 8332 |
+
background: rgba(0, 0, 0, 0.6);
|
| 8333 |
+
color: var(--text);
|
| 8334 |
+
font: 500 1rem var(--font-body);
|
| 8335 |
+
white-space: nowrap;
|
| 8336 |
+
}
|
| 8337 |
+
|
| 8338 |
+
.sd-opts .vw-chip.on {
|
| 8339 |
+
border-color: var(--blue);
|
| 8340 |
+
background: var(--blue);
|
| 8341 |
+
color: var(--white);
|
| 8342 |
+
}
|
| 8343 |
+
|
| 8344 |
+
.sd-opts .vw-chip:hover:not(.on) {
|
| 8345 |
+
border-color: var(--blue-60);
|
| 8346 |
+
}
|
| 8347 |
+
|
| 8348 |
+
/* The format's description wraps under the pills instead of widening their column */
|
| 8349 |
+
.sd-opts > .field:first-child > small {
|
| 8350 |
+
display: block;
|
| 8351 |
+
width: 0;
|
| 8352 |
+
min-width: 100%;
|
| 8353 |
+
}
|
| 8354 |
+
|
| 8355 |
+
/* A little more room than other dialogs, for the three settings side by side */
|
| 8356 |
+
.modal.modal-wide:has(.sd-dialog) {
|
| 8357 |
+
width: min(840px, calc(100vw - 32px));
|
| 8358 |
+
}
|
| 8359 |
+
|
| 8360 |
+
.sd-prompt {
|
| 8361 |
+
width: 100%;
|
| 8362 |
+
min-height: 92px;
|
| 8363 |
+
padding: 14px 18px;
|
| 8364 |
+
border-radius: 14px;
|
| 8365 |
+
line-height: 1.55;
|
| 8366 |
+
resize: vertical;
|
| 8367 |
+
}
|
| 8368 |
+
|
| 8369 |
+
/* One 1920 x 1080 slide, scaled into whatever box it is given. */
|
| 8370 |
+
.sd-frame {
|
| 8371 |
+
position: relative;
|
| 8372 |
+
width: 100%;
|
| 8373 |
+
overflow: hidden;
|
| 8374 |
+
background: #000;
|
| 8375 |
+
}
|
| 8376 |
+
|
| 8377 |
+
.sd-frame-width {
|
| 8378 |
+
aspect-ratio: 16 / 9;
|
| 8379 |
+
}
|
| 8380 |
+
|
| 8381 |
+
.sd-frame-contain {
|
| 8382 |
+
position: absolute;
|
| 8383 |
+
inset: 0;
|
| 8384 |
+
background: transparent;
|
| 8385 |
+
}
|
| 8386 |
+
|
| 8387 |
+
.sd-frame iframe {
|
| 8388 |
+
position: absolute;
|
| 8389 |
+
top: 50%;
|
| 8390 |
+
left: 50%;
|
| 8391 |
+
border: 0;
|
| 8392 |
+
transform-origin: center center;
|
| 8393 |
+
pointer-events: none;
|
| 8394 |
+
}
|
| 8395 |
+
|
| 8396 |
+
/* In edit mode the slide's text is edited in place, so the frame takes the mouse */
|
| 8397 |
+
.sd-frame-edit iframe {
|
| 8398 |
+
pointer-events: auto;
|
| 8399 |
+
}
|
| 8400 |
+
|
| 8401 |
+
/* A deck in the notebook's list: a quiet row (icon, title, "2 sources · 5d ago", menu), highlighted on hover */
|
| 8402 |
+
.sd-item {
|
| 8403 |
+
display: grid;
|
| 8404 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 8405 |
+
align-items: center;
|
| 8406 |
+
column-gap: 4px;
|
| 8407 |
+
padding: 4px 4px 4px 0;
|
| 8408 |
+
border-radius: 16px;
|
| 8409 |
+
transition: background 0.15s;
|
| 8410 |
+
}
|
| 8411 |
+
|
| 8412 |
+
.sd-item.is-ready:hover,
|
| 8413 |
+
.sd-item:focus-within {
|
| 8414 |
+
background: var(--surface-2);
|
| 8415 |
+
}
|
| 8416 |
+
|
| 8417 |
+
.sd-item-main {
|
| 8418 |
+
display: grid;
|
| 8419 |
+
grid-template-columns: 40px minmax(0, 1fr);
|
| 8420 |
+
align-items: center;
|
| 8421 |
+
gap: 10px;
|
| 8422 |
+
min-width: 0;
|
| 8423 |
+
padding: 8px 4px 8px 10px;
|
| 8424 |
+
border-radius: 16px;
|
| 8425 |
+
outline: none;
|
| 8426 |
+
}
|
| 8427 |
+
|
| 8428 |
+
.sd-item.is-ready .sd-item-main {
|
| 8429 |
+
cursor: pointer;
|
| 8430 |
+
}
|
| 8431 |
+
|
| 8432 |
+
.sd-item-main:focus-visible {
|
| 8433 |
+
box-shadow: 0 0 0 2px var(--blue);
|
| 8434 |
+
}
|
| 8435 |
+
|
| 8436 |
+
.sd-item-icon {
|
| 8437 |
+
display: grid;
|
| 8438 |
+
place-items: center;
|
| 8439 |
+
width: 40px;
|
| 8440 |
+
height: 40px;
|
| 8441 |
+
color: var(--blue);
|
| 8442 |
+
}
|
| 8443 |
+
|
| 8444 |
+
.sd-item-text {
|
| 8445 |
+
display: grid;
|
| 8446 |
+
gap: 2px;
|
| 8447 |
+
min-width: 0;
|
| 8448 |
+
}
|
| 8449 |
+
|
| 8450 |
+
.sd-item-text strong {
|
| 8451 |
+
overflow: hidden;
|
| 8452 |
+
text-overflow: ellipsis;
|
| 8453 |
+
white-space: nowrap;
|
| 8454 |
+
font-weight: 500;
|
| 8455 |
+
font-size: 0.95rem;
|
| 8456 |
+
color: var(--text);
|
| 8457 |
+
}
|
| 8458 |
+
|
| 8459 |
+
.sd-item-meta {
|
| 8460 |
+
overflow: hidden;
|
| 8461 |
+
text-overflow: ellipsis;
|
| 8462 |
+
white-space: nowrap;
|
| 8463 |
+
font-size: 0.82rem;
|
| 8464 |
+
color: var(--text-3);
|
| 8465 |
+
}
|
| 8466 |
+
|
| 8467 |
+
.sd-item .sd-menu-wrap .sd-icon-btn {
|
| 8468 |
+
color: var(--text-2);
|
| 8469 |
+
}
|
| 8470 |
+
|
| 8471 |
+
.sd-item-progress {
|
| 8472 |
+
grid-column: 1 / -1;
|
| 8473 |
+
padding: 0 12px 8px 60px;
|
| 8474 |
+
}
|
| 8475 |
+
|
| 8476 |
+
.sd-item-failed {
|
| 8477 |
+
display: flex;
|
| 8478 |
+
align-items: center;
|
| 8479 |
+
gap: 12px;
|
| 8480 |
+
flex-wrap: wrap;
|
| 8481 |
+
}
|
| 8482 |
+
|
| 8483 |
+
.sd-item-failed p {
|
| 8484 |
+
flex: 1;
|
| 8485 |
+
margin: 0;
|
| 8486 |
+
}
|
| 8487 |
+
|
| 8488 |
+
.sd-rename {
|
| 8489 |
+
width: 100%;
|
| 8490 |
+
padding: 4px 10px;
|
| 8491 |
+
font-size: 0.95rem;
|
| 8492 |
+
}
|
| 8493 |
+
|
| 8494 |
+
/* The deck's popup: actions along the top, every slide below it */
|
| 8495 |
+
.modal.modal-wide:has(.sd-view) {
|
| 8496 |
+
width: min(980px, calc(100vw - 32px));
|
| 8497 |
+
max-height: calc(100vh - 32px);
|
| 8498 |
+
display: flex;
|
| 8499 |
+
flex-direction: column;
|
| 8500 |
+
transition: width 0.2s ease;
|
| 8501 |
+
}
|
| 8502 |
+
|
| 8503 |
+
.modal.modal-wide:has(.sd-view.big) {
|
| 8504 |
+
width: calc(100vw - 32px);
|
| 8505 |
+
height: calc(100vh - 32px);
|
| 8506 |
+
}
|
| 8507 |
+
|
| 8508 |
+
.modal:has(.sd-view) .modal-head {
|
| 8509 |
+
gap: 12px;
|
| 8510 |
+
}
|
| 8511 |
+
|
| 8512 |
+
.modal:has(.sd-view) .modal-head h2 {
|
| 8513 |
+
min-width: 0;
|
| 8514 |
+
overflow: hidden;
|
| 8515 |
+
text-overflow: ellipsis;
|
| 8516 |
+
white-space: nowrap;
|
| 8517 |
+
}
|
| 8518 |
+
|
| 8519 |
+
.sd-head-actions {
|
| 8520 |
+
display: flex;
|
| 8521 |
+
align-items: center;
|
| 8522 |
+
gap: 4px;
|
| 8523 |
+
flex: none;
|
| 8524 |
+
}
|
| 8525 |
+
|
| 8526 |
+
.sd-icon-btn {
|
| 8527 |
+
display: grid;
|
| 8528 |
+
place-items: center;
|
| 8529 |
+
width: 38px;
|
| 8530 |
+
height: 38px;
|
| 8531 |
+
padding: 0;
|
| 8532 |
+
border: 0;
|
| 8533 |
+
border-radius: 50%;
|
| 8534 |
+
background: transparent;
|
| 8535 |
+
color: var(--text);
|
| 8536 |
+
cursor: pointer;
|
| 8537 |
+
transition: background 0.15s, color 0.15s;
|
| 8538 |
+
}
|
| 8539 |
+
|
| 8540 |
+
.sd-icon-btn:hover:not(:disabled),
|
| 8541 |
+
.sd-icon-btn[aria-expanded="true"],
|
| 8542 |
+
.sd-icon-btn[aria-pressed="true"] {
|
| 8543 |
+
background: var(--surface-2);
|
| 8544 |
+
color: var(--white);
|
| 8545 |
+
}
|
| 8546 |
+
|
| 8547 |
+
.sd-icon-btn:focus-visible {
|
| 8548 |
+
outline: 2px solid var(--blue);
|
| 8549 |
+
outline-offset: 1px;
|
| 8550 |
+
}
|
| 8551 |
+
|
| 8552 |
+
.sd-icon-btn:disabled {
|
| 8553 |
+
opacity: 0.5;
|
| 8554 |
+
cursor: default;
|
| 8555 |
+
}
|
| 8556 |
+
|
| 8557 |
+
.sd-menu-wrap {
|
| 8558 |
+
position: relative;
|
| 8559 |
+
}
|
| 8560 |
+
|
| 8561 |
+
/* In <body>, above popups (z-index over the modal's backdrop) */
|
| 8562 |
+
.sd-menu.sd-menu-floating {
|
| 8563 |
+
position: fixed;
|
| 8564 |
+
top: auto;
|
| 8565 |
+
right: auto;
|
| 8566 |
+
z-index: 2100;
|
| 8567 |
+
}
|
| 8568 |
+
|
| 8569 |
+
.sd-menu {
|
| 8570 |
+
position: absolute;
|
| 8571 |
+
top: calc(100% + 6px);
|
| 8572 |
+
right: 0;
|
| 8573 |
+
z-index: 20;
|
| 8574 |
+
display: grid;
|
| 8575 |
+
min-width: 270px;
|
| 8576 |
+
padding: 6px;
|
| 8577 |
+
border: 1px solid var(--line-strong);
|
| 8578 |
+
border-radius: var(--radius-sm);
|
| 8579 |
+
background: var(--black);
|
| 8580 |
+
box-shadow: var(--glow-lg);
|
| 8581 |
+
}
|
| 8582 |
+
|
| 8583 |
+
.sd-menu button {
|
| 8584 |
+
display: flex;
|
| 8585 |
+
align-items: center;
|
| 8586 |
+
gap: 12px;
|
| 8587 |
+
padding: 10px 12px;
|
| 8588 |
+
border: 0;
|
| 8589 |
+
border-radius: 6px;
|
| 8590 |
+
background: transparent;
|
| 8591 |
+
color: var(--white);
|
| 8592 |
+
font: 500 0.92rem var(--font-body);
|
| 8593 |
+
text-align: left;
|
| 8594 |
+
white-space: nowrap;
|
| 8595 |
+
cursor: pointer;
|
| 8596 |
+
}
|
| 8597 |
+
|
| 8598 |
+
.sd-menu button:hover,
|
| 8599 |
+
.sd-menu button:focus-visible {
|
| 8600 |
+
background: var(--surface-2);
|
| 8601 |
+
}
|
| 8602 |
+
|
| 8603 |
+
.sd-menu button.danger {
|
| 8604 |
+
color: #ff8a8a;
|
| 8605 |
+
}
|
| 8606 |
+
|
| 8607 |
+
.sd-confirm p {
|
| 8608 |
+
margin: 0;
|
| 8609 |
+
line-height: 1.6;
|
| 8610 |
+
}
|
| 8611 |
+
|
| 8612 |
+
.sd-confirm-actions {
|
| 8613 |
+
justify-content: flex-end;
|
| 8614 |
+
gap: 10px;
|
| 8615 |
+
}
|
| 8616 |
+
|
| 8617 |
+
.sd-menu .error-text {
|
| 8618 |
+
margin: 4px 8px;
|
| 8619 |
+
}
|
| 8620 |
+
|
| 8621 |
+
.sd-menu-error {
|
| 8622 |
+
position: absolute;
|
| 8623 |
+
top: calc(100% + 6px);
|
| 8624 |
+
right: 0;
|
| 8625 |
+
width: 240px;
|
| 8626 |
+
text-align: right;
|
| 8627 |
+
}
|
| 8628 |
+
|
| 8629 |
+
.sd-view {
|
| 8630 |
+
display: grid;
|
| 8631 |
+
gap: 12px;
|
| 8632 |
+
min-height: 0;
|
| 8633 |
+
flex: 1;
|
| 8634 |
+
}
|
| 8635 |
+
|
| 8636 |
+
.sd-meta {
|
| 8637 |
+
margin: 0;
|
| 8638 |
+
}
|
| 8639 |
+
|
| 8640 |
+
.sd-stack {
|
| 8641 |
+
display: grid;
|
| 8642 |
+
gap: 16px;
|
| 8643 |
+
max-height: calc(100vh - 190px);
|
| 8644 |
+
margin: 0;
|
| 8645 |
+
padding: 0 6px 6px 0;
|
| 8646 |
+
overflow-y: auto;
|
| 8647 |
+
list-style: none;
|
| 8648 |
+
}
|
| 8649 |
+
|
| 8650 |
+
.sd-view.big .sd-stack {
|
| 8651 |
+
max-height: calc(100vh - 160px);
|
| 8652 |
+
justify-items: center;
|
| 8653 |
+
}
|
| 8654 |
+
|
| 8655 |
+
.sd-view.big .sd-stack > li {
|
| 8656 |
+
width: min(100%, calc((100vh - 190px) * 16 / 9));
|
| 8657 |
+
}
|
| 8658 |
+
|
| 8659 |
+
.sd-slide {
|
| 8660 |
+
display: block;
|
| 8661 |
+
width: 100%;
|
| 8662 |
+
padding: 0;
|
| 8663 |
+
border: 1px solid var(--line);
|
| 8664 |
+
border-radius: 14px;
|
| 8665 |
+
overflow: hidden;
|
| 8666 |
+
background: none;
|
| 8667 |
+
cursor: pointer;
|
| 8668 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 8669 |
+
}
|
| 8670 |
+
|
| 8671 |
+
.sd-slide:hover,
|
| 8672 |
+
.sd-slide:focus-visible {
|
| 8673 |
+
border-color: var(--blue-60);
|
| 8674 |
+
box-shadow: var(--glow-sm);
|
| 8675 |
+
}
|
| 8676 |
+
|
| 8677 |
+
/* Edit mode: the slide's text boxes are clicked into, so no pointer; the slide being typed in is lit */
|
| 8678 |
+
.sd-slide-editing {
|
| 8679 |
+
cursor: default;
|
| 8680 |
+
}
|
| 8681 |
+
|
| 8682 |
+
.sd-slide-editing:focus-within {
|
| 8683 |
+
border-color: var(--blue);
|
| 8684 |
+
box-shadow: var(--glow-sm);
|
| 8685 |
+
}
|
| 8686 |
+
|
| 8687 |
+
.sd-save {
|
| 8688 |
+
height: 36px;
|
| 8689 |
+
margin-right: 6px;
|
| 8690 |
+
padding: 0 18px;
|
| 8691 |
+
}
|
| 8692 |
+
|
| 8693 |
+
.sd-edit-hint {
|
| 8694 |
+
color: var(--text-2);
|
| 8695 |
+
}
|
| 8696 |
+
|
| 8697 |
+
.sd-edit-error {
|
| 8698 |
+
margin: -4px 0 10px;
|
| 8699 |
+
}
|
| 8700 |
+
|
| 8701 |
+
/* Edit mode: each slide's controls above it (layout, text boxes to add, move, duplicate, delete) */
|
| 8702 |
+
.sd-tools {
|
| 8703 |
+
display: flex;
|
| 8704 |
+
flex-wrap: wrap;
|
| 8705 |
+
align-items: center;
|
| 8706 |
+
gap: 6px;
|
| 8707 |
+
margin-bottom: 8px;
|
| 8708 |
+
}
|
| 8709 |
+
|
| 8710 |
+
.sd-tools-no {
|
| 8711 |
+
margin-right: 4px;
|
| 8712 |
+
color: var(--text-2);
|
| 8713 |
+
font-size: 0.8125rem;
|
| 8714 |
+
font-weight: 600;
|
| 8715 |
+
}
|
| 8716 |
+
|
| 8717 |
+
.sd-tools-layout select,
|
| 8718 |
+
.sd-add-slide select {
|
| 8719 |
+
width: auto;
|
| 8720 |
+
height: 32px;
|
| 8721 |
+
padding: 0 30px 0 12px;
|
| 8722 |
+
font-size: 0.8125rem;
|
| 8723 |
+
}
|
| 8724 |
+
|
| 8725 |
+
.sd-tool {
|
| 8726 |
+
height: 32px;
|
| 8727 |
+
padding: 0 12px;
|
| 8728 |
+
border: 1px dashed var(--line-strong);
|
| 8729 |
+
border-radius: 999px;
|
| 8730 |
+
background: transparent;
|
| 8731 |
+
color: var(--text);
|
| 8732 |
+
font: 600 0.8125rem var(--font-body);
|
| 8733 |
+
cursor: pointer;
|
| 8734 |
+
}
|
| 8735 |
+
|
| 8736 |
+
.sd-tool:hover:not(:disabled) {
|
| 8737 |
+
border-color: var(--blue);
|
| 8738 |
+
background: var(--surface-2);
|
| 8739 |
+
}
|
| 8740 |
+
|
| 8741 |
+
.sd-tool:disabled {
|
| 8742 |
+
opacity: 0.5;
|
| 8743 |
+
cursor: default;
|
| 8744 |
+
}
|
| 8745 |
+
|
| 8746 |
+
.sd-tools-gap {
|
| 8747 |
+
flex: 1;
|
| 8748 |
+
}
|
| 8749 |
+
|
| 8750 |
+
.sd-tool-icon {
|
| 8751 |
+
width: 32px;
|
| 8752 |
+
height: 32px;
|
| 8753 |
+
}
|
| 8754 |
+
|
| 8755 |
+
.sd-tool-danger:hover:not(:disabled) {
|
| 8756 |
+
color: #ff8a8a;
|
| 8757 |
+
}
|
| 8758 |
+
|
| 8759 |
+
/* Between two slides in edit mode: a divider line with "+ Add slide here" on it */
|
| 8760 |
+
.sd-add-slide {
|
| 8761 |
+
display: flex;
|
| 8762 |
+
align-items: center;
|
| 8763 |
+
gap: 16px;
|
| 8764 |
+
margin: 20px 0 12px;
|
| 8765 |
+
}
|
| 8766 |
+
|
| 8767 |
+
.sd-add-slide::before,
|
| 8768 |
+
.sd-add-slide::after {
|
| 8769 |
+
content: "";
|
| 8770 |
+
flex: 1;
|
| 8771 |
+
height: 1px;
|
| 8772 |
+
background: var(--line-strong);
|
| 8773 |
+
}
|
| 8774 |
+
|
| 8775 |
+
.sd-add-slide select {
|
| 8776 |
+
border-style: dashed;
|
| 8777 |
+
}
|
| 8778 |
+
|
| 8779 |
+
/* The server is drawing the slides again: they wait, not taking clicks */
|
| 8780 |
+
.sd-stack.sd-busy .sd-slide {
|
| 8781 |
+
opacity: 0.6;
|
| 8782 |
+
pointer-events: none;
|
| 8783 |
+
transition: opacity 0.15s;
|
| 8784 |
+
}
|
| 8785 |
+
|
| 8786 |
+
.sd-notes-edit {
|
| 8787 |
+
min-height: 84px;
|
| 8788 |
+
resize: vertical;
|
| 8789 |
+
line-height: 1.6;
|
| 8790 |
+
}
|
| 8791 |
+
|
| 8792 |
+
.sd-notes {
|
| 8793 |
+
display: grid;
|
| 8794 |
+
gap: 6px;
|
| 8795 |
+
margin-top: 8px;
|
| 8796 |
+
padding: 14px 18px;
|
| 8797 |
+
border: 1px solid var(--line);
|
| 8798 |
+
border-radius: var(--radius-sm);
|
| 8799 |
+
background: var(--surface);
|
| 8800 |
+
line-height: 1.6;
|
| 8801 |
+
}
|
| 8802 |
+
|
| 8803 |
+
.sd-notes p {
|
| 8804 |
+
margin: 0;
|
| 8805 |
+
}
|
| 8806 |
+
|
| 8807 |
+
/* Presenting: the slide fills the screen, letterboxed in its own sky colour; the controls fade when the mouse rests. */
|
| 8808 |
+
.sd-present {
|
| 8809 |
+
position: fixed;
|
| 8810 |
+
inset: 0;
|
| 8811 |
+
z-index: 1000;
|
| 8812 |
+
background: #000;
|
| 8813 |
+
cursor: pointer;
|
| 8814 |
+
}
|
| 8815 |
+
|
| 8816 |
+
.sd-present.light {
|
| 8817 |
+
background: #e9eef9;
|
| 8818 |
+
}
|
| 8819 |
+
|
| 8820 |
+
.sd-present-stage {
|
| 8821 |
+
position: absolute;
|
| 8822 |
+
inset: 0;
|
| 8823 |
+
}
|
| 8824 |
+
|
| 8825 |
+
.sd-present-bar {
|
| 8826 |
+
position: absolute;
|
| 8827 |
+
left: 50%;
|
| 8828 |
+
bottom: 22px;
|
| 8829 |
+
display: flex;
|
| 8830 |
+
align-items: center;
|
| 8831 |
+
gap: 10px;
|
| 8832 |
+
padding: 8px 12px;
|
| 8833 |
+
transform: translateX(-50%);
|
| 8834 |
+
border: 1px solid var(--line-strong);
|
| 8835 |
+
border-radius: 999px;
|
| 8836 |
+
background: rgba(0, 0, 0, 0.72);
|
| 8837 |
+
color: var(--white);
|
| 8838 |
+
cursor: default;
|
| 8839 |
+
transition: opacity 0.3s;
|
| 8840 |
+
}
|
| 8841 |
+
|
| 8842 |
+
.sd-present.idle {
|
| 8843 |
+
cursor: none;
|
| 8844 |
+
}
|
| 8845 |
+
|
| 8846 |
+
.sd-present.idle .sd-present-bar {
|
| 8847 |
+
opacity: 0;
|
| 8848 |
+
}
|
| 8849 |
+
|
| 8850 |
+
.sd-count {
|
| 8851 |
+
min-width: 64px;
|
| 8852 |
+
text-align: center;
|
| 8853 |
+
font: 500 0.85rem var(--font-mono, monospace);
|
| 8854 |
+
}
|
| 8855 |
+
|
| 8856 |
+
@media (max-width: 760px) {
|
| 8857 |
+
.sd-opts {
|
| 8858 |
+
grid-template-columns: 1fr;
|
| 8859 |
+
}
|
| 8860 |
+
|
| 8861 |
+
.sd-opts .vw-chips {
|
| 8862 |
+
flex-wrap: wrap;
|
| 8863 |
+
}
|
| 8864 |
+
|
| 8865 |
+
.modal:has(.sd-view) .modal-head {
|
| 8866 |
+
flex-wrap: wrap;
|
| 8867 |
+
}
|
| 8868 |
+
|
| 8869 |
+
.sd-icon-btn {
|
| 8870 |
+
width: 34px;
|
| 8871 |
+
height: 34px;
|
| 8872 |
+
}
|
| 8873 |
+
}
|
| 8874 |
+
|
| 8875 |
+
@media (prefers-reduced-motion: reduce) {
|
| 8876 |
+
.sd-present-bar,
|
| 8877 |
+
.sd-item,
|
| 8878 |
+
.sd-slide,
|
| 8879 |
+
.sd-icon-btn,
|
| 8880 |
+
.modal.modal-wide:has(.sd-view),
|
| 8881 |
+
.sd-theme {
|
| 8882 |
+
transition: none;
|
| 8883 |
+
}
|
| 8884 |
+
}
|
| 8885 |
+
|
| 8886 |
+
|
| 8887 |
+
/* ---- Notebook: side panels resized by dragging the line between a panel and the chat (components/PaneResizer) ---- */
|
| 8888 |
+
|
| 8889 |
+
.nbv-resizer {
|
| 8890 |
+
display: none;
|
| 8891 |
+
}
|
| 8892 |
+
|
| 8893 |
+
@media (min-width: 900px) {
|
| 8894 |
+
.nbv-resizer {
|
| 8895 |
+
position: absolute;
|
| 8896 |
+
top: 0;
|
| 8897 |
+
bottom: 0;
|
| 8898 |
+
z-index: 6;
|
| 8899 |
+
display: block;
|
| 8900 |
+
width: 12px;
|
| 8901 |
+
margin-left: -6px;
|
| 8902 |
+
cursor: col-resize;
|
| 8903 |
+
touch-action: none;
|
| 8904 |
+
}
|
| 8905 |
+
|
| 8906 |
+
.nbv-resizer::after {
|
| 8907 |
+
content: "";
|
| 8908 |
+
position: absolute;
|
| 8909 |
+
top: 14px;
|
| 8910 |
+
bottom: 14px;
|
| 8911 |
+
left: 5px;
|
| 8912 |
+
width: 2px;
|
| 8913 |
+
border-radius: 2px;
|
| 8914 |
+
background: transparent;
|
| 8915 |
+
transition: background 0.15s, box-shadow 0.15s;
|
| 8916 |
+
}
|
| 8917 |
+
|
| 8918 |
+
.nbv-resizer:hover::after,
|
| 8919 |
+
.nbv-resizer.dragging::after,
|
| 8920 |
+
.nbv-resizer:focus-visible::after {
|
| 8921 |
+
background: var(--blue);
|
| 8922 |
+
box-shadow: 0 0 10px var(--blue-60);
|
| 8923 |
+
}
|
| 8924 |
+
|
| 8925 |
+
.nbv-resizer:focus-visible {
|
| 8926 |
+
outline: none;
|
| 8927 |
+
}
|
| 8928 |
+
}
|
| 8929 |
+
|
| 8930 |
+
/* While dragging: no text selection, the resize cursor everywhere, and frames (slides, infographics) let the pointer
|
| 8931 |
+
through so the drag is never lost over them. */
|
| 8932 |
+
body.nbv-resizing,
|
| 8933 |
+
body.nbv-resizing * {
|
| 8934 |
+
cursor: col-resize !important;
|
| 8935 |
+
user-select: none;
|
| 8936 |
+
}
|
| 8937 |
+
|
| 8938 |
+
body.nbv-resizing iframe {
|
| 8939 |
+
pointer-events: none;
|
| 8940 |
+
}
|
| 8941 |
+
|
| 8942 |
+
/* The panels' contents follow the panels' own widths, not the window's. */
|
| 8943 |
+
.nbv-side,
|
| 8944 |
+
.nbv-history,
|
| 8945 |
+
.nbv-chat {
|
| 8946 |
+
container-type: inline-size;
|
| 8947 |
+
}
|
| 8948 |
+
|
| 8949 |
+
/* Create tiles never wrap a word: the label shortens with an ellipsis, the icon stays. */
|
| 8950 |
+
.nbv-make > button,
|
| 8951 |
+
.nbv-make .nbv-make-label {
|
| 8952 |
+
min-width: 0;
|
| 8953 |
+
overflow: hidden;
|
| 8954 |
+
}
|
| 8955 |
+
|
| 8956 |
+
.nbv-make .nbv-make-label {
|
| 8957 |
+
flex: 1;
|
| 8958 |
+
}
|
| 8959 |
+
|
| 8960 |
+
.nbv-make-text {
|
| 8961 |
+
min-width: 0;
|
| 8962 |
+
overflow: hidden;
|
| 8963 |
+
text-overflow: ellipsis;
|
| 8964 |
+
white-space: nowrap;
|
| 8965 |
+
}
|
| 8966 |
+
|
| 8967 |
+
/* Tiles per row by the panel's width: one when narrow, two by default, three or four when the panel is wide. */
|
| 8968 |
+
@container (max-width: 299px) {
|
| 8969 |
+
.nbv-make {
|
| 8970 |
+
grid-template-columns: minmax(0, 1fr);
|
| 8971 |
+
}
|
| 8972 |
+
}
|
| 8973 |
+
|
| 8974 |
+
@container (min-width: 560px) {
|
| 8975 |
+
.nbv-make {
|
| 8976 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 8977 |
+
}
|
| 8978 |
+
|
| 8979 |
+
.nbv-make .nbv-make-wide {
|
| 8980 |
+
grid-column: auto;
|
| 8981 |
+
}
|
| 8982 |
+
}
|
| 8983 |
+
|
| 8984 |
+
@container (min-width: 760px) {
|
| 8985 |
+
.nbv-make {
|
| 8986 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 8987 |
+
}
|
| 8988 |
+
}
|
| 8989 |
+
|
| 8990 |
+
@media (prefers-reduced-motion: reduce) {
|
| 8991 |
+
.nbv-resizer::after {
|
| 8992 |
+
transition: none;
|
| 8993 |
+
}
|
| 8994 |
+
}
|
| 8995 |
+
|
| 8996 |
+
|
| 8997 |
+
/* ---- Audio overview settings (components/AudioOverview) ---- */
|
| 8998 |
+
|
| 8999 |
+
.ao-dialog {
|
| 9000 |
+
gap: 20px;
|
| 9001 |
+
}
|
| 9002 |
+
|
| 9003 |
+
.ao-styles {
|
| 9004 |
+
display: grid;
|
| 9005 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 9006 |
+
gap: 10px;
|
| 9007 |
+
}
|
| 9008 |
+
|
| 9009 |
+
.ao-style {
|
| 9010 |
+
display: grid;
|
| 9011 |
+
align-content: start;
|
| 9012 |
+
gap: 8px;
|
| 9013 |
+
min-height: 132px;
|
| 9014 |
+
padding: 14px 14px 16px;
|
| 9015 |
+
text-align: left;
|
| 9016 |
+
border: 1px solid var(--line-strong);
|
| 9017 |
+
border-radius: 18px;
|
| 9018 |
+
background: rgba(0, 0, 0, 0.6);
|
| 9019 |
+
color: var(--text);
|
| 9020 |
+
cursor: pointer;
|
| 9021 |
+
transition: border-color 0.15s, background 0.15s;
|
| 9022 |
+
}
|
| 9023 |
+
|
| 9024 |
+
.ao-style:hover:not(:disabled):not(.on) {
|
| 9025 |
+
border-color: var(--blue-60);
|
| 9026 |
+
}
|
| 9027 |
+
|
| 9028 |
+
.ao-style.on {
|
| 9029 |
+
border-color: var(--blue);
|
| 9030 |
+
background: var(--blue-20);
|
| 9031 |
+
}
|
| 9032 |
+
|
| 9033 |
+
.ao-style:disabled {
|
| 9034 |
+
opacity: 0.45;
|
| 9035 |
+
cursor: not-allowed;
|
| 9036 |
+
}
|
| 9037 |
+
|
| 9038 |
+
.ao-style-head {
|
| 9039 |
+
display: flex;
|
| 9040 |
+
align-items: center;
|
| 9041 |
+
justify-content: space-between;
|
| 9042 |
+
gap: 8px;
|
| 9043 |
+
}
|
| 9044 |
+
|
| 9045 |
+
.ao-style-head strong {
|
| 9046 |
+
font-weight: 600;
|
| 9047 |
+
}
|
| 9048 |
+
|
| 9049 |
+
.ao-radio {
|
| 9050 |
+
display: grid;
|
| 9051 |
+
place-items: center;
|
| 9052 |
+
flex: none;
|
| 9053 |
+
width: 22px;
|
| 9054 |
+
height: 22px;
|
| 9055 |
+
border-radius: 50%;
|
| 9056 |
+
border: 1px solid var(--line-strong);
|
| 9057 |
+
}
|
| 9058 |
+
|
| 9059 |
+
.ao-style.on .ao-radio {
|
| 9060 |
+
border-color: var(--blue);
|
| 9061 |
+
background: var(--black);
|
| 9062 |
+
}
|
| 9063 |
+
|
| 9064 |
+
.ao-style-blurb {
|
| 9065 |
+
font-size: 0.85rem;
|
| 9066 |
+
line-height: 1.45;
|
| 9067 |
+
color: var(--text-2);
|
| 9068 |
+
}
|
| 9069 |
+
|
| 9070 |
+
/* Hosts pills share the slide deck's pill look (.sd-opts); the two voice pickers sit side by side */
|
| 9071 |
+
.ao-voices {
|
| 9072 |
+
display: grid;
|
| 9073 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 9074 |
+
gap: 14px;
|
| 9075 |
+
}
|
| 9076 |
+
|
| 9077 |
+
.ao-voices > p {
|
| 9078 |
+
grid-column: 1 / -1;
|
| 9079 |
+
margin: 0;
|
| 9080 |
+
}
|
| 9081 |
+
|
| 9082 |
+
@media (max-width: 760px) {
|
| 9083 |
+
.ao-styles {
|
| 9084 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 9085 |
+
}
|
| 9086 |
+
|
| 9087 |
+
.ao-voices {
|
| 9088 |
+
grid-template-columns: 1fr;
|
| 9089 |
+
}
|
| 9090 |
+
}
|
| 9091 |
+
|
| 9092 |
+
@media (prefers-reduced-motion: reduce) {
|
| 9093 |
+
.ao-style {
|
| 9094 |
+
transition: none;
|
| 9095 |
+
}
|
| 9096 |
+
}
|
| 9097 |
+
|
| 9098 |
+
.modal.modal-wide:has(.ao-dialog) {
|
| 9099 |
+
width: min(880px, calc(100vw - 32px));
|
| 9100 |
+
}
|
| 9101 |
+
|
| 9102 |
+
/* ---- Audio overview in the notebook's list (components/AudioOverview: AudioOverviewCard) ---- */
|
| 9103 |
+
|
| 9104 |
+
.ao-card {
|
| 9105 |
+
border: 1px solid var(--line);
|
| 9106 |
+
border-radius: var(--radius);
|
| 9107 |
+
background: var(--surface);
|
| 9108 |
+
transition: border-color 0.15s;
|
| 9109 |
+
}
|
| 9110 |
+
|
| 9111 |
+
.ao-card.open {
|
| 9112 |
+
border-color: var(--line-strong);
|
| 9113 |
+
}
|
| 9114 |
+
|
| 9115 |
+
.ao-card-head {
|
| 9116 |
+
display: grid;
|
| 9117 |
+
grid-template-columns: 40px minmax(0, 1fr) auto;
|
| 9118 |
+
align-items: center;
|
| 9119 |
+
gap: 10px;
|
| 9120 |
+
padding: 10px 10px 10px 8px;
|
| 9121 |
+
border-radius: var(--radius);
|
| 9122 |
+
outline: none;
|
| 9123 |
+
}
|
| 9124 |
+
|
| 9125 |
+
.ao-card-head[role="button"] {
|
| 9126 |
+
cursor: pointer;
|
| 9127 |
+
}
|
| 9128 |
+
|
| 9129 |
+
.ao-card-head[role="button"]:hover {
|
| 9130 |
+
background: var(--surface-2);
|
| 9131 |
+
}
|
| 9132 |
+
|
| 9133 |
+
.ao-card-head:focus-visible {
|
| 9134 |
+
box-shadow: 0 0 0 2px var(--blue);
|
| 9135 |
+
}
|
| 9136 |
+
|
| 9137 |
+
.ao-card-icon {
|
| 9138 |
+
display: grid;
|
| 9139 |
+
place-items: center;
|
| 9140 |
+
width: 40px;
|
| 9141 |
+
height: 40px;
|
| 9142 |
+
color: var(--blue);
|
| 9143 |
+
}
|
| 9144 |
+
|
| 9145 |
+
.ao-card-text {
|
| 9146 |
+
display: grid;
|
| 9147 |
+
gap: 2px;
|
| 9148 |
+
min-width: 0;
|
| 9149 |
+
}
|
| 9150 |
+
|
| 9151 |
+
.ao-card-text strong {
|
| 9152 |
+
overflow: hidden;
|
| 9153 |
+
text-overflow: ellipsis;
|
| 9154 |
+
white-space: nowrap;
|
| 9155 |
+
font-weight: 500;
|
| 9156 |
+
font-size: 0.95rem;
|
| 9157 |
+
}
|
| 9158 |
+
|
| 9159 |
+
.ao-card-meta {
|
| 9160 |
+
overflow: hidden;
|
| 9161 |
+
text-overflow: ellipsis;
|
| 9162 |
+
white-space: nowrap;
|
| 9163 |
+
font-size: 0.82rem;
|
| 9164 |
+
color: var(--text-3);
|
| 9165 |
+
}
|
| 9166 |
+
|
| 9167 |
+
.ao-chevron {
|
| 9168 |
+
display: grid;
|
| 9169 |
+
place-items: center;
|
| 9170 |
+
width: 32px;
|
| 9171 |
+
height: 32px;
|
| 9172 |
+
color: var(--text-2);
|
| 9173 |
+
}
|
| 9174 |
+
|
| 9175 |
+
.ao-card-progress {
|
| 9176 |
+
padding: 0 12px 10px 58px;
|
| 9177 |
+
}
|
| 9178 |
+
|
| 9179 |
+
.ao-card-body {
|
| 9180 |
+
display: grid;
|
| 9181 |
+
gap: 12px;
|
| 9182 |
+
padding: 4px 14px 14px;
|
| 9183 |
+
}
|
| 9184 |
+
|
| 9185 |
+
/* The player: play, time, a seek bar and the speed, in one pill */
|
| 9186 |
+
.ao-player {
|
| 9187 |
+
display: grid;
|
| 9188 |
+
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
| 9189 |
+
align-items: center;
|
| 9190 |
+
gap: 12px;
|
| 9191 |
+
padding: 6px 8px 6px 6px;
|
| 9192 |
+
border-radius: 999px;
|
| 9193 |
+
background: var(--surface-2);
|
| 9194 |
+
}
|
| 9195 |
+
|
| 9196 |
+
.ao-play {
|
| 9197 |
+
display: grid;
|
| 9198 |
+
place-items: center;
|
| 9199 |
+
width: 38px;
|
| 9200 |
+
height: 38px;
|
| 9201 |
+
border: 0;
|
| 9202 |
+
border-radius: 50%;
|
| 9203 |
+
background: var(--blue);
|
| 9204 |
+
color: var(--white);
|
| 9205 |
+
cursor: pointer;
|
| 9206 |
+
}
|
| 9207 |
+
|
| 9208 |
+
.ao-play:focus-visible,
|
| 9209 |
+
.ao-speed:focus-visible {
|
| 9210 |
+
outline: 2px solid var(--blue);
|
| 9211 |
+
outline-offset: 2px;
|
| 9212 |
+
}
|
| 9213 |
+
|
| 9214 |
+
.ao-time {
|
| 9215 |
+
font-size: 0.8rem;
|
| 9216 |
+
color: var(--text-2);
|
| 9217 |
+
white-space: nowrap;
|
| 9218 |
+
}
|
| 9219 |
+
|
| 9220 |
+
.ao-seek {
|
| 9221 |
+
width: 100%;
|
| 9222 |
+
height: 4px;
|
| 9223 |
+
margin: 0;
|
| 9224 |
+
border-radius: 2px;
|
| 9225 |
+
background: linear-gradient(to right, var(--blue) var(--ao-fill, 0%), var(--line-strong) var(--ao-fill, 0%));
|
| 9226 |
+
appearance: none;
|
| 9227 |
+
cursor: pointer;
|
| 9228 |
+
}
|
| 9229 |
+
|
| 9230 |
+
.ao-seek::-webkit-slider-thumb {
|
| 9231 |
+
width: 14px;
|
| 9232 |
+
height: 14px;
|
| 9233 |
+
border-radius: 50%;
|
| 9234 |
+
background: var(--white);
|
| 9235 |
+
appearance: none;
|
| 9236 |
+
}
|
| 9237 |
+
|
| 9238 |
+
.ao-seek::-moz-range-thumb {
|
| 9239 |
+
width: 14px;
|
| 9240 |
+
height: 14px;
|
| 9241 |
+
border: 0;
|
| 9242 |
+
border-radius: 50%;
|
| 9243 |
+
background: var(--white);
|
| 9244 |
+
}
|
| 9245 |
+
|
| 9246 |
+
.ao-speed {
|
| 9247 |
+
min-width: 52px;
|
| 9248 |
+
height: 30px;
|
| 9249 |
+
padding: 0 10px;
|
| 9250 |
+
border: 1px solid var(--line-strong);
|
| 9251 |
+
border-radius: 999px;
|
| 9252 |
+
background: transparent;
|
| 9253 |
+
color: var(--text);
|
| 9254 |
+
font-size: 0.8rem;
|
| 9255 |
+
cursor: pointer;
|
| 9256 |
+
}
|
| 9257 |
+
|
| 9258 |
+
.ao-speed:hover {
|
| 9259 |
+
border-color: var(--blue-60);
|
| 9260 |
+
}
|
| 9261 |
+
|
| 9262 |
+
.ao-transcript-toggle {
|
| 9263 |
+
justify-self: start;
|
| 9264 |
+
}
|
| 9265 |
+
|
| 9266 |
+
.ao-card-actions {
|
| 9267 |
+
gap: 8px;
|
| 9268 |
+
}
|
| 9269 |
+
|
| 9270 |
+
/* Delete reads as a normal button (not dimmed), red when pointed at */
|
| 9271 |
+
.ao-delete:hover,
|
| 9272 |
+
.ao-delete:focus-visible {
|
| 9273 |
+
border-color: #ef4444;
|
| 9274 |
+
color: #ff8a8a;
|
| 9275 |
+
}
|
| 9276 |
+
|
| 9277 |
+
@media (prefers-reduced-motion: reduce) {
|
| 9278 |
+
.ao-card {
|
| 9279 |
+
transition: none;
|
| 9280 |
+
}
|
| 9281 |
+
}
|