plotlink-ows 1.0.33 → 1.2.94
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.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +203 -22
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +951 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
package/app/lib/cuts.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { hasVisibleSpeechTail, CARTOON_BUBBLE_RENDERER_VERSION, type Overlay } from "./overlays";
|
|
4
|
+
|
|
5
|
+
export const SHOT_TYPES = ["wide", "medium", "close-up", "extreme-close-up"] as const;
|
|
6
|
+
export type ShotType = (typeof SHOT_TYPES)[number];
|
|
7
|
+
|
|
8
|
+
export interface CutDialogue {
|
|
9
|
+
speaker: string;
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Panel kind (#350). An "image" cut is the normal art panel (needs a clean
|
|
15
|
+
* image → lettering → export → upload). A "text" panel is a text/interstitial
|
|
16
|
+
* card (no clean image; text on a styled background, still exported + uploaded
|
|
17
|
+
* as a final image for MVP). The field is OPTIONAL and backward-compatible:
|
|
18
|
+
* a missing `kind` means "image".
|
|
19
|
+
*/
|
|
20
|
+
export type CutKind = "image" | "text";
|
|
21
|
+
|
|
22
|
+
export interface Cut {
|
|
23
|
+
id: number;
|
|
24
|
+
shotType: ShotType;
|
|
25
|
+
description: string;
|
|
26
|
+
characters: string[];
|
|
27
|
+
dialogue: CutDialogue[];
|
|
28
|
+
narration: string;
|
|
29
|
+
sfx: string;
|
|
30
|
+
cleanImagePath: string | null;
|
|
31
|
+
finalImagePath: string | null;
|
|
32
|
+
exportedAt: string | null;
|
|
33
|
+
uploadedCid: string | null;
|
|
34
|
+
uploadedUrl: string | null;
|
|
35
|
+
overlays: Overlay[];
|
|
36
|
+
/**
|
|
37
|
+
* Bubble-renderer revision the final image was exported with (#381). Absent on
|
|
38
|
+
* cuts exported before versioning (treated as stale for tailed bubbles). Stamped
|
|
39
|
+
* by the export-final endpoint with CARTOON_BUBBLE_RENDERER_VERSION.
|
|
40
|
+
*/
|
|
41
|
+
finalRendererVersion?: number;
|
|
42
|
+
/** Panel kind (#350). Absent ⇒ "image" (backward-compatible). */
|
|
43
|
+
kind?: CutKind;
|
|
44
|
+
/** Text-panel background color (CSS color), e.g. "#101820". Optional (#350). */
|
|
45
|
+
background?: string;
|
|
46
|
+
/** Text-panel aspect ratio hint, e.g. "4:5". Optional (#350). */
|
|
47
|
+
aspectRatio?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether a cut is a text/interstitial panel (#350); missing kind ⇒ image. */
|
|
51
|
+
export function isTextPanel(cut: Pick<Cut, "kind">): boolean {
|
|
52
|
+
return cut.kind === "text";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stable key for a cut's single next production step (#371). */
|
|
56
|
+
export type CutActionKey = "add-art" | "letter" | "review";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The one next production action for a single cut (#371), used to deep-link from
|
|
60
|
+
* the Preview / Cut Inspector straight into that cut's editing step instead of
|
|
61
|
+
* making the writer hunt for it in the Edit tab. Mirrors the per-cut status the
|
|
62
|
+
* Edit tab shows (no clean art → letter → final ready) but in creator-facing
|
|
63
|
+
* language, and reports whether the lettering editor can open directly for it.
|
|
64
|
+
*
|
|
65
|
+
* - "add-art": an image cut with no clean image yet → the writer adds/imports the
|
|
66
|
+
* clean art first, so the CTA focuses the cut's row (there is nothing to letter
|
|
67
|
+
* yet, so the editor does not open).
|
|
68
|
+
* - "letter": a clean image cut, or a text/interstitial panel, that still needs
|
|
69
|
+
* overlays or a final export → open the lettering editor directly.
|
|
70
|
+
* - "review": a final image already exists → open the editor to review/redo it.
|
|
71
|
+
*/
|
|
72
|
+
export interface CutNextAction {
|
|
73
|
+
key: CutActionKey;
|
|
74
|
+
/** Creator-facing CTA label — no markdown/schema jargon (#371). */
|
|
75
|
+
label: string;
|
|
76
|
+
/** Whether the lettering editor can be opened directly for this cut. */
|
|
77
|
+
opensEditor: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function cutNextAction(
|
|
81
|
+
cut: Pick<Cut, "cleanImagePath" | "finalImagePath" | "exportedAt" | "kind">,
|
|
82
|
+
): CutNextAction {
|
|
83
|
+
const hasFinal = !!cut.finalImagePath || !!cut.exportedAt;
|
|
84
|
+
if (hasFinal) {
|
|
85
|
+
return { key: "review", label: "Review final panel", opensEditor: true };
|
|
86
|
+
}
|
|
87
|
+
// A clean image or a text/interstitial panel is ready to letter; a text panel
|
|
88
|
+
// letters on its background and needs no clean image (#350).
|
|
89
|
+
if (cut.cleanImagePath || isTextPanel(cut)) {
|
|
90
|
+
return { key: "letter", label: "Letter this cut", opensEditor: true };
|
|
91
|
+
}
|
|
92
|
+
return { key: "add-art", label: "Add clean art for this cut", opensEditor: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a cut's exported final image is STALE for #381: it has a final image
|
|
97
|
+
* AND renders at least one visible speech-bubble tail AND was exported by an
|
|
98
|
+
* older bubble renderer (its `finalRendererVersion` is absent — pre-versioning —
|
|
99
|
+
* or below `currentVersion`). Such an image may show the old separate-tail seam
|
|
100
|
+
* and must be re-exported before publish. Tailless cuts are never stale (the
|
|
101
|
+
* seam fixes only affect tailed bubbles), so existing exports aren't churned.
|
|
102
|
+
*/
|
|
103
|
+
export function isStaleTailedExport(
|
|
104
|
+
cut: Pick<Cut, "finalImagePath" | "finalRendererVersion" | "overlays">,
|
|
105
|
+
currentVersion: number = CARTOON_BUBBLE_RENDERER_VERSION,
|
|
106
|
+
): boolean {
|
|
107
|
+
if (!cut.finalImagePath) return false;
|
|
108
|
+
const tailed = (cut.overlays ?? []).some(hasVisibleSpeechTail);
|
|
109
|
+
if (!tailed) return false;
|
|
110
|
+
return (cut.finalRendererVersion ?? 0) < currentVersion;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Ids of cuts whose final image is a stale tailed export (#381), in order. */
|
|
114
|
+
export function staleTailedCutIds(
|
|
115
|
+
cutsFile: Pick<CutsFile, "cuts">,
|
|
116
|
+
currentVersion: number = CARTOON_BUBBLE_RENDERER_VERSION,
|
|
117
|
+
): number[] {
|
|
118
|
+
return cutsFile.cuts.filter((c) => isStaleTailedExport(c, currentVersion)).map((c) => c.id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Base canvas width for a text panel sized from its aspect ratio (#351). */
|
|
122
|
+
export const TEXT_PANEL_BASE_WIDTH = 800;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Canvas dimensions for a text panel from an "W:H" aspect ratio (#351) — shared
|
|
126
|
+
* by the lettering editor (so its surface matches) and the export, so a text
|
|
127
|
+
* panel letters and exports at the SAME shape. Returns null for a missing or
|
|
128
|
+
* malformed ratio; callers fall back to 800×600.
|
|
129
|
+
*/
|
|
130
|
+
export function textPanelDimensions(aspectRatio: string | undefined): { width: number; height: number } | null {
|
|
131
|
+
if (!aspectRatio) return null;
|
|
132
|
+
const m = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
|
|
133
|
+
if (!m) return null;
|
|
134
|
+
const w = parseFloat(m[1]);
|
|
135
|
+
const h = parseFloat(m[2]);
|
|
136
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
137
|
+
return { width: TEXT_PANEL_BASE_WIDTH, height: Math.round((TEXT_PANEL_BASE_WIDTH * h) / w) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface CutsFile {
|
|
141
|
+
version: 1;
|
|
142
|
+
plotFile: string;
|
|
143
|
+
/**
|
|
144
|
+
* Optional human-readable episode title (#347). When present it becomes the
|
|
145
|
+
* published chapter title for a cartoon episode whose plot-NN.md has no H1
|
|
146
|
+
* (cartoon publish markdown is image-only by design), so the episode never
|
|
147
|
+
* publishes as the raw "plot-NN" filename. Absent in v1 cut plans — callers
|
|
148
|
+
* fall back to a friendly "Episode NN".
|
|
149
|
+
*/
|
|
150
|
+
title?: string;
|
|
151
|
+
cuts: Cut[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createDefaultCut(id: number, _plotFile: string): Cut {
|
|
155
|
+
return {
|
|
156
|
+
id,
|
|
157
|
+
shotType: "medium",
|
|
158
|
+
description: "",
|
|
159
|
+
characters: [],
|
|
160
|
+
dialogue: [],
|
|
161
|
+
narration: "",
|
|
162
|
+
sfx: "",
|
|
163
|
+
cleanImagePath: null,
|
|
164
|
+
finalImagePath: null,
|
|
165
|
+
exportedAt: null,
|
|
166
|
+
uploadedCid: null,
|
|
167
|
+
uploadedUrl: null,
|
|
168
|
+
overlays: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createCutsFile(plotFile: string, cutCount = 1): CutsFile {
|
|
173
|
+
const cuts = Array.from({ length: cutCount }, (_, i) => createDefaultCut(i + 1, plotFile));
|
|
174
|
+
return { version: 1, plotFile, cuts };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cutsFilePath(storyDir: string, plotFile: string): string {
|
|
178
|
+
return path.join(storyDir, `${plotFile}.cuts.json`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function readCutsFile(storyDir: string, plotFile: string): CutsFile | null {
|
|
182
|
+
const filePath = cutsFilePath(storyDir, plotFile);
|
|
183
|
+
if (!fs.existsSync(filePath)) return null;
|
|
184
|
+
|
|
185
|
+
let raw: string;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new Error(`Cannot read ${plotFile}.cuts.json: ${(err as Error).message}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let data: unknown;
|
|
193
|
+
try {
|
|
194
|
+
data = JSON.parse(raw);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error(`${plotFile}.cuts.json contains invalid JSON`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const validation = validateCutsFile(data);
|
|
200
|
+
if (!validation.valid) {
|
|
201
|
+
throw new Error(`${plotFile}.cuts.json is invalid: ${validation.error}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return data as CutsFile;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function writeCutsFile(storyDir: string, plotFile: string, cutsFile: CutsFile): void {
|
|
208
|
+
const filePath = cutsFilePath(storyDir, plotFile);
|
|
209
|
+
fs.writeFileSync(filePath, JSON.stringify(cutsFile, null, 2) + "\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function validateCutsFile(data: unknown): { valid: boolean; error?: string } {
|
|
213
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
214
|
+
return { valid: false, error: "Must be a JSON object" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const obj = data as Record<string, unknown>;
|
|
218
|
+
|
|
219
|
+
if (obj.version !== 1) {
|
|
220
|
+
return { valid: false, error: "Unsupported version (expected 1)" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (typeof obj.plotFile !== "string" || !obj.plotFile) {
|
|
224
|
+
return { valid: false, error: "Missing or invalid plotFile" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!Array.isArray(obj.cuts)) {
|
|
228
|
+
return { valid: false, error: "cuts must be an array" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Optional episode title (#347) — string when present.
|
|
232
|
+
if (obj.title !== undefined && typeof obj.title !== "string") {
|
|
233
|
+
return { valid: false, error: "title must be a string" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const validShots = new Set<string>(SHOT_TYPES);
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < obj.cuts.length; i++) {
|
|
239
|
+
const cut = obj.cuts[i] as Record<string, unknown>;
|
|
240
|
+
if (typeof cut !== "object" || cut === null) {
|
|
241
|
+
return { valid: false, error: `Cut ${i} is not an object` };
|
|
242
|
+
}
|
|
243
|
+
if (typeof cut.id !== "number") {
|
|
244
|
+
return { valid: false, error: `Cut ${i} missing numeric id` };
|
|
245
|
+
}
|
|
246
|
+
if (typeof cut.shotType !== "string" || !validShots.has(cut.shotType)) {
|
|
247
|
+
return { valid: false, error: `Cut ${i} has invalid shotType` };
|
|
248
|
+
}
|
|
249
|
+
if (typeof cut.description !== "string") {
|
|
250
|
+
return { valid: false, error: `Cut ${i} missing description` };
|
|
251
|
+
}
|
|
252
|
+
if (!Array.isArray(cut.characters)) {
|
|
253
|
+
return { valid: false, error: `Cut ${i} characters must be an array` };
|
|
254
|
+
}
|
|
255
|
+
for (let j = 0; j < (cut.characters as unknown[]).length; j++) {
|
|
256
|
+
if (typeof (cut.characters as unknown[])[j] !== "string") {
|
|
257
|
+
return { valid: false, error: `Cut ${i} characters[${j}] must be a string` };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!Array.isArray(cut.dialogue)) {
|
|
261
|
+
return { valid: false, error: `Cut ${i} dialogue must be an array` };
|
|
262
|
+
}
|
|
263
|
+
for (let j = 0; j < (cut.dialogue as unknown[]).length; j++) {
|
|
264
|
+
const d = (cut.dialogue as Record<string, unknown>[])[j];
|
|
265
|
+
if (typeof d !== "object" || d === null || typeof d.speaker !== "string" || typeof d.text !== "string") {
|
|
266
|
+
return { valid: false, error: `Cut ${i} dialogue[${j}] must have speaker and text strings` };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (typeof cut.narration !== "string") {
|
|
270
|
+
return { valid: false, error: `Cut ${i} missing narration` };
|
|
271
|
+
}
|
|
272
|
+
if (typeof cut.sfx !== "string") {
|
|
273
|
+
return { valid: false, error: `Cut ${i} missing sfx` };
|
|
274
|
+
}
|
|
275
|
+
const nullableStrings = ["cleanImagePath", "finalImagePath", "exportedAt", "uploadedCid", "uploadedUrl"] as const;
|
|
276
|
+
for (const field of nullableStrings) {
|
|
277
|
+
if (cut[field] !== null && typeof cut[field] !== "string") {
|
|
278
|
+
return { valid: false, error: `Cut ${i} ${field} must be a string or null` };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (cut.overlays !== undefined && !Array.isArray(cut.overlays)) {
|
|
282
|
+
return { valid: false, error: `Cut ${i} overlays must be an array` };
|
|
283
|
+
}
|
|
284
|
+
// Text-panel fields (#350) — all optional and backward-compatible.
|
|
285
|
+
if (cut.kind !== undefined && cut.kind !== "image" && cut.kind !== "text") {
|
|
286
|
+
return { valid: false, error: `Cut ${i} kind must be "image" or "text"` };
|
|
287
|
+
}
|
|
288
|
+
if (cut.background !== undefined && typeof cut.background !== "string") {
|
|
289
|
+
return { valid: false, error: `Cut ${i} background must be a string` };
|
|
290
|
+
}
|
|
291
|
+
if (cut.aspectRatio !== undefined && typeof cut.aspectRatio !== "string") {
|
|
292
|
+
return { valid: false, error: `Cut ${i} aspectRatio must be a string` };
|
|
293
|
+
}
|
|
294
|
+
// Bubble-renderer version stamp (#381) — optional, backward-compatible
|
|
295
|
+
// (absent ⇒ pre-versioning final image).
|
|
296
|
+
if (cut.finalRendererVersion !== undefined && typeof cut.finalRendererVersion !== "number") {
|
|
297
|
+
return { valid: false, error: `Cut ${i} finalRendererVersion must be a number` };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { valid: true };
|
|
302
|
+
}
|
package/app/lib/fonts.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface FontEntry {
|
|
2
|
+
family: string;
|
|
3
|
+
googleFontsId: string;
|
|
4
|
+
license: string;
|
|
5
|
+
licenseUrl: string;
|
|
6
|
+
category: "body" | "display";
|
|
7
|
+
weights: number[];
|
|
8
|
+
languages: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FONT_REGISTRY: FontEntry[] = [
|
|
12
|
+
{
|
|
13
|
+
family: "Noto Sans",
|
|
14
|
+
googleFontsId: "Noto+Sans",
|
|
15
|
+
license: "OFL-1.1",
|
|
16
|
+
licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans/about",
|
|
17
|
+
category: "body",
|
|
18
|
+
weights: [400, 500, 700],
|
|
19
|
+
languages: ["English", "Spanish", "French", "Portuguese", "Russian", "Others"],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
family: "Noto Sans KR",
|
|
23
|
+
googleFontsId: "Noto+Sans+KR",
|
|
24
|
+
license: "OFL-1.1",
|
|
25
|
+
licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+KR/about",
|
|
26
|
+
category: "body",
|
|
27
|
+
weights: [400, 500, 700],
|
|
28
|
+
languages: ["Korean"],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
family: "Noto Sans JP",
|
|
32
|
+
googleFontsId: "Noto+Sans+JP",
|
|
33
|
+
license: "OFL-1.1",
|
|
34
|
+
licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+JP/about",
|
|
35
|
+
category: "body",
|
|
36
|
+
weights: [400, 500, 700],
|
|
37
|
+
languages: ["Japanese"],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
family: "Noto Sans SC",
|
|
41
|
+
googleFontsId: "Noto+Sans+SC",
|
|
42
|
+
license: "OFL-1.1",
|
|
43
|
+
licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+SC/about",
|
|
44
|
+
category: "body",
|
|
45
|
+
weights: [400, 500, 700],
|
|
46
|
+
languages: ["Chinese"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
family: "Noto Sans Devanagari",
|
|
50
|
+
googleFontsId: "Noto+Sans+Devanagari",
|
|
51
|
+
license: "OFL-1.1",
|
|
52
|
+
licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+Devanagari/about",
|
|
53
|
+
category: "body",
|
|
54
|
+
weights: [400, 500, 700],
|
|
55
|
+
languages: ["Hindi"],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
family: "Noto Naskh Arabic",
|
|
59
|
+
googleFontsId: "Noto+Naskh+Arabic",
|
|
60
|
+
license: "OFL-1.1",
|
|
61
|
+
licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Naskh+Arabic/about",
|
|
62
|
+
category: "body",
|
|
63
|
+
weights: [400, 500, 700],
|
|
64
|
+
languages: ["Arabic"],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
family: "Bangers",
|
|
68
|
+
googleFontsId: "Bangers",
|
|
69
|
+
license: "OFL-1.1",
|
|
70
|
+
licenseUrl: "https://fonts.google.com/specimen/Bangers/about",
|
|
71
|
+
category: "display",
|
|
72
|
+
weights: [400],
|
|
73
|
+
languages: [],
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export const FONT_FALLBACK_STACK = "system-ui, sans-serif";
|
|
78
|
+
|
|
79
|
+
const defaultFont = FONT_REGISTRY.find((f) => f.family === "Noto Sans")!;
|
|
80
|
+
const displayFont = FONT_REGISTRY.find((f) => f.category === "display")!;
|
|
81
|
+
|
|
82
|
+
export function getDefaultFont(language: string): FontEntry {
|
|
83
|
+
const match = FONT_REGISTRY.find(
|
|
84
|
+
(f) => f.category === "body" && f.languages.includes(language),
|
|
85
|
+
);
|
|
86
|
+
return match || defaultFont;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getDisplayFont(): FontEntry {
|
|
90
|
+
return displayFont;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getFontCdnUrl(font: FontEntry): string {
|
|
94
|
+
const weights = font.weights.join(";");
|
|
95
|
+
return `https://fonts.googleapis.com/css2?family=${font.googleFontsId}:wght@${weights}&display=swap`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getFontFamily(font: FontEntry): string {
|
|
99
|
+
return `"${font.family}", ${FONT_FALLBACK_STACK}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const LANGUAGE_FONT_SAMPLES: Record<string, { text: string; font: string }> = {
|
|
103
|
+
English: { text: "The quick brown fox jumps", font: "Noto Sans" },
|
|
104
|
+
Korean: { text: "한국어 샘플 텍스트", font: "Noto Sans KR" },
|
|
105
|
+
Japanese: { text: "日本語のサンプル", font: "Noto Sans JP" },
|
|
106
|
+
Chinese: { text: "中文示例文本", font: "Noto Sans SC" },
|
|
107
|
+
Hindi: { text: "हिंदी नमूना पाठ", font: "Noto Sans Devanagari" },
|
|
108
|
+
Arabic: { text: "نص عربي نموذجي", font: "Noto Naskh Arabic" },
|
|
109
|
+
};
|
|
@@ -27,6 +27,11 @@ All endpoints except auth use \`Authorization: Bearer {token}\` headers.
|
|
|
27
27
|
The OWS passphrase is stored in \`~/.plotlink-ows/.env\` as \`OWS_PASSPHRASE\`.
|
|
28
28
|
For login, the passphrase is hashed with HMAC-SHA256 and compared against the stored hash.
|
|
29
29
|
|
|
30
|
+
**Never print secrets into the terminal.** Do not \`echo\`, \`cat\`, or log the
|
|
31
|
+
\`OWS_PASSPHRASE\`, the session token, or any \`Authorization: Bearer\` header / login
|
|
32
|
+
command that contains the passphrase. The app authenticates for you — you never
|
|
33
|
+
need to read or print these.
|
|
34
|
+
|
|
30
35
|
| Endpoint | Method | Auth | Purpose |
|
|
31
36
|
|----------|--------|------|---------|
|
|
32
37
|
| \`/api/auth/status\` | GET | No | Check if passphrase is configured |
|
|
@@ -39,7 +44,7 @@ For login, the passphrase is hashed with HMAC-SHA256 and compared against the st
|
|
|
39
44
|
|
|
40
45
|
| Endpoint | Method | Purpose |
|
|
41
46
|
|----------|--------|---------|
|
|
42
|
-
| \`/api/publish/preflight\` | GET | Check wallet balance
|
|
47
|
+
| \`/api/publish/preflight\` | GET | Check wallet balance vs. creation fee (uploads go through the PlotLink API) |
|
|
43
48
|
| \`/api/publish/file\` | POST | Publish story on-chain (SSE stream of progress events) |
|
|
44
49
|
| \`/api/publish/retry-index\` | POST | Retry indexing for a published file |
|
|
45
50
|
| \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 1MB → returns \`{ cid }\` |
|
|
@@ -77,6 +82,7 @@ Both upload-cover and update-storyline sign messages with the OWS wallet.
|
|
|
77
82
|
| \`/api/stories/:name/:file\` | GET | Single file content and publish status |
|
|
78
83
|
| \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` |
|
|
79
84
|
| \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) |
|
|
85
|
+
| \`/api/stories/:name/metadata\` | POST | Write story metadata \`{ contentType }\` |
|
|
80
86
|
| \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` |
|
|
81
87
|
|
|
82
88
|
## Terminal
|
|
@@ -110,6 +116,7 @@ Stories live in \`~/.plotlink-ows/stories/{story-name}/\`:
|
|
|
110
116
|
|
|
111
117
|
\`\`\`
|
|
112
118
|
stories/{story-name}/
|
|
119
|
+
.story.json # Content type metadata (fiction | cartoon)
|
|
113
120
|
structure.md # Outline, characters, arc
|
|
114
121
|
genesis.md # Synopsis hook (~1000 chars)
|
|
115
122
|
plot-01.md # Chapter 1 (max 10K chars)
|