@heyramzi/review 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/client/http.d.ts +18 -0
  3. package/dist/node/client/http.js +79 -0
  4. package/dist/node/core/draft.d.ts +36 -0
  5. package/dist/node/core/draft.js +75 -0
  6. package/dist/node/core/enums.d.ts +13 -0
  7. package/dist/node/core/enums.js +28 -0
  8. package/dist/node/core/notes.d.ts +34 -0
  9. package/dist/node/core/notes.js +57 -0
  10. package/dist/node/core/openscreen.d.ts +63 -0
  11. package/dist/node/core/openscreen.js +97 -0
  12. package/dist/node/core/render-words.d.ts +8 -0
  13. package/dist/node/core/render-words.js +12 -0
  14. package/dist/node/core/types.d.ts +434 -0
  15. package/dist/node/core/types.js +2 -0
  16. package/dist/node/diagrams/figure.d.ts +20 -0
  17. package/dist/node/diagrams/figure.js +27 -0
  18. package/dist/node/diagrams/placeholder.d.ts +21 -0
  19. package/dist/node/diagrams/placeholder.js +42 -0
  20. package/dist/node/index.d.ts +4 -0
  21. package/dist/node/index.js +3 -0
  22. package/dist/node/mount.d.ts +49 -0
  23. package/dist/node/mount.js +63 -0
  24. package/dist/node/server/brand.d.ts +29 -0
  25. package/dist/node/server/brand.js +144 -0
  26. package/dist/node/server/errors.d.ts +5 -0
  27. package/dist/node/server/errors.js +9 -0
  28. package/dist/node/server/guards.d.ts +6 -0
  29. package/dist/node/server/guards.js +69 -0
  30. package/dist/node/server/handler.d.ts +14 -0
  31. package/dist/node/server/handler.js +143 -0
  32. package/dist/node/server/http.d.ts +33 -0
  33. package/dist/node/server/http.js +167 -0
  34. package/dist/node/server/index.d.ts +7 -0
  35. package/dist/node/server/index.js +6 -0
  36. package/dist/node/server/notes.d.ts +31 -0
  37. package/dist/node/server/notes.js +73 -0
  38. package/dist/node/ui/sprite.d.ts +11 -0
  39. package/dist/node/ui/sprite.js +56 -0
  40. package/dist/review.css +2 -0
  41. package/dist/review.js +18369 -0
  42. package/package.json +67 -3
  43. package/src/theme/base.css +346 -0
  44. package/src/theme/tokens.css +198 -0
  45. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramzi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ import type { ActionResult, ReviewAction, ReviewModel, Shelf } from "../core/types.js";
2
+ /** Everything the page needs from a server, so a test or a demo can stand in for HTTP. */
3
+ export type ReviewClient = {
4
+ load(): Promise<ReviewModel>;
5
+ /** The shelves the note box's @ picker offers. Left out, the picker stays shut. */
6
+ library?(): Promise<Shelf[]>;
7
+ act(action: ReviewAction): Promise<ActionResult>;
8
+ /** Push fresh models and the connection state. Returns the unsubscribe. */
9
+ subscribe(handlers: {
10
+ model(model: ReviewModel): void;
11
+ connection(up: boolean): void;
12
+ }): () => void;
13
+ };
14
+ export type ClientOptions = {
15
+ base: string;
16
+ id: string;
17
+ };
18
+ export declare function createHttpClient({ base, id }: ClientOptions): ReviewClient;
@@ -0,0 +1,79 @@
1
+ function isModel(value) {
2
+ if (typeof value !== "object" || value === null)
3
+ return false;
4
+ const model = value;
5
+ return (typeof model.id === "string" &&
6
+ Array.isArray(model.passes) &&
7
+ Array.isArray(model.groups) &&
8
+ Array.isArray(model.beats) &&
9
+ Array.isArray(model.notes) &&
10
+ Array.isArray(model.paragraphs) &&
11
+ Array.isArray(model.kinds));
12
+ }
13
+ const RECONNECT = [1000, 2000, 4000, 8000];
14
+ export function createHttpClient({ base, id }) {
15
+ const root = `${base.replace(/\/+$/, "")}/${encodeURIComponent(id)}`;
16
+ return {
17
+ async load() {
18
+ const response = await fetch(`${root}/state`, { headers: { accept: "application/json" } });
19
+ const body = await response.json().catch(() => null);
20
+ if (!response.ok) {
21
+ const message = typeof body === "object" && body !== null && "error" in body
22
+ ? String(body.error)
23
+ : response.statusText;
24
+ throw new Error(message || "Couldn't load this video.");
25
+ }
26
+ if (!isModel(body))
27
+ throw new Error("Editor OS sent something this page can't read.");
28
+ return body;
29
+ },
30
+ async library() {
31
+ const response = await fetch(`${root}/library`, { headers: { accept: "application/json" } });
32
+ const body = await response.json().catch(() => null);
33
+ return response.ok && Array.isArray(body) ? body : [];
34
+ },
35
+ async act(action) {
36
+ const response = await fetch(`${root}/act`, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify(action),
40
+ });
41
+ const body = await response.json().catch(() => null);
42
+ if (typeof body === "object" && body !== null && "ok" in body)
43
+ return body;
44
+ return { ok: false, error: response.statusText || "Editor OS didn't answer." };
45
+ },
46
+ subscribe({ model, connection }) {
47
+ let source;
48
+ let retry;
49
+ let attempt = 0;
50
+ let stopped = false;
51
+ const open = () => {
52
+ source = new EventSource(`${root}/events`);
53
+ source.addEventListener("open", () => {
54
+ attempt = 0;
55
+ connection(true);
56
+ });
57
+ source.addEventListener("model", (event) => {
58
+ const data = JSON.parse(event.data);
59
+ if (isModel(data))
60
+ model(data);
61
+ });
62
+ source.addEventListener("error", () => {
63
+ connection(false);
64
+ // WHY: a non-200 answer closes the EventSource for good, so we reopen it ourselves.
65
+ if (source?.readyState !== EventSource.CLOSED || stopped)
66
+ return;
67
+ source.close();
68
+ retry = setTimeout(open, RECONNECT[Math.min(attempt++, RECONNECT.length - 1)]);
69
+ });
70
+ };
71
+ open();
72
+ return () => {
73
+ stopped = true;
74
+ clearTimeout(retry);
75
+ source?.close();
76
+ };
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,36 @@
1
+ import type { JobStatus, RenderProgress } from "./types.js";
2
+ /**
3
+ * The preview file the player should hold: a render rewrites it in pieces, so the new one waits
4
+ * until the studio says the render is over.
5
+ */
6
+ export declare function previewToShow(shown: string | undefined, next: string | undefined, state: JobStatus["state"] | undefined): string | undefined;
7
+ /** What a preview render with no progress feed shows: how far along it probably is, and how the time left reads. The page words it. */
8
+ export type RenderEstimate = {
9
+ fraction: number;
10
+ pace: "left" | "soon" | "late";
11
+ /** For pace `left`: the time left, rounded the way a person says it. */
12
+ seconds: number;
13
+ };
14
+ /** A new project has no past renders, so a preview is guessed at half its length, and never under 15 seconds. */
15
+ export declare const guessedRenderMs: (videoSeconds: number) => number;
16
+ /**
17
+ * Time against the expected render, eased to 90% at the expected time and 95% after, because
18
+ * only the studio knows when the render is over and gets to say 100%.
19
+ */
20
+ export declare function renderEstimate(elapsedMs: number, expectedMs: number): RenderEstimate;
21
+ /**
22
+ * The renderer's progress as a bar and a percent; the matte is about two thirds of the wait
23
+ * (140s of 215s on P04), so it gets half the bar.
24
+ */
25
+ export declare function progressOf(progress: RenderProgress): {
26
+ fraction: number;
27
+ words: string;
28
+ };
29
+ /** The studio route that starts a final render of this video. */
30
+ export declare const finalRoute: (id: string) => string;
31
+ /** What the Render final button says, and whether it can be pressed. A final that's in line, drawing or up to date can't be asked for again. */
32
+ export declare function finalButton(final: JobStatus): {
33
+ label: string;
34
+ disabled: boolean;
35
+ tip?: string;
36
+ };
@@ -0,0 +1,75 @@
1
+ import { renderWords } from "./render-words.js";
2
+ /**
3
+ * The preview file the player should hold: a render rewrites it in pieces, so the new one waits
4
+ * until the studio says the render is over.
5
+ */
6
+ export function previewToShow(shown, next, state) {
7
+ return state === "queued" || state === "rendering" ? shown : next;
8
+ }
9
+ /** A new project has no past renders, so a preview is guessed at half its length, and never under 15 seconds. */
10
+ export const guessedRenderMs = (videoSeconds) => Math.max(15, videoSeconds * 0.5) * 1000;
11
+ /**
12
+ * Time against the expected render, eased to 90% at the expected time and 95% after, because
13
+ * only the studio knows when the render is over and gets to say 100%.
14
+ */
15
+ export function renderEstimate(elapsedMs, expectedMs) {
16
+ const expected = Math.max(expectedMs, 1000);
17
+ const fraction = 0.95 * (1 - Math.exp((-3 * Math.max(elapsedMs, 0)) / expected));
18
+ const left = (expected - elapsedMs) / 1000;
19
+ if (elapsedMs > expected * 1.25)
20
+ return { fraction, pace: "late", seconds: 0 };
21
+ if (left < 5)
22
+ return { fraction, pace: "soon", seconds: 0 };
23
+ const seconds = left < 60 ? Math.round(left / 5) * 5 : Math.round(left / 15) * 15;
24
+ return { fraction, pace: "left", seconds };
25
+ }
26
+ /**
27
+ * The renderer's progress as a bar and a percent; the matte is about two thirds of the wait
28
+ * (140s of 215s on P04), so it gets half the bar.
29
+ */
30
+ export function progressOf(progress) {
31
+ if (progress.stage === "preparing")
32
+ return { fraction: 0.03, words: "getting ready" };
33
+ if (progress.stage === "finishing")
34
+ return { fraction: 0.97, words: "almost done" };
35
+ const share = progress.total > 0 ? progress.done / progress.total : 0;
36
+ const fraction = progress.stage === "matting" ? 0.05 + 0.5 * share : 0.55 + 0.4 * share;
37
+ const percent = `${Math.round(fraction * 100)}%`;
38
+ // The encode of a whole picture is minutes of one melt: it says so, instead of sitting at the matte's 55%.
39
+ return { fraction, words: progress.stage === "encoding" ? `encoding ${percent}` : percent };
40
+ }
41
+ /** The studio route that starts a final render of this video. */
42
+ export const finalRoute = (id) => `/p/${encodeURIComponent(id)}/render`;
43
+ /** What the Render final button says, and whether it can be pressed. A final that's in line, drawing or up to date can't be asked for again. */
44
+ export function finalButton(final) {
45
+ switch (final.state) {
46
+ case "queued":
47
+ return {
48
+ label: renderWords.queued("final", final.ahead === 0 ? 0 : undefined),
49
+ disabled: true,
50
+ };
51
+ case "rendering":
52
+ return {
53
+ label: final.progress
54
+ ? `${renderWords.making("final")}, ${progressOf(final.progress).words}`
55
+ : renderWords.making("final"),
56
+ disabled: true,
57
+ };
58
+ case "current":
59
+ return { label: "Final is up to date", disabled: true };
60
+ case "failed":
61
+ return {
62
+ label: "Render final",
63
+ disabled: false,
64
+ tip: "The last try didn't finish. Try again.",
65
+ };
66
+ case "outdated":
67
+ return {
68
+ label: "Render final",
69
+ disabled: false,
70
+ tip: "The video changed since the last final",
71
+ };
72
+ case "none":
73
+ return { label: "Render final", disabled: false };
74
+ }
75
+ }
@@ -0,0 +1,13 @@
1
+ import type { BeatState, NoteAct, NoteStatus } from "./types.js";
2
+ /** Each list is checked against its union at compile time, so a new member can't be forgotten. */
3
+ type Covers<Union, List extends readonly Union[]> = Exclude<Union, List[number]> extends never ? List : never;
4
+ export declare const BEAT_STATES: readonly ["proposed", "changed", "approved", "skipped"];
5
+ export declare const NOTE_ACTS: readonly ["tick", "reopen", "accept", "dismiss", "remove"];
6
+ export declare const NOTE_STATUSES: readonly ["open", "queued", "working", "answered", "done", "proposal", "dismissed"];
7
+ export declare const TONES: readonly ["neutral", "accent", "ok", "warn", "bad"];
8
+ export declare const LINE_MARKS: readonly ["cut", "moved", "pause"];
9
+ export declare const checkedBeatStates: Covers<BeatState, typeof BEAT_STATES>;
10
+ export declare const checkedNoteActs: Covers<NoteAct, typeof NOTE_ACTS>;
11
+ export declare const checkedNoteStatuses: Covers<NoteStatus, typeof NOTE_STATUSES>;
12
+ export declare const isOneOf: <T extends string>(list: readonly T[], value: unknown) => value is T;
13
+ export {};
@@ -0,0 +1,28 @@
1
+ export const BEAT_STATES = [
2
+ "proposed",
3
+ "changed",
4
+ "approved",
5
+ "skipped",
6
+ ];
7
+ export const NOTE_ACTS = [
8
+ "tick",
9
+ "reopen",
10
+ "accept",
11
+ "dismiss",
12
+ "remove",
13
+ ];
14
+ export const NOTE_STATUSES = [
15
+ "open",
16
+ "queued",
17
+ "working",
18
+ "answered",
19
+ "done",
20
+ "proposal",
21
+ "dismissed",
22
+ ];
23
+ export const TONES = ["neutral", "accent", "ok", "warn", "bad"];
24
+ export const LINE_MARKS = ["cut", "moved", "pause"];
25
+ export const checkedBeatStates = BEAT_STATES;
26
+ export const checkedNoteActs = NOTE_ACTS;
27
+ export const checkedNoteStatuses = NOTE_STATUSES;
28
+ export const isOneOf = (list, value) => typeof value === "string" && list.includes(value);
@@ -0,0 +1,34 @@
1
+ import type { IconName, Note, NoteAct, NoteStatus, NoteTarget } from "./types.js";
2
+ /**
3
+ * The note verbs, once. The server applies them, the optimistic reducer applies them, and the
4
+ * feedback panel draws exactly the ones legal for a note's status.
5
+ */
6
+ export type Verb = {
7
+ act: NoteAct;
8
+ label: string;
9
+ icon: IconName;
10
+ /** A single-letter shortcut on the active note. */
11
+ key?: string;
12
+ /** The statuses this verb is legal from. */
13
+ from: readonly NoteStatus[];
14
+ /** Where the note lands. `null` removes it. */
15
+ to: NoteStatus | null;
16
+ /** The main button when it is the first legal verb. */
17
+ primary?: boolean;
18
+ };
19
+ export declare const NOTE_VERBS: Record<NoteAct, Verb>;
20
+ export declare const verbsFor: (status: NoteStatus) => Verb[];
21
+ /** The four tabs of the panel, and which statuses each one holds. */
22
+ export type Stage = "needs" | "agents" | "done";
23
+ export declare const STAGES: Record<Stage, {
24
+ label: string;
25
+ statuses: readonly NoteStatus[];
26
+ empty: string;
27
+ }>;
28
+ export declare const STAGE_ORDER: readonly Stage[];
29
+ export declare const stageOf: (note: Pick<Note, "status">) => Stage;
30
+ /** Nothing left for anyone to do on it. */
31
+ export declare const isSettled: (note: Pick<Note, "status">) => boolean;
32
+ /** A note the page made and the server hasn't answered for yet. */
33
+ export declare const isPending: (note: Pick<Note, "id">) => boolean;
34
+ export declare const sameTarget: (a: NoteTarget, b: NoteTarget) => boolean;
@@ -0,0 +1,57 @@
1
+ export const NOTE_VERBS = {
2
+ accept: {
3
+ act: "accept",
4
+ label: "Accept",
5
+ icon: "check",
6
+ key: "a",
7
+ from: ["proposal"],
8
+ to: "open",
9
+ primary: true,
10
+ },
11
+ dismiss: {
12
+ act: "dismiss",
13
+ label: "Ignore",
14
+ icon: "x",
15
+ key: "x",
16
+ from: ["proposal"],
17
+ to: "dismissed",
18
+ },
19
+ tick: {
20
+ act: "tick",
21
+ label: "Mark done",
22
+ icon: "check",
23
+ key: "t",
24
+ from: ["answered"],
25
+ to: "done",
26
+ primary: true,
27
+ },
28
+ reopen: { act: "reopen", label: "Reopen", icon: "undo", from: ["done", "dismissed"], to: "open" },
29
+ remove: {
30
+ act: "remove",
31
+ label: "Delete",
32
+ icon: "trash",
33
+ from: ["open", "done", "dismissed"],
34
+ to: null,
35
+ },
36
+ };
37
+ export const verbsFor = (status) => Object.values(NOTE_VERBS).filter((verb) => verb.from.includes(status));
38
+ export const STAGES = {
39
+ needs: {
40
+ label: "Needs you",
41
+ statuses: ["proposal", "answered"],
42
+ empty: "Nothing waiting on you.",
43
+ },
44
+ agents: {
45
+ label: "Agents",
46
+ statuses: ["open", "queued", "working"],
47
+ empty: "No note is with an agent.",
48
+ },
49
+ done: { label: "Done", statuses: ["done", "dismissed"], empty: "Nothing done yet." },
50
+ };
51
+ export const STAGE_ORDER = ["needs", "agents", "done"];
52
+ export const stageOf = (note) => STAGE_ORDER.find((stage) => STAGES[stage].statuses.includes(note.status)) ?? "agents";
53
+ /** Nothing left for anyone to do on it. */
54
+ export const isSettled = (note) => note.status === "done" || note.status === "dismissed";
55
+ /** A note the page made and the server hasn't answered for yet. */
56
+ export const isPending = (note) => note.id.startsWith("tmp-");
57
+ export const sameTarget = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The OpenScreen backend's host side: the feature gate, its studio routes and UI conversions, with no DOM so it tests alone.
3
+ */
4
+ import type { ReviewBackend, ReviewModel } from "./types.js";
5
+ /** What the studio's `/features` route answers, whether the gate is on and whether the app is there. */
6
+ export type Features = {
7
+ openscreen: boolean;
8
+ openscreenInstalled: boolean;
9
+ };
10
+ /** The gate is shut until the studio says otherwise, so an unread answer is off. */
11
+ export declare const FEATURES_OFF: Features;
12
+ /** The routes Worker C serves on the studio. The UI calls these and never touches OpenScreen. */
13
+ export declare const OPENSCREEN_ROUTES: {
14
+ readonly features: "/features";
15
+ readonly pair: "/projects/openscreen/pair";
16
+ readonly record: "/projects/openscreen/record";
17
+ readonly preview: "/projects/openscreen/preview";
18
+ readonly sync: "/projects/openscreen/sync";
19
+ readonly export: "/projects/openscreen/export";
20
+ };
21
+ /** The OpenScreen build this UI was written against. Anything older is untested. */
22
+ export declare const OPENSCREEN_TESTED_VERSION = "1.13.0";
23
+ /** Homebrew has no cask for OpenScreen, so the install line points at the releases page. */
24
+ export declare const OPENSCREEN_RELEASES = "https://github.com/getopenscreen/openscreen/releases";
25
+ /** A route's answer. `out` carries the exported file's path. */
26
+ export type HostResult = {
27
+ ok: true;
28
+ out?: string;
29
+ } | {
30
+ ok: false;
31
+ error: string;
32
+ };
33
+ /**
34
+ * POST one studio route: a body with `ok` is taken as is, a bare 2xx counts as success, and every failure becomes readable text.
35
+ */
36
+ export declare function hostPost(path: string, body: unknown): Promise<HostResult>;
37
+ /** The gate is open only when the studio says so. A missing answer keeps it shut. */
38
+ export declare function gateOpen(features: Features | null | undefined): boolean;
39
+ /** A model is on the OpenScreen backend when its adapter says so. */
40
+ export declare function isOpenScreen(model: Pick<ReviewModel, "backend"> | null | undefined): boolean;
41
+ /** Read the `/features` body without trusting its shape. */
42
+ export declare function readFeatures(value: unknown): Features;
43
+ /** Seconds from a field into whole milliseconds. A blank or unreadable value means no offset. */
44
+ export declare function secondsToMs(value: string | number | null | undefined): number;
45
+ export type PairFields = {
46
+ name: string;
47
+ screen: string;
48
+ camera: string;
49
+ offset: string | number;
50
+ };
51
+ /** What the pair route's body wants: trimmed paths and the offset in milliseconds. */
52
+ export declare function pairBody(fields: PairFields): {
53
+ name: string;
54
+ screen: string;
55
+ camera: string;
56
+ offsetMs: number;
57
+ };
58
+ /** What the form still needs before it can send. Empty means it can. */
59
+ export declare function pairProblem(fields: Pick<PairFields, "name" | "screen">): string;
60
+ /** A route's error as one line a person reads, or an empty line when it worked. */
61
+ export declare function resultText(result: HostResult | null | undefined): string;
62
+ /** The backend a model runs on, defaulting to what every existing video uses. */
63
+ export declare function backendOf(model: Pick<ReviewModel, "backend">): ReviewBackend;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The OpenScreen backend's host side: the feature gate, its studio routes and UI conversions, with no DOM so it tests alone.
3
+ */
4
+ /** The gate is shut until the studio says otherwise, so an unread answer is off. */
5
+ export const FEATURES_OFF = { openscreen: false, openscreenInstalled: false };
6
+ /** The routes Worker C serves on the studio. The UI calls these and never touches OpenScreen. */
7
+ export const OPENSCREEN_ROUTES = {
8
+ features: "/features",
9
+ pair: "/projects/openscreen/pair",
10
+ record: "/projects/openscreen/record",
11
+ preview: "/projects/openscreen/preview",
12
+ sync: "/projects/openscreen/sync",
13
+ export: "/projects/openscreen/export",
14
+ };
15
+ /** The OpenScreen build this UI was written against. Anything older is untested. */
16
+ export const OPENSCREEN_TESTED_VERSION = "1.13.0";
17
+ /** Homebrew has no cask for OpenScreen, so the install line points at the releases page. */
18
+ export const OPENSCREEN_RELEASES = "https://github.com/getopenscreen/openscreen/releases";
19
+ /**
20
+ * POST one studio route: a body with `ok` is taken as is, a bare 2xx counts as success, and every failure becomes readable text.
21
+ */
22
+ export async function hostPost(path, body) {
23
+ try {
24
+ const response = await fetch(path, {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" },
27
+ body: JSON.stringify(body),
28
+ });
29
+ const answer = await response.json().catch(() => null);
30
+ if (typeof answer === "object" && answer !== null) {
31
+ const record = answer;
32
+ if ("ok" in record)
33
+ return record;
34
+ if (!response.ok && typeof record.error === "string" && record.error)
35
+ return { ok: false, error: record.error };
36
+ }
37
+ if (response.ok)
38
+ return { ok: true };
39
+ return { ok: false, error: response.statusText || "The studio didn't answer." };
40
+ }
41
+ catch {
42
+ return { ok: false, error: "Couldn't reach the studio." };
43
+ }
44
+ }
45
+ /** The gate is open only when the studio says so. A missing answer keeps it shut. */
46
+ export function gateOpen(features) {
47
+ return features?.openscreen === true;
48
+ }
49
+ /** A model is on the OpenScreen backend when its adapter says so. */
50
+ export function isOpenScreen(model) {
51
+ return model?.backend === "openscreen";
52
+ }
53
+ /** Read the `/features` body without trusting its shape. */
54
+ export function readFeatures(value) {
55
+ const body = (typeof value === "object" && value !== null ? value : {});
56
+ return {
57
+ openscreen: body.openscreen === true,
58
+ openscreenInstalled: body.openscreenInstalled === true,
59
+ };
60
+ }
61
+ /** Seconds from a field into whole milliseconds. A blank or unreadable value means no offset. */
62
+ export function secondsToMs(value) {
63
+ if (value === null || value === undefined)
64
+ return 0;
65
+ const text = String(value).trim();
66
+ if (text === "")
67
+ return 0;
68
+ const seconds = Number(text);
69
+ return Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0;
70
+ }
71
+ /** What the pair route's body wants: trimmed paths and the offset in milliseconds. */
72
+ export function pairBody(fields) {
73
+ return {
74
+ name: fields.name.trim(),
75
+ screen: fields.screen.trim(),
76
+ camera: fields.camera.trim(),
77
+ offsetMs: secondsToMs(fields.offset),
78
+ };
79
+ }
80
+ /** What the form still needs before it can send. Empty means it can. */
81
+ export function pairProblem(fields) {
82
+ if (!fields.name.trim())
83
+ return "Name the video first.";
84
+ if (!fields.screen.trim())
85
+ return "Add the screen recording.";
86
+ return "";
87
+ }
88
+ /** A route's error as one line a person reads, or an empty line when it worked. */
89
+ export function resultText(result) {
90
+ if (!result || result.ok)
91
+ return "";
92
+ return result.error || "That didn't work.";
93
+ }
94
+ /** The backend a model runs on, defaulting to what every existing video uses. */
95
+ export function backendOf(model) {
96
+ return model.backend ?? "descript";
97
+ }
@@ -0,0 +1,8 @@
1
+ /** What a render is, in the words a person uses: the preview video, or the final. */
2
+ export type RenderNoun = "video" | "final";
3
+ /** Every render state, worded once. The cover on the frame and the Render final button both read from here. */
4
+ export declare const renderWords: {
5
+ /** `ahead` left out says only that it's waiting, for a space with no room for the count. */
6
+ queued: (noun: RenderNoun, ahead?: number) => string;
7
+ making: (noun: RenderNoun, updating?: boolean) => string;
8
+ };
@@ -0,0 +1,12 @@
1
+ /** Every render state, worded once. The cover on the frame and the Render final button both read from here. */
2
+ export const renderWords = {
3
+ /** `ahead` left out says only that it's waiting, for a space with no room for the count. */
4
+ queued: (noun, ahead) => {
5
+ if (ahead === 0)
6
+ return `Your ${noun} is next in line`;
7
+ if (ahead === undefined)
8
+ return `Your ${noun} is in line`;
9
+ return `Your ${noun} is in line, ${ahead} ${ahead === 1 ? "video" : "videos"} ahead of it`;
10
+ },
11
+ making: (noun, updating = false) => `${updating ? "Updating" : "Making"} your ${noun}`,
12
+ };