Spaces:
Runtime error
Runtime error
Commit ·
fa8df4c
1
Parent(s): 9b5cc9d
slide deck and audio implemented
Browse files- 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 +45 -5
- backend/app/services/artifacts.py +4 -0
- backend/app/slides/__init__.py +3 -0
- backend/app/slides/build.py +78 -0
- backend/app/slides/content.py +187 -0
- backend/app/slides/decor.py +152 -0
- backend/app/slides/export.py +308 -0
- backend/app/slides/layout.py +730 -0
- backend/app/slides/metrics.py +39 -0
- backend/app/slides/render.py +145 -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 +56 -3
- backend/tests/test_slides.py +438 -0
- frontend/src/api/endpoints.ts +9 -2
- frontend/src/api/types.ts +1 -1
- frontend/src/components/ArtifactCard.tsx +1 -1
- frontend/src/components/AudioOverview.tsx +318 -0
- frontend/src/components/PaneResizer.tsx +188 -0
- frontend/src/components/SlideDeck.tsx +605 -0
- frontend/src/components/icons/Icons.tsx +56 -0
- frontend/src/components/ui.tsx +38 -0
- frontend/src/pages/NotebookView.tsx +62 -97
- frontend/src/styles/pages.css +957 -52
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,18 @@ 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 +43,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 +66,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 +160,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 +196,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 +279,24 @@ 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 stored as stored_deck
|
| 23 |
+
from app.slides.themes import theme_id as slide_theme_id
|
| 24 |
|
| 25 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
| 26 |
|
| 27 |
ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
|
| 28 |
+
"flashcards", "report", "infographic", "slide_deck"]
|
| 29 |
|
| 30 |
|
| 31 |
+
# Made from posts or chats picked in the dialog
|
| 32 |
+
SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic", "slide_deck", "audio_overview")
|
| 33 |
|
| 34 |
|
| 35 |
class RenderIn(BaseModel):
|
|
|
|
| 43 |
document_id: uuid.UUID | None = None
|
| 44 |
archive: bool = False # no notebook or post picked: use the "All posts" notebook
|
| 45 |
# audio_overview
|
| 46 |
+
format: Literal["deep_dive", "brief", "critique", "debate"] = "deep_dive"
|
| 47 |
+
hosts: Literal[1, 2] = 2 # audio_overview: a single narrator or a two host conversation
|
| 48 |
minutes: int = Field(6, ge=1, le=30)
|
| 49 |
# the two hosts' voices, from the workspace's voices (none: the defaults)
|
| 50 |
host_a: str | None = Field(None, max_length=100)
|
|
|
|
| 66 |
question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
|
| 67 |
default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
|
| 68 |
language: str | None = Field(None, max_length=40)
|
| 69 |
+
theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes
|
| 70 |
+
# slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
|
| 71 |
+
deck_format: Literal["detailed", "presenter"] = "detailed"
|
| 72 |
+
deck_length: Literal["short", "default", "long"] = "default"
|
| 73 |
# report: a written "document" or an "interactive" one, the template it started from, and the instructions the
|
| 74 |
# writer is given (the dialog's editable text; empty means the template's own)
|
| 75 |
report_format: Literal["document", "interactive"] = "document"
|
|
|
|
| 160 |
raise HTTPException(400, "Pick at least one post or chat to make this from")
|
| 161 |
target = _target_title(ctx, body.notebook_id, body.document_id)
|
| 162 |
if body.type == "audio_overview":
|
| 163 |
+
if body.format == "debate" and body.hosts == 1:
|
| 164 |
+
raise HTTPException(400, "A debate needs two hosts")
|
| 165 |
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 166 |
+
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 167 |
+
hosts = _hosts(ctx, body)
|
| 168 |
+
if body.hosts == 1:
|
| 169 |
+
hosts.pop("host_b", None)
|
| 170 |
+
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 171 |
+
"format": body.format, "minutes": body.minutes, "hosts": body.hosts, "language": body.language,
|
| 172 |
+
"instructions": (body.instructions or "").strip(), **hosts}
|
| 173 |
title = f"Audio overview: {target}"
|
| 174 |
elif body.type == "mind_map":
|
| 175 |
if not body.notebook_id:
|
|
|
|
| 196 |
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 197 |
"theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
|
| 198 |
title = f"Infographic: {params['instructions'][:60] or target}"
|
| 199 |
+
elif body.type == "slide_deck":
|
| 200 |
+
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 201 |
+
params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
|
| 202 |
+
"theme": slide_theme_id(body.theme), "deck_format": body.deck_format, "deck_length": body.deck_length,
|
| 203 |
+
"language": body.language, "instructions": (body.instructions or "").strip()}
|
| 204 |
+
title = f"Slide deck: {target}"
|
| 205 |
elif body.type == "quiz":
|
| 206 |
ids = body.document_ids or ([body.document_id] if body.document_id else [])
|
| 207 |
params = {"document_ids": [str(i) for i in ids],
|
|
|
|
| 279 |
return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
|
| 280 |
|
| 281 |
|
| 282 |
+
@router.get("/{artifact_id}/slides.{ext}")
|
| 283 |
+
def slide_deck_file(artifact_id: uuid.UUID, ext: Literal["pdf", "pptx"], ctx: Ctx = Depends(get_ctx)):
|
| 284 |
+
"""A finished slide deck as a PDF (exactly as shown) or an editable PowerPoint."""
|
| 285 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 286 |
+
got = stored_deck(a.content_json or {}) if a.type == "slide_deck" and a.status == "ready" else None
|
| 287 |
+
if not got:
|
| 288 |
+
raise HTTPException(404, "This slide deck has nothing to download yet.")
|
| 289 |
+
deck, theme, fmt, seed = got
|
| 290 |
+
try:
|
| 291 |
+
data = (slide_export.render_pdf if ext == "pdf" else slide_export.build_pptx)(deck, theme, fmt, seed)
|
| 292 |
+
except ImageUnavailable as exc:
|
| 293 |
+
raise HTTPException(503, str(exc)) from exc
|
| 294 |
+
name = re.sub(r"[^\w\- ]+", "", deck.get("title") or "Slide deck").strip()[:80] or "Slide deck"
|
| 295 |
+
media = "application/pdf" if ext == "pdf" else \
|
| 296 |
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
| 297 |
+
return Response(data, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}.{ext}"'})
|
| 298 |
+
|
| 299 |
+
|
| 300 |
@router.patch("/{artifact_id}")
|
| 301 |
def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
|
| 302 |
a = get_artifact_or_404(ctx, artifact_id)
|
backend/app/services/artifacts.py
CHANGED
|
@@ -9,6 +9,7 @@ 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 +23,7 @@ TYPE_LABELS = {
|
|
| 22 |
"flashcards": "Flashcards",
|
| 23 |
"report": "Report",
|
| 24 |
"infographic": "Infographic",
|
|
|
|
| 25 |
"upload": "Upload",
|
| 26 |
}
|
| 27 |
|
|
@@ -84,6 +86,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 []
|
|
|
|
| 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 view as slide_pages
|
| 13 |
|
| 14 |
TYPE_LABELS = {
|
| 15 |
"summary": "Summary",
|
|
|
|
| 23 |
"flashcards": "Flashcards",
|
| 24 |
"report": "Report",
|
| 25 |
"infographic": "Infographic",
|
| 26 |
+
"slide_deck": "Slide deck",
|
| 27 |
"upload": "Upload",
|
| 28 |
}
|
| 29 |
|
|
|
|
| 86 |
from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
|
| 87 |
|
| 88 |
content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
|
| 89 |
+
if a.type == "slide_deck" and a.status == "ready":
|
| 90 |
+
content = {**content, "slides_html": slide_pages(content)}
|
| 91 |
if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
|
| 92 |
content = {**content, "blocks": report_blocks(a)}
|
| 93 |
slides = content.get("slide_keys") or []
|
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,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. `view` reads a stored deck back (clamped again, so older or edited decks
|
| 3 |
+
still draw) and gives its pages; one slide that cannot be drawn becomes a plain one instead of breaking the deck."""
|
| 4 |
+
|
| 5 |
+
import copy
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from app.infographics.image import ImageUnavailable
|
| 9 |
+
from app.slides import export
|
| 10 |
+
from app.slides.content import clamp_deck, clamp_slide, empty_slide
|
| 11 |
+
from app.slides.layout import SCALES, assign_variants, compose, seed_of
|
| 12 |
+
from app.slides.render import slide_html
|
| 13 |
+
from app.slides.themes import theme_id
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def fit(deck: dict, theme: str, fmt: str, seed: int) -> dict:
|
| 19 |
+
"""Every slide as it will be drawn: text shortened where it had to be, so what is stored is what is shown."""
|
| 20 |
+
out = copy.deepcopy(deck)
|
| 21 |
+
out["slides"] = [compose(out, i, theme, fmt, seed).slide for i in range(len(out["slides"]))]
|
| 22 |
+
return out
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def finish(deck: dict, theme: str, fmt: str, seed: int, previous: list[tuple[str, str]] | None = None) -> dict:
|
| 26 |
+
"""Variants, fitted text, and a Chrome check that no text box overflows (slides that do are drawn a step
|
| 27 |
+
smaller and fitted again). Raises ValueError when a slide cannot be drawn at all."""
|
| 28 |
+
theme = theme_id(theme)
|
| 29 |
+
deck = copy.deepcopy(deck)
|
| 30 |
+
assign_variants(deck["slides"], theme, seed, previous)
|
| 31 |
+
deck = fit(deck, theme, fmt, seed)
|
| 32 |
+
for _ in range(3):
|
| 33 |
+
try:
|
| 34 |
+
bad = export.overflowing(deck, theme, fmt, seed)
|
| 35 |
+
except ImageUnavailable:
|
| 36 |
+
break # no Chrome here: the measured fit alone keeps text inside its boxes
|
| 37 |
+
if not bad:
|
| 38 |
+
break
|
| 39 |
+
log.info("slides %s overflow in Chrome, drawing them smaller", sorted(bad))
|
| 40 |
+
for i in bad:
|
| 41 |
+
deck["slides"][i]["shrink"] = min(int(deck["slides"][i].get("shrink") or 0) + 1, len(SCALES) - 1)
|
| 42 |
+
deck = fit(deck, theme, fmt, seed)
|
| 43 |
+
for i in range(len(deck["slides"])):
|
| 44 |
+
slide_html(deck, i, theme, fmt, seed) # raises if a slide cannot be drawn: better now than when shown
|
| 45 |
+
return deck
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def stored(content: dict) -> tuple[dict, str, str, int] | None:
|
| 49 |
+
"""A stored deck, clamped, with its theme, format and seed (None when there is none)."""
|
| 50 |
+
fmt = content.get("format") if content.get("format") in ("detailed", "presenter") else "detailed"
|
| 51 |
+
deck = clamp_deck(content.get("deck") or {}, fmt)
|
| 52 |
+
if not deck:
|
| 53 |
+
return None
|
| 54 |
+
return deck, theme_id(content.get("theme")), fmt, seed_of(content.get("seed"))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _placeholder(deck: dict, index: int) -> dict:
|
| 58 |
+
s = empty_slide("section")
|
| 59 |
+
s["heading"] = deck.get("title") or "Slide"
|
| 60 |
+
return clamp_slide(s, "detailed")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def view(content: dict) -> list[str]:
|
| 64 |
+
"""Each slide of a stored deck as a standalone page."""
|
| 65 |
+
got = stored(content)
|
| 66 |
+
if not got:
|
| 67 |
+
return []
|
| 68 |
+
deck, theme, fmt, seed = got
|
| 69 |
+
pages = []
|
| 70 |
+
for i in range(len(deck["slides"])):
|
| 71 |
+
try:
|
| 72 |
+
pages.append(slide_html(deck, i, theme, fmt, seed))
|
| 73 |
+
except Exception:
|
| 74 |
+
log.exception("slide %s could not be drawn", i)
|
| 75 |
+
safe = copy.deepcopy(deck)
|
| 76 |
+
safe["slides"][i] = _placeholder(deck, i)
|
| 77 |
+
pages.append(slide_html(safe, i, theme, fmt, seed))
|
| 78 |
+
return pages
|
backend/app/slides/content.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
def empty_slide(layout: str = "points") -> dict:
|
| 115 |
+
return {"layout": layout, "variant": "", "kicker": "", "heading": "", "lead": "", "points": [],
|
| 116 |
+
"left": {"label": "", "items": []}, "right": {"label": "", "items": []},
|
| 117 |
+
"stat": {"value": "", "label": ""}, "quote": {"text": "", "by": ""}, "takeaways": [], "closing": "",
|
| 118 |
+
"notes": "", "sources": [], "shrink": 0}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def clamp_slide(raw: object, fmt: str) -> dict | None:
|
| 122 |
+
"""One slide in the fixed shape, or None when nothing on it can be shown."""
|
| 123 |
+
lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
|
| 124 |
+
r = _dict(raw)
|
| 125 |
+
s = empty_slide(r.get("layout") if r.get("layout") in LAYOUTS else "points")
|
| 126 |
+
s["variant"] = plain(r.get("variant"))[:4].lower()
|
| 127 |
+
s["kicker"] = clip(r.get("kicker"), lim["kicker"])
|
| 128 |
+
s["heading"] = clip(r.get("heading"), lim["title"] if s["layout"] == "title" else lim["heading"])
|
| 129 |
+
s["lead"] = clip(r.get("lead"), lim["subtitle"] if s["layout"] == "title" else lim["lead"])
|
| 130 |
+
lo, hi = lim["points"]
|
| 131 |
+
points = _points(r.get("points"), lim)
|
| 132 |
+
if s["layout"] == "agenda":
|
| 133 |
+
s["points"] = [{"term": "", "text": clip(p["text"], lim["agenda"])} for p in points][: lim["agenda_items"]]
|
| 134 |
+
else:
|
| 135 |
+
s["points"] = points[:hi]
|
| 136 |
+
s["left"], s["right"] = _column(r.get("left"), lim), _column(r.get("right"), lim)
|
| 137 |
+
stat = _dict(r.get("stat"))
|
| 138 |
+
s["stat"] = {"value": clip(stat.get("value"), lim["value"]), "label": clip(stat.get("label"), lim["stat_label"])}
|
| 139 |
+
quote = _dict(r.get("quote"))
|
| 140 |
+
s["quote"] = {"text": clip(quote.get("text"), lim["quote"]).strip("\"'“”"), "by": clip(quote.get("by"), lim["by"])}
|
| 141 |
+
s["takeaways"] = [t for t in (clip(t, lim["takeaway"]) for t in _list(r.get("takeaways"))) if t][: lim["takeaways"]]
|
| 142 |
+
s["closing"] = clip(r.get("closing"), lim["closing"])
|
| 143 |
+
s["notes"] = clip(r.get("notes"), lim["notes"])
|
| 144 |
+
s["sources"] = _sources(r.get("sources"))
|
| 145 |
+
try:
|
| 146 |
+
s["shrink"] = max(0, min(int(r.get("shrink") or 0), 4))
|
| 147 |
+
except (TypeError, ValueError):
|
| 148 |
+
s["shrink"] = 0
|
| 149 |
+
|
| 150 |
+
# A layout missing what it needs becomes a simpler one, so the drawing never meets an empty slot.
|
| 151 |
+
lay = s["layout"]
|
| 152 |
+
if lay == "two_column" and not (s["left"]["items"] and s["right"]["items"]):
|
| 153 |
+
merged = [{"term": "", "text": t} for t in s["left"]["items"] + s["right"]["items"]]
|
| 154 |
+
s["points"] = s["points"] or merged[:hi]
|
| 155 |
+
lay = "points"
|
| 156 |
+
if lay == "stat" and not (s["stat"]["value"] and s["stat"]["label"]):
|
| 157 |
+
lay = "points"
|
| 158 |
+
if lay == "quote" and not s["quote"]["text"]:
|
| 159 |
+
lay = "points"
|
| 160 |
+
if lay == "agenda" and len(s["points"]) < 2:
|
| 161 |
+
lay = "section"
|
| 162 |
+
if lay == "closing" and not s["takeaways"] and not s["closing"]:
|
| 163 |
+
lay = "section"
|
| 164 |
+
if lay == "points" and len(s["points"]) < lo:
|
| 165 |
+
if s["points"] and not s["lead"]: # one point: say it as the slide's lead
|
| 166 |
+
s["lead"] = clip(s["points"][0]["text"], lim["lead"])
|
| 167 |
+
s["points"] = []
|
| 168 |
+
lay = "section"
|
| 169 |
+
if lay != s["layout"]:
|
| 170 |
+
s["variant"] = ""
|
| 171 |
+
s["layout"] = lay
|
| 172 |
+
if not s["heading"]:
|
| 173 |
+
s["heading"] = s["kicker"] or (s["closing"] if lay == "closing" else "")
|
| 174 |
+
if not s["heading"] and lay not in ("quote", "closing"):
|
| 175 |
+
return None
|
| 176 |
+
return s
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def clamp_deck(raw: object, fmt: str) -> dict | None:
|
| 180 |
+
"""The deck in the fixed shape, or None when it has no slide that can be shown."""
|
| 181 |
+
r = _dict(raw)
|
| 182 |
+
lim = LIMITS[fmt if fmt in FORMATS else "detailed"]
|
| 183 |
+
slides = [s for s in (clamp_slide(x, fmt) for x in _list(r.get("slides"))) if s]
|
| 184 |
+
if not slides:
|
| 185 |
+
return None
|
| 186 |
+
title = clip(r.get("title"), lim["title"]) or (slides[0]["heading"] if slides[0]["layout"] == "title" else "") or "Slide deck"
|
| 187 |
+
return {"title": title, "subtitle": clip(r.get("subtitle"), lim["subtitle"]), "slides": slides}
|
backend/app/slides/decor.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 | 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 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class Decor:
|
| 31 |
+
hot: tuple[float, float] # where the sky is brightest
|
| 32 |
+
shapes: list[Shape]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# Where a variant has room for a drawing: (x0, y0, x1, y1). None: only stars and a soft glow.
|
| 36 |
+
FREE = {
|
| 37 |
+
"dark-space": {
|
| 38 |
+
("points", "a"): (1520, 140, 1900, 940), ("points", "c"): None, ("points", "b"): None, ("points", "d"): None,
|
| 39 |
+
("agenda", "a"): (120, 660, 820, 1000), ("agenda", "b"): None,
|
| 40 |
+
("two_column", "a"): None, ("two_column", "b"): None,
|
| 41 |
+
("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (140, 60, 760, 140),
|
| 42 |
+
("section", "b"): (1500, 60, 1900, 560), ("closing", "b"): None, ("closing", "c"): (140, 860, 960, 1060),
|
| 43 |
+
},
|
| 44 |
+
"light-space": {
|
| 45 |
+
("points", "a"): None, ("points", "b"): None, ("points", "c"): None, ("points", "d"): None,
|
| 46 |
+
("agenda", "a"): None, ("agenda", "b"): (120, 760, 820, 1000),
|
| 47 |
+
("two_column", "a"): None, ("two_column", "b"): None,
|
| 48 |
+
("stat", "a"): None, ("stat", "b"): None, ("quote", "a"): None, ("quote", "b"): (100, 360, 400, 760),
|
| 49 |
+
("closing", "a"): None, ("closing", "b"): None, ("closing", "c"): None,
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _stars(rnd: random.Random, dark: bool) -> Shape:
|
| 55 |
+
n = 120 if dark else 70
|
| 56 |
+
pts = []
|
| 57 |
+
for _ in range(n):
|
| 58 |
+
if dark:
|
| 59 |
+
r, op = rnd.choice((0.8, 1.0, 1.2, 1.2, 1.6, 2.2)), round(rnd.uniform(0.18, 0.85), 2)
|
| 60 |
+
else:
|
| 61 |
+
r, op = rnd.choice((0.9, 1.2, 1.5, 2.0)), round(rnd.uniform(0.08, 0.26), 2)
|
| 62 |
+
pts.append((rnd.uniform(0, W), rnd.uniform(0, H), r, op))
|
| 63 |
+
return Shape("stars", points=pts, color="star")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _constellation(rnd: random.Random, box: tuple[float, float, float, float], n: int = 7) -> Shape:
|
| 67 |
+
x0, y0, x1, y1 = box
|
| 68 |
+
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)]
|
| 69 |
+
pts.sort(key=lambda p: p[0])
|
| 70 |
+
edges = [(i, i + 1) for i in range(n - 1)]
|
| 71 |
+
if n > 4:
|
| 72 |
+
edges.append((rnd.randrange(0, n - 3), rnd.randrange(n - 2, n)))
|
| 73 |
+
return Shape("const", points=pts, edges=edges, color="accent", opacity=0.85)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build(theme: str, layout: str, variant: str, seed: int, index: int) -> Decor:
|
| 77 |
+
"""The drawing for one slide."""
|
| 78 |
+
dark = theme == "dark-space"
|
| 79 |
+
rnd = random.Random(int(hashlib.sha1(f"{seed}:{index}:{layout}:{variant}".encode()).hexdigest()[:12], 16))
|
| 80 |
+
j = lambda span: rnd.uniform(-span, span) # noqa: E731 (a small, seeded nudge)
|
| 81 |
+
shapes = [_stars(rnd, dark)]
|
| 82 |
+
hot = (W * rnd.uniform(0.25, 0.75), H * rnd.uniform(0.15, 0.5))
|
| 83 |
+
key = (layout, variant)
|
| 84 |
+
|
| 85 |
+
if dark:
|
| 86 |
+
if key == ("title", "a"):
|
| 87 |
+
hot = (960, 760)
|
| 88 |
+
shapes += [Shape("glow", 960, 900, 900, color="accent2", opacity=0.55),
|
| 89 |
+
Shape("planet", 960 + j(60), 1080 + 560 + j(40), 820),
|
| 90 |
+
Shape("ring", 960, 860 + j(20), rx=1150, ry=150, rot=-4 + j(3), color="accent", opacity=0.45, width=2.5)]
|
| 91 |
+
elif key == ("title", "b"):
|
| 92 |
+
cx, cy = 1520 + j(60), 300 + j(50)
|
| 93 |
+
hot = (cx, cy)
|
| 94 |
+
shapes += [Shape("glow", cx, cy, 520, color="accent2", opacity=0.5), Shape("planet", cx, cy, 230 + j(25)),
|
| 95 |
+
Shape("ring", cx, cy, rx=400, ry=78, rot=-18 + j(6), color="accent", opacity=0.55, width=2.5)]
|
| 96 |
+
elif key == ("title", "c"):
|
| 97 |
+
hot = (1450, 540)
|
| 98 |
+
shapes += [Shape("glow", 1450, 540, 620, color="accent2", opacity=0.35),
|
| 99 |
+
_constellation(rnd, (1120, 170, 1800, 910), 8)]
|
| 100 |
+
elif layout == "section" and variant == "a":
|
| 101 |
+
hot = (960, 540)
|
| 102 |
+
shapes += [Shape("glow", 960, 540, 760, color="accent2", opacity=0.35),
|
| 103 |
+
Shape("ring", 960, 540 + j(20), rx=980, ry=260, rot=-8 + j(5), color="accent", opacity=0.3, width=2)]
|
| 104 |
+
elif layout == "closing" and variant == "a":
|
| 105 |
+
hot = (960, 980)
|
| 106 |
+
shapes += [Shape("glow", 960, 1120, 760, color="accent2", opacity=0.5),
|
| 107 |
+
Shape("ring", 960, 1210, rx=1080, ry=880, rot=0, color="accent", opacity=0.35, width=2.5)]
|
| 108 |
+
else:
|
| 109 |
+
corner = rnd.choice(((0, 0), (W, 0), (0, H), (W, H)))
|
| 110 |
+
shapes.append(Shape("glow", corner[0], corner[1], 700, color="accent2", opacity=0.32))
|
| 111 |
+
hot = (corner[0] * 0.8 + W * 0.1, corner[1] * 0.8 + H * 0.1)
|
| 112 |
+
free = FREE["dark-space"].get(key)
|
| 113 |
+
if free:
|
| 114 |
+
x0, y0, x1, y1 = free
|
| 115 |
+
r = min(x1 - x0, y1 - y0) * rnd.uniform(0.26, 0.34)
|
| 116 |
+
cx, cy = rnd.uniform(x0 + r, x1 - r), rnd.uniform(y0 + r, y1 - r)
|
| 117 |
+
shapes += [Shape("glow", cx, cy, r * 2.2, color="accent2", opacity=0.35), Shape("planet", cx, cy, r),
|
| 118 |
+
Shape("ring", cx, cy, rx=r * 1.7, ry=r * 0.34, rot=-16 + j(10), color="accent", opacity=0.5, width=2)]
|
| 119 |
+
else:
|
| 120 |
+
ring = lambda cx, cy, r, op=0.3: Shape("ring", cx, cy, rx=r, ry=r, color="accent2", opacity=op, width=2) # noqa: E731
|
| 121 |
+
if key == ("title", "a"):
|
| 122 |
+
cx, cy = 2010 + j(40), 540 + j(60)
|
| 123 |
+
hot = (1500, 540)
|
| 124 |
+
shapes += [ring(cx, cy, 360), ring(cx, cy, 540, 0.24), ring(cx, cy, 740, 0.18)]
|
| 125 |
+
a = math.radians(200 + j(20))
|
| 126 |
+
shapes.append(Shape("planet", cx + 540 * math.cos(a), cy + 540 * math.sin(a), 34))
|
| 127 |
+
elif key == ("title", "b"):
|
| 128 |
+
hot = (1500, 820)
|
| 129 |
+
shapes += [Shape("glow", 1560, 840, 520, color="accent", opacity=0.12),
|
| 130 |
+
_constellation(rnd, (1360, 660, 1800, 980), 7)]
|
| 131 |
+
elif key == ("title", "c"):
|
| 132 |
+
hot = (960, 540)
|
| 133 |
+
shapes += [Shape("ring", 960, 520, rx=900, ry=330, rot=-10 + j(4), color="accent2", opacity=0.28, width=2.5),
|
| 134 |
+
Shape("ring", 960, 520, rx=1040, ry=400, rot=-10 + j(4), color="accent2", opacity=0.14, width=2)]
|
| 135 |
+
a = math.radians(rnd.choice((20, 160, 200, 340)) + j(10))
|
| 136 |
+
shapes.append(Shape("planet", 960 + 900 * math.cos(a), 520 + 330 * math.sin(a), 30))
|
| 137 |
+
elif key == ("section", "a"):
|
| 138 |
+
shapes += [ring(1960, 560, 300), ring(1960, 560, 460, 0.22), ring(1960, 560, 640, 0.15),
|
| 139 |
+
Shape("planet", 1960 - 460 + 6, 560 + j(80), 26)]
|
| 140 |
+
elif key == ("section", "b"):
|
| 141 |
+
shapes += [ring(-40, 560, 300), ring(-40, 560, 460, 0.22), ring(-40, 560, 640, 0.15),
|
| 142 |
+
Shape("planet", -40 + 460 - 6, 560 + j(80), 26)]
|
| 143 |
+
elif key == ("closing", "c"):
|
| 144 |
+
hot = (960, 860)
|
| 145 |
+
shapes += [Shape("ring", 960, 540, rx=1000, ry=470, rot=6 + j(4), color="accent2", opacity=0.2, width=2.5)]
|
| 146 |
+
else:
|
| 147 |
+
corner = rnd.choice(((W + 60, -60), (W + 60, H + 60), (-60, H + 60)))
|
| 148 |
+
shapes += [ring(corner[0], corner[1], 260, 0.22), ring(corner[0], corner[1], 400, 0.15)]
|
| 149 |
+
free = FREE["light-space"].get(key)
|
| 150 |
+
if free:
|
| 151 |
+
shapes.append(_constellation(rnd, free, 5))
|
| 152 |
+
return Decor(hot, shapes)
|
backend/app/slides/export.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 26 |
+
from app.slides.layout import El, compose
|
| 27 |
+
from app.slides.render import color, deck_print_html, planet_colors
|
| 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 |
+
# --- overflow check -------------------------------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
_MEASURE = """<script>
|
| 94 |
+
document.fonts.ready.then(function () {
|
| 95 |
+
var bad = [];
|
| 96 |
+
document.querySelectorAll('.slide').forEach(function (s, i) {
|
| 97 |
+
s.querySelectorAll('.t').forEach(function (t) {
|
| 98 |
+
var inner = t.firstElementChild; // the text's own height, not its glyphs' ink
|
| 99 |
+
if (inner.offsetHeight > t.clientHeight + 2 || inner.scrollWidth > t.clientWidth + 2) { if (bad.indexOf(i) < 0) bad.push(i); }
|
| 100 |
+
});
|
| 101 |
+
});
|
| 102 |
+
var out = document.createElement('pre'); out.id = 'overflow'; out.textContent = 'OVERFLOW:' + bad.join(',') + ':END';
|
| 103 |
+
document.body.appendChild(out);
|
| 104 |
+
});
|
| 105 |
+
</script>"""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def overflowing(deck: dict, theme: str, fmt: str, seed: int) -> set[int]:
|
| 109 |
+
"""Indexes of slides where Chrome finds text taller or wider than its box. Raises ImageUnavailable without Chrome."""
|
| 110 |
+
html = deck_print_html(deck, theme_id(theme), fmt, seed, script=_MEASURE)
|
| 111 |
+
_, dom = _run_chrome(html, ["--dump-dom"], None)
|
| 112 |
+
m = re.search(r"OVERFLOW:([0-9,]*):END", dom)
|
| 113 |
+
if not m:
|
| 114 |
+
raise ImageUnavailable("The slides could not be measured.")
|
| 115 |
+
return {int(i) for i in m.group(1).split(",") if i}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# --- PowerPoint -----------------------------------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
def _rgb(hex_: str) -> RGBColor:
|
| 121 |
+
return RGBColor.from_string(hex_.lstrip("#").upper())
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _hex_rgb(hex_: str) -> tuple[int, int, int]:
|
| 125 |
+
h = hex_.lstrip("#")
|
| 126 |
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _gradient_disc(size: int, inner: tuple[int, int, int, int], outer: tuple[int, int, int, int]) -> Image.Image:
|
| 130 |
+
"""A size x size image fading from `inner` at the centre to `outer` at the edge (RGBA)."""
|
| 131 |
+
mask = Image.radial_gradient("L").resize((size, size)) # 0 in the centre, 255 at the corners
|
| 132 |
+
mask = mask.point(lambda v: min(255, int(v * 1.42))) # 255 at the disc's edge
|
| 133 |
+
a, b = Image.new("RGBA", (size, size), inner), Image.new("RGBA", (size, size), outer)
|
| 134 |
+
return Image.composite(b, a, mask)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def background(decor: Decor, pal: Palette, dark: bool) -> bytes:
|
| 138 |
+
"""The slide's sky and drawing as a JPEG, for the PowerPoint's background."""
|
| 139 |
+
hx, hy = decor.hot
|
| 140 |
+
sky = Image.new("RGBA", (W, H), _hex_rgb(pal.bg1) + (255,))
|
| 141 |
+
big = int(W * 1.7)
|
| 142 |
+
disc = _gradient_disc(big, _hex_rgb(pal.bg2) + (255,), _hex_rgb(pal.bg1) + (255,))
|
| 143 |
+
sky.paste(disc, (int(hx - big / 2), int(hy - big / 2)))
|
| 144 |
+
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
| 145 |
+
draw = ImageDraw.Draw(layer)
|
| 146 |
+
for s in decor.shapes:
|
| 147 |
+
_draw(sky, layer, draw, s, pal, dark)
|
| 148 |
+
sky = Image.alpha_composite(sky, layer).convert("RGB")
|
| 149 |
+
buf = io.BytesIO()
|
| 150 |
+
sky.save(buf, "JPEG", quality=90)
|
| 151 |
+
return buf.getvalue()
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _draw(sky: Image.Image, layer: Image.Image, draw: ImageDraw.ImageDraw, s: Shape, pal: Palette, dark: bool) -> None:
|
| 155 |
+
c = _hex_rgb(color(pal, s.color))
|
| 156 |
+
if s.kind == "stars":
|
| 157 |
+
for x, y, r, o in s.points:
|
| 158 |
+
draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * o),))
|
| 159 |
+
elif s.kind == "glow":
|
| 160 |
+
size = int(s.r * 2)
|
| 161 |
+
if size < 4:
|
| 162 |
+
return
|
| 163 |
+
_paste_clipped(sky, _gradient_disc(size, c + (int(255 * s.opacity),), c + (0,)), s.cx - s.r, s.cy - s.r)
|
| 164 |
+
elif s.kind == "planet":
|
| 165 |
+
hi, lo, rim = planet_colors(pal, dark)
|
| 166 |
+
size = int(s.r * 2)
|
| 167 |
+
if size < 4:
|
| 168 |
+
return
|
| 169 |
+
disc = Image.new("RGBA", (size, size), _hex_rgb(lo) + (255,))
|
| 170 |
+
shine = _gradient_disc(int(size * 1.6), _hex_rgb(hi) + (255,), _hex_rgb(lo) + (0,))
|
| 171 |
+
_paste_clipped(disc, shine, -size * 0.45, -size * 0.52) # lit from the upper left, as in the page's gradient
|
| 172 |
+
mask = Image.new("L", (size, size), 0)
|
| 173 |
+
ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
|
| 174 |
+
disc.putalpha(mask)
|
| 175 |
+
_paste_clipped(sky, disc, s.cx - s.r, s.cy - s.r)
|
| 176 |
+
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)
|
| 177 |
+
elif s.kind == "ring":
|
| 178 |
+
steps = 360
|
| 179 |
+
rot = math.radians(s.rot)
|
| 180 |
+
pts = []
|
| 181 |
+
for k in range(steps + 1):
|
| 182 |
+
a = 2 * math.pi * k / steps
|
| 183 |
+
x, y = s.rx * math.cos(a), s.ry * math.sin(a)
|
| 184 |
+
pts.append((s.cx + x * math.cos(rot) - y * math.sin(rot), s.cy + x * math.sin(rot) + y * math.cos(rot)))
|
| 185 |
+
draw.line(pts, fill=c + (int(255 * s.opacity),), width=max(1, round(s.width)))
|
| 186 |
+
elif s.kind == "const":
|
| 187 |
+
for a, b in s.edges:
|
| 188 |
+
(x1, y1, *_), (x2, y2, *_) = s.points[a], s.points[b]
|
| 189 |
+
draw.line((x1, y1, x2, y2), fill=c + (int(255 * s.opacity * 0.5),), width=2)
|
| 190 |
+
for x, y, r, _ in s.points:
|
| 191 |
+
draw.ellipse((x - r, y - r, x + r, y + r), fill=c + (int(255 * s.opacity),))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _paste_clipped(base: Image.Image, img: Image.Image, x: float, y: float) -> None:
|
| 195 |
+
"""alpha_composite `img` onto `base` at (x, y), clipping whatever falls outside."""
|
| 196 |
+
x, y = int(x), int(y)
|
| 197 |
+
left, top = max(0, -x), max(0, -y)
|
| 198 |
+
right, bottom = min(img.width, base.width - x), min(img.height, base.height - y)
|
| 199 |
+
if right <= left or bottom <= top:
|
| 200 |
+
return
|
| 201 |
+
base.alpha_composite(img.crop((left, top, right, bottom)), (x + left, y + top))
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _emu(px: float) -> Emu:
|
| 205 |
+
return Emu(int(round(px * EMU_PX)))
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _no_style(shape) -> None:
|
| 209 |
+
"""Drop the theme's default shape style (shadows, outlines) so only what we set is drawn."""
|
| 210 |
+
style = shape._element.find(qn("p:style"))
|
| 211 |
+
if style is not None:
|
| 212 |
+
shape._element.remove(style)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _alpha(fill_fore_color_el, opacity: float) -> None:
|
| 216 |
+
if opacity >= 1:
|
| 217 |
+
return
|
| 218 |
+
clr = fill_fore_color_el.find(qn("a:srgbClr"))
|
| 219 |
+
if clr is not None:
|
| 220 |
+
a = clr.makeelement(qn("a:alpha"), {"val": str(int(opacity * 100000))})
|
| 221 |
+
clr.append(a)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _add_el(slide, e: El, pal: Palette) -> None:
|
| 225 |
+
if e.kind == "text":
|
| 226 |
+
box = slide.shapes.add_textbox(_emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
|
| 227 |
+
tf = box.text_frame
|
| 228 |
+
tf.word_wrap = True
|
| 229 |
+
tf.auto_size = MSO_AUTO_SIZE.NONE
|
| 230 |
+
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
| 231 |
+
tf.vertical_anchor = {"middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}.get(e.valign, MSO_ANCHOR.TOP)
|
| 232 |
+
for i, runs in enumerate(e.paras):
|
| 233 |
+
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
| 234 |
+
p.alignment = {"center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(e.align, PP_ALIGN.LEFT)
|
| 235 |
+
p.line_spacing = e.lh * 0.96
|
| 236 |
+
if i and e.gap:
|
| 237 |
+
p.space_before = Pt(e.gap * 0.5)
|
| 238 |
+
for r in runs:
|
| 239 |
+
run = p.add_run()
|
| 240 |
+
run.text = r.text
|
| 241 |
+
f = run.font
|
| 242 |
+
f.name = FONTS[e.font]
|
| 243 |
+
f.size = Pt(e.size * 0.5)
|
| 244 |
+
f.bold = r.bold or e.font == "display"
|
| 245 |
+
f.color.rgb = _rgb(color(pal, r.color))
|
| 246 |
+
rpr = run._r.get_or_add_rPr()
|
| 247 |
+
if e.upper:
|
| 248 |
+
rpr.set("cap", "all")
|
| 249 |
+
if e.track:
|
| 250 |
+
rpr.set("spc", str(int(e.track * e.size * 0.5 * 100)))
|
| 251 |
+
return
|
| 252 |
+
if e.kind == "line":
|
| 253 |
+
horizontal = e.h == 0
|
| 254 |
+
x2, y2 = (e.x + e.w, e.y) if horizontal else (e.x, e.y + e.h)
|
| 255 |
+
ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, _emu(e.x), _emu(e.y), _emu(x2), _emu(y2))
|
| 256 |
+
_no_style(ln)
|
| 257 |
+
ln.line.color.rgb = _rgb(color(pal, e.stroke))
|
| 258 |
+
ln.line.width = _emu(e.stroke_w)
|
| 259 |
+
return
|
| 260 |
+
kind = MSO_SHAPE.OVAL if e.kind == "circle" else (MSO_SHAPE.ROUNDED_RECTANGLE if e.radius else MSO_SHAPE.RECTANGLE)
|
| 261 |
+
sh = slide.shapes.add_shape(kind, _emu(e.x), _emu(e.y), _emu(e.w), _emu(e.h))
|
| 262 |
+
_no_style(sh)
|
| 263 |
+
if kind == MSO_SHAPE.ROUNDED_RECTANGLE:
|
| 264 |
+
sh.adjustments[0] = min(0.5, e.radius / max(min(e.w, e.h), 1))
|
| 265 |
+
if e.fill:
|
| 266 |
+
sh.fill.solid()
|
| 267 |
+
sh.fill.fore_color.rgb = _rgb(color(pal, e.fill))
|
| 268 |
+
_alpha(sh.fill._xPr.find(qn("a:solidFill")), e.opacity)
|
| 269 |
+
else:
|
| 270 |
+
sh.fill.background()
|
| 271 |
+
if e.stroke and e.stroke_w:
|
| 272 |
+
sh.line.color.rgb = _rgb(color(pal, e.stroke))
|
| 273 |
+
sh.line.width = _emu(e.stroke_w)
|
| 274 |
+
else:
|
| 275 |
+
sh.line.fill.background()
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _notes(slide_data: dict) -> str:
|
| 279 |
+
notes = slide_data.get("notes") or ""
|
| 280 |
+
srcs = [f"{s.get('title') or s['path']} (lines {s['line_start']}-{s['line_end']})" for s in slide_data.get("sources") or []]
|
| 281 |
+
return notes + (("\n\nSources: " + "; ".join(dict.fromkeys(srcs))) if srcs else "")
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def build_pptx(deck: dict, theme: str, fmt: str, seed: int) -> bytes:
|
| 285 |
+
"""The deck as an editable PowerPoint: text boxes you can type in, over each slide's drawn background."""
|
| 286 |
+
theme = theme_id(theme)
|
| 287 |
+
|
| 288 |
+
def make() -> bytes:
|
| 289 |
+
t = THEMES[theme]
|
| 290 |
+
prs = Presentation()
|
| 291 |
+
prs.slide_width, prs.slide_height = _emu(W), _emu(H)
|
| 292 |
+
blank = prs.slide_layouts[6]
|
| 293 |
+
for i in range(len(deck["slides"])):
|
| 294 |
+
c = compose(deck, i, theme, fmt, seed)
|
| 295 |
+
slide = prs.slides.add_slide(blank)
|
| 296 |
+
d = build(theme, c.slide["layout"], c.slide["variant"], seed, i)
|
| 297 |
+
slide.shapes.add_picture(io.BytesIO(background(d, t.pal, t.dark)), 0, 0, _emu(W), _emu(H))
|
| 298 |
+
for e in c.elements:
|
| 299 |
+
_add_el(slide, e, t.pal)
|
| 300 |
+
note = _notes(c.slide)
|
| 301 |
+
if note:
|
| 302 |
+
slide.notes_slide.notes_text_frame.text = note
|
| 303 |
+
prs.core_properties.title = deck.get("title") or "Slide deck"
|
| 304 |
+
buf = io.BytesIO()
|
| 305 |
+
prs.save(buf)
|
| 306 |
+
return buf.getvalue()
|
| 307 |
+
|
| 308 |
+
return _cached(_key("pptx", deck, theme, fmt, seed), make)
|
backend/app/slides/layout.py
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# Dark space: cinematic, centred openers and closers, asymmetric splits. Light space: editorial, left aligned, top
|
| 22 |
+
# 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(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(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 |
+
|
| 223 |
+
|
| 224 |
+
def _lines(runs: list[Run], width: float, size: float, font: str, upper: bool, track: float) -> int:
|
| 225 |
+
"""Lines a paragraph wraps to in `width` pixels."""
|
| 226 |
+
words: list[tuple[str, bool]] = []
|
| 227 |
+
for r in runs:
|
| 228 |
+
t = r.text.upper() if upper else r.text
|
| 229 |
+
words += [(w, r.bold) for w in t.split(" ") if w]
|
| 230 |
+
if not words:
|
| 231 |
+
return 0
|
| 232 |
+
space = (_char_w(" ", font, False) + track) * size
|
| 233 |
+
lines, x = 1, 0.0
|
| 234 |
+
for word, bold in words:
|
| 235 |
+
ww = sum((_char_w(c, font, bold) + track) * size for c in word)
|
| 236 |
+
if ww > width: # a word longer than the line breaks anywhere
|
| 237 |
+
extra = x + (space if x else 0) + ww
|
| 238 |
+
lines += int(extra // width)
|
| 239 |
+
x = extra % width
|
| 240 |
+
continue
|
| 241 |
+
need = ww if x == 0 else x + space + ww
|
| 242 |
+
if need <= width:
|
| 243 |
+
x = need
|
| 244 |
+
else:
|
| 245 |
+
lines += 1
|
| 246 |
+
x = ww
|
| 247 |
+
return lines
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# --- placed elements ------------------------------------------------------------------------------------------------
|
| 251 |
+
|
| 252 |
+
@dataclass
|
| 253 |
+
class El:
|
| 254 |
+
kind: str # text | rect | circle | line
|
| 255 |
+
role: str
|
| 256 |
+
x: float
|
| 257 |
+
y: float
|
| 258 |
+
w: float
|
| 259 |
+
h: float
|
| 260 |
+
paras: list[list[Run]] = field(default_factory=list)
|
| 261 |
+
size: float = 0
|
| 262 |
+
lh: float = 1.3
|
| 263 |
+
font: str = "body" # display | body | mono
|
| 264 |
+
align: str = "left"
|
| 265 |
+
valign: str = "top"
|
| 266 |
+
upper: bool = False
|
| 267 |
+
track: float = 0.0 # letter spacing in em
|
| 268 |
+
gap: float = 0.0 # space between paragraphs, in px
|
| 269 |
+
fill: str | None = None # palette key
|
| 270 |
+
stroke: str | None = None
|
| 271 |
+
stroke_w: float = 0
|
| 272 |
+
radius: float = 0
|
| 273 |
+
opacity: float = 1.0
|
| 274 |
+
|
| 275 |
+
def text_height(self) -> float:
|
| 276 |
+
return text_height(self.paras, self.w, self.size, self.lh, self.font, self.upper, self.track, self.gap)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def text_height(paras: list[list[Run]], width: float, size: float, lh: float, font: str, upper: bool = False,
|
| 280 |
+
track: float = 0.0, gap: float = 0.0) -> float:
|
| 281 |
+
n = [_lines(p, width, size, font, upper, track) for p in paras]
|
| 282 |
+
used = [k for k in n if k]
|
| 283 |
+
return sum(k * size * lh for k in used) + max(len(used) - 1, 0) * gap
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
SCALES = (1.0, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55)
|
| 287 |
+
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
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _scales(start: int, grow: bool = False) -> tuple[float, ...]:
|
| 291 |
+
"""Scales to try, largest first: from `start` down, with the growing ones first when allowed and not shrunk."""
|
| 292 |
+
return (GROW + SCALES) if grow and start == 0 else SCALES[start:]
|
| 293 |
+
|
| 294 |
+
# Base font sizes in px, by format. Presenter slides carry fewer words, so they are drawn larger.
|
| 295 |
+
TYPE = {
|
| 296 |
+
"detailed": {"kicker": 22, "heading": 60, "title": 100, "section": 80, "lead": 30, "title_lead": 34, "point": 28,
|
| 297 |
+
"card_term": 30, "card_text": 25, "value": 220, "label": 34, "quote": 52, "by": 24, "take": 28,
|
| 298 |
+
"closing": 46, "closing_big": 66, "agenda": 32, "col_label": 28, "col_item": 27, "num": 22,
|
| 299 |
+
"footer": 18},
|
| 300 |
+
"presenter": {"kicker": 24, "heading": 72, "title": 112, "section": 92, "lead": 36, "title_lead": 38,
|
| 301 |
+
"point": 40, "card_term": 34, "card_text": 34, "value": 260, "label": 40, "quote": 60, "by": 26,
|
| 302 |
+
"take": 36, "closing": 56, "closing_big": 78, "agenda": 36, "col_label": 30, "col_item": 36,
|
| 303 |
+
"num": 24, "footer": 18},
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _fit_size(paras: list[list[Run]], width: float, height: float, base: float, lh: float, font: str,
|
| 308 |
+
start: int, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, grow: bool = False) -> float | None:
|
| 309 |
+
"""The largest size (base times a scale, from `start` down) at which the paragraphs fit the box, or None."""
|
| 310 |
+
for scale in _scales(start, grow):
|
| 311 |
+
size = round(base * scale, 1)
|
| 312 |
+
if text_height(paras, width, size, lh, font, upper, track, size * gap_em) <= height:
|
| 313 |
+
return size
|
| 314 |
+
return None
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class NoFit(Exception):
|
| 318 |
+
"""Text that does not fit even at the smallest size: the caller shortens it and tries again."""
|
| 319 |
+
|
| 320 |
+
def __init__(self, field_name: str, index: int | None = None):
|
| 321 |
+
super().__init__(field_name)
|
| 322 |
+
self.field_name = field_name
|
| 323 |
+
self.index = index
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@dataclass
|
| 327 |
+
class Composed:
|
| 328 |
+
slide: dict # the slide as drawn (text shortened when it had to be)
|
| 329 |
+
elements: list[El]
|
| 330 |
+
seed: int
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _text(role: str, rect: tuple, paras: list[list[Run]], base: float, start: int, *, font: str = "body",
|
| 334 |
+
lh: float = 1.35, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, field_name: str = "",
|
| 335 |
+
index: int | None = None, grow: bool = False) -> El:
|
| 336 |
+
x, y, w, h, align, valign = rect[:6]
|
| 337 |
+
size = _fit_size(paras, w, h, base, lh, font, start, upper, track, gap_em, grow)
|
| 338 |
+
if size is None:
|
| 339 |
+
raise NoFit(field_name or role, index)
|
| 340 |
+
return El("text", role, x, y, w, h, paras=paras, size=size, lh=lh, font=font, align=align, valign=valign,
|
| 341 |
+
upper=upper, track=track, gap=size * gap_em)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _point_runs(p: dict, fmt: str) -> list[Run]:
|
| 345 |
+
if p["term"] and fmt == "detailed":
|
| 346 |
+
return [Run(p["term"] + ".", True, "ink"), Run(" " + p["text"], False, "mute")]
|
| 347 |
+
return [Run(p["text"], False, "ink")]
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, start: int, dark: bool) -> None:
|
| 351 |
+
"""The slide's main content: points, agenda items or takeaways, drawn as `kind`."""
|
| 352 |
+
x, y, w, h, _align, valign, _ = rect
|
| 353 |
+
pts = s["points"]
|
| 354 |
+
|
| 355 |
+
if kind in ("list", "list2", "agenda_list", "agenda_grid", "take_stack"):
|
| 356 |
+
if kind in ("agenda_list", "agenda_grid"):
|
| 357 |
+
items = [[Run(p["text"], False, "ink")] for p in pts]
|
| 358 |
+
base, numbered = t["agenda"], True
|
| 359 |
+
elif kind == "take_stack":
|
| 360 |
+
items = [[Run(tx, False, "ink")] for tx in s["takeaways"]]
|
| 361 |
+
base, numbered = t["take"], True
|
| 362 |
+
else:
|
| 363 |
+
items = [_point_runs(p, fmt) for p in pts]
|
| 364 |
+
base, numbered = t["point"], False
|
| 365 |
+
cols = 2 if kind in ("list2", "agenda_grid") and len(items) >= 3 else 1
|
| 366 |
+
per = math.ceil(len(items) / cols)
|
| 367 |
+
col_gap = 70
|
| 368 |
+
cw = (w - col_gap * (cols - 1)) / cols
|
| 369 |
+
indent = 78 if numbered else 44
|
| 370 |
+
tw = cw - indent
|
| 371 |
+
# One size for every item: the largest at which the tallest column fits with breathing room between items.
|
| 372 |
+
size = None
|
| 373 |
+
for scale in _scales(start, True):
|
| 374 |
+
sz = round(base * scale, 1)
|
| 375 |
+
gap = sz * 0.9
|
| 376 |
+
heights = [text_height([it], tw, sz, 1.38, "body") for it in items]
|
| 377 |
+
tallest = max(sum(heights[c * per:(c + 1) * per]) + gap * (len(heights[c * per:(c + 1) * per]) - 1)
|
| 378 |
+
for c in range(cols))
|
| 379 |
+
if tallest <= h:
|
| 380 |
+
size = sz
|
| 381 |
+
break
|
| 382 |
+
if size is None:
|
| 383 |
+
raise NoFit("takeaways" if kind == "take_stack" else "points")
|
| 384 |
+
heights = [text_height([it], tw, size, 1.38, "body") for it in items]
|
| 385 |
+
for c in range(cols):
|
| 386 |
+
chunk = list(range(c * per, min((c + 1) * per, len(items))))
|
| 387 |
+
total = sum(heights[i] for i in chunk)
|
| 388 |
+
free = h - total
|
| 389 |
+
gap = min(free / max(len(chunk) - 1, 1), size * 2.2) if len(chunk) > 1 else 0
|
| 390 |
+
block_h = total + gap * max(len(chunk) - 1, 0)
|
| 391 |
+
cy = y + (h - block_h) / 2 if valign == "middle" else y
|
| 392 |
+
cx = x + c * (cw + col_gap)
|
| 393 |
+
for i in chunk:
|
| 394 |
+
ih = heights[i]
|
| 395 |
+
if numbered:
|
| 396 |
+
els.append(El("text", "num", cx, cy + size * 0.12, 64, size * 1.3, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
|
| 397 |
+
size=round(size * 0.82, 1), lh=1.2, font="mono"))
|
| 398 |
+
else:
|
| 399 |
+
d = size * 0.34
|
| 400 |
+
els.append(El("circle", "dot", cx + 4, cy + size * 0.69 - d / 2, d, d, fill="accent"))
|
| 401 |
+
els.append(El("text", "item", cx + indent, cy, tw, ih + 2, paras=[items[i]], size=size, lh=1.38))
|
| 402 |
+
if numbered and kind != "take_stack" and i != chunk[-1]:
|
| 403 |
+
els.append(El("line", "rule", cx + indent, cy + ih + gap / 2, tw, 0, stroke="line", stroke_w=1.5))
|
| 404 |
+
cy += ih + gap
|
| 405 |
+
return
|
| 406 |
+
|
| 407 |
+
if kind in ("cards", "take_cols", "take_row", "take_arc"):
|
| 408 |
+
if kind == "cards":
|
| 409 |
+
cells = [(p["term"] if fmt == "detailed" else "", p["text"]) for p in pts]
|
| 410 |
+
else:
|
| 411 |
+
cells = [("", tx) for tx in s["takeaways"]]
|
| 412 |
+
n = max(len(cells), 1)
|
| 413 |
+
gap = 36 if kind != "take_row" else 60
|
| 414 |
+
cw = (w - gap * (n - 1)) / n
|
| 415 |
+
boxed = kind in ("cards", "take_cols")
|
| 416 |
+
pad = 40 if boxed else 0
|
| 417 |
+
top = 0.0
|
| 418 |
+
if kind == "take_arc":
|
| 419 |
+
top = 120 # the glowing marker above each takeaway
|
| 420 |
+
elif kind == "take_row":
|
| 421 |
+
top = 70
|
| 422 |
+
else:
|
| 423 |
+
top = 64 # number row inside the card
|
| 424 |
+
inner_w = cw - 2 * pad
|
| 425 |
+
inner_h = h - 2 * pad - top
|
| 426 |
+
term_base = t["card_term"]
|
| 427 |
+
text_base = t["card_text"] if kind == "cards" else t["take"]
|
| 428 |
+
size = None
|
| 429 |
+
for scale in _scales(start, True):
|
| 430 |
+
ts, xs = round(term_base * scale, 1), round(text_base * scale, 1)
|
| 431 |
+
ok = True
|
| 432 |
+
for term, text in cells:
|
| 433 |
+
hh = (text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
|
| 434 |
+
hh += text_height([[Run(text)]], inner_w, xs, 1.4, "body")
|
| 435 |
+
if hh > inner_h:
|
| 436 |
+
ok = False
|
| 437 |
+
break
|
| 438 |
+
if ok:
|
| 439 |
+
size = (ts, xs)
|
| 440 |
+
break
|
| 441 |
+
if size is None:
|
| 442 |
+
raise NoFit("takeaways" if kind != "cards" else "points")
|
| 443 |
+
ts, xs = size
|
| 444 |
+
# Cards as tall as the tallest content (not the whole box), so short text does not float in a tall card.
|
| 445 |
+
tallest = max((text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0)
|
| 446 |
+
+ text_height([[Run(text)]], inner_w, xs, 1.4, "body") for term, text in cells)
|
| 447 |
+
h = min(h, tallest + 2 * pad + top + 12)
|
| 448 |
+
for i, (term, text) in enumerate(cells):
|
| 449 |
+
cx = x + i * (cw + gap)
|
| 450 |
+
if boxed:
|
| 451 |
+
els.append(El("rect", "card", cx, y, cw, h, fill="card", stroke="cardline", stroke_w=2, radius=22))
|
| 452 |
+
if kind == "take_arc":
|
| 453 |
+
r = 46
|
| 454 |
+
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))
|
| 455 |
+
els.append(El("circle", "marker", cx + cw / 2 - r, y + 2, r * 2, r * 2, fill="accent2"))
|
| 456 |
+
els.append(El("text", "num", cx + cw / 2 - r, y + 2, r * 2, r * 2, paras=[[Run(f"{i + 1}", True, "white")]],
|
| 457 |
+
size=34, lh=1.0, font="display", align="center", valign="middle"))
|
| 458 |
+
elif kind == "take_row":
|
| 459 |
+
els.append(El("rect", "bar", cx, y, cw, 5, fill="accent"))
|
| 460 |
+
els.append(El("text", "num", cx, y + 20, cw, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
|
| 461 |
+
size=24, lh=1.2, font="mono"))
|
| 462 |
+
else:
|
| 463 |
+
els.append(El("text", "num", cx + pad, y + pad, inner_w, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]],
|
| 464 |
+
size=t["num"], lh=1.2, font="mono"))
|
| 465 |
+
cy = y + pad + top
|
| 466 |
+
align = "center" if kind == "take_arc" else "left"
|
| 467 |
+
if term:
|
| 468 |
+
th = text_height([[Run(term, True)]], inner_w, ts, 1.15, "display")
|
| 469 |
+
els.append(El("text", "term", cx + pad, cy, inner_w, th + 2, paras=[[Run(term, True, "ink")]], size=ts,
|
| 470 |
+
lh=1.15, font="display", align=align))
|
| 471 |
+
cy += th + ts * 0.5
|
| 472 |
+
els.append(El("text", "item", cx + pad, cy, inner_w, y + h - pad - cy, paras=[[Run(text, False, "mute" if term else "ink")]],
|
| 473 |
+
size=xs, lh=1.4, align=align))
|
| 474 |
+
return
|
| 475 |
+
|
| 476 |
+
if kind in ("timeline_v", "timeline_h"):
|
| 477 |
+
items = [_point_runs(p, fmt) for p in pts]
|
| 478 |
+
n = len(items)
|
| 479 |
+
r = 28
|
| 480 |
+
if kind == "timeline_v":
|
| 481 |
+
tw = w - 96
|
| 482 |
+
size = None
|
| 483 |
+
for scale in _scales(start, True):
|
| 484 |
+
sz = round(t["point"] * scale, 1)
|
| 485 |
+
heights = [max(text_height([it], tw, sz, 1.38, "body"), r * 2) for it in items]
|
| 486 |
+
if sum(heights) + sz * 1.2 * (n - 1) <= h:
|
| 487 |
+
size = sz
|
| 488 |
+
break
|
| 489 |
+
if size is None:
|
| 490 |
+
raise NoFit("points")
|
| 491 |
+
heights = [max(text_height([it], tw, size, 1.38, "body"), r * 2) for it in items]
|
| 492 |
+
gap = min((h - sum(heights)) / max(n - 1, 1), size * 2.4)
|
| 493 |
+
total = sum(heights) + gap * (n - 1)
|
| 494 |
+
cy = y + (h - total) / 2 if valign == "middle" else y
|
| 495 |
+
els.append(El("line", "spine", x + r, cy + r, 0, total - 2 * r if total > 2 * r else 0, stroke="line", stroke_w=3))
|
| 496 |
+
for i, it in enumerate(items):
|
| 497 |
+
els.append(El("circle", "marker", x, cy, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
|
| 498 |
+
els.append(El("text", "num", x, cy, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
|
| 499 |
+
font="display", align="center", valign="middle"))
|
| 500 |
+
els.append(El("text", "item", x + 96, cy + max((r * 2 - size * 1.38) / 2, 0) if heights[i] <= r * 2 else cy,
|
| 501 |
+
tw, heights[i] + 2, paras=[it], size=size, lh=1.38))
|
| 502 |
+
cy += heights[i] + gap
|
| 503 |
+
else:
|
| 504 |
+
gap = 40
|
| 505 |
+
cw = (w - gap * (n - 1)) / n
|
| 506 |
+
th = h - r * 2 - 40
|
| 507 |
+
size = _fit_all([[it] for it in items], cw, th, t["point"] * 0.9, 1.38, start)
|
| 508 |
+
if size is None:
|
| 509 |
+
raise NoFit("points")
|
| 510 |
+
els.append(El("line", "spine", x + r, y + r, w - 2 * r, 0, stroke="line", stroke_w=3))
|
| 511 |
+
for i, it in enumerate(items):
|
| 512 |
+
cx = x + i * (cw + gap)
|
| 513 |
+
els.append(El("circle", "marker", cx, y, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3))
|
| 514 |
+
els.append(El("text", "num", cx, y, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0,
|
| 515 |
+
font="display", align="center", valign="middle"))
|
| 516 |
+
els.append(El("text", "item", cx, y + r * 2 + 40, cw, th, paras=[it], size=size, lh=1.38))
|
| 517 |
+
return
|
| 518 |
+
raise ValueError(f"unknown body kind {kind}")
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def _fit_all(blocks: list[list[list[Run]]], width: float, height: float, base: float, lh: float, start: int) -> float | None:
|
| 522 |
+
for scale in _scales(start, True):
|
| 523 |
+
size = round(base * scale, 1)
|
| 524 |
+
if all(text_height(b, width, size, lh, "body") <= height for b in blocks):
|
| 525 |
+
return size
|
| 526 |
+
return None
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start: int) -> None:
|
| 530 |
+
"""The two sides of a comparison, at one font size, each panel as tall as the taller side's content."""
|
| 531 |
+
kind = rects[0][6]
|
| 532 |
+
pad = 44 if kind == "panel" else 0
|
| 533 |
+
top = 0 if kind == "panel" else 28
|
| 534 |
+
sides = [s["left"], s["right"]]
|
| 535 |
+
|
| 536 |
+
def measure(col: dict, iw: float, ls: float, xs: float) -> tuple[float, list[float]]:
|
| 537 |
+
label = [[Run(col["label"], True, "accent")]] if col["label"] else []
|
| 538 |
+
lab = text_height(label, iw, ls, 1.2, "display") + (xs * 0.9 if label else 0)
|
| 539 |
+
heights = [text_height([[Run(i)]], iw - 40, xs, 1.38, "body") for i in col["items"]]
|
| 540 |
+
return lab + sum(heights) + xs * 0.75 * (len(heights) - 1), heights
|
| 541 |
+
|
| 542 |
+
for scale in _scales(start, True):
|
| 543 |
+
ls, xs = round(t["col_label"] * scale, 1), round(t["col_item"] * scale, 1)
|
| 544 |
+
need = [measure(col, r[2] - 2 * pad, ls, xs)[0] for col, r in zip(sides, rects, strict=True)]
|
| 545 |
+
bad = [i for i, (n, r) in enumerate(zip(need, rects, strict=True)) if n > r[3] - 2 * pad - top]
|
| 546 |
+
if not bad:
|
| 547 |
+
break
|
| 548 |
+
else:
|
| 549 |
+
raise NoFit(("left", "right")[bad[0]] + "_items")
|
| 550 |
+
panel_h = max(need) + 2 * pad + top + 8
|
| 551 |
+
for col, (x, y, w, h, *_) in zip(sides, rects, strict=True):
|
| 552 |
+
if kind == "panel":
|
| 553 |
+
els.append(El("rect", "panel", x, y, w, min(h, panel_h), fill="card", stroke="cardline", stroke_w=2, radius=22))
|
| 554 |
+
else:
|
| 555 |
+
els.append(El("rect", "bar", x, y, 72, 5, fill="accent"))
|
| 556 |
+
ix, iw = x + pad, w - 2 * pad
|
| 557 |
+
cy = y + pad + top
|
| 558 |
+
_, heights = measure(col, iw, ls, xs)
|
| 559 |
+
if col["label"]:
|
| 560 |
+
lab_h = text_height([[Run(col["label"], True)]], iw, ls, 1.2, "display")
|
| 561 |
+
els.append(El("text", "col_label", ix, cy, iw, lab_h + 2, paras=[[Run(col["label"], True, "accent")]],
|
| 562 |
+
size=ls, lh=1.2, font="display"))
|
| 563 |
+
cy += lab_h + xs * 0.9
|
| 564 |
+
for item, hh in zip(col["items"], heights, strict=True):
|
| 565 |
+
d = xs * 0.32
|
| 566 |
+
els.append(El("circle", "dot", ix + 2, cy + xs * 0.69 - d / 2, d, d, fill="accent"))
|
| 567 |
+
els.append(El("text", "item", ix + 40, cy, iw - 40, hh + 2, paras=[[Run(item, False, "ink")]], size=xs, lh=1.38))
|
| 568 |
+
cy += hh + xs * 0.75
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start: int) -> list[El]:
|
| 572 |
+
from app.slides.themes import THEMES
|
| 573 |
+
|
| 574 |
+
t = TYPE[fmt]
|
| 575 |
+
dark = THEMES[theme].dark
|
| 576 |
+
layout, v = s["layout"], s["variant"]
|
| 577 |
+
els: list[El] = []
|
| 578 |
+
|
| 579 |
+
lead_slot = block(theme, layout, v, "lead")
|
| 580 |
+
|
| 581 |
+
def rect(name: str) -> tuple | None:
|
| 582 |
+
r = block(theme, layout, v, name)
|
| 583 |
+
# No lead: what sits right under the lead's slot (in the same column) moves up into it.
|
| 584 |
+
if r and name in ("body", "left", "right") and lead_slot and not s["lead"]:
|
| 585 |
+
lx, ly, lw, lh_ = lead_slot[:4]
|
| 586 |
+
if r[1] >= ly + lh_ - 2 and r[1] - (ly + lh_) < 80 and r[0] < lx + lw and lx < r[0] + r[2]:
|
| 587 |
+
return (r[0], ly, r[2], r[3] + r[1] - ly, *r[4:])
|
| 588 |
+
return r
|
| 589 |
+
|
| 590 |
+
k = rect("kicker")
|
| 591 |
+
if k and s["kicker"]:
|
| 592 |
+
x, y, w, h, align, *_ = k
|
| 593 |
+
bar_x = x + (w - 64) / 2 if align == "center" else (x + w - 64 if align == "right" else x)
|
| 594 |
+
els.append(El("rect", "bar", bar_x, y - 18, 64, 5, fill="accent"))
|
| 595 |
+
els.append(_text("kicker", k, [[Run(s["kicker"], True, "accent")]], t["kicker"], 0, font="mono", lh=1.2,
|
| 596 |
+
upper=True, track=0.16, field_name="kicker"))
|
| 597 |
+
|
| 598 |
+
heading_base = {"title": t["title"], "section": t["section"]}.get(layout, t["heading"])
|
| 599 |
+
hr = rect("heading")
|
| 600 |
+
if hr and s["heading"]:
|
| 601 |
+
els.append(_text("heading", hr, [[Run(s["heading"], True, "ink")]], heading_base, start, font="display",
|
| 602 |
+
lh=1.08, field_name="heading"))
|
| 603 |
+
|
| 604 |
+
lr = rect("lead")
|
| 605 |
+
if lr and s["lead"]:
|
| 606 |
+
base = t["title_lead"] if layout == "title" else t["lead"]
|
| 607 |
+
head = next((e for e in els if e.role == "heading"), None)
|
| 608 |
+
if head and head.valign == "top" and abs(head.x - lr[0]) < 1 and head.y < lr[1]:
|
| 609 |
+
# Under a top aligned heading in the same column: follow the heading instead of waiting at the box's top.
|
| 610 |
+
top = head.y + head.text_height() + head.size * 0.55
|
| 611 |
+
lr = (lr[0], top, lr[2], lr[1] + lr[3] - top, *lr[4:])
|
| 612 |
+
els.append(_text("lead", lr, [[Run(s["lead"], False, "mute")]], base, start, lh=1.4, field_name="lead"))
|
| 613 |
+
|
| 614 |
+
if layout == "stat":
|
| 615 |
+
els.append(_text("value", rect("value"), [[Run(s["stat"]["value"], True, "accent")]], t["value"], start,
|
| 616 |
+
font="display", lh=1.0, field_name="stat_value"))
|
| 617 |
+
els.append(_text("label", rect("label"), [[Run(s["stat"]["label"], False, "ink")]], t["label"], start, lh=1.35,
|
| 618 |
+
field_name="stat_label", grow=True))
|
| 619 |
+
if layout == "quote":
|
| 620 |
+
q = rect("quote")
|
| 621 |
+
els.append(_text("quote", q, [[Run("“", True, "accent"), Run(s["quote"]["text"], False, "ink"),
|
| 622 |
+
Run("”", True, "accent")]], t["quote"], start, font="display", lh=1.22,
|
| 623 |
+
field_name="quote", grow=True))
|
| 624 |
+
if s["quote"]["by"] and (b := rect("by")):
|
| 625 |
+
els.append(_text("by", b, [[Run(s["quote"]["by"], False, "mute")]], t["by"], 0, font="mono", lh=1.3,
|
| 626 |
+
upper=True, track=0.08, field_name="by"))
|
| 627 |
+
if layout == "two_column":
|
| 628 |
+
_columns(els, s, (rect("left"), rect("right")), t, start)
|
| 629 |
+
br = rect("body")
|
| 630 |
+
if br:
|
| 631 |
+
_body(els, s, br, br[6], fmt, t, start, dark)
|
| 632 |
+
cr = rect("closing")
|
| 633 |
+
if cr and s["closing"]:
|
| 634 |
+
kind = cr[6]
|
| 635 |
+
if kind == "band":
|
| 636 |
+
x, y, w, h, *_ = cr
|
| 637 |
+
els.append(El("rect", "band", x, y, w, h, fill="band", radius=24))
|
| 638 |
+
inner = (x + 60, y, w - 120, h, "left", "middle")
|
| 639 |
+
els.append(_text("closing", inner, [[Run(s["closing"], True, "bandink")]], t["closing"], start,
|
| 640 |
+
font="display", lh=1.15, field_name="closing"))
|
| 641 |
+
else:
|
| 642 |
+
base = t["closing_big"] if kind == "big" else t["closing"]
|
| 643 |
+
els.append(_text("closing", cr, [[Run(s["closing"], True, "ink")]], base, start, font="display", lh=1.12,
|
| 644 |
+
field_name="closing", grow=kind == "big"))
|
| 645 |
+
|
| 646 |
+
if layout != "title":
|
| 647 |
+
total = len(deck["slides"])
|
| 648 |
+
foot = [[Run(clip(deck.get("title"), 70), False, "mute")]]
|
| 649 |
+
els.append(El("text", "footer", 140, 1018, 1200, 30, paras=foot, size=t["footer"], lh=1.2, font="mono"))
|
| 650 |
+
els.append(El("text", "footer", 1480, 1018, 300, 30, paras=[[Run(f"{index + 1:02d} / {total:02d}", False, "mute")]],
|
| 651 |
+
size=t["footer"], lh=1.2, font="mono", align="right"))
|
| 652 |
+
return els
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
def _shorten(s: dict, err: NoFit, fmt: str) -> bool:
|
| 656 |
+
"""Make the text that did not fit shorter (or drop a point). False when there is nothing left to shorten."""
|
| 657 |
+
lo = LIMITS[fmt]["points"][0]
|
| 658 |
+
name = err.field_name
|
| 659 |
+
|
| 660 |
+
def cut(text: str) -> str:
|
| 661 |
+
return clip(text, max(int(len(text) * 0.82), 12)) if len(text) > 14 else text
|
| 662 |
+
|
| 663 |
+
if name == "points":
|
| 664 |
+
pts = s["points"]
|
| 665 |
+
if s["layout"] == "agenda" or len(pts) > max(lo, 3):
|
| 666 |
+
if len(pts) > 2:
|
| 667 |
+
pts.pop()
|
| 668 |
+
return True
|
| 669 |
+
longest = max(range(len(pts)), key=lambda i: len(pts[i]["text"]) + len(pts[i]["term"]), default=None)
|
| 670 |
+
if longest is None or len(pts[longest]["text"]) <= 14:
|
| 671 |
+
return False
|
| 672 |
+
pts[longest]["text"] = cut(pts[longest]["text"])
|
| 673 |
+
return True
|
| 674 |
+
if name == "takeaways":
|
| 675 |
+
tk = s["takeaways"]
|
| 676 |
+
i = max(range(len(tk)), key=lambda j: len(tk[j]), default=None)
|
| 677 |
+
if i is None or len(tk[i]) <= 14:
|
| 678 |
+
return False
|
| 679 |
+
tk[i] = cut(tk[i])
|
| 680 |
+
return True
|
| 681 |
+
if name in ("left_items", "right_items"):
|
| 682 |
+
col = s[name.split("_")[0]]
|
| 683 |
+
if len(col["items"]) > 2:
|
| 684 |
+
col["items"].pop()
|
| 685 |
+
return True
|
| 686 |
+
i = max(range(len(col["items"])), key=lambda j: len(col["items"][j]), default=None)
|
| 687 |
+
if i is None or len(col["items"][i]) <= 14:
|
| 688 |
+
return False
|
| 689 |
+
col["items"][i] = cut(col["items"][i])
|
| 690 |
+
return True
|
| 691 |
+
path = {"stat_value": ("stat", "value"), "stat_label": ("stat", "label"), "quote": ("quote", "text"),
|
| 692 |
+
"by": ("quote", "by")}.get(name)
|
| 693 |
+
if path:
|
| 694 |
+
holder, key = s[path[0]], path[1]
|
| 695 |
+
elif name in s and isinstance(s[name], str):
|
| 696 |
+
holder, key = s, name
|
| 697 |
+
else:
|
| 698 |
+
return False
|
| 699 |
+
if len(holder[key]) <= 14:
|
| 700 |
+
if name in ("lead", "by", "kicker"):
|
| 701 |
+
holder[key] = ""
|
| 702 |
+
return True
|
| 703 |
+
return False
|
| 704 |
+
holder[key] = cut(holder[key])
|
| 705 |
+
return True
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def compose(deck: dict, index: int, theme: str, fmt: str, seed: int = 0) -> Composed:
|
| 709 |
+
"""Slide `index` of a clamped deck, placed. Text that does not fit is shortened until it does; a slide that
|
| 710 |
+
still cannot be drawn becomes a plain section slide with its heading."""
|
| 711 |
+
theme, fmt = theme_id(theme), fmt if fmt in TYPE else "detailed"
|
| 712 |
+
s = copy.deepcopy(deck["slides"][index])
|
| 713 |
+
if s["variant"] not in VARIANTS[s["layout"]]:
|
| 714 |
+
s["variant"] = VARIANTS[s["layout"]][0]
|
| 715 |
+
start = min(int(s.get("shrink") or 0), len(SCALES) - 1)
|
| 716 |
+
for _ in range(60):
|
| 717 |
+
try:
|
| 718 |
+
return Composed(s, _compose_once(s, deck, index, theme, fmt, start), seed)
|
| 719 |
+
except NoFit as err:
|
| 720 |
+
if not _shorten(s, err, fmt):
|
| 721 |
+
break
|
| 722 |
+
plain = {**copy.deepcopy(s), "layout": "section", "variant": VARIANTS["section"][0], "lead": "", "points": [],
|
| 723 |
+
"heading": clip(s["heading"] or deck.get("title"), 60)}
|
| 724 |
+
return Composed(plain, _compose_once(plain, deck, index, theme, fmt, 0), seed)
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
def seed_of(value: object) -> int:
|
| 728 |
+
"""A stable integer seed from a deck id."""
|
| 729 |
+
digits = re.sub(r"[^0-9a-f]", "", str(value or "").lower())
|
| 730 |
+
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,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from markupsafe import escape
|
| 5 |
+
|
| 6 |
+
from app.slides.decor import Decor, Shape, build
|
| 7 |
+
from app.slides.layout import Composed, El, compose
|
| 8 |
+
from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id
|
| 9 |
+
|
| 10 |
+
FONT_LINK = ("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;700"
|
| 11 |
+
"&family=JetBrains+Mono:wght@400;700&display=swap")
|
| 12 |
+
|
| 13 |
+
_FAMILY = {"display": f"'{FONTS['display']}','{FONTS['body']}',sans-serif", "body": f"'{FONTS['body']}',sans-serif",
|
| 14 |
+
"mono": f"'{FONTS['mono']}',monospace"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def color(pal: Palette, key: str | None) -> str:
|
| 18 |
+
if not key:
|
| 19 |
+
return "transparent"
|
| 20 |
+
return "#ffffff" if key == "white" else getattr(pal, key)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
CSS = f"""
|
| 24 |
+
html,body{{margin:0;padding:0}}
|
| 25 |
+
.slide{{position:relative;width:{W}px;height:{H}px;overflow:hidden;font-family:{_FAMILY['body']};
|
| 26 |
+
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}}
|
| 27 |
+
.slide svg.bg{{position:absolute;inset:0}}
|
| 28 |
+
.e{{position:absolute;box-sizing:border-box;margin:0}}
|
| 29 |
+
.t{{display:flex;flex-direction:column;overflow:visible;overflow-wrap:anywhere}}
|
| 30 |
+
.t>.in{{display:block}}
|
| 31 |
+
.t.v-middle{{justify-content:center}}.t.v-bottom{{justify-content:flex-end}}
|
| 32 |
+
.t p{{margin:0}}
|
| 33 |
+
.t b{{font-weight:700}}
|
| 34 |
+
.r-heading,.r-quote,.r-closing,.r-term{{text-wrap:balance}}
|
| 35 |
+
.dark .r-heading.l-title,.dark .r-value{{text-shadow:0 0 48px rgba(33,124,255,.45)}}
|
| 36 |
+
.dark .r-marker{{box-shadow:0 0 36px rgba(33,124,255,.75)}}
|
| 37 |
+
.dark .r-bar{{box-shadow:0 0 18px rgba(77,148,255,.8)}}
|
| 38 |
+
.light .r-card,.light .r-panel{{box-shadow:0 18px 44px rgba(11,26,58,.08)}}
|
| 39 |
+
.light .r-band{{box-shadow:0 18px 44px rgba(11,26,58,.18)}}
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _style(**kw) -> str:
|
| 44 |
+
return ";".join(f"{k.replace('_', '-')}:{v}" for k, v in kw.items() if v is not None)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _el(e: El, pal: Palette, layout: str) -> str:
|
| 48 |
+
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")
|
| 49 |
+
if e.kind == "text":
|
| 50 |
+
paras = []
|
| 51 |
+
for i, p in enumerate(e.paras):
|
| 52 |
+
runs = "".join(
|
| 53 |
+
f'<span style="color:{color(pal, r.color)}{";font-weight:700" if r.bold else ""}">{escape(r.text)}</span>'
|
| 54 |
+
for r in p)
|
| 55 |
+
gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else ""
|
| 56 |
+
paras.append(f"<p{gap}>{runs}</p>")
|
| 57 |
+
css = _style(**box, font_size=f"{e.size}px", line_height=str(e.lh), font_family=_FAMILY[e.font],
|
| 58 |
+
text_align=e.align, letter_spacing=f"{e.track}em" if e.track else None,
|
| 59 |
+
text_transform="uppercase" if e.upper else None,
|
| 60 |
+
font_weight="700" if e.font == "display" else None)
|
| 61 |
+
return f'<div class="e t r-{e.role} l-{layout} v-{e.valign}" style="{css}"><div class="in">{"".join(paras)}</div></div>'
|
| 62 |
+
if e.kind == "line":
|
| 63 |
+
horizontal = e.h == 0
|
| 64 |
+
css = _style(left=f"{e.x:.1f}px", top=f"{e.y - (e.stroke_w / 2 if horizontal else 0):.1f}px",
|
| 65 |
+
width=f"{e.w if horizontal else e.stroke_w:.1f}px", height=f"{e.stroke_w if horizontal else e.h:.1f}px",
|
| 66 |
+
background=color(pal, e.stroke), border_radius="2px")
|
| 67 |
+
return f'<div class="e r-{e.role}" style="{css}"></div>'
|
| 68 |
+
radius = "50%" if e.kind == "circle" else f"{e.radius}px"
|
| 69 |
+
border = f"{e.stroke_w}px solid {color(pal, e.stroke)}" if e.stroke and e.stroke_w else None
|
| 70 |
+
css = _style(**box, background=color(pal, e.fill), border=border, border_radius=radius,
|
| 71 |
+
opacity=str(e.opacity) if e.opacity < 1 else None)
|
| 72 |
+
return f'<div class="e r-{e.role}" style="{css}"></div>'
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _svg(d: Decor, pal: Palette, dark: bool, uid: str) -> str:
|
| 76 |
+
defs, out = [], []
|
| 77 |
+
for n, s in enumerate(d.shapes):
|
| 78 |
+
out.append(_shape(s, pal, dark, f"{uid}{n}", defs))
|
| 79 |
+
hx, hy = d.hot
|
| 80 |
+
sky = (f'<radialGradient id="{uid}sky" gradientUnits="userSpaceOnUse" cx="{hx:.0f}" cy="{hy:.0f}" r="{W * 0.85:.0f}">'
|
| 81 |
+
f'<stop offset="0" stop-color="{pal.bg2}"/><stop offset="1" stop-color="{pal.bg1}"/></radialGradient>')
|
| 82 |
+
return (f'<svg class="bg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{sky}{"".join(defs)}</defs>'
|
| 83 |
+
f'<rect width="{W}" height="{H}" fill="url(#{uid}sky)"/>{"".join(out)}</svg>')
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str:
|
| 87 |
+
c = color(pal, s.color)
|
| 88 |
+
if s.kind == "stars":
|
| 89 |
+
return "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o}"/>' for x, y, r, o in s.points)
|
| 90 |
+
if s.kind == "glow":
|
| 91 |
+
defs.append(f'<radialGradient id="{gid}"><stop offset="0" stop-color="{c}" stop-opacity="{s.opacity}"/>'
|
| 92 |
+
f'<stop offset="1" stop-color="{c}" stop-opacity="0"/></radialGradient>')
|
| 93 |
+
return f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
|
| 94 |
+
if s.kind == "planet":
|
| 95 |
+
hi, lo, rim = planet_colors(pal, dark)
|
| 96 |
+
defs.append(f'<radialGradient id="{gid}" cx="35%" cy="28%" r="80%"><stop offset="0" stop-color="{hi}"/>'
|
| 97 |
+
f'<stop offset="1" stop-color="{lo}"/></radialGradient>')
|
| 98 |
+
return (f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
|
| 99 |
+
f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="none" stroke="{rim}" stroke-width="3" opacity=".7"/>')
|
| 100 |
+
if s.kind == "ring":
|
| 101 |
+
return (f'<ellipse cx="{s.cx:.0f}" cy="{s.cy:.0f}" rx="{s.rx:.0f}" ry="{s.ry:.0f}" fill="none" stroke="{c}" '
|
| 102 |
+
f'stroke-width="{s.width}" opacity="{s.opacity}" transform="rotate({s.rot:.1f} {s.cx:.0f} {s.cy:.0f})"/>')
|
| 103 |
+
if s.kind == "const":
|
| 104 |
+
lines = "".join(f'<line x1="{s.points[a][0]:.0f}" y1="{s.points[a][1]:.0f}" x2="{s.points[b][0]:.0f}" '
|
| 105 |
+
f'y2="{s.points[b][1]:.0f}" stroke="{c}" stroke-width="1.6" opacity="{s.opacity * 0.5:.2f}"/>'
|
| 106 |
+
for a, b in s.edges)
|
| 107 |
+
dots = "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{s.opacity}"/>' for x, y, r, _ in s.points)
|
| 108 |
+
return lines + dots
|
| 109 |
+
return ""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def planet_colors(pal: Palette, dark: bool) -> tuple[str, str, str]:
|
| 113 |
+
"""Lit side, dark side and rim of a planet."""
|
| 114 |
+
return ("#1d4fb3", "#020713", pal.accent) if dark else ("#5d9bff", pal.accent2, pal.accent)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def slide_body(c: Composed, deck: dict, index: int, theme: str) -> str:
|
| 118 |
+
t = THEMES[theme]
|
| 119 |
+
s = c.slide
|
| 120 |
+
d = build(theme, s["layout"], s["variant"], c.seed, index)
|
| 121 |
+
els = "".join(_el(e, t.pal, s["layout"]) for e in c.elements)
|
| 122 |
+
cls = f"slide {'dark' if t.dark else 'light'} l-{s['layout']} v-{s['variant']}"
|
| 123 |
+
return f'<div class="{cls}" style="background:{t.pal.bg1}">{_svg(d, t.pal, t.dark, f"s{index}g")}{els}</div>'
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _page(body: str, extra_css: str = "") -> str:
|
| 127 |
+
return (f'<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="{FONT_LINK}">'
|
| 128 |
+
f"<style>{CSS}{extra_css}</style></head><body>{body}</body></html>")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str:
|
| 132 |
+
"""One slide as a standalone page."""
|
| 133 |
+
theme = theme_id(theme)
|
| 134 |
+
return _page(slide_body(compose(deck, index, theme, fmt, seed), deck, index, theme),
|
| 135 |
+
f"body{{background:{THEMES[theme].pal.bg1}}}")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str:
|
| 139 |
+
"""Every slide on one page, one per printed page."""
|
| 140 |
+
theme = theme_id(theme)
|
| 141 |
+
body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme) for i in range(len(deck["slides"])))
|
| 142 |
+
css = (f"@page{{size:{W}px {H}px;margin:0}}body{{background:{THEMES[theme].pal.bg1}}}"
|
| 143 |
+
".slide{break-after:page;page-break-after:always}.slide:last-child{break-after:auto;page-break-after:auto}"
|
| 144 |
+
"*{-webkit-print-color-adjust:exact;print-color-adjust:exact}")
|
| 145 |
+
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", "Dark space", 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", "Light space", False, Palette(
|
| 38 |
+
bg1="#e9eef9", bg2="#ffffff", ink="#0b1a3a", mute="#4b5a7a", accent="#217cff", accent2="#0b3d91",
|
| 39 |
+
card="#ffffff", cardline="#d3dcef", line="#c6d1ea", 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}]},
|
|
@@ -1094,8 +1094,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 +1116,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}]},
|
|
|
|
| 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["WriteAudioScript"] = {"turns": [{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 1098 |
+
{"speaker": "host_b", "text": "Thanks.", "sources": []}]}
|
| 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 |
assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
|
| 1117 |
|
| 1118 |
|
| 1119 |
+
def test_audio_overview_alternates_two_hosts_or_uses_one(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
|
| 1120 |
+
from app.config import settings
|
| 1121 |
+
from app.services import tts
|
| 1122 |
+
|
| 1123 |
+
connect(client, auth, run_jobs)
|
| 1124 |
+
pricing = next(d for d in docs(client, auth) if d["title"] == "On Pricing")
|
| 1125 |
+
nb = client.post("/api/notebooks", json={"title": "Money", "document_ids": [pricing["id"]]}, headers=auth).json()
|
| 1126 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 1127 |
+
used = []
|
| 1128 |
+
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
|
| 1129 |
+
b"\x00" * 16000)
|
| 1130 |
+
seen = {}
|
| 1131 |
+
|
| 1132 |
+
def script(**kw):
|
| 1133 |
+
seen.update(kw)
|
| 1134 |
+
return {"turns": [ # host A twice in a row, **markup**, an empty turn: cleaned and made to alternate
|
| 1135 |
+
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 1136 |
+
{"speaker": "host_a", "text": "Today, **pricing**.", "sources": []},
|
| 1137 |
+
{"speaker": "host_b", "text": "", "sources": []},
|
| 1138 |
+
{"speaker": "host_b", "text": "Prices went up.", "sources": []},
|
| 1139 |
+
{"speaker": "host_b", "text": "And readers stayed.", "sources": []},
|
| 1140 |
+
{"speaker": "host_a", "text": "Exactly.", "sources": []},
|
| 1141 |
+
]}
|
| 1142 |
+
|
| 1143 |
+
llm["WriteAudioScript"] = script
|
| 1144 |
+
body = {"type": "audio_overview", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "minutes": 3,
|
| 1145 |
+
"format": "critique", "language": "Spanish", "instructions": "- Focus on raising prices"}
|
| 1146 |
+
art = client.post("/api/artifacts/generate", json=body, headers=auth).json()
|
| 1147 |
+
run_jobs()
|
| 1148 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 1149 |
+
assert done["status"] == "ready", done["content"]
|
| 1150 |
+
segs = done["content"]["segments"]
|
| 1151 |
+
assert [(s["speaker"], s["text"]) for s in segs] == [
|
| 1152 |
+
("host_a", "Welcome in. Today, pricing."), ("host_b", "Prices went up. And readers stayed."), ("host_a", "Exactly.")]
|
| 1153 |
+
assert seen["hosts"] == 2 and seen["target_words"] == 450 and seen["language"] == "Spanish"
|
| 1154 |
+
assert seen["focus"] == "- Focus on raising prices" and seen["style"].startswith("Critique")
|
| 1155 |
+
assert any(pricing["path"] in m for m in seen["material"]) # the picked post is what the script reads
|
| 1156 |
+
voices = done["content"]["voices"]
|
| 1157 |
+
assert used[1][1] == voices["host_b"] != voices["host_a"] == used[0][1]
|
| 1158 |
+
|
| 1159 |
+
used.clear()
|
| 1160 |
+
one = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "brief"}, headers=auth).json()
|
| 1161 |
+
run_jobs()
|
| 1162 |
+
done = client.get(f"/api/artifacts/{one['id']}", headers=auth).json()
|
| 1163 |
+
assert {s["speaker"] for s in done["content"]["segments"]} == {"host_a"} and seen["hosts"] == 1
|
| 1164 |
+
assert list(done["content"]["voices"]) == ["host_a"] and {v for _, v in used} == {done["content"]["voices"]["host_a"]}
|
| 1165 |
+
assert done["content"]["hosts"] == 1 and done["content"]["focus"] == "- Focus on raising prices"
|
| 1166 |
+
|
| 1167 |
+
r = client.post("/api/artifacts/generate", json={**body, "hosts": 1, "format": "debate"}, headers=auth)
|
| 1168 |
+
assert r.status_code == 400 and "two hosts" in r.json()["detail"]
|
| 1169 |
+
assert client.post("/api/artifacts/generate", json={**body, "document_ids": []}, headers=auth).status_code == 400
|
| 1170 |
+
|
| 1171 |
+
|
| 1172 |
def test_tts_retries_rate_limits(monkeypatch):
|
| 1173 |
from app.config import settings
|
| 1174 |
from app.services import tts
|
backend/tests/test_slides.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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,8 @@ 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 = {
|
|
|
|
| 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 |
};
|
| 165 |
|
| 166 |
export const jobsApi = {
|
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 = {
|
|
|
|
| 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 = {
|
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/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/SlideDeck.tsx
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
| 2 |
+
import { createPortal } from "react-dom";
|
| 3 |
+
import { artifactsApi, type GenerateBody } from "../api/endpoints";
|
| 4 |
+
import type { Artifact, ChatSummary } from "../api/types";
|
| 5 |
+
import { Dropdown } from "./Dropdown";
|
| 6 |
+
import { useJob } from "../hooks/useJob";
|
| 7 |
+
import { CheckIcon, CollapseIcon, DownloadIcon, ExpandIcon, MoreIcon, PdfIcon, PlayIcon, PptxIcon, SlidesIcon, TrashIcon } from "./icons/Icons";
|
| 8 |
+
import { LANGUAGES, Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
|
| 9 |
+
import { ConfirmDeleteModal, errorMessage, formatDate, JobProgress, Modal } from "./ui";
|
| 10 |
+
|
| 11 |
+
/** The two deck themes. Their slides are laid out on the server (backend/app/slides); the thumbnails are rendered from
|
| 12 |
+
* them (backend/scripts/render_slide_thumbnails.py). */
|
| 13 |
+
export const SLIDE_THEMES = [
|
| 14 |
+
{ id: "dark-space", name: "Dark space", blurb: "Night sky, planets and a blue glow" },
|
| 15 |
+
{ id: "light-space", name: "Light space", blurb: "Daylight sky, orbits and constellations" },
|
| 16 |
+
] as const;
|
| 17 |
+
|
| 18 |
+
const FORMATS = [
|
| 19 |
+
{ id: "detailed", label: "Detailed deck", blurb: "Full text and details, perfect for emailing or reading on its own." },
|
| 20 |
+
{ id: "presenter", label: "Presenter slides", blurb: "Clean slides with key talking points to support you while you speak." },
|
| 21 |
+
] as const;
|
| 22 |
+
|
| 23 |
+
const LENGTHS = [
|
| 24 |
+
{ id: "short", label: "Short", range: "6 to 8 slides" },
|
| 25 |
+
{ id: "default", label: "Default", range: "9 to 11 slides" },
|
| 26 |
+
{ id: "long", label: "Long", range: "12 to 16 slides" },
|
| 27 |
+
] as const;
|
| 28 |
+
|
| 29 |
+
const SLIDE_W = 1920;
|
| 30 |
+
const SLIDE_H = 1080;
|
| 31 |
+
|
| 32 |
+
export type SlideDeckRequest = Omit<GenerateBody, "type">;
|
| 33 |
+
type DeckFormat = "detailed" | "presenter";
|
| 34 |
+
type DeckLength = "short" | "default" | "long";
|
| 35 |
+
|
| 36 |
+
/** The slide deck settings: a visual style on top, then the format, language, length, sources and what it should say. */
|
| 37 |
+
export function SlideDeckDialog({ notebookId, notebookTitle, chats, currentChatId, onClose, onCreate }: {
|
| 38 |
+
notebookId: string;
|
| 39 |
+
notebookTitle?: string;
|
| 40 |
+
chats: ChatSummary[];
|
| 41 |
+
currentChatId?: string | null;
|
| 42 |
+
onClose: () => void;
|
| 43 |
+
onCreate: (body: SlideDeckRequest) => void;
|
| 44 |
+
}) {
|
| 45 |
+
const [sel, setSel] = useState<SourceSelection | null>(null);
|
| 46 |
+
const [theme, setTheme] = useState<string>("dark-space");
|
| 47 |
+
const [format, setFormat] = useState<DeckFormat>("detailed");
|
| 48 |
+
const [length, setLength] = useState<DeckLength>("default");
|
| 49 |
+
const [language, setLanguage] = useState("English");
|
| 50 |
+
const [prompt, setPrompt] = useState("");
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<Modal title="Customize slide deck" onClose={onClose} wide>
|
| 54 |
+
<div className="stack vw-in-modal sd-dialog">
|
| 55 |
+
<section className="field">
|
| 56 |
+
<span className="vw-label">Visual style</span>
|
| 57 |
+
<div className="sd-themes" role="radiogroup" aria-label="Visual style">
|
| 58 |
+
{SLIDE_THEMES.map((t) => (
|
| 59 |
+
<button key={t.id} type="button" role="radio" aria-checked={theme === t.id} title={t.blurb}
|
| 60 |
+
className={`sd-theme${theme === t.id ? " on" : ""}`} onClick={() => setTheme(t.id)}>
|
| 61 |
+
<span className="sd-theme-img">
|
| 62 |
+
<img src={`/slides/${t.id}.png`} alt="" loading="lazy" />
|
| 63 |
+
{theme === t.id && <span className="sd-check" aria-hidden="true"><CheckIcon size={12} /></span>}
|
| 64 |
+
</span>
|
| 65 |
+
<span className="sd-theme-name">{t.name}</span>
|
| 66 |
+
</button>
|
| 67 |
+
))}
|
| 68 |
+
</div>
|
| 69 |
+
</section>
|
| 70 |
+
|
| 71 |
+
<section className="sd-opts">
|
| 72 |
+
<div className="field">
|
| 73 |
+
<span className="vw-label">Format</span>
|
| 74 |
+
<Pills<DeckFormat> label="Format" value={format} onChange={setFormat} options={FORMATS} />
|
| 75 |
+
<small className="muted">{FORMATS.find((f) => f.id === format)?.blurb}</small>
|
| 76 |
+
</div>
|
| 77 |
+
<div className="field">
|
| 78 |
+
<span className="vw-label">Length</span>
|
| 79 |
+
<Dropdown<DeckLength> label="Length" value={length} onChange={setLength}
|
| 80 |
+
options={LENGTHS.map((l) => ({ value: l.id, label: l.label, hint: l.range }))} />
|
| 81 |
+
</div>
|
| 82 |
+
<div className="field">
|
| 83 |
+
<span className="vw-label">Language</span>
|
| 84 |
+
<Dropdown<string> label="Language" value={language} onChange={setLanguage}
|
| 85 |
+
options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
|
| 86 |
+
</div>
|
| 87 |
+
</section>
|
| 88 |
+
|
| 89 |
+
<SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
|
| 90 |
+
what="slide deck" noFocus onChange={setSel} />
|
| 91 |
+
|
| 92 |
+
<label className="field">
|
| 93 |
+
<span className="vw-label">Describe the slide deck you want to create <span className="muted">(optional)</span></span>
|
| 94 |
+
<textarea className="input sd-prompt" rows={3} maxLength={4000} value={prompt} onChange={(e) => setPrompt(e.target.value)}
|
| 95 |
+
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."'} />
|
| 96 |
+
</label>
|
| 97 |
+
|
| 98 |
+
{!sel?.ready && <p className="muted small">Select at least one post or chat to make a slide deck.</p>}
|
| 99 |
+
<div className="vw-nav">
|
| 100 |
+
<button type="button" className="btn btn-primary vw-next" disabled={!sel?.ready}
|
| 101 |
+
onClick={() => sel && onCreate({ ...sel.request, theme, deck_format: format, deck_length: length,
|
| 102 |
+
language, instructions: prompt.trim() })}>
|
| 103 |
+
Generate slide deck
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</Modal>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/** One slide (HTML from our own layout, every word escaped) in a sandboxed frame, scaled to fit the box it is given. */
|
| 112 |
+
export function SlideFrame({ html, title, fit = "width", hidden = false }: {
|
| 113 |
+
html: string;
|
| 114 |
+
title: string;
|
| 115 |
+
/** "width": as wide as the box, 16:9. "contain": as large as fits in the box both ways (presenting). */
|
| 116 |
+
fit?: "width" | "contain";
|
| 117 |
+
hidden?: boolean;
|
| 118 |
+
}) {
|
| 119 |
+
const box = useRef<HTMLDivElement>(null);
|
| 120 |
+
const [scale, setScale] = useState(0.2);
|
| 121 |
+
useEffect(() => {
|
| 122 |
+
const el = box.current;
|
| 123 |
+
if (!el) return;
|
| 124 |
+
const measure = () => {
|
| 125 |
+
const s = fit === "contain" ? Math.min(el.clientWidth / SLIDE_W, el.clientHeight / SLIDE_H) : el.clientWidth / SLIDE_W;
|
| 126 |
+
if (s > 0) setScale(s);
|
| 127 |
+
};
|
| 128 |
+
measure();
|
| 129 |
+
if (typeof ResizeObserver === "undefined") return;
|
| 130 |
+
const ro = new ResizeObserver(measure);
|
| 131 |
+
ro.observe(el);
|
| 132 |
+
return () => ro.disconnect();
|
| 133 |
+
}, [fit]);
|
| 134 |
+
return (
|
| 135 |
+
<div ref={box} className={`sd-frame sd-frame-${fit}`} style={hidden ? { visibility: "hidden" } : undefined} aria-hidden={hidden}>
|
| 136 |
+
<iframe title={title} srcDoc={html} sandbox="" tabIndex={-1} width={SLIDE_W} height={SLIDE_H}
|
| 137 |
+
style={{ transform: `translate(-50%, -50%) scale(${scale})` }} />
|
| 138 |
+
</div>
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
type DeckSlide = { layout: string; heading: string; notes?: string; sources?: { title?: string; path: string; line_start: number; line_end: number }[] };
|
| 143 |
+
|
| 144 |
+
function deckOf(artifact: Artifact): { pages: string[]; slides: DeckSlide[] } {
|
| 145 |
+
const c = artifact.content;
|
| 146 |
+
const pages = Array.isArray(c.slides_html) ? (c.slides_html as string[]) : [];
|
| 147 |
+
const slides = Array.isArray(c.deck?.slides) ? (c.deck.slides as DeckSlide[]) : [];
|
| 148 |
+
return { pages, slides };
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/** Playing the deck: full screen, one slide at a time. Arrows, Space, Page Up/Down, Home and End move; a click goes on;
|
| 152 |
+
* Escape (or leaving full screen) stops. Every slide is loaded at once and only the current one shown, so moving between
|
| 153 |
+
* them never flashes. */
|
| 154 |
+
export function SlidePresenter({ artifact, start = 0, onClose }: { artifact: Artifact; start?: number; onClose: () => void }) {
|
| 155 |
+
const { pages } = deckOf(artifact);
|
| 156 |
+
const [i, setI] = useState(Math.min(start, Math.max(pages.length - 1, 0)));
|
| 157 |
+
const [idle, setIdle] = useState(false);
|
| 158 |
+
const root = useRef<HTMLDivElement>(null);
|
| 159 |
+
const timer = useRef<number | undefined>(undefined);
|
| 160 |
+
const dark = artifact.content.theme !== "light-space";
|
| 161 |
+
|
| 162 |
+
const go = useCallback((to: number) => setI(Math.max(0, Math.min(pages.length - 1, to))), [pages.length]);
|
| 163 |
+
|
| 164 |
+
useEffect(() => {
|
| 165 |
+
const el = root.current;
|
| 166 |
+
// Full screen when the browser allows it (not iOS Safari): the overlay already fills the window either way.
|
| 167 |
+
if (el?.requestFullscreen) el.requestFullscreen().catch(() => undefined);
|
| 168 |
+
const onChange = () => {
|
| 169 |
+
if (!document.fullscreenElement) onClose();
|
| 170 |
+
};
|
| 171 |
+
document.addEventListener("fullscreenchange", onChange);
|
| 172 |
+
return () => {
|
| 173 |
+
document.removeEventListener("fullscreenchange", onChange);
|
| 174 |
+
if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
|
| 175 |
+
};
|
| 176 |
+
}, [onClose]);
|
| 177 |
+
|
| 178 |
+
useEffect(() => {
|
| 179 |
+
const onKey = (e: KeyboardEvent) => {
|
| 180 |
+
if (["ArrowRight", "ArrowDown", "PageDown", " ", "Enter"].includes(e.key)) go(i + 1);
|
| 181 |
+
else if (["ArrowLeft", "ArrowUp", "PageUp", "Backspace"].includes(e.key)) go(i - 1);
|
| 182 |
+
else if (e.key === "Home") go(0);
|
| 183 |
+
else if (e.key === "End") go(pages.length - 1);
|
| 184 |
+
else if (e.key === "Escape") onClose();
|
| 185 |
+
else return;
|
| 186 |
+
// Ours alone: the viewer (and its modal) underneath must not move or close too.
|
| 187 |
+
e.preventDefault();
|
| 188 |
+
e.stopImmediatePropagation();
|
| 189 |
+
};
|
| 190 |
+
document.addEventListener("keydown", onKey, true);
|
| 191 |
+
return () => document.removeEventListener("keydown", onKey, true);
|
| 192 |
+
}, [go, i, pages.length, onClose]);
|
| 193 |
+
|
| 194 |
+
const wake = () => {
|
| 195 |
+
setIdle(false);
|
| 196 |
+
window.clearTimeout(timer.current);
|
| 197 |
+
timer.current = window.setTimeout(() => setIdle(true), 2200);
|
| 198 |
+
};
|
| 199 |
+
useEffect(() => {
|
| 200 |
+
wake();
|
| 201 |
+
return () => window.clearTimeout(timer.current);
|
| 202 |
+
}, []);
|
| 203 |
+
|
| 204 |
+
return createPortal(
|
| 205 |
+
<div ref={root} className={`sd-present${dark ? "" : " light"}${idle ? " idle" : ""}`} role="dialog" aria-modal="true"
|
| 206 |
+
aria-label={`${artifact.title}, slide ${i + 1} of ${pages.length}`} onMouseMove={wake}
|
| 207 |
+
onClick={(e) => (e.target as HTMLElement).closest(".sd-present-bar") ? undefined : go(i + 1)}>
|
| 208 |
+
<div className="sd-present-stage">
|
| 209 |
+
{pages.map((html, k) => (
|
| 210 |
+
<SlideFrame key={k} html={html} title={`Slide ${k + 1}`} fit="contain" hidden={k !== i} />
|
| 211 |
+
))}
|
| 212 |
+
</div>
|
| 213 |
+
<div className="sd-present-bar" role="toolbar" aria-label="Presenting">
|
| 214 |
+
<button type="button" className="btn btn-small" onClick={() => go(i - 1)} disabled={i === 0} aria-label="Previous slide">Prev</button>
|
| 215 |
+
<span className="sd-count" aria-live="polite">{i + 1} / {pages.length}</span>
|
| 216 |
+
<button type="button" className="btn btn-small" onClick={() => go(i + 1)} disabled={i === pages.length - 1} aria-label="Next slide">Next</button>
|
| 217 |
+
<button type="button" className="btn btn-small" onClick={onClose}>Exit</button>
|
| 218 |
+
</div>
|
| 219 |
+
</div>,
|
| 220 |
+
document.body,
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/** A small menu under an icon button. Closes on a click outside or Escape (an Escape that closes the menu does not
|
| 225 |
+
* also close the popup it sits in). */
|
| 226 |
+
function useMenu() {
|
| 227 |
+
const [open, setOpen] = useState(false);
|
| 228 |
+
const ref = useRef<HTMLDivElement>(null); // the button's wrapper
|
| 229 |
+
const menuRef = useRef<HTMLDivElement>(null); // the floating menu (rendered in <body>)
|
| 230 |
+
useEffect(() => {
|
| 231 |
+
if (!open) return;
|
| 232 |
+
const inside = (t: EventTarget | null) => !!t && (ref.current?.contains(t as Node) || menuRef.current?.contains(t as Node));
|
| 233 |
+
const onDown = (e: MouseEvent) => {
|
| 234 |
+
if (!inside(e.target)) setOpen(false);
|
| 235 |
+
};
|
| 236 |
+
const onKey = (e: KeyboardEvent) => {
|
| 237 |
+
if (e.key !== "Escape") return;
|
| 238 |
+
e.stopImmediatePropagation();
|
| 239 |
+
setOpen(false);
|
| 240 |
+
};
|
| 241 |
+
// The menu floats at a fixed place: scrolling what is under it (not the menu itself) closes it.
|
| 242 |
+
const onScroll = (e: Event) => {
|
| 243 |
+
if (!inside(e.target)) setOpen(false);
|
| 244 |
+
};
|
| 245 |
+
const onResize = () => setOpen(false);
|
| 246 |
+
document.addEventListener("mousedown", onDown);
|
| 247 |
+
document.addEventListener("keydown", onKey, true);
|
| 248 |
+
window.addEventListener("scroll", onScroll, true);
|
| 249 |
+
window.addEventListener("resize", onResize);
|
| 250 |
+
return () => {
|
| 251 |
+
document.removeEventListener("mousedown", onDown);
|
| 252 |
+
document.removeEventListener("keydown", onKey, true);
|
| 253 |
+
window.removeEventListener("scroll", onScroll, true);
|
| 254 |
+
window.removeEventListener("resize", onResize);
|
| 255 |
+
};
|
| 256 |
+
}, [open]);
|
| 257 |
+
return { open, setOpen, ref, menuRef };
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/** A menu under its button, drawn in <body> so a scrolling list or a popup can never clip it. Opens below and to the
|
| 261 |
+
* left of the button's right edge, and above it when there is no room below. */
|
| 262 |
+
function FloatingMenu({ menu, children }: { menu: ReturnType<typeof useMenu>; children: ReactNode }) {
|
| 263 |
+
const [pos, setPos] = useState<CSSProperties>({ visibility: "hidden" });
|
| 264 |
+
useLayoutEffect(() => {
|
| 265 |
+
const button = menu.ref.current?.getBoundingClientRect();
|
| 266 |
+
const box = menu.menuRef.current?.getBoundingClientRect();
|
| 267 |
+
if (!button || !box) return;
|
| 268 |
+
const below = button.bottom + 6 + box.height <= window.innerHeight - 8;
|
| 269 |
+
setPos({
|
| 270 |
+
top: below ? button.bottom + 6 : Math.max(8, button.top - 6 - box.height),
|
| 271 |
+
left: Math.max(8, Math.min(button.right - box.width, window.innerWidth - box.width - 8)),
|
| 272 |
+
});
|
| 273 |
+
}, [menu.ref, menu.menuRef]);
|
| 274 |
+
return createPortal(
|
| 275 |
+
<div ref={menu.menuRef} className="sd-menu sd-menu-floating" role="menu" style={pos} onClick={(e) => e.stopPropagation()}>
|
| 276 |
+
{children}
|
| 277 |
+
</div>,
|
| 278 |
+
document.body,
|
| 279 |
+
);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/** Saves the deck as a PDF (exactly as shown) or an editable PowerPoint, drawn by the server. */
|
| 283 |
+
function useDeckDownload(artifact: Artifact) {
|
| 284 |
+
const [busy, setBusy] = useState<"pdf" | "pptx" | null>(null);
|
| 285 |
+
const [error, setError] = useState<string | null>(null);
|
| 286 |
+
const name = deckTitle(artifact).replace(/[^\w\- ]+/g, "").trim().slice(0, 80) || "Slide deck";
|
| 287 |
+
const save = async (ext: "pdf" | "pptx") => {
|
| 288 |
+
setBusy(ext);
|
| 289 |
+
setError(null);
|
| 290 |
+
try {
|
| 291 |
+
const blob = await artifactsApi.slidesFile(artifact.id, ext);
|
| 292 |
+
const url = URL.createObjectURL(blob);
|
| 293 |
+
const a = document.createElement("a");
|
| 294 |
+
a.href = url;
|
| 295 |
+
a.download = `${name}.${ext}`;
|
| 296 |
+
document.body.appendChild(a);
|
| 297 |
+
a.click();
|
| 298 |
+
a.remove();
|
| 299 |
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
| 300 |
+
} catch (e) {
|
| 301 |
+
setError(errorMessage(e));
|
| 302 |
+
} finally {
|
| 303 |
+
setBusy(null);
|
| 304 |
+
}
|
| 305 |
+
};
|
| 306 |
+
return { busy, error, save };
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function deckTitle(artifact: Artifact): string {
|
| 310 |
+
return artifact.title.replace(/^Slide deck: /, "") || (artifact.content.deck?.title as string | undefined) || "Slide deck";
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/** "5d ago", "3h ago", "just now". */
|
| 314 |
+
function ago(iso: string | null): string {
|
| 315 |
+
if (!iso) return "";
|
| 316 |
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
| 317 |
+
if (s < 60) return "just now";
|
| 318 |
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
| 319 |
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
| 320 |
+
if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
|
| 321 |
+
return formatDate(iso);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
/** Download, in the popup's header: a menu of PDF or PowerPoint. */
|
| 325 |
+
function DownloadMenu({ artifact }: { artifact: Artifact }) {
|
| 326 |
+
const menu = useMenu();
|
| 327 |
+
const dl = useDeckDownload(artifact);
|
| 328 |
+
return (
|
| 329 |
+
<div className="sd-menu-wrap" ref={menu.ref}>
|
| 330 |
+
<button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} disabled={dl.busy !== null}
|
| 331 |
+
title={dl.busy ? "Preparing the file..." : "Download"} aria-label={dl.busy ? "Preparing the file" : "Download"}
|
| 332 |
+
onClick={() => menu.setOpen((o) => !o)}>
|
| 333 |
+
{dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <DownloadIcon size={20} />}
|
| 334 |
+
</button>
|
| 335 |
+
{menu.open && (
|
| 336 |
+
<FloatingMenu menu={menu}>
|
| 337 |
+
<button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pdf"); }}>
|
| 338 |
+
<PdfIcon size={20} /> Download PDF Document (.pdf)
|
| 339 |
+
</button>
|
| 340 |
+
<button type="button" role="menuitem" onClick={() => { menu.setOpen(false); void dl.save("pptx"); }}>
|
| 341 |
+
<PptxIcon size={20} /> Download PowerPoint (.pptx)
|
| 342 |
+
</button>
|
| 343 |
+
</FloatingMenu>
|
| 344 |
+
)}
|
| 345 |
+
{dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
|
| 346 |
+
</div>
|
| 347 |
+
);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/** Everything a deck can do, behind its three dots (the row's and the popup's): rename, download, present, speaker
|
| 351 |
+
* notes (in the popup) and delete (asks once more first). */
|
| 352 |
+
function DeckMenu({ artifact, onRename, onPresent, onDelete, notes, onNotes }: {
|
| 353 |
+
artifact: Artifact;
|
| 354 |
+
onRename?: () => void;
|
| 355 |
+
onPresent: () => void;
|
| 356 |
+
onDelete: () => Promise<void>;
|
| 357 |
+
notes?: boolean;
|
| 358 |
+
onNotes?: () => void;
|
| 359 |
+
}) {
|
| 360 |
+
const menu = useMenu();
|
| 361 |
+
const dl = useDeckDownload(artifact);
|
| 362 |
+
const [confirming, setConfirming] = useState(false);
|
| 363 |
+
const pick = (fn: () => void) => () => {
|
| 364 |
+
menu.setOpen(false);
|
| 365 |
+
fn();
|
| 366 |
+
};
|
| 367 |
+
return (
|
| 368 |
+
<div className="sd-menu-wrap" ref={menu.ref} onClick={(e) => e.stopPropagation()}>
|
| 369 |
+
<button type="button" className="sd-icon-btn" aria-haspopup="menu" aria-expanded={menu.open} title="More" aria-label="More"
|
| 370 |
+
onClick={() => menu.setOpen((o) => !o)}>
|
| 371 |
+
{dl.busy ? <span className="nbv-send-spinner" aria-hidden="true" /> : <MoreIcon size={20} />}
|
| 372 |
+
</button>
|
| 373 |
+
{menu.open && (
|
| 374 |
+
<FloatingMenu menu={menu}>
|
| 375 |
+
{onRename && <button type="button" role="menuitem" onClick={pick(onRename)}><PencilIcon /> Rename</button>}
|
| 376 |
+
<button type="button" role="menuitem" onClick={pick(() => void dl.save("pdf"))}><PdfIcon size={20} /> Download PDF Document (.pdf)</button>
|
| 377 |
+
<button type="button" role="menuitem" onClick={pick(() => void dl.save("pptx"))}><PptxIcon size={20} /> Download PowerPoint (.pptx)</button>
|
| 378 |
+
<button type="button" role="menuitem" onClick={pick(onPresent)}><PlayIcon size={20} /> Start slideshow</button>
|
| 379 |
+
{onNotes && (
|
| 380 |
+
<button type="button" role="menuitemcheckbox" aria-checked={notes} onClick={pick(onNotes)}>
|
| 381 |
+
<SpeakerNotesIcon /> {notes ? "Hide speaker notes" : "Show speaker notes"}
|
| 382 |
+
</button>
|
| 383 |
+
)}
|
| 384 |
+
<button type="button" role="menuitem" className="danger" onClick={pick(() => setConfirming(true))}>
|
| 385 |
+
<TrashIcon size={20} /> Delete
|
| 386 |
+
</button>
|
| 387 |
+
</FloatingMenu>
|
| 388 |
+
)}
|
| 389 |
+
{confirming && <ConfirmDeleteModal heading="Delete slide deck?" name={deckTitle(artifact)} onCancel={() => setConfirming(false)}
|
| 390 |
+
onConfirm={onDelete} />}
|
| 391 |
+
{dl.error && <span className="sd-menu-error error-text small" role="alert">{dl.error}</span>}
|
| 392 |
+
</div>
|
| 393 |
+
);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
function SvgIcon({ children }: { children: ReactNode }) {
|
| 397 |
+
return (
|
| 398 |
+
<svg className="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}
|
| 399 |
+
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 400 |
+
{children}
|
| 401 |
+
</svg>
|
| 402 |
+
);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
const SpeakerNotesIcon = () => (
|
| 406 |
+
<SvgIcon>
|
| 407 |
+
<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" />
|
| 408 |
+
<path d="M7.5 9h9M7.5 12.5h6" />
|
| 409 |
+
</SvgIcon>
|
| 410 |
+
);
|
| 411 |
+
const PencilIcon = () => (
|
| 412 |
+
<SvgIcon>
|
| 413 |
+
<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zM13.5 6.5l4 4" />
|
| 414 |
+
</SvgIcon>
|
| 415 |
+
);
|
| 416 |
+
|
| 417 |
+
/** The deck in a popup: its actions along the top (present, a larger preview, download, more) and every slide below,
|
| 418 |
+
* one after another. A click on a slide presents from it. */
|
| 419 |
+
export function SlideDeckViewer({ artifact, onClose, onDelete }: {
|
| 420 |
+
artifact: Artifact;
|
| 421 |
+
onClose: () => void;
|
| 422 |
+
onDelete: () => Promise<void>;
|
| 423 |
+
}) {
|
| 424 |
+
const { pages, slides } = deckOf(artifact);
|
| 425 |
+
const [big, setBig] = useState(false);
|
| 426 |
+
const [notes, setNotes] = useState(false);
|
| 427 |
+
const [presenting, setPresenting] = useState<number | null>(null);
|
| 428 |
+
const [inView, setInView] = useState(0);
|
| 429 |
+
const stack = useRef<HTMLOListElement>(null);
|
| 430 |
+
const c = artifact.content;
|
| 431 |
+
const theme = SLIDE_THEMES.find((t) => t.id === c.theme)?.name ?? "Dark space";
|
| 432 |
+
|
| 433 |
+
// The slide most in view: Present starts there.
|
| 434 |
+
useEffect(() => {
|
| 435 |
+
const root = stack.current;
|
| 436 |
+
if (!root || typeof IntersectionObserver === "undefined") return;
|
| 437 |
+
const seen = new Map<number, number>();
|
| 438 |
+
const io = new IntersectionObserver((entries) => {
|
| 439 |
+
for (const e of entries) seen.set(Number((e.target as HTMLElement).dataset.i), e.intersectionRatio);
|
| 440 |
+
let best = 0;
|
| 441 |
+
let ratio = -1;
|
| 442 |
+
seen.forEach((r, i) => {
|
| 443 |
+
if (r > ratio) [best, ratio] = [i, r];
|
| 444 |
+
});
|
| 445 |
+
setInView(best);
|
| 446 |
+
}, { root, threshold: [0, 0.25, 0.5, 0.75, 1] });
|
| 447 |
+
root.querySelectorAll("[data-i]").forEach((el) => io.observe(el));
|
| 448 |
+
return () => io.disconnect();
|
| 449 |
+
}, [pages.length, notes]);
|
| 450 |
+
|
| 451 |
+
const actions = (
|
| 452 |
+
<div className="sd-head-actions" role="toolbar" aria-label="Slide deck">
|
| 453 |
+
<button type="button" className="sd-icon-btn" title="Start slideshow" aria-label="Start slideshow" disabled={!pages.length}
|
| 454 |
+
onClick={() => setPresenting(inView)}>
|
| 455 |
+
<PlayIcon size={20} />
|
| 456 |
+
</button>
|
| 457 |
+
<button type="button" className="sd-icon-btn" title={big ? "Smaller preview" : "Larger preview"} aria-pressed={big}
|
| 458 |
+
aria-label={big ? "Smaller preview" : "Larger preview"} onClick={() => setBig((b) => !b)}>
|
| 459 |
+
{big ? <CollapseIcon size={20} /> : <ExpandIcon size={20} />}
|
| 460 |
+
</button>
|
| 461 |
+
{pages.length > 0 && <DownloadMenu artifact={artifact} />}
|
| 462 |
+
<DeckMenu artifact={artifact} onPresent={() => setPresenting(inView)} onDelete={onDelete}
|
| 463 |
+
notes={notes} onNotes={() => setNotes((n) => !n)} />
|
| 464 |
+
<button type="button" className="sd-icon-btn" onClick={onClose} title="Close" aria-label="Close">
|
| 465 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
| 466 |
+
<path d="M6 6l12 12M18 6L6 18" />
|
| 467 |
+
</svg>
|
| 468 |
+
</button>
|
| 469 |
+
</div>
|
| 470 |
+
);
|
| 471 |
+
|
| 472 |
+
return (
|
| 473 |
+
<Modal title={deckTitle(artifact)} onClose={onClose} wide actions={actions}>
|
| 474 |
+
<div className={`sd-view${big ? " big" : ""}`}>
|
| 475 |
+
<p className="muted small sd-meta">
|
| 476 |
+
{pages.length} slides · {c.format === "presenter" ? "Presenter" : "Detailed"} · {theme}
|
| 477 |
+
{c.source ? ` · from ${sourceSummary(c.source)}` : ""}
|
| 478 |
+
</p>
|
| 479 |
+
{pages.length === 0 ? (
|
| 480 |
+
<p className="error-text">This deck has no slides to show. Try generating it again.</p>
|
| 481 |
+
) : (
|
| 482 |
+
<ol className="sd-stack" ref={stack} aria-label="Slides">
|
| 483 |
+
{pages.map((html, k) => {
|
| 484 |
+
const s = slides[k];
|
| 485 |
+
const sources = [...new Map((s?.sources ?? []).map((x) => [x.title || x.path, x])).values()];
|
| 486 |
+
return (
|
| 487 |
+
<li key={k} data-i={k}>
|
| 488 |
+
<button type="button" className="sd-slide" onClick={() => setPresenting(k)}
|
| 489 |
+
aria-label={`Present from slide ${k + 1}: ${s?.heading ?? ""}`}>
|
| 490 |
+
<SlideFrame html={html} title={`Slide ${k + 1}`} />
|
| 491 |
+
</button>
|
| 492 |
+
{notes && (
|
| 493 |
+
<div className="sd-notes">
|
| 494 |
+
<span className="vw-label">Slide {k + 1}</span>
|
| 495 |
+
<p>{s?.notes || <span className="muted">No notes for this slide.</span>}</p>
|
| 496 |
+
{sources.length > 0 && (
|
| 497 |
+
<p className="muted small">
|
| 498 |
+
From {sources.map((x) => `${x.title || x.path} (lines ${x.line_start} to ${x.line_end})`).join("; ")}
|
| 499 |
+
</p>
|
| 500 |
+
)}
|
| 501 |
+
</div>
|
| 502 |
+
)}
|
| 503 |
+
</li>
|
| 504 |
+
);
|
| 505 |
+
})}
|
| 506 |
+
</ol>
|
| 507 |
+
)}
|
| 508 |
+
</div>
|
| 509 |
+
{presenting !== null && <SlidePresenter artifact={artifact} start={presenting} onClose={() => setPresenting(null)} />}
|
| 510 |
+
</Modal>
|
| 511 |
+
);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
/** A deck in the notebook's list, as one quiet row: its icon, its title, "2 sources · 5d ago", and its menu. A click
|
| 515 |
+
* opens it. While it is being made the row says so and shows the progress. */
|
| 516 |
+
export function SlideDeckRow({ artifact: initial, onRemoved }: { artifact: Artifact; onRemoved: (id: string) => void }) {
|
| 517 |
+
const [artifact, setArtifact] = useState(initial);
|
| 518 |
+
const [open, setOpen] = useState(false);
|
| 519 |
+
const [presenting, setPresenting] = useState(false);
|
| 520 |
+
const [renaming, setRenaming] = useState(false);
|
| 521 |
+
const [name, setName] = useState("");
|
| 522 |
+
const [error, setError] = useState<string | null>(null);
|
| 523 |
+
const job = useJob(artifact.job, () => {
|
| 524 |
+
artifactsApi.get(artifact.id).then(setArtifact);
|
| 525 |
+
});
|
| 526 |
+
const running = job && (job.status === "queued" || job.status === "running");
|
| 527 |
+
const ready = artifact.status === "ready";
|
| 528 |
+
const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not make this deck." : null;
|
| 529 |
+
const title = deckTitle(artifact);
|
| 530 |
+
const count = (artifact.content.source?.count as number | undefined) ?? 0;
|
| 531 |
+
const meta = running || !ready
|
| 532 |
+
? (failed ? "Slide deck · could not be made" : `Generating slide deck... based on ${count || "your"} source${count === 1 ? "" : "s"}`)
|
| 533 |
+
: `${count} source${count === 1 ? "" : "s"} · ${ago(artifact.created_at)}`;
|
| 534 |
+
|
| 535 |
+
const remove = async () => {
|
| 536 |
+
await artifactsApi.remove(artifact.id);
|
| 537 |
+
setOpen(false);
|
| 538 |
+
onRemoved(artifact.id);
|
| 539 |
+
};
|
| 540 |
+
const rename = async () => {
|
| 541 |
+
const next = name.trim();
|
| 542 |
+
setRenaming(false);
|
| 543 |
+
if (!next || next === title) return;
|
| 544 |
+
setError(null);
|
| 545 |
+
try {
|
| 546 |
+
const deck = artifact.content.deck ? { ...artifact.content.deck, title: next } : undefined;
|
| 547 |
+
setArtifact(await artifactsApi.update(artifact.id, { title: next, ...(deck ? { content: { deck } } : {}) }));
|
| 548 |
+
} catch (e) {
|
| 549 |
+
setError(errorMessage(e));
|
| 550 |
+
}
|
| 551 |
+
};
|
| 552 |
+
|
| 553 |
+
return (
|
| 554 |
+
<div className={`sd-item${ready ? " is-ready" : ""}${running ? " is-running" : ""}`}>
|
| 555 |
+
<div className="sd-item-main" role={ready && !renaming ? "button" : undefined} tabIndex={ready && !renaming ? 0 : undefined}
|
| 556 |
+
aria-label={ready && !renaming ? `Open slide deck: ${title}` : undefined}
|
| 557 |
+
onClick={() => ready && !renaming && setOpen(true)}
|
| 558 |
+
onKeyDown={(e) => ready && !renaming && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), setOpen(true))}>
|
| 559 |
+
<span className="sd-item-icon" aria-hidden="true">
|
| 560 |
+
{running ? <span className="nbv-send-spinner" /> : <SlidesIcon size={22} />}
|
| 561 |
+
</span>
|
| 562 |
+
<span className="sd-item-text">
|
| 563 |
+
{renaming ? (
|
| 564 |
+
<input className="input sd-rename" autoFocus value={name} maxLength={140} aria-label="Slide deck name"
|
| 565 |
+
onChange={(e) => setName(e.target.value)} onBlur={rename} onClick={(e) => e.stopPropagation()}
|
| 566 |
+
onKeyDown={(e) => {
|
| 567 |
+
e.stopPropagation();
|
| 568 |
+
if (e.key === "Enter") void rename();
|
| 569 |
+
if (e.key === "Escape") setRenaming(false);
|
| 570 |
+
}} />
|
| 571 |
+
) : (
|
| 572 |
+
<strong title={title}>{running ? "Generating slide deck..." : title}</strong>
|
| 573 |
+
)}
|
| 574 |
+
<span className="sd-item-meta">{meta}</span>
|
| 575 |
+
</span>
|
| 576 |
+
</div>
|
| 577 |
+
{ready && (
|
| 578 |
+
<DeckMenu artifact={artifact} onRename={() => { setName(title); setRenaming(true); }} onPresent={() => setPresenting(true)}
|
| 579 |
+
onDelete={remove} />
|
| 580 |
+
)}
|
| 581 |
+
{running && job && (
|
| 582 |
+
<div className="sd-item-progress">
|
| 583 |
+
<JobProgress job={job} compact />
|
| 584 |
+
</div>
|
| 585 |
+
)}
|
| 586 |
+
{failed && (
|
| 587 |
+
<div className="sd-item-progress sd-item-failed">
|
| 588 |
+
<p className="error-text small">{failed}</p>
|
| 589 |
+
<button type="button" className="link-btn" onClick={async () => {
|
| 590 |
+
setError(null);
|
| 591 |
+
try {
|
| 592 |
+
setArtifact(await artifactsApi.retry(artifact.id));
|
| 593 |
+
} catch (e) {
|
| 594 |
+
setError(errorMessage(e));
|
| 595 |
+
}
|
| 596 |
+
}}>Retry</button>
|
| 597 |
+
<button type="button" className="link-btn" onClick={() => void remove()}>Remove</button>
|
| 598 |
+
</div>
|
| 599 |
+
)}
|
| 600 |
+
{error && <p className="error-text small sd-item-progress">{error}</p>}
|
| 601 |
+
{open && <SlideDeckViewer artifact={artifact} onClose={() => setOpen(false)} onDelete={remove} />}
|
| 602 |
+
{presenting && <SlidePresenter artifact={artifact} onClose={() => setPresenting(false)} />}
|
| 603 |
+
</div>
|
| 604 |
+
);
|
| 605 |
+
}
|
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/ui.tsx
CHANGED
|
@@ -171,6 +171,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,
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
/** Two step destructive button: first click arms it, second confirms. No browser dialogs. */
|
| 174 |
+
/** "Delete this?": deletes only on confirm, shows what went wrong if it fails, and closes once it is gone. */
|
| 175 |
+
export function ConfirmDeleteModal({ heading, name, onCancel, onConfirm }: {
|
| 176 |
+
heading: string;
|
| 177 |
+
name: string;
|
| 178 |
+
onCancel: () => void;
|
| 179 |
+
onConfirm: () => Promise<void>;
|
| 180 |
+
}) {
|
| 181 |
+
const [busy, setBusy] = useState(false);
|
| 182 |
+
const [error, setError] = useState<string | null>(null);
|
| 183 |
+
const confirm = async () => {
|
| 184 |
+
setBusy(true);
|
| 185 |
+
setError(null);
|
| 186 |
+
try {
|
| 187 |
+
await onConfirm();
|
| 188 |
+
onCancel(); // gone: close the warning
|
| 189 |
+
} catch (e) {
|
| 190 |
+
setError(errorMessage(e));
|
| 191 |
+
setBusy(false);
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
return (
|
| 195 |
+
<Modal title={heading} onClose={busy ? () => undefined : onCancel}>
|
| 196 |
+
<div className="stack sd-confirm">
|
| 197 |
+
<p>
|
| 198 |
+
<strong>{name}</strong> will be deleted for good. This cannot be undone.
|
| 199 |
+
</p>
|
| 200 |
+
{error && <p className="error-text" role="alert">{error}</p>}
|
| 201 |
+
<div className="row sd-confirm-actions">
|
| 202 |
+
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
| 203 |
+
<button type="button" className="btn btn-danger" onClick={confirm} disabled={busy} autoFocus>
|
| 204 |
+
{busy ? (<><span className="nbv-send-spinner" aria-hidden="true" /> Deleting...</>) : "Delete"}
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</Modal>
|
| 209 |
+
);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
export function ConfirmButton({
|
| 213 |
onConfirm,
|
| 214 |
children,
|
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/styles/pages.css
CHANGED
|
@@ -3415,7 +3415,8 @@ select.input,
|
|
| 3415 |
}
|
| 3416 |
|
| 3417 |
.nbv {
|
| 3418 |
-
|
|
|
|
| 3419 |
grid-template-areas:
|
| 3420 |
"history chat"
|
| 3421 |
"history side";
|
|
@@ -3483,12 +3484,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 |
|
|
@@ -4612,52 +4613,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 +7514,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 +8175,954 @@ details.map-legend[open] summary {
|
|
| 8221 |
.nbv-starting-text strong {
|
| 8222 |
font: 500 0.92rem var(--font-body);
|
| 8223 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3415 |
}
|
| 3416 |
|
| 3417 |
.nbv {
|
| 3418 |
+
position: relative; /* the resize handles are placed over the boundaries */
|
| 3419 |
+
grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr);
|
| 3420 |
grid-template-areas:
|
| 3421 |
"history chat"
|
| 3422 |
"history side";
|
|
|
|
| 3484 |
}
|
| 3485 |
|
| 3486 |
.nbv {
|
| 3487 |
+
grid-template-columns: var(--nbv-left, clamp(16rem, 20vw, 20rem)) minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
|
| 3488 |
grid-template-areas: "history chat side";
|
| 3489 |
}
|
| 3490 |
|
| 3491 |
.nbv.nbv-history-closed {
|
| 3492 |
+
grid-template-columns: minmax(0, 1fr) var(--nbv-right, clamp(18rem, 24vw, 24rem));
|
| 3493 |
grid-template-areas: "chat side";
|
| 3494 |
}
|
| 3495 |
|
|
|
|
| 4613 |
border-color: var(--blue);
|
| 4614 |
}
|
| 4615 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4616 |
/* ---------- Mind map: the right-rail tile and list, the dialog, and the galaxy explorer ---------- */
|
| 4617 |
.nbv-make .nbv-make-map {
|
| 4618 |
grid-column: 1 / -1;
|
|
|
|
| 7514 |
border-top-color: var(--black);
|
| 7515 |
}
|
| 7516 |
|
| 7517 |
+
/* Create buttons: two to a row. Audio overview shares its row with Slide deck; its options and Mind Constellation take
|
| 7518 |
+
a whole row. */
|
| 7519 |
.nbv-make {
|
| 7520 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 7521 |
}
|
| 7522 |
|
|
|
|
|
|
|
| 7523 |
.nbv-make .nbv-make-stars {
|
| 7524 |
grid-column: 1 / -1;
|
| 7525 |
}
|
|
|
|
| 8175 |
.nbv-starting-text strong {
|
| 8176 |
font: 500 0.92rem var(--font-body);
|
| 8177 |
}
|
| 8178 |
+
|
| 8179 |
+
|
| 8180 |
+
/* ---- Slide decks ------------------------------------------------------------------------------------------------ */
|
| 8181 |
+
|
| 8182 |
+
.sd-dialog {
|
| 8183 |
+
gap: 22px;
|
| 8184 |
+
}
|
| 8185 |
+
|
| 8186 |
+
/* The settings: the two styles across one row, then format, length and language side by side */
|
| 8187 |
+
.sd-themes {
|
| 8188 |
+
display: grid;
|
| 8189 |
+
grid-template-columns: repeat(2, minmax(0, 220px));
|
| 8190 |
+
justify-content: center;
|
| 8191 |
+
gap: 16px;
|
| 8192 |
+
}
|
| 8193 |
+
|
| 8194 |
+
.sd-theme {
|
| 8195 |
+
display: grid;
|
| 8196 |
+
gap: 6px;
|
| 8197 |
+
padding: 5px 5px 8px;
|
| 8198 |
+
text-align: left;
|
| 8199 |
+
border: 1px solid var(--line);
|
| 8200 |
+
border-radius: 12px;
|
| 8201 |
+
background: transparent;
|
| 8202 |
+
color: inherit;
|
| 8203 |
+
cursor: pointer;
|
| 8204 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 8205 |
+
}
|
| 8206 |
+
|
| 8207 |
+
.sd-theme:hover {
|
| 8208 |
+
border-color: var(--line-strong);
|
| 8209 |
+
}
|
| 8210 |
+
|
| 8211 |
+
.sd-theme.on {
|
| 8212 |
+
border-color: var(--blue);
|
| 8213 |
+
box-shadow: 0 0 0 1px var(--blue), var(--glow-sm);
|
| 8214 |
+
}
|
| 8215 |
+
|
| 8216 |
+
.sd-theme-img {
|
| 8217 |
+
position: relative;
|
| 8218 |
+
display: block;
|
| 8219 |
+
}
|
| 8220 |
+
|
| 8221 |
+
.sd-theme-img img {
|
| 8222 |
+
display: block;
|
| 8223 |
+
width: 100%;
|
| 8224 |
+
aspect-ratio: 16 / 9; /* the whole slide, small */
|
| 8225 |
+
object-fit: cover;
|
| 8226 |
+
border-radius: 8px;
|
| 8227 |
+
}
|
| 8228 |
+
|
| 8229 |
+
.sd-check {
|
| 8230 |
+
position: absolute;
|
| 8231 |
+
top: 6px;
|
| 8232 |
+
right: 6px;
|
| 8233 |
+
display: grid;
|
| 8234 |
+
place-items: center;
|
| 8235 |
+
width: 22px;
|
| 8236 |
+
height: 22px;
|
| 8237 |
+
border-radius: 50%;
|
| 8238 |
+
background: var(--black);
|
| 8239 |
+
color: var(--white);
|
| 8240 |
+
box-shadow: 0 0 0 2px var(--blue);
|
| 8241 |
+
}
|
| 8242 |
+
|
| 8243 |
+
.sd-theme-name {
|
| 8244 |
+
padding: 0 4px;
|
| 8245 |
+
text-align: center;
|
| 8246 |
+
font-size: 0.85rem;
|
| 8247 |
+
font-weight: 600;
|
| 8248 |
+
}
|
| 8249 |
+
|
| 8250 |
+
.sd-opts {
|
| 8251 |
+
display: grid;
|
| 8252 |
+
grid-template-columns: auto minmax(210px, 1fr) minmax(140px, 0.7fr); /* format as wide as its pills */
|
| 8253 |
+
column-gap: 28px;
|
| 8254 |
+
row-gap: 14px;
|
| 8255 |
+
align-items: start;
|
| 8256 |
+
}
|
| 8257 |
+
|
| 8258 |
+
.sd-opts .field {
|
| 8259 |
+
gap: 8px;
|
| 8260 |
+
min-width: 0;
|
| 8261 |
+
}
|
| 8262 |
+
|
| 8263 |
+
/* The dropdowns stay inside their column: a long value shortens with an ellipsis */
|
| 8264 |
+
.sd-opts .dropdown,
|
| 8265 |
+
.sd-opts .dropdown-btn {
|
| 8266 |
+
min-width: 0;
|
| 8267 |
+
max-width: 100%;
|
| 8268 |
+
}
|
| 8269 |
+
|
| 8270 |
+
.sd-opts .dropdown-value {
|
| 8271 |
+
overflow: hidden;
|
| 8272 |
+
text-overflow: ellipsis;
|
| 8273 |
+
white-space: nowrap;
|
| 8274 |
+
}
|
| 8275 |
+
|
| 8276 |
+
/* The length's slide range, inside its dropdown: the same small grey text as the hints under the other fields */
|
| 8277 |
+
.sd-opts .dropdown-value .muted {
|
| 8278 |
+
font-size: 0.83em;
|
| 8279 |
+
}
|
| 8280 |
+
|
| 8281 |
+
.sd-opts .dropdown-btn {
|
| 8282 |
+
padding-left: 16px;
|
| 8283 |
+
padding-right: 14px;
|
| 8284 |
+
}
|
| 8285 |
+
|
| 8286 |
+
/* Format pills look exactly like the dropdowns beside them: same height, text, border and fill */
|
| 8287 |
+
.sd-opts .vw-chips {
|
| 8288 |
+
flex-wrap: nowrap;
|
| 8289 |
+
gap: 8px;
|
| 8290 |
+
}
|
| 8291 |
+
|
| 8292 |
+
.sd-opts .vw-chip {
|
| 8293 |
+
display: inline-flex;
|
| 8294 |
+
align-items: center;
|
| 8295 |
+
height: 48px;
|
| 8296 |
+
padding: 0 18px;
|
| 8297 |
+
border: 1px solid var(--line-strong);
|
| 8298 |
+
border-radius: 999px;
|
| 8299 |
+
background: rgba(0, 0, 0, 0.6);
|
| 8300 |
+
color: var(--text);
|
| 8301 |
+
font: 500 1rem var(--font-body);
|
| 8302 |
+
white-space: nowrap;
|
| 8303 |
+
}
|
| 8304 |
+
|
| 8305 |
+
.sd-opts .vw-chip.on {
|
| 8306 |
+
border-color: var(--blue);
|
| 8307 |
+
background: var(--blue);
|
| 8308 |
+
color: var(--white);
|
| 8309 |
+
}
|
| 8310 |
+
|
| 8311 |
+
.sd-opts .vw-chip:hover:not(.on) {
|
| 8312 |
+
border-color: var(--blue-60);
|
| 8313 |
+
}
|
| 8314 |
+
|
| 8315 |
+
/* The format's description wraps under the pills instead of widening their column */
|
| 8316 |
+
.sd-opts > .field:first-child > small {
|
| 8317 |
+
display: block;
|
| 8318 |
+
width: 0;
|
| 8319 |
+
min-width: 100%;
|
| 8320 |
+
}
|
| 8321 |
+
|
| 8322 |
+
/* A little more room than other dialogs, for the three settings side by side */
|
| 8323 |
+
.modal.modal-wide:has(.sd-dialog) {
|
| 8324 |
+
width: min(840px, calc(100vw - 32px));
|
| 8325 |
+
}
|
| 8326 |
+
|
| 8327 |
+
.sd-prompt {
|
| 8328 |
+
width: 100%;
|
| 8329 |
+
min-height: 92px;
|
| 8330 |
+
padding: 14px 18px;
|
| 8331 |
+
border-radius: 14px;
|
| 8332 |
+
line-height: 1.55;
|
| 8333 |
+
resize: vertical;
|
| 8334 |
+
}
|
| 8335 |
+
|
| 8336 |
+
/* One 1920 x 1080 slide, scaled into whatever box it is given. */
|
| 8337 |
+
.sd-frame {
|
| 8338 |
+
position: relative;
|
| 8339 |
+
width: 100%;
|
| 8340 |
+
overflow: hidden;
|
| 8341 |
+
background: #000;
|
| 8342 |
+
}
|
| 8343 |
+
|
| 8344 |
+
.sd-frame-width {
|
| 8345 |
+
aspect-ratio: 16 / 9;
|
| 8346 |
+
}
|
| 8347 |
+
|
| 8348 |
+
.sd-frame-contain {
|
| 8349 |
+
position: absolute;
|
| 8350 |
+
inset: 0;
|
| 8351 |
+
background: transparent;
|
| 8352 |
+
}
|
| 8353 |
+
|
| 8354 |
+
.sd-frame iframe {
|
| 8355 |
+
position: absolute;
|
| 8356 |
+
top: 50%;
|
| 8357 |
+
left: 50%;
|
| 8358 |
+
border: 0;
|
| 8359 |
+
transform-origin: center center;
|
| 8360 |
+
pointer-events: none;
|
| 8361 |
+
}
|
| 8362 |
+
|
| 8363 |
+
/* A deck in the notebook's list: a quiet row (icon, title, "2 sources · 5d ago", menu), highlighted on hover */
|
| 8364 |
+
.sd-item {
|
| 8365 |
+
display: grid;
|
| 8366 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 8367 |
+
align-items: center;
|
| 8368 |
+
column-gap: 4px;
|
| 8369 |
+
padding: 4px 4px 4px 0;
|
| 8370 |
+
border-radius: 16px;
|
| 8371 |
+
transition: background 0.15s;
|
| 8372 |
+
}
|
| 8373 |
+
|
| 8374 |
+
.sd-item.is-ready:hover,
|
| 8375 |
+
.sd-item:focus-within {
|
| 8376 |
+
background: var(--surface-2);
|
| 8377 |
+
}
|
| 8378 |
+
|
| 8379 |
+
.sd-item-main {
|
| 8380 |
+
display: grid;
|
| 8381 |
+
grid-template-columns: 40px minmax(0, 1fr);
|
| 8382 |
+
align-items: center;
|
| 8383 |
+
gap: 10px;
|
| 8384 |
+
min-width: 0;
|
| 8385 |
+
padding: 8px 4px 8px 10px;
|
| 8386 |
+
border-radius: 16px;
|
| 8387 |
+
outline: none;
|
| 8388 |
+
}
|
| 8389 |
+
|
| 8390 |
+
.sd-item.is-ready .sd-item-main {
|
| 8391 |
+
cursor: pointer;
|
| 8392 |
+
}
|
| 8393 |
+
|
| 8394 |
+
.sd-item-main:focus-visible {
|
| 8395 |
+
box-shadow: 0 0 0 2px var(--blue);
|
| 8396 |
+
}
|
| 8397 |
+
|
| 8398 |
+
.sd-item-icon {
|
| 8399 |
+
display: grid;
|
| 8400 |
+
place-items: center;
|
| 8401 |
+
width: 40px;
|
| 8402 |
+
height: 40px;
|
| 8403 |
+
color: var(--blue);
|
| 8404 |
+
}
|
| 8405 |
+
|
| 8406 |
+
.sd-item-text {
|
| 8407 |
+
display: grid;
|
| 8408 |
+
gap: 2px;
|
| 8409 |
+
min-width: 0;
|
| 8410 |
+
}
|
| 8411 |
+
|
| 8412 |
+
.sd-item-text strong {
|
| 8413 |
+
overflow: hidden;
|
| 8414 |
+
text-overflow: ellipsis;
|
| 8415 |
+
white-space: nowrap;
|
| 8416 |
+
font-weight: 500;
|
| 8417 |
+
font-size: 0.95rem;
|
| 8418 |
+
color: var(--text);
|
| 8419 |
+
}
|
| 8420 |
+
|
| 8421 |
+
.sd-item-meta {
|
| 8422 |
+
overflow: hidden;
|
| 8423 |
+
text-overflow: ellipsis;
|
| 8424 |
+
white-space: nowrap;
|
| 8425 |
+
font-size: 0.82rem;
|
| 8426 |
+
color: var(--text-3);
|
| 8427 |
+
}
|
| 8428 |
+
|
| 8429 |
+
.sd-item .sd-menu-wrap .sd-icon-btn {
|
| 8430 |
+
color: var(--text-2);
|
| 8431 |
+
}
|
| 8432 |
+
|
| 8433 |
+
.sd-item-progress {
|
| 8434 |
+
grid-column: 1 / -1;
|
| 8435 |
+
padding: 0 12px 8px 60px;
|
| 8436 |
+
}
|
| 8437 |
+
|
| 8438 |
+
.sd-item-failed {
|
| 8439 |
+
display: flex;
|
| 8440 |
+
align-items: center;
|
| 8441 |
+
gap: 12px;
|
| 8442 |
+
flex-wrap: wrap;
|
| 8443 |
+
}
|
| 8444 |
+
|
| 8445 |
+
.sd-item-failed p {
|
| 8446 |
+
flex: 1;
|
| 8447 |
+
margin: 0;
|
| 8448 |
+
}
|
| 8449 |
+
|
| 8450 |
+
.sd-rename {
|
| 8451 |
+
width: 100%;
|
| 8452 |
+
padding: 4px 10px;
|
| 8453 |
+
font-size: 0.95rem;
|
| 8454 |
+
}
|
| 8455 |
+
|
| 8456 |
+
/* The deck's popup: actions along the top, every slide below it */
|
| 8457 |
+
.modal.modal-wide:has(.sd-view) {
|
| 8458 |
+
width: min(980px, calc(100vw - 32px));
|
| 8459 |
+
max-height: calc(100vh - 32px);
|
| 8460 |
+
display: flex;
|
| 8461 |
+
flex-direction: column;
|
| 8462 |
+
transition: width 0.2s ease;
|
| 8463 |
+
}
|
| 8464 |
+
|
| 8465 |
+
.modal.modal-wide:has(.sd-view.big) {
|
| 8466 |
+
width: calc(100vw - 32px);
|
| 8467 |
+
height: calc(100vh - 32px);
|
| 8468 |
+
}
|
| 8469 |
+
|
| 8470 |
+
.modal:has(.sd-view) .modal-head {
|
| 8471 |
+
gap: 12px;
|
| 8472 |
+
}
|
| 8473 |
+
|
| 8474 |
+
.modal:has(.sd-view) .modal-head h2 {
|
| 8475 |
+
min-width: 0;
|
| 8476 |
+
overflow: hidden;
|
| 8477 |
+
text-overflow: ellipsis;
|
| 8478 |
+
white-space: nowrap;
|
| 8479 |
+
}
|
| 8480 |
+
|
| 8481 |
+
.sd-head-actions {
|
| 8482 |
+
display: flex;
|
| 8483 |
+
align-items: center;
|
| 8484 |
+
gap: 4px;
|
| 8485 |
+
flex: none;
|
| 8486 |
+
}
|
| 8487 |
+
|
| 8488 |
+
.sd-icon-btn {
|
| 8489 |
+
display: grid;
|
| 8490 |
+
place-items: center;
|
| 8491 |
+
width: 38px;
|
| 8492 |
+
height: 38px;
|
| 8493 |
+
padding: 0;
|
| 8494 |
+
border: 0;
|
| 8495 |
+
border-radius: 50%;
|
| 8496 |
+
background: transparent;
|
| 8497 |
+
color: var(--text);
|
| 8498 |
+
cursor: pointer;
|
| 8499 |
+
transition: background 0.15s, color 0.15s;
|
| 8500 |
+
}
|
| 8501 |
+
|
| 8502 |
+
.sd-icon-btn:hover:not(:disabled),
|
| 8503 |
+
.sd-icon-btn[aria-expanded="true"],
|
| 8504 |
+
.sd-icon-btn[aria-pressed="true"] {
|
| 8505 |
+
background: var(--surface-2);
|
| 8506 |
+
color: var(--white);
|
| 8507 |
+
}
|
| 8508 |
+
|
| 8509 |
+
.sd-icon-btn:focus-visible {
|
| 8510 |
+
outline: 2px solid var(--blue);
|
| 8511 |
+
outline-offset: 1px;
|
| 8512 |
+
}
|
| 8513 |
+
|
| 8514 |
+
.sd-icon-btn:disabled {
|
| 8515 |
+
opacity: 0.5;
|
| 8516 |
+
cursor: default;
|
| 8517 |
+
}
|
| 8518 |
+
|
| 8519 |
+
.sd-menu-wrap {
|
| 8520 |
+
position: relative;
|
| 8521 |
+
}
|
| 8522 |
+
|
| 8523 |
+
/* In <body>, above popups (z-index over the modal's backdrop) */
|
| 8524 |
+
.sd-menu.sd-menu-floating {
|
| 8525 |
+
position: fixed;
|
| 8526 |
+
top: auto;
|
| 8527 |
+
right: auto;
|
| 8528 |
+
z-index: 2100;
|
| 8529 |
+
}
|
| 8530 |
+
|
| 8531 |
+
.sd-menu {
|
| 8532 |
+
position: absolute;
|
| 8533 |
+
top: calc(100% + 6px);
|
| 8534 |
+
right: 0;
|
| 8535 |
+
z-index: 20;
|
| 8536 |
+
display: grid;
|
| 8537 |
+
min-width: 270px;
|
| 8538 |
+
padding: 6px;
|
| 8539 |
+
border: 1px solid var(--line-strong);
|
| 8540 |
+
border-radius: var(--radius-sm);
|
| 8541 |
+
background: var(--black);
|
| 8542 |
+
box-shadow: var(--glow-lg);
|
| 8543 |
+
}
|
| 8544 |
+
|
| 8545 |
+
.sd-menu button {
|
| 8546 |
+
display: flex;
|
| 8547 |
+
align-items: center;
|
| 8548 |
+
gap: 12px;
|
| 8549 |
+
padding: 10px 12px;
|
| 8550 |
+
border: 0;
|
| 8551 |
+
border-radius: 6px;
|
| 8552 |
+
background: transparent;
|
| 8553 |
+
color: var(--white);
|
| 8554 |
+
font: 500 0.92rem var(--font-body);
|
| 8555 |
+
text-align: left;
|
| 8556 |
+
white-space: nowrap;
|
| 8557 |
+
cursor: pointer;
|
| 8558 |
+
}
|
| 8559 |
+
|
| 8560 |
+
.sd-menu button:hover,
|
| 8561 |
+
.sd-menu button:focus-visible {
|
| 8562 |
+
background: var(--surface-2);
|
| 8563 |
+
}
|
| 8564 |
+
|
| 8565 |
+
.sd-menu button.danger {
|
| 8566 |
+
color: #ff8a8a;
|
| 8567 |
+
}
|
| 8568 |
+
|
| 8569 |
+
.sd-confirm p {
|
| 8570 |
+
margin: 0;
|
| 8571 |
+
line-height: 1.6;
|
| 8572 |
+
}
|
| 8573 |
+
|
| 8574 |
+
.sd-confirm-actions {
|
| 8575 |
+
justify-content: flex-end;
|
| 8576 |
+
gap: 10px;
|
| 8577 |
+
}
|
| 8578 |
+
|
| 8579 |
+
.sd-menu .error-text {
|
| 8580 |
+
margin: 4px 8px;
|
| 8581 |
+
}
|
| 8582 |
+
|
| 8583 |
+
.sd-menu-error {
|
| 8584 |
+
position: absolute;
|
| 8585 |
+
top: calc(100% + 6px);
|
| 8586 |
+
right: 0;
|
| 8587 |
+
width: 240px;
|
| 8588 |
+
text-align: right;
|
| 8589 |
+
}
|
| 8590 |
+
|
| 8591 |
+
.sd-view {
|
| 8592 |
+
display: grid;
|
| 8593 |
+
gap: 12px;
|
| 8594 |
+
min-height: 0;
|
| 8595 |
+
flex: 1;
|
| 8596 |
+
}
|
| 8597 |
+
|
| 8598 |
+
.sd-meta {
|
| 8599 |
+
margin: 0;
|
| 8600 |
+
}
|
| 8601 |
+
|
| 8602 |
+
.sd-stack {
|
| 8603 |
+
display: grid;
|
| 8604 |
+
gap: 16px;
|
| 8605 |
+
max-height: calc(100vh - 190px);
|
| 8606 |
+
margin: 0;
|
| 8607 |
+
padding: 0 6px 6px 0;
|
| 8608 |
+
overflow-y: auto;
|
| 8609 |
+
list-style: none;
|
| 8610 |
+
}
|
| 8611 |
+
|
| 8612 |
+
.sd-view.big .sd-stack {
|
| 8613 |
+
max-height: calc(100vh - 160px);
|
| 8614 |
+
justify-items: center;
|
| 8615 |
+
}
|
| 8616 |
+
|
| 8617 |
+
.sd-view.big .sd-stack > li {
|
| 8618 |
+
width: min(100%, calc((100vh - 190px) * 16 / 9));
|
| 8619 |
+
}
|
| 8620 |
+
|
| 8621 |
+
.sd-slide {
|
| 8622 |
+
display: block;
|
| 8623 |
+
width: 100%;
|
| 8624 |
+
padding: 0;
|
| 8625 |
+
border: 1px solid var(--line);
|
| 8626 |
+
border-radius: 14px;
|
| 8627 |
+
overflow: hidden;
|
| 8628 |
+
background: none;
|
| 8629 |
+
cursor: pointer;
|
| 8630 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 8631 |
+
}
|
| 8632 |
+
|
| 8633 |
+
.sd-slide:hover,
|
| 8634 |
+
.sd-slide:focus-visible {
|
| 8635 |
+
border-color: var(--blue-60);
|
| 8636 |
+
box-shadow: var(--glow-sm);
|
| 8637 |
+
}
|
| 8638 |
+
|
| 8639 |
+
.sd-notes {
|
| 8640 |
+
display: grid;
|
| 8641 |
+
gap: 6px;
|
| 8642 |
+
margin-top: 8px;
|
| 8643 |
+
padding: 14px 18px;
|
| 8644 |
+
border: 1px solid var(--line);
|
| 8645 |
+
border-radius: var(--radius-sm);
|
| 8646 |
+
background: var(--surface);
|
| 8647 |
+
line-height: 1.6;
|
| 8648 |
+
}
|
| 8649 |
+
|
| 8650 |
+
.sd-notes p {
|
| 8651 |
+
margin: 0;
|
| 8652 |
+
}
|
| 8653 |
+
|
| 8654 |
+
/* Presenting: the slide fills the screen, letterboxed in its own sky colour; the controls fade when the mouse rests. */
|
| 8655 |
+
.sd-present {
|
| 8656 |
+
position: fixed;
|
| 8657 |
+
inset: 0;
|
| 8658 |
+
z-index: 1000;
|
| 8659 |
+
background: #000;
|
| 8660 |
+
cursor: pointer;
|
| 8661 |
+
}
|
| 8662 |
+
|
| 8663 |
+
.sd-present.light {
|
| 8664 |
+
background: #e9eef9;
|
| 8665 |
+
}
|
| 8666 |
+
|
| 8667 |
+
.sd-present-stage {
|
| 8668 |
+
position: absolute;
|
| 8669 |
+
inset: 0;
|
| 8670 |
+
}
|
| 8671 |
+
|
| 8672 |
+
.sd-present-bar {
|
| 8673 |
+
position: absolute;
|
| 8674 |
+
left: 50%;
|
| 8675 |
+
bottom: 22px;
|
| 8676 |
+
display: flex;
|
| 8677 |
+
align-items: center;
|
| 8678 |
+
gap: 10px;
|
| 8679 |
+
padding: 8px 12px;
|
| 8680 |
+
transform: translateX(-50%);
|
| 8681 |
+
border: 1px solid var(--line-strong);
|
| 8682 |
+
border-radius: 999px;
|
| 8683 |
+
background: rgba(0, 0, 0, 0.72);
|
| 8684 |
+
color: var(--white);
|
| 8685 |
+
cursor: default;
|
| 8686 |
+
transition: opacity 0.3s;
|
| 8687 |
+
}
|
| 8688 |
+
|
| 8689 |
+
.sd-present.idle {
|
| 8690 |
+
cursor: none;
|
| 8691 |
+
}
|
| 8692 |
+
|
| 8693 |
+
.sd-present.idle .sd-present-bar {
|
| 8694 |
+
opacity: 0;
|
| 8695 |
+
}
|
| 8696 |
+
|
| 8697 |
+
.sd-count {
|
| 8698 |
+
min-width: 64px;
|
| 8699 |
+
text-align: center;
|
| 8700 |
+
font: 500 0.85rem var(--font-mono, monospace);
|
| 8701 |
+
}
|
| 8702 |
+
|
| 8703 |
+
@media (max-width: 760px) {
|
| 8704 |
+
.sd-opts {
|
| 8705 |
+
grid-template-columns: 1fr;
|
| 8706 |
+
}
|
| 8707 |
+
|
| 8708 |
+
.sd-opts .vw-chips {
|
| 8709 |
+
flex-wrap: wrap;
|
| 8710 |
+
}
|
| 8711 |
+
|
| 8712 |
+
.modal:has(.sd-view) .modal-head {
|
| 8713 |
+
flex-wrap: wrap;
|
| 8714 |
+
}
|
| 8715 |
+
|
| 8716 |
+
.sd-icon-btn {
|
| 8717 |
+
width: 34px;
|
| 8718 |
+
height: 34px;
|
| 8719 |
+
}
|
| 8720 |
+
}
|
| 8721 |
+
|
| 8722 |
+
@media (prefers-reduced-motion: reduce) {
|
| 8723 |
+
.sd-present-bar,
|
| 8724 |
+
.sd-item,
|
| 8725 |
+
.sd-slide,
|
| 8726 |
+
.sd-icon-btn,
|
| 8727 |
+
.modal.modal-wide:has(.sd-view),
|
| 8728 |
+
.sd-theme {
|
| 8729 |
+
transition: none;
|
| 8730 |
+
}
|
| 8731 |
+
}
|
| 8732 |
+
|
| 8733 |
+
|
| 8734 |
+
/* ---- Notebook: side panels resized by dragging the line between a panel and the chat (components/PaneResizer) ---- */
|
| 8735 |
+
|
| 8736 |
+
.nbv-resizer {
|
| 8737 |
+
display: none;
|
| 8738 |
+
}
|
| 8739 |
+
|
| 8740 |
+
@media (min-width: 900px) {
|
| 8741 |
+
.nbv-resizer {
|
| 8742 |
+
position: absolute;
|
| 8743 |
+
top: 0;
|
| 8744 |
+
bottom: 0;
|
| 8745 |
+
z-index: 6;
|
| 8746 |
+
display: block;
|
| 8747 |
+
width: 12px;
|
| 8748 |
+
margin-left: -6px;
|
| 8749 |
+
cursor: col-resize;
|
| 8750 |
+
touch-action: none;
|
| 8751 |
+
}
|
| 8752 |
+
|
| 8753 |
+
.nbv-resizer::after {
|
| 8754 |
+
content: "";
|
| 8755 |
+
position: absolute;
|
| 8756 |
+
top: 14px;
|
| 8757 |
+
bottom: 14px;
|
| 8758 |
+
left: 5px;
|
| 8759 |
+
width: 2px;
|
| 8760 |
+
border-radius: 2px;
|
| 8761 |
+
background: transparent;
|
| 8762 |
+
transition: background 0.15s, box-shadow 0.15s;
|
| 8763 |
+
}
|
| 8764 |
+
|
| 8765 |
+
.nbv-resizer:hover::after,
|
| 8766 |
+
.nbv-resizer.dragging::after,
|
| 8767 |
+
.nbv-resizer:focus-visible::after {
|
| 8768 |
+
background: var(--blue);
|
| 8769 |
+
box-shadow: 0 0 10px var(--blue-60);
|
| 8770 |
+
}
|
| 8771 |
+
|
| 8772 |
+
.nbv-resizer:focus-visible {
|
| 8773 |
+
outline: none;
|
| 8774 |
+
}
|
| 8775 |
+
}
|
| 8776 |
+
|
| 8777 |
+
/* While dragging: no text selection, the resize cursor everywhere, and frames (slides, infographics) let the pointer
|
| 8778 |
+
through so the drag is never lost over them. */
|
| 8779 |
+
body.nbv-resizing,
|
| 8780 |
+
body.nbv-resizing * {
|
| 8781 |
+
cursor: col-resize !important;
|
| 8782 |
+
user-select: none;
|
| 8783 |
+
}
|
| 8784 |
+
|
| 8785 |
+
body.nbv-resizing iframe {
|
| 8786 |
+
pointer-events: none;
|
| 8787 |
+
}
|
| 8788 |
+
|
| 8789 |
+
/* The panels' contents follow the panels' own widths, not the window's. */
|
| 8790 |
+
.nbv-side,
|
| 8791 |
+
.nbv-history,
|
| 8792 |
+
.nbv-chat {
|
| 8793 |
+
container-type: inline-size;
|
| 8794 |
+
}
|
| 8795 |
+
|
| 8796 |
+
/* Create tiles never wrap a word: the label shortens with an ellipsis, the icon stays. */
|
| 8797 |
+
.nbv-make > button,
|
| 8798 |
+
.nbv-make .nbv-make-label {
|
| 8799 |
+
min-width: 0;
|
| 8800 |
+
overflow: hidden;
|
| 8801 |
+
}
|
| 8802 |
+
|
| 8803 |
+
.nbv-make .nbv-make-label {
|
| 8804 |
+
flex: 1;
|
| 8805 |
+
}
|
| 8806 |
+
|
| 8807 |
+
.nbv-make-text {
|
| 8808 |
+
min-width: 0;
|
| 8809 |
+
overflow: hidden;
|
| 8810 |
+
text-overflow: ellipsis;
|
| 8811 |
+
white-space: nowrap;
|
| 8812 |
+
}
|
| 8813 |
+
|
| 8814 |
+
/* Tiles per row by the panel's width: one when narrow, two by default, three or four when the panel is wide. */
|
| 8815 |
+
@container (max-width: 299px) {
|
| 8816 |
+
.nbv-make {
|
| 8817 |
+
grid-template-columns: minmax(0, 1fr);
|
| 8818 |
+
}
|
| 8819 |
+
}
|
| 8820 |
+
|
| 8821 |
+
@container (min-width: 560px) {
|
| 8822 |
+
.nbv-make {
|
| 8823 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 8824 |
+
}
|
| 8825 |
+
|
| 8826 |
+
.nbv-make .nbv-make-wide {
|
| 8827 |
+
grid-column: auto;
|
| 8828 |
+
}
|
| 8829 |
+
}
|
| 8830 |
+
|
| 8831 |
+
@container (min-width: 760px) {
|
| 8832 |
+
.nbv-make {
|
| 8833 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 8834 |
+
}
|
| 8835 |
+
}
|
| 8836 |
+
|
| 8837 |
+
@media (prefers-reduced-motion: reduce) {
|
| 8838 |
+
.nbv-resizer::after {
|
| 8839 |
+
transition: none;
|
| 8840 |
+
}
|
| 8841 |
+
}
|
| 8842 |
+
|
| 8843 |
+
|
| 8844 |
+
/* ---- Audio overview settings (components/AudioOverview) ---- */
|
| 8845 |
+
|
| 8846 |
+
.ao-dialog {
|
| 8847 |
+
gap: 20px;
|
| 8848 |
+
}
|
| 8849 |
+
|
| 8850 |
+
.ao-styles {
|
| 8851 |
+
display: grid;
|
| 8852 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 8853 |
+
gap: 10px;
|
| 8854 |
+
}
|
| 8855 |
+
|
| 8856 |
+
.ao-style {
|
| 8857 |
+
display: grid;
|
| 8858 |
+
align-content: start;
|
| 8859 |
+
gap: 8px;
|
| 8860 |
+
min-height: 132px;
|
| 8861 |
+
padding: 14px 14px 16px;
|
| 8862 |
+
text-align: left;
|
| 8863 |
+
border: 1px solid var(--line-strong);
|
| 8864 |
+
border-radius: 18px;
|
| 8865 |
+
background: rgba(0, 0, 0, 0.6);
|
| 8866 |
+
color: var(--text);
|
| 8867 |
+
cursor: pointer;
|
| 8868 |
+
transition: border-color 0.15s, background 0.15s;
|
| 8869 |
+
}
|
| 8870 |
+
|
| 8871 |
+
.ao-style:hover:not(:disabled):not(.on) {
|
| 8872 |
+
border-color: var(--blue-60);
|
| 8873 |
+
}
|
| 8874 |
+
|
| 8875 |
+
.ao-style.on {
|
| 8876 |
+
border-color: var(--blue);
|
| 8877 |
+
background: var(--blue-20);
|
| 8878 |
+
}
|
| 8879 |
+
|
| 8880 |
+
.ao-style:disabled {
|
| 8881 |
+
opacity: 0.45;
|
| 8882 |
+
cursor: not-allowed;
|
| 8883 |
+
}
|
| 8884 |
+
|
| 8885 |
+
.ao-style-head {
|
| 8886 |
+
display: flex;
|
| 8887 |
+
align-items: center;
|
| 8888 |
+
justify-content: space-between;
|
| 8889 |
+
gap: 8px;
|
| 8890 |
+
}
|
| 8891 |
+
|
| 8892 |
+
.ao-style-head strong {
|
| 8893 |
+
font-weight: 600;
|
| 8894 |
+
}
|
| 8895 |
+
|
| 8896 |
+
.ao-radio {
|
| 8897 |
+
display: grid;
|
| 8898 |
+
place-items: center;
|
| 8899 |
+
flex: none;
|
| 8900 |
+
width: 22px;
|
| 8901 |
+
height: 22px;
|
| 8902 |
+
border-radius: 50%;
|
| 8903 |
+
border: 1px solid var(--line-strong);
|
| 8904 |
+
}
|
| 8905 |
+
|
| 8906 |
+
.ao-style.on .ao-radio {
|
| 8907 |
+
border-color: var(--blue);
|
| 8908 |
+
background: var(--black);
|
| 8909 |
+
}
|
| 8910 |
+
|
| 8911 |
+
.ao-style-blurb {
|
| 8912 |
+
font-size: 0.85rem;
|
| 8913 |
+
line-height: 1.45;
|
| 8914 |
+
color: var(--text-2);
|
| 8915 |
+
}
|
| 8916 |
+
|
| 8917 |
+
/* Hosts pills share the slide deck's pill look (.sd-opts); the two voice pickers sit side by side */
|
| 8918 |
+
.ao-voices {
|
| 8919 |
+
display: grid;
|
| 8920 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 8921 |
+
gap: 14px;
|
| 8922 |
+
}
|
| 8923 |
+
|
| 8924 |
+
.ao-voices > p {
|
| 8925 |
+
grid-column: 1 / -1;
|
| 8926 |
+
margin: 0;
|
| 8927 |
+
}
|
| 8928 |
+
|
| 8929 |
+
@media (max-width: 760px) {
|
| 8930 |
+
.ao-styles {
|
| 8931 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 8932 |
+
}
|
| 8933 |
+
|
| 8934 |
+
.ao-voices {
|
| 8935 |
+
grid-template-columns: 1fr;
|
| 8936 |
+
}
|
| 8937 |
+
}
|
| 8938 |
+
|
| 8939 |
+
@media (prefers-reduced-motion: reduce) {
|
| 8940 |
+
.ao-style {
|
| 8941 |
+
transition: none;
|
| 8942 |
+
}
|
| 8943 |
+
}
|
| 8944 |
+
|
| 8945 |
+
.modal.modal-wide:has(.ao-dialog) {
|
| 8946 |
+
width: min(880px, calc(100vw - 32px));
|
| 8947 |
+
}
|
| 8948 |
+
|
| 8949 |
+
/* ---- Audio overview in the notebook's list (components/AudioOverview: AudioOverviewCard) ---- */
|
| 8950 |
+
|
| 8951 |
+
.ao-card {
|
| 8952 |
+
border: 1px solid var(--line);
|
| 8953 |
+
border-radius: var(--radius);
|
| 8954 |
+
background: var(--surface);
|
| 8955 |
+
transition: border-color 0.15s;
|
| 8956 |
+
}
|
| 8957 |
+
|
| 8958 |
+
.ao-card.open {
|
| 8959 |
+
border-color: var(--line-strong);
|
| 8960 |
+
}
|
| 8961 |
+
|
| 8962 |
+
.ao-card-head {
|
| 8963 |
+
display: grid;
|
| 8964 |
+
grid-template-columns: 40px minmax(0, 1fr) auto;
|
| 8965 |
+
align-items: center;
|
| 8966 |
+
gap: 10px;
|
| 8967 |
+
padding: 10px 10px 10px 8px;
|
| 8968 |
+
border-radius: var(--radius);
|
| 8969 |
+
outline: none;
|
| 8970 |
+
}
|
| 8971 |
+
|
| 8972 |
+
.ao-card-head[role="button"] {
|
| 8973 |
+
cursor: pointer;
|
| 8974 |
+
}
|
| 8975 |
+
|
| 8976 |
+
.ao-card-head[role="button"]:hover {
|
| 8977 |
+
background: var(--surface-2);
|
| 8978 |
+
}
|
| 8979 |
+
|
| 8980 |
+
.ao-card-head:focus-visible {
|
| 8981 |
+
box-shadow: 0 0 0 2px var(--blue);
|
| 8982 |
+
}
|
| 8983 |
+
|
| 8984 |
+
.ao-card-icon {
|
| 8985 |
+
display: grid;
|
| 8986 |
+
place-items: center;
|
| 8987 |
+
width: 40px;
|
| 8988 |
+
height: 40px;
|
| 8989 |
+
color: var(--blue);
|
| 8990 |
+
}
|
| 8991 |
+
|
| 8992 |
+
.ao-card-text {
|
| 8993 |
+
display: grid;
|
| 8994 |
+
gap: 2px;
|
| 8995 |
+
min-width: 0;
|
| 8996 |
+
}
|
| 8997 |
+
|
| 8998 |
+
.ao-card-text strong {
|
| 8999 |
+
overflow: hidden;
|
| 9000 |
+
text-overflow: ellipsis;
|
| 9001 |
+
white-space: nowrap;
|
| 9002 |
+
font-weight: 500;
|
| 9003 |
+
font-size: 0.95rem;
|
| 9004 |
+
}
|
| 9005 |
+
|
| 9006 |
+
.ao-card-meta {
|
| 9007 |
+
overflow: hidden;
|
| 9008 |
+
text-overflow: ellipsis;
|
| 9009 |
+
white-space: nowrap;
|
| 9010 |
+
font-size: 0.82rem;
|
| 9011 |
+
color: var(--text-3);
|
| 9012 |
+
}
|
| 9013 |
+
|
| 9014 |
+
.ao-chevron {
|
| 9015 |
+
display: grid;
|
| 9016 |
+
place-items: center;
|
| 9017 |
+
width: 32px;
|
| 9018 |
+
height: 32px;
|
| 9019 |
+
color: var(--text-2);
|
| 9020 |
+
}
|
| 9021 |
+
|
| 9022 |
+
.ao-card-progress {
|
| 9023 |
+
padding: 0 12px 10px 58px;
|
| 9024 |
+
}
|
| 9025 |
+
|
| 9026 |
+
.ao-card-body {
|
| 9027 |
+
display: grid;
|
| 9028 |
+
gap: 12px;
|
| 9029 |
+
padding: 4px 14px 14px;
|
| 9030 |
+
}
|
| 9031 |
+
|
| 9032 |
+
/* The player: play, time, a seek bar and the speed, in one pill */
|
| 9033 |
+
.ao-player {
|
| 9034 |
+
display: grid;
|
| 9035 |
+
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
| 9036 |
+
align-items: center;
|
| 9037 |
+
gap: 12px;
|
| 9038 |
+
padding: 6px 8px 6px 6px;
|
| 9039 |
+
border-radius: 999px;
|
| 9040 |
+
background: var(--surface-2);
|
| 9041 |
+
}
|
| 9042 |
+
|
| 9043 |
+
.ao-play {
|
| 9044 |
+
display: grid;
|
| 9045 |
+
place-items: center;
|
| 9046 |
+
width: 38px;
|
| 9047 |
+
height: 38px;
|
| 9048 |
+
border: 0;
|
| 9049 |
+
border-radius: 50%;
|
| 9050 |
+
background: var(--blue);
|
| 9051 |
+
color: var(--white);
|
| 9052 |
+
cursor: pointer;
|
| 9053 |
+
}
|
| 9054 |
+
|
| 9055 |
+
.ao-play:focus-visible,
|
| 9056 |
+
.ao-speed:focus-visible {
|
| 9057 |
+
outline: 2px solid var(--blue);
|
| 9058 |
+
outline-offset: 2px;
|
| 9059 |
+
}
|
| 9060 |
+
|
| 9061 |
+
.ao-time {
|
| 9062 |
+
font-size: 0.8rem;
|
| 9063 |
+
color: var(--text-2);
|
| 9064 |
+
white-space: nowrap;
|
| 9065 |
+
}
|
| 9066 |
+
|
| 9067 |
+
.ao-seek {
|
| 9068 |
+
width: 100%;
|
| 9069 |
+
height: 4px;
|
| 9070 |
+
margin: 0;
|
| 9071 |
+
border-radius: 2px;
|
| 9072 |
+
background: linear-gradient(to right, var(--blue) var(--ao-fill, 0%), var(--line-strong) var(--ao-fill, 0%));
|
| 9073 |
+
appearance: none;
|
| 9074 |
+
cursor: pointer;
|
| 9075 |
+
}
|
| 9076 |
+
|
| 9077 |
+
.ao-seek::-webkit-slider-thumb {
|
| 9078 |
+
width: 14px;
|
| 9079 |
+
height: 14px;
|
| 9080 |
+
border-radius: 50%;
|
| 9081 |
+
background: var(--white);
|
| 9082 |
+
appearance: none;
|
| 9083 |
+
}
|
| 9084 |
+
|
| 9085 |
+
.ao-seek::-moz-range-thumb {
|
| 9086 |
+
width: 14px;
|
| 9087 |
+
height: 14px;
|
| 9088 |
+
border: 0;
|
| 9089 |
+
border-radius: 50%;
|
| 9090 |
+
background: var(--white);
|
| 9091 |
+
}
|
| 9092 |
+
|
| 9093 |
+
.ao-speed {
|
| 9094 |
+
min-width: 52px;
|
| 9095 |
+
height: 30px;
|
| 9096 |
+
padding: 0 10px;
|
| 9097 |
+
border: 1px solid var(--line-strong);
|
| 9098 |
+
border-radius: 999px;
|
| 9099 |
+
background: transparent;
|
| 9100 |
+
color: var(--text);
|
| 9101 |
+
font-size: 0.8rem;
|
| 9102 |
+
cursor: pointer;
|
| 9103 |
+
}
|
| 9104 |
+
|
| 9105 |
+
.ao-speed:hover {
|
| 9106 |
+
border-color: var(--blue-60);
|
| 9107 |
+
}
|
| 9108 |
+
|
| 9109 |
+
.ao-transcript-toggle {
|
| 9110 |
+
justify-self: start;
|
| 9111 |
+
}
|
| 9112 |
+
|
| 9113 |
+
.ao-card-actions {
|
| 9114 |
+
gap: 8px;
|
| 9115 |
+
}
|
| 9116 |
+
|
| 9117 |
+
/* Delete reads as a normal button (not dimmed), red when pointed at */
|
| 9118 |
+
.ao-delete:hover,
|
| 9119 |
+
.ao-delete:focus-visible {
|
| 9120 |
+
border-color: #ef4444;
|
| 9121 |
+
color: #ff8a8a;
|
| 9122 |
+
}
|
| 9123 |
+
|
| 9124 |
+
@media (prefers-reduced-motion: reduce) {
|
| 9125 |
+
.ao-card {
|
| 9126 |
+
transition: none;
|
| 9127 |
+
}
|
| 9128 |
+
}
|