Muhammad-Mehdi commited on
Commit
bcfc897
·
1 Parent(s): 1e1198f

error messages added and loaders fixed

Browse files
backend/app/pipeline/launchkit.py CHANGED
@@ -1,16 +1,22 @@
1
  """Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
2
  the post's own claims."""
3
 
 
 
4
  from sqlalchemy.orm import Session
5
 
 
6
  from app.corpus import INDEX, Corpus
7
  from app.llm import run
8
  from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
9
  from app.models import Artifact, Document, Job
10
  from app.pipeline.generate import NothingToDo
11
  from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
 
12
  from app.services.jobs import create_job, update_job
13
 
 
 
14
  PLATFORMS = {
15
  "x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
16
  "No hashtags except at most one at the end. The last post links to the article with {url}."),
@@ -94,8 +100,13 @@ def build_launch_kit(db: Session, job: Job, artifact: Artifact) -> dict:
94
  artifact.status = "ready"
95
  db.commit()
96
 
97
- # Quote cards render as their own artifacts, so a missing renderer does not sink the kit.
 
98
  card_ids = []
 
 
 
 
99
  for q in quotes:
100
  card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
101
  content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
 
1
  """Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
2
  the post's own claims."""
3
 
4
+ import logging
5
+
6
  from sqlalchemy.orm import Session
7
 
8
+ from app.config import settings
9
  from app.corpus import INDEX, Corpus
10
  from app.llm import run
11
  from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
12
  from app.models import Artifact, Document, Job
13
  from app.pipeline.generate import NothingToDo
14
  from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
15
+ from app.services import renderer
16
  from app.services.jobs import create_job, update_job
17
 
18
+ log = logging.getLogger(__name__)
19
+
20
  PLATFORMS = {
21
  "x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
22
  "No hashtags except at most one at the end. The last post links to the article with {url}."),
 
100
  artifact.status = "ready"
101
  db.commit()
102
 
103
+ # Quote cards render as their own artifacts, so a missing renderer does not sink the kit. With the renderer
104
+ # down they would only fail, so skip them: the kit keeps its quotes as text either way.
105
  card_ids = []
106
+ if quotes and not renderer.available():
107
+ log.warning("Renderer unreachable at %s: launch kit %s made without quote cards",
108
+ settings.renderer_url, artifact.id)
109
+ quotes = []
110
  for q in quotes:
111
  card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
112
  content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
backend/app/services/blog2video.py CHANGED
@@ -85,7 +85,7 @@ class Denied(B2VError):
85
 
86
 
87
  UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
88
- NOT_RESPONDING = "The video service is not responding. Try again in a minute."
89
 
90
 
91
  def configured() -> bool:
 
85
 
86
 
87
  UNAVAILABLE = "Video creation is temporarily unavailable. Try again later."
88
+ NOT_RESPONDING = "The video service is being updated. Please try again in a while."
89
 
90
 
91
  def configured() -> bool:
backend/app/services/renderer.py CHANGED
@@ -17,6 +17,14 @@ class PermanentJobError(RuntimeError):
17
  """Fail the job now; retrying will not help."""
18
 
19
 
 
 
 
 
 
 
 
 
20
  def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
21
  stills: list[dict] | None = None) -> None:
22
  """Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
 
17
  """Fail the job now; retrying will not help."""
18
 
19
 
20
+ def available() -> bool:
21
+ """Whether the render service answers right now (a quick health check, not a promise for later)."""
22
+ try:
23
+ return httpx.get(f"{settings.renderer_url}/health", timeout=2).status_code == 200
24
+ except httpx.HTTPError:
25
+ return False
26
+
27
+
28
  def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
29
  stills: list[dict] | None = None) -> None:
30
  """Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
backend/tests/test_buildout.py CHANGED
@@ -542,7 +542,10 @@ def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
542
  "PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
543
  "source": ref}]},
544
  })
 
 
545
  monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
 
546
  art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
547
  headers=auth).json()
548
  run_jobs()
@@ -563,6 +566,15 @@ def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
563
 
564
  listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
565
  assert listing["total"] == 1
 
 
 
 
 
 
 
 
 
566
  assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
567
 
568
 
 
542
  "PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
543
  "source": ref}]},
544
  })
545
+ from app.services import renderer
546
+
547
  monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
548
+ monkeypatch.setattr(renderer, "available", lambda: True)
549
  art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
550
  headers=auth).json()
551
  run_jobs()
 
566
 
567
  listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
568
  assert listing["total"] == 1
569
+
570
+ # Renderer down: the kit keeps its quotes as text and makes no quote cards that could only fail.
571
+ monkeypatch.setattr(renderer, "available", lambda: False)
572
+ art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
573
+ headers=auth).json()
574
+ run_jobs()
575
+ kit = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
576
+ assert kit["status"] == "ready"
577
+ assert kit["content"]["quotes"] and kit["content"]["quote_card_ids"] == []
578
  assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
579
 
580
 
backend/tests/test_videos.py CHANGED
@@ -440,7 +440,7 @@ def test_slow_catalog_is_a_clean_error_not_a_500(client, b2v, owner, db_session)
440
  b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
441
  r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
442
  headers=headers)
443
- assert r.status_code == 502 and "not responding" in r.json()["detail"]
444
  assert not creates(b2v) and used(db_session, ws) == 0
445
 
446
 
 
440
  b2v.queue("GET", "/api/templates", (0, httpx.ReadTimeout("slow")))
441
  r = client.post("/api/videos", json={"url": "https://ada.example.com/p/a", "template": "geometric"},
442
  headers=headers)
443
+ assert r.status_code == 502 and "being updated" in r.json()["detail"]
444
  assert not creates(b2v) and used(db_session, ws) == 0
445
 
446
 
frontend/src/components/ui.tsx CHANGED
@@ -8,6 +8,24 @@ export function errorMessage(e: unknown, fallback = "Something went wrong. Try a
8
  return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback;
9
  }
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  /** Launch progress bar for a job. */
12
  export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) {
13
  const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100);
@@ -216,8 +234,12 @@ export function EmptyState({ title, body, action }: { title: string; body?: stri
216
  );
217
  }
218
 
219
- export function Loading({ label = "Loading" }: { label?: string }) {
220
- return (
 
 
 
 
221
  <div className="loading mono muted" role="status">
222
  <div className="orbit-loader">
223
  <span />
@@ -225,6 +247,7 @@ export function Loading({ label = "Loading" }: { label?: string }) {
225
  {label}...
226
  </div>
227
  );
 
228
  }
229
 
230
  export function StatusPill({ status }: { status: string }) {
 
8
  return e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : fallback;
9
  }
10
 
11
+ /** Matches blog2video.NOT_RESPONDING on the backend: the video service is down or redeploying. */
12
+ export const VIDEO_UPDATING = "The video service is being updated";
13
+
14
+ /**
15
+ * An inline error. The "video service is being updated" message is a status, not a mistake: it is shown centered
16
+ * and in bold instead, and with `page` it fills the page under the header.
17
+ */
18
+ export function ErrorText({ message, page = false }: { message: string; page?: boolean }) {
19
+ if (message.startsWith(VIDEO_UPDATING)) {
20
+ return (
21
+ <div className={`service-notice${page ? " page" : ""}`} role="status">
22
+ <strong>{message}</strong>
23
+ </div>
24
+ );
25
+ }
26
+ return <p className="error-text">{message}</p>;
27
+ }
28
+
29
  /** Launch progress bar for a job. */
30
  export function JobProgress({ job, compact = false }: { job: Job; compact?: boolean }) {
31
  const pct = Math.round((job.status === "done" ? 1 : job.progress) * 100);
 
234
  );
235
  }
236
 
237
+ /**
238
+ * Spinner with its label underneath, centered. `center="page"` puts it in the middle of the space under a page
239
+ * header; `center="full"` in the middle of the window, for a page that has no header yet.
240
+ */
241
+ export function Loading({ label = "Loading", center }: { label?: string; center?: "page" | "full" }) {
242
+ const loader = (
243
  <div className="loading mono muted" role="status">
244
  <div className="orbit-loader">
245
  <span />
 
247
  {label}...
248
  </div>
249
  );
250
+ return center ? <div className={`loading-center${center === "full" ? " full" : ""}`}>{loader}</div> : loader;
251
  }
252
 
253
  export function StatusPill({ status }: { status: string }) {
frontend/src/components/video/EditorPanels.tsx CHANGED
@@ -7,7 +7,7 @@ import type {
7
  VideoScene,
8
  } from "../../api/types";
9
  import { ChevronIcon, TrashIcon } from "../icons/Icons";
10
- import { ConfirmButton, errorMessage } from "../ui";
11
  import { Premium } from "./parts";
12
  import { DeleteAssetModal, RemoveMediaModal } from "./SceneMedia";
13
  import { Voiceovers } from "./Voiceovers";
@@ -77,7 +77,7 @@ export function ScriptPanel(p: PanelProps) {
77
  Try again
78
  </button>
79
  </div>
80
- {error && <p className="error-text">{error}</p>}
81
  {preview && <pre className="vw-pre">{JSON.stringify(preview, null, 2)}</pre>}
82
  </section>
83
  );
@@ -137,7 +137,7 @@ export function ChatPanel(p: PanelProps) {
137
  {lines.length === 0 && <p className="muted">Ask for changes, like "make scene 2 shorter" or "use warmer colors".</p>}
138
  {lines.map((l, i) => <p key={i} className={`vw-chat-line ${l.role}`}>{l.text}</p>)}
139
  </div>
140
- {error && <p className="error-text">{error}</p>}
141
  <div className="row">
142
  <input className="input" value={message} maxLength={4000} disabled={busy || p.disabled} placeholder="Ask for a change"
143
  onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
@@ -247,7 +247,7 @@ export function FramesPanel(p: PanelProps) {
247
  <button className="btn btn-small" disabled={busy} onClick={load}>{busy ? "Rendering..." : "Show frame"}</button>
248
  {src && <a className="btn btn-small" href={src} download={`frame-${frame}.png`}>Download</a>}
249
  </div>
250
- {error && <p className="error-text">{error}</p>}
251
  {src && <img className="vw-still" src={src} alt={`Frame ${frame}`} />}
252
  </section>
253
  );
 
7
  VideoScene,
8
  } from "../../api/types";
9
  import { ChevronIcon, TrashIcon } from "../icons/Icons";
10
+ import { ConfirmButton, ErrorText, errorMessage } from "../ui";
11
  import { Premium } from "./parts";
12
  import { DeleteAssetModal, RemoveMediaModal } from "./SceneMedia";
13
  import { Voiceovers } from "./Voiceovers";
 
77
  Try again
78
  </button>
79
  </div>
80
+ {error && <ErrorText message={error} />}
81
  {preview && <pre className="vw-pre">{JSON.stringify(preview, null, 2)}</pre>}
82
  </section>
83
  );
 
137
  {lines.length === 0 && <p className="muted">Ask for changes, like "make scene 2 shorter" or "use warmer colors".</p>}
138
  {lines.map((l, i) => <p key={i} className={`vw-chat-line ${l.role}`}>{l.text}</p>)}
139
  </div>
140
+ {error && <ErrorText message={error} />}
141
  <div className="row">
142
  <input className="input" value={message} maxLength={4000} disabled={busy || p.disabled} placeholder="Ask for a change"
143
  onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
 
247
  <button className="btn btn-small" disabled={busy} onClick={load}>{busy ? "Rendering..." : "Show frame"}</button>
248
  {src && <a className="btn btn-small" href={src} download={`frame-${frame}.png`}>Download</a>}
249
  </div>
250
+ {error && <ErrorText message={error} />}
251
  {src && <img className="vw-still" src={src} alt={`Frame ${frame}`} />}
252
  </section>
253
  );
frontend/src/components/voice/YourVoices.tsx CHANGED
@@ -69,7 +69,7 @@ export function YourVoices({ reloadKey = 0, onAdd }: {
69
  <button className="btn btn-primary" onClick={onAdd}>Add voices</button>
70
  </div>
71
  {error && <p className="error-text">{error}</p>}
72
- {!voices && !error && <div className="loading-center inline stacked"><Loading label="Loading voices" /></div>}
73
 
74
  {voices && (
75
  <>
 
69
  <button className="btn btn-primary" onClick={onAdd}>Add voices</button>
70
  </div>
71
  {error && <p className="error-text">{error}</p>}
72
+ {!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
73
 
74
  {voices && (
75
  <>
frontend/src/pages/Archive.tsx CHANGED
@@ -63,7 +63,7 @@ function MadeList({ filter }: { filter: Filter }) {
63
  <div className="row end wrap">
64
  <input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
65
  </div>
66
- {!items && <div className="loading-center stacked"><Loading label="Loading your Library" /></div>}
67
  {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
68
  action={
69
  <Link className="btn btn-primary" to="/app/studio">
 
63
  <div className="row end wrap">
64
  <input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
65
  </div>
66
+ {!items && <div className="loading-center"><Loading label="Loading your Library" /></div>}
67
  {items?.length === 0 && <EmptyState title="Nothing here yet" body="Summaries, audio, videos and launch kits you create show up here."
68
  action={
69
  <Link className="btn btn-primary" to="/app/studio">
frontend/src/pages/LaunchKit.tsx CHANGED
@@ -252,7 +252,7 @@ export function LaunchKitPage() {
252
  <div className="page-wrap">
253
  <BackArrow fallback="/app/launchpad/kits" />
254
  {error && <p className="error-text">{error}</p>}
255
- {!kit && !error && <div className="loading-center full stacked"><Loading label="Opening the kit" /></div>}
256
  {kit && <KitView key={kit.id} kit={kit} onDeleted={() => navigate("/app/launchpad/kits", { replace: true })} />}
257
  </div>
258
  );
 
252
  <div className="page-wrap">
253
  <BackArrow fallback="/app/launchpad/kits" />
254
  {error && <p className="error-text">{error}</p>}
255
+ {!kit && !error && <div className="loading-center full"><Loading label="Opening the kit" /></div>}
256
  {kit && <KitView key={kit.id} kit={kit} onDeleted={() => navigate("/app/launchpad/kits", { replace: true })} />}
257
  </div>
258
  );
frontend/src/pages/Launchpad.tsx CHANGED
@@ -253,7 +253,7 @@ function ConnectionsPanel({ accounts, onConnect, onChanged }: {
253
  <aside className="card stack lp-connections" aria-label="Connected accounts">
254
  <h2>Connections</h2>
255
  <p className="muted small">Posts go out through these accounts at their scheduled time.</p>
256
- {!accounts && <div className="loading-center inline stacked"><Loading label="Loading your connections" /></div>}
257
  {accounts && CONNECT.map((c) => {
258
  const a = accounts.accounts.find((x) => x.platform === c.id);
259
  const days = a?.expires_at ? Math.max(0, Math.ceil((new Date(a.expires_at).getTime() - Date.now()) / 864e5)) : null;
@@ -426,7 +426,7 @@ export default function Launchpad() {
426
  </div>
427
  <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
428
  </div>
429
- {!items && <div className="loading-center stacked lp-cal-loading"><Loading label="Loading your calendar" /></div>}
430
  {items && items.length === 0 && (
431
  <div className="lp-empty">
432
  <p className="muted">Nothing scheduled this month.</p>
 
253
  <aside className="card stack lp-connections" aria-label="Connected accounts">
254
  <h2>Connections</h2>
255
  <p className="muted small">Posts go out through these accounts at their scheduled time.</p>
256
+ {!accounts && <div className="loading-center inline"><Loading label="Loading your connections" /></div>}
257
  {accounts && CONNECT.map((c) => {
258
  const a = accounts.accounts.find((x) => x.platform === c.id);
259
  const days = a?.expires_at ? Math.max(0, Math.ceil((new Date(a.expires_at).getTime() - Date.now()) / 864e5)) : null;
 
426
  </div>
427
  <Tabs tabs={[{ id: "month", label: "Month" }, { id: "agenda", label: "Agenda" }]} value={view} onChange={setView} />
428
  </div>
429
+ {!items && <div className="loading-center lp-cal-loading"><Loading label="Loading your calendar" /></div>}
430
  {items && items.length === 0 && (
431
  <div className="lp-empty">
432
  <p className="muted">Nothing scheduled this month.</p>
frontend/src/pages/NotebookView.tsx CHANGED
@@ -440,7 +440,7 @@ export default function NotebookView() {
440
  }, [nb]);
441
 
442
  if (loadError) return <p className="error-text">{loadError}</p>;
443
- if (!nb) return <Loading label="Opening notebook" />;
444
 
445
  const cite = (c: Citation) => {
446
  if (c.kind === "chat") setQuoted(c);
 
440
  }, [nb]);
441
 
442
  if (loadError) return <p className="error-text">{loadError}</p>;
443
+ if (!nb) return <Loading label="Opening notebook" center="full" />;
444
 
445
  const cite = (c: Citation) => {
446
  if (c.kind === "chat") setQuoted(c);
frontend/src/pages/Notebooks.tsx CHANGED
@@ -136,7 +136,7 @@ export default function Notebooks() {
136
  New notebook
137
  </button>
138
  </PageHeader>
139
- {!notebooks && <Loading />}
140
  <section className="nb-grid">
141
  {notebooks?.map((n) => (
142
  <article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}>
 
136
  New notebook
137
  </button>
138
  </PageHeader>
139
+ {!notebooks && <Loading center="page" />}
140
  <section className="nb-grid">
141
  {notebooks?.map((n) => (
142
  <article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}>
frontend/src/pages/Resurface.tsx CHANGED
@@ -56,7 +56,7 @@ export function ResurfaceIdeas({ onScheduled }: { onScheduled?: () => void }) {
56
  const live = useJob(job, () => load());
57
  const running = live && (live.status === "queued" || live.status === "running");
58
 
59
- if (!data) return <Loading />;
60
  const items = [...data.on_this_day, ...data.evergreen.filter((e) => !data.on_this_day.some((o) => o.id === e.id))];
61
 
62
  return (
 
56
  const live = useJob(job, () => load());
57
  const running = live && (live.status === "queued" || live.status === "running");
58
 
59
+ if (!data) return <Loading center="full" />;
60
  const items = [...data.on_this_day, ...data.evergreen.filter((e) => !data.on_this_day.some((o) => o.id === e.id))];
61
 
62
  return (
frontend/src/pages/Settings.tsx CHANGED
@@ -74,7 +74,7 @@ export default function Settings() {
74
  }
75
  };
76
 
77
- if (!s) return error ? <p className="error-text">{error}</p> : <Loading />;
78
  const u = s.usage;
79
  // Fields save on their own when the writer leaves them, so there are no Save buttons.
80
  const saveProfile = () => {
 
74
  }
75
  };
76
 
77
+ if (!s) return error ? <p className="error-text">{error}</p> : <Loading center="full" />;
78
  const u = s.usage;
79
  // Fields save on their own when the writer leaves them, so there are no Save buttons.
80
  const saveProfile = () => {
frontend/src/pages/Sources.tsx CHANGED
@@ -227,8 +227,9 @@ export default function Sources() {
227
  <AddSource onAdded={added} />
228
  {error && <p className="error-text">{error}</p>}
229
 
 
 
230
  <section className="grid-cards">
231
- {!sources && <Loading />}
232
  {sources?.map((s) => {
233
  const job = jobs[s.id];
234
  const active = job && (job.status === "queued" || job.status === "running");
 
227
  <AddSource onAdded={added} />
228
  {error && <p className="error-text">{error}</p>}
229
 
230
+ {/* Outside the card grid, so the loader is centered across the page rather than in the first column */}
231
+ {!sources && <Loading center="page" />}
232
  <section className="grid-cards">
 
233
  {sources?.map((s) => {
234
  const job = jobs[s.id];
235
  const active = job && (job.status === "queued" || job.status === "running");
frontend/src/pages/TopicMap.tsx CHANGED
@@ -504,7 +504,7 @@ export default function TopicMap() {
504
  </PageHeader>
505
  {live && (running || live.status === "failed") && <JobProgress job={live} />}
506
  {error && <p className="error-text">{error}</p>}
507
- {!data && <Loading />}
508
  {data && data.nodes.length === 0 && !running && (
509
  <EmptyState
510
  title="No constellations yet"
 
504
  </PageHeader>
505
  {live && (running || live.status === "failed") && <JobProgress job={live} />}
506
  {error && <p className="error-text">{error}</p>}
507
+ {!data && <Loading center="page" />}
508
  {data && data.nodes.length === 0 && !running && (
509
  <EmptyState
510
  title="No constellations yet"
frontend/src/pages/VideoCreate.tsx CHANGED
@@ -16,7 +16,7 @@ import type {
16
  } from "../api/types";
17
  import { Dropdown } from "../components/Dropdown";
18
  import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
19
- import { errorMessage, Loading, PageHeader } from "../components/ui";
20
  import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
21
  import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
22
  import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
@@ -248,11 +248,11 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
248
  return (
249
  <div className={inModal ? "vw-in-modal" : "page-wrap"}>
250
  {!inModal && <PageHeader eyebrow="Videos" title="New video" />}
251
- <p className="error-text">{loadError}</p>
252
  </div>
253
  );
254
  }
255
- if (!config) return <Loading label="Loading" />;
256
  if (!config.configured) {
257
  return (
258
  <div className={inModal ? "vw-in-modal" : "page-wrap"}>
@@ -371,7 +371,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
371
  </div>
372
  )}
373
 
374
- {error && <p className="error-text">{error}</p>}
375
  <div className="vw-nav">
376
  {/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
377
  <button className="btn btn-primary vw-next" disabled={!sourceReady || shownTopics === null}
@@ -487,7 +487,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
487
  )}
488
  </div>
489
 
490
- {error && <p className="error-text">{error}</p>}
491
  {left === 0 && (
492
  <p className="notice">
493
  You have used all your videos{quota?.resets_at ? " this period" : " on your plan"}.{" "}
 
16
  } from "../api/types";
17
  import { Dropdown } from "../components/Dropdown";
18
  import { CheckIcon, SparkleIcon } from "../components/icons/Icons";
19
+ import { ErrorText, errorMessage, Loading, PageHeader } from "../components/ui";
20
  import { SourcePicker, type VideoSource } from "../components/video/SourcePicker";
21
  import { loadArchiveId, sourceCache } from "../components/video/sourceCache";
22
  import { PlayButton, TemplateThumb, useAudio } from "../components/video/parts";
 
248
  return (
249
  <div className={inModal ? "vw-in-modal" : "page-wrap"}>
250
  {!inModal && <PageHeader eyebrow="Videos" title="New video" />}
251
+ <ErrorText message={loadError} page />
252
  </div>
253
  );
254
  }
255
+ if (!config) return <Loading label="Loading" center="full" />;
256
  if (!config.configured) {
257
  return (
258
  <div className={inModal ? "vw-in-modal" : "page-wrap"}>
 
371
  </div>
372
  )}
373
 
374
+ {error && <ErrorText message={error} />}
375
  <div className="vw-nav">
376
  {/* Waits for the focus suggestions (shown, or none to show), so they are never skipped past */}
377
  <button className="btn btn-primary vw-next" disabled={!sourceReady || shownTopics === null}
 
487
  )}
488
  </div>
489
 
490
+ {error && <ErrorText message={error} />}
491
  {left === 0 && (
492
  <p className="notice">
493
  You have used all your videos{quota?.resets_at ? " this period" : " on your plan"}.{" "}
frontend/src/pages/VideoEditor.tsx CHANGED
@@ -4,7 +4,7 @@ import { ApiError } from "../api/client";
4
  import { videoEditApi, videosApi, type LayoutInfo, type LayoutSchema } from "../api/endpoints";
5
  import type { LegacyVideo, ScriptScene, VideoCatalog, VideoConfig, VideoJobState, VideoProject, VideoStatus } from "../api/types";
6
  import { CheckIcon, DownloadIcon, SparkleIcon } from "../components/icons/Icons";
7
- import { ConfirmButton, errorMessage, Loading, Modal, PageHeader, StatusPill, Tabs } from "../components/ui";
8
  import { ImagesPanel, VoicePanel, type PanelProps } from "../components/video/EditorPanels";
9
  import { LookPanel } from "../components/video/SettingsPanel";
10
  import { SceneList } from "../components/video/SceneList";
@@ -157,7 +157,7 @@ export default function VideoEditor() {
157
  () =>
158
  videosApi.get(id).then((p) => {
159
  setProject(p);
160
- setError((err) => (err?.startsWith("The video service is not responding") ? null : err));
161
  }).catch((e) => {
162
  if (e instanceof ApiError && e.status === 404) setNotFound(true);
163
  else setError(errorMessage(e));
@@ -382,7 +382,7 @@ export default function VideoEditor() {
382
  {failed && (
383
  <section className="card stack">
384
  <h2>This video could not be made</h2>
385
- <p className="error-text">{status?.error ?? "Generation failed."}</p>
386
  <p className="muted">It was not charged against your videos.</p>
387
  <div className="row">
388
  <Link to="/app/videos/new" className="btn btn-primary">Try again</Link>
@@ -415,7 +415,7 @@ export default function VideoEditor() {
415
  </section>
416
  )}
417
 
418
- {error && <p className="error-text">{error}</p>}
419
  {/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */}
420
  {fullScreenJob && running && <JobProgress label={running.label} name={title} state={job.data ?? null} />}
421
 
@@ -582,7 +582,7 @@ function ScriptReview({ id, premium, locked, onApproved }: { id: string; premium
582
  </div>
583
  </div>
584
  ))}
585
- {error && <p className="error-text">{error}</p>}
586
  <div className="row">
587
  <button className="btn btn-primary" onClick={approve} disabled={!!busy || !scenes}>
588
  {busy === "approve" ? "Starting..." : "Generate Video"}
 
4
  import { videoEditApi, videosApi, type LayoutInfo, type LayoutSchema } from "../api/endpoints";
5
  import type { LegacyVideo, ScriptScene, VideoCatalog, VideoConfig, VideoJobState, VideoProject, VideoStatus } from "../api/types";
6
  import { CheckIcon, DownloadIcon, SparkleIcon } from "../components/icons/Icons";
7
+ import { ConfirmButton, ErrorText, errorMessage, Loading, Modal, PageHeader, StatusPill, Tabs, VIDEO_UPDATING } from "../components/ui";
8
  import { ImagesPanel, VoicePanel, type PanelProps } from "../components/video/EditorPanels";
9
  import { LookPanel } from "../components/video/SettingsPanel";
10
  import { SceneList } from "../components/video/SceneList";
 
157
  () =>
158
  videosApi.get(id).then((p) => {
159
  setProject(p);
160
+ setError((err) => (err?.startsWith(VIDEO_UPDATING) ? null : err));
161
  }).catch((e) => {
162
  if (e instanceof ApiError && e.status === 404) setNotFound(true);
163
  else setError(errorMessage(e));
 
382
  {failed && (
383
  <section className="card stack">
384
  <h2>This video could not be made</h2>
385
+ <ErrorText message={status?.error ?? "Generation failed."} />
386
  <p className="muted">It was not charged against your videos.</p>
387
  <div className="row">
388
  <Link to="/app/videos/new" className="btn btn-primary">Try again</Link>
 
415
  </section>
416
  )}
417
 
418
+ {error && <ErrorText message={error} />}
419
  {/* A background job (not a render, which shows its own progress) takes the editor's place until it ends */}
420
  {fullScreenJob && running && <JobProgress label={running.label} name={title} state={job.data ?? null} />}
421
 
 
582
  </div>
583
  </div>
584
  ))}
585
+ {error && <ErrorText message={error} />}
586
  <div className="row">
587
  <button className="btn btn-primary" onClick={approve} disabled={!!busy || !scenes}>
588
  {busy === "approve" ? "Starting..." : "Generate Video"}
frontend/src/pages/VideoTemplateEditor.tsx CHANGED
@@ -97,7 +97,7 @@ export default function VideoTemplateEditor() {
97
  </div>
98
  );
99
  }
100
- if (!tpl) return error ? <p className="error-text">{error}</p> : <Loading label="Loading template" />;
101
 
102
  const premium = !!config?.premium;
103
  const sceneKeys = ["intro", ...(tpl.content_codes ?? []).map((_, i) => `content_${i}`), "outro"];
 
97
  </div>
98
  );
99
  }
100
+ if (!tpl) return error ? <p className="error-text">{error}</p> : <Loading label="Loading template" center="full" />;
101
 
102
  const premium = !!config?.premium;
103
  const sceneKeys = ["intro", ...(tpl.content_codes ?? []).map((_, i) => `content_${i}`), "outro"];
frontend/src/pages/VideoTemplates.tsx CHANGED
@@ -25,7 +25,7 @@ export default function VideoTemplates() {
25
  </p>
26
  )}
27
  {error && <p className="error-text">{error}</p>}
28
- {!data && !error && <Loading label="Loading templates" />}
29
  {data?.templates.length === 0 && (
30
  <EmptyState title="No templates yet"
31
  body="Make a template from your website, a brand document or a description. Your videos then match your brand."
 
25
  </p>
26
  )}
27
  {error && <p className="error-text">{error}</p>}
28
+ {!data && !error && <Loading label="Loading templates" center="page" />}
29
  {data?.templates.length === 0 && (
30
  <EmptyState title="No templates yet"
31
  body="Make a template from your website, a brand document or a description. Your videos then match your brand."
frontend/src/pages/Videos.tsx CHANGED
@@ -94,7 +94,7 @@ export default function Videos() {
94
  </div>
95
  {notice && <p className="notice">{notice}</p>}
96
  {error && <p className="error-text">{error}</p>}
97
- {!videos && !error && <div className="loading-center stacked"><Loading label="Loading videos" /></div>}
98
  {videos?.length === 0 && (
99
  <EmptyState
100
  title="No videos yet"
 
94
  </div>
95
  {notice && <p className="notice">{notice}</p>}
96
  {error && <p className="error-text">{error}</p>}
97
+ {!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
98
  {videos?.length === 0 && (
99
  <EmptyState
100
  title="No videos yet"
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -488,7 +488,7 @@ export default function VoiceProfile() {
488
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
489
  });
490
 
491
- if (!state) return <div className="loading-center stacked"><Loading label="Loading your voice" /></div>;
492
  const building = build && (build.status === "queued" || build.status === "running");
493
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
494
  const showClone = state.clone.status !== "none" || Boolean(clone);
 
488
  loadVoices(); // a finished clone is saved to Your voices as "My voice"
489
  });
490
 
491
+ if (!state) return <div className="loading-center"><Loading label="Loading your voice" /></div>;
492
  const building = build && (build.status === "queued" || build.status === "running");
493
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
494
  const showClone = state.clone.status !== "none" || Boolean(clone);
frontend/src/styles/pages.css CHANGED
@@ -446,11 +446,15 @@ select.input option {
446
  height: 4px;
447
  }
448
 
 
449
  .loading {
450
  display: flex;
 
451
  align-items: center;
452
- gap: 12px;
 
453
  padding: 24px 0;
 
454
  }
455
 
456
  /* A page's first load: the loader in the middle of the space under the header */
@@ -475,10 +479,17 @@ select.input option {
475
  min-height: 200px;
476
  }
477
 
478
- /* The label sits under the spinner, both centred */
479
- .loading-center .loading {
480
- flex-direction: column;
481
- gap: 16px;
 
 
 
 
 
 
 
482
  }
483
 
484
  .empty-state h3 {
@@ -4339,14 +4350,6 @@ select.input,
4339
  min-height: calc(4.5 * 64px + 4 * 6px);
4340
  }
4341
 
4342
- /* These loaders (Library, Create a launch kit, opening a kit): the text under the spinner, centered */
4343
- .lp-loading .loading,
4344
- .lk-loading .loading,
4345
- .loading-center.stacked .loading {
4346
- flex-direction: column;
4347
- text-align: center;
4348
- }
4349
-
4350
  .lp-loading.fill {
4351
  min-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 340px));
4352
  }
 
446
  height: 4px;
447
  }
448
 
449
+ /* Every loader: the spinner with its label underneath, both centered */
450
  .loading {
451
  display: flex;
452
+ flex-direction: column;
453
  align-items: center;
454
+ justify-content: center;
455
+ gap: 16px;
456
  padding: 24px 0;
457
+ text-align: center;
458
  }
459
 
460
  /* A page's first load: the loader in the middle of the space under the header */
 
479
  min-height: 200px;
480
  }
481
 
482
+ /* "The video service is being updated": a centered status line, not an error */
483
+ .service-notice {
484
+ display: grid;
485
+ place-items: center;
486
+ padding: 24px 0;
487
+ text-align: center;
488
+ color: var(--white);
489
+ }
490
+
491
+ .service-notice.page {
492
+ min-height: 60vh;
493
  }
494
 
495
  .empty-state h3 {
 
4350
  min-height: calc(4.5 * 64px + 4 * 6px);
4351
  }
4352
 
 
 
 
 
 
 
 
 
4353
  .lp-loading.fill {
4354
  min-height: max(calc(4.5 * 64px + 4 * 6px), calc(100dvh - 340px));
4355
  }
renderer/package.json CHANGED
@@ -5,7 +5,7 @@
5
  "type": "module",
6
  "scripts": {
7
  "studio": "remotion studio src/index.ts",
8
- "start": "tsx --env-file-if-exists=../.env server/index.ts",
9
  "typecheck": "tsc --noEmit"
10
  },
11
  "dependencies": {
 
5
  "type": "module",
6
  "scripts": {
7
  "studio": "remotion studio src/index.ts",
8
+ "start": "tsx --env-file-if-exists=../.env --env-file-if-exists=../backend/.env server/index.ts",
9
  "typecheck": "tsc --noEmit"
10
  },
11
  "dependencies": {