plotlink-ows 1.0.32 → 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 +10 -3
- 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 +209 -28
- 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 +1017 -144
- 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-BFw-v-OZ.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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { Cut } from "./cuts";
|
|
2
|
+
|
|
3
|
+
export interface CleanImageSyncResult {
|
|
4
|
+
cuts: Cut[];
|
|
5
|
+
changed: boolean;
|
|
6
|
+
synced: number[];
|
|
7
|
+
/** Cut ids whose stale recorded `cleanImagePath` was cleared back to null
|
|
8
|
+
* because the referenced file no longer exists and no valid candidate was
|
|
9
|
+
* found (#302). */
|
|
10
|
+
cleared: number[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A recorded cut asset path that no longer points to a valid local image. */
|
|
14
|
+
export interface StaleAssetIssue {
|
|
15
|
+
cutId: number;
|
|
16
|
+
field: "cleanImagePath" | "finalImagePath";
|
|
17
|
+
path: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Preference order for clean-image extensions when several files exist. */
|
|
22
|
+
export const CLEAN_IMAGE_EXTENSIONS = ["webp", "jpg", "jpeg"] as const;
|
|
23
|
+
|
|
24
|
+
/** Image type detected from a file's leading magic bytes. */
|
|
25
|
+
export type SniffedType = "webp" | "jpeg" | "png" | "unknown";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect an image type from leading magic bytes. Pure (no filesystem). Returns
|
|
29
|
+
* "unknown" for non-image / mismatched / too-short input.
|
|
30
|
+
*
|
|
31
|
+
* - JPEG: FF D8 FF
|
|
32
|
+
* - PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
33
|
+
* - WebP: bytes 0-3 = "RIFF" (52 49 46 46) AND bytes 8-11 = "WEBP" (57 45 42 50)
|
|
34
|
+
*/
|
|
35
|
+
export function sniffImageType(bytes: Uint8Array): SniffedType {
|
|
36
|
+
// JPEG: FF D8 FF
|
|
37
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
38
|
+
return "jpeg";
|
|
39
|
+
}
|
|
40
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
41
|
+
if (
|
|
42
|
+
bytes.length >= 8 &&
|
|
43
|
+
bytes[0] === 0x89 &&
|
|
44
|
+
bytes[1] === 0x50 &&
|
|
45
|
+
bytes[2] === 0x4e &&
|
|
46
|
+
bytes[3] === 0x47 &&
|
|
47
|
+
bytes[4] === 0x0d &&
|
|
48
|
+
bytes[5] === 0x0a &&
|
|
49
|
+
bytes[6] === 0x1a &&
|
|
50
|
+
bytes[7] === 0x0a
|
|
51
|
+
) {
|
|
52
|
+
return "png";
|
|
53
|
+
}
|
|
54
|
+
// WebP: "RIFF" .... "WEBP"
|
|
55
|
+
if (
|
|
56
|
+
bytes.length >= 12 &&
|
|
57
|
+
bytes[0] === 0x52 &&
|
|
58
|
+
bytes[1] === 0x49 &&
|
|
59
|
+
bytes[2] === 0x46 &&
|
|
60
|
+
bytes[3] === 0x46 &&
|
|
61
|
+
bytes[8] === 0x57 &&
|
|
62
|
+
bytes[9] === 0x45 &&
|
|
63
|
+
bytes[10] === 0x42 &&
|
|
64
|
+
bytes[11] === 0x50
|
|
65
|
+
) {
|
|
66
|
+
return "webp";
|
|
67
|
+
}
|
|
68
|
+
return "unknown";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate that an uploaded file's actual magic bytes match its claimed image
|
|
73
|
+
* MIME type. Pure (no fs). Used by the manual `upload-clean` route (#266) so a
|
|
74
|
+
* renamed text/PNG file labeled `image/webp` cannot be recorded as a clean
|
|
75
|
+
* image. Only the cartoon clean-image formats (WebP/JPEG) are accepted.
|
|
76
|
+
*/
|
|
77
|
+
export function cleanImageBytesMatchMime(bytes: Uint8Array, mime: string): boolean {
|
|
78
|
+
const expected: SniffedType | null =
|
|
79
|
+
mime === "image/webp" ? "webp" : mime === "image/jpeg" ? "jpeg" : null;
|
|
80
|
+
if (expected === null) return false;
|
|
81
|
+
return sniffImageType(bytes) === expected;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Canonical clean-image relative paths for a cut, in preference order. */
|
|
85
|
+
export function cleanImageCandidates(plotFile: string, cutId: number): string[] {
|
|
86
|
+
const padded = String(cutId).padStart(2, "0");
|
|
87
|
+
return CLEAN_IMAGE_EXTENSIONS.map((ext) => `assets/${plotFile}/cut-${padded}-clean.${ext}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pure detector that records `cleanImagePath` for cuts whose clean image
|
|
92
|
+
* actually exists on disk. The caller injects `fileExists(relPath)` so this
|
|
93
|
+
* function performs no filesystem access and no mime/size validation — those
|
|
94
|
+
* are the route's responsibility (the route's `fileExists` should return true
|
|
95
|
+
* ONLY for files that exist AND pass validation).
|
|
96
|
+
*
|
|
97
|
+
* Rules (idempotent, only-if-exists, never fake):
|
|
98
|
+
* - For each cut, find the first existing canonical candidate (webp > jpg >
|
|
99
|
+
* jpeg > png).
|
|
100
|
+
* - Set `cleanImagePath` to the found file ONLY when:
|
|
101
|
+
* (a) the cut's current path is null and a file is found; or
|
|
102
|
+
* (b) the cut's current path is set but no longer exists on disk (stale/
|
|
103
|
+
* broken) AND a different existing file is found.
|
|
104
|
+
* - A cut whose current `cleanImagePath` still exists on disk is preserved
|
|
105
|
+
* (manual uploads are never clobbered).
|
|
106
|
+
* - A recorded `cleanImagePath` whose file no longer exists/validates is
|
|
107
|
+
* cleared back to null when no valid candidate is found, so the cut plan
|
|
108
|
+
* stops claiming a clean image that isn't there (#302). A cut already null
|
|
109
|
+
* stays null.
|
|
110
|
+
*
|
|
111
|
+
* Returns a new array; the input is not mutated.
|
|
112
|
+
*/
|
|
113
|
+
export function syncCleanImages(
|
|
114
|
+
cuts: Cut[],
|
|
115
|
+
plotFile: string,
|
|
116
|
+
fileExists: (relPath: string) => boolean,
|
|
117
|
+
): CleanImageSyncResult {
|
|
118
|
+
const synced: number[] = [];
|
|
119
|
+
const cleared: number[] = [];
|
|
120
|
+
let changed = false;
|
|
121
|
+
|
|
122
|
+
const next = cuts.map((cut) => {
|
|
123
|
+
const current = cut.cleanImagePath;
|
|
124
|
+
const currentExists = current != null && fileExists(current);
|
|
125
|
+
|
|
126
|
+
// Preserve a still-valid manual/existing path.
|
|
127
|
+
if (currentExists) return cut;
|
|
128
|
+
|
|
129
|
+
const candidates = cleanImageCandidates(plotFile, cut.id);
|
|
130
|
+
const found = candidates.find((rel) => fileExists(rel)) ?? null;
|
|
131
|
+
if (!found) {
|
|
132
|
+
// No valid file for this cut. If a path was recorded but the file is
|
|
133
|
+
// gone/invalid (stale), clear it back to null rather than preserving a
|
|
134
|
+
// reference to a missing asset. Already-null cuts are untouched.
|
|
135
|
+
if (current !== null) {
|
|
136
|
+
changed = true;
|
|
137
|
+
cleared.push(cut.id);
|
|
138
|
+
return { ...cut, cleanImagePath: null };
|
|
139
|
+
}
|
|
140
|
+
return cut;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// (a) null → found, or (b) stale/broken path replaced by a different file.
|
|
144
|
+
if (current === null || current !== found) {
|
|
145
|
+
changed = true;
|
|
146
|
+
synced.push(cut.id);
|
|
147
|
+
return { ...cut, cleanImagePath: found };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return cut;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return { cuts: next, changed, synced, cleared };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pure detector for cut asset paths recorded in cuts.json that no longer point
|
|
158
|
+
* to a valid local image (#302). The caller injects `assetExists(relPath)`,
|
|
159
|
+
* which must return true only when the file exists AND validates (exists, image
|
|
160
|
+
* bytes, size). Reports both `cleanImagePath` and `finalImagePath`; the cut
|
|
161
|
+
* label uses 1-based position to match the readiness messaging ("Cut N ...").
|
|
162
|
+
*
|
|
163
|
+
* Pure: no filesystem access, no mutation. Cuts with null paths produce no
|
|
164
|
+
* issue (an absent path is a normal not-yet-generated state, not staleness).
|
|
165
|
+
*/
|
|
166
|
+
const STALE_FIELDS = ["cleanImagePath", "finalImagePath"] as const;
|
|
167
|
+
|
|
168
|
+
/** Build the precise stale-path issue for a cut field (1-based "Cut N" label). */
|
|
169
|
+
function staleAssetIssue(
|
|
170
|
+
cut: Cut,
|
|
171
|
+
index: number,
|
|
172
|
+
field: "cleanImagePath" | "finalImagePath",
|
|
173
|
+
path: string,
|
|
174
|
+
): StaleAssetIssue {
|
|
175
|
+
const noun = field === "cleanImagePath" ? "clean" : "final";
|
|
176
|
+
return {
|
|
177
|
+
cutId: cut.id,
|
|
178
|
+
field,
|
|
179
|
+
path,
|
|
180
|
+
message: `Cut ${index + 1} ${noun} image path is recorded but the file is missing`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function findStaleAssetPaths(
|
|
185
|
+
cuts: Cut[],
|
|
186
|
+
assetExists: (relPath: string) => boolean,
|
|
187
|
+
): StaleAssetIssue[] {
|
|
188
|
+
const issues: StaleAssetIssue[] = [];
|
|
189
|
+
cuts.forEach((cut, i) => {
|
|
190
|
+
for (const field of STALE_FIELDS) {
|
|
191
|
+
const recorded = cut[field];
|
|
192
|
+
if (recorded && !assetExists(recorded)) {
|
|
193
|
+
issues.push(staleAssetIssue(cut, i, field, recorded));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
return issues;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ClearStaleResult {
|
|
201
|
+
cuts: Cut[];
|
|
202
|
+
changed: boolean;
|
|
203
|
+
cleared: StaleAssetIssue[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Repair stale recorded asset paths (#302): clear any `cleanImagePath` /
|
|
208
|
+
* `finalImagePath` that no longer points to a valid local image back to null,
|
|
209
|
+
* while preserving valid paths. This is the real per-cut repair behind the UI's
|
|
210
|
+
* "Clear stale path" action — unlike `syncCleanImages` (clean-only), it also
|
|
211
|
+
* clears a stale `finalImagePath`, so a final-only stale cut is actually
|
|
212
|
+
* repairable rather than left blocking publish.
|
|
213
|
+
*
|
|
214
|
+
* Already-uploaded cuts (`uploadedUrl` set) are left untouched — their content
|
|
215
|
+
* is on IPFS, so a missing LOCAL file is not a defect to repair, and their
|
|
216
|
+
* `uploadedCid`/`uploadedUrl` are never modified.
|
|
217
|
+
*
|
|
218
|
+
* Pure: no filesystem access. `assetExists(relPath)` must return true only for a
|
|
219
|
+
* real, valid image on disk.
|
|
220
|
+
*/
|
|
221
|
+
export function clearStaleAssetPaths(
|
|
222
|
+
cuts: Cut[],
|
|
223
|
+
assetExists: (relPath: string) => boolean,
|
|
224
|
+
): ClearStaleResult {
|
|
225
|
+
const cleared: StaleAssetIssue[] = [];
|
|
226
|
+
let changed = false;
|
|
227
|
+
|
|
228
|
+
const next = cuts.map((cut, i) => {
|
|
229
|
+
// Preserve already-uploaded cuts (content is on IPFS).
|
|
230
|
+
if (cut.uploadedUrl) return cut;
|
|
231
|
+
|
|
232
|
+
let result = cut;
|
|
233
|
+
for (const field of STALE_FIELDS) {
|
|
234
|
+
const recorded = cut[field];
|
|
235
|
+
if (recorded && !assetExists(recorded)) {
|
|
236
|
+
cleared.push(staleAssetIssue(cut, i, field, recorded));
|
|
237
|
+
result = { ...result, [field]: null };
|
|
238
|
+
changed = true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return { cuts: next, changed, cleared };
|
|
245
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Codex generated-image cache handoff (#403).
|
|
6
|
+
*
|
|
7
|
+
* Built-in image generation drops finished art into a CACHE such as
|
|
8
|
+
* `~/.codex/generated_images/.../ig_<hash>.png` — a PNG, often > 1MB — which the
|
|
9
|
+
* OWS clean-image slot cannot accept directly (it requires WebP/JPEG < 1MB) and
|
|
10
|
+
* the agent terminal cannot convert (image tooling is banned). Rather than make
|
|
11
|
+
* the writer hunt through that hidden cache in an OS file dialog, OWS surfaces
|
|
12
|
+
* the cache contents so they can import a generated image into a specific cut in
|
|
13
|
+
* one click; the browser then converts/compresses it exactly like a manual
|
|
14
|
+
* upload.
|
|
15
|
+
*
|
|
16
|
+
* This module is the path-safety + listing core for that handoff. It is pure
|
|
17
|
+
* path math + read-only fs, so the traversal guards are unit-testable without a
|
|
18
|
+
* running server.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface CodexImageEntry {
|
|
22
|
+
/** Opaque, URL-safe token encoding the path RELATIVE to the cache root. */
|
|
23
|
+
token: string;
|
|
24
|
+
/** Base file name for display, e.g. `ig_0f26….png`. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** File size in bytes. */
|
|
27
|
+
size: number;
|
|
28
|
+
/** Last-modified time (ms since epoch) — listings are newest first. */
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Max images returned by a single listing (newest first). */
|
|
33
|
+
export const CODEX_LIST_LIMIT = 40;
|
|
34
|
+
/**
|
|
35
|
+
* Max raw bytes served for one cache image. A generated PNG is a few MB; this is
|
|
36
|
+
* a generous upper bound that still refuses to stream something pathological
|
|
37
|
+
* before the browser converts it down to the < 1MB final asset.
|
|
38
|
+
*/
|
|
39
|
+
export const CODEX_MAX_RAW_BYTES = 25 * 1024 * 1024;
|
|
40
|
+
/** Bounded recursion so a huge or symlink-looping cache tree can't stall a scan. */
|
|
41
|
+
const MAX_SCAN_DEPTH = 4;
|
|
42
|
+
const MAX_SCAN_FILES = 2000;
|
|
43
|
+
|
|
44
|
+
const IMAGE_EXTS = new Set(["png", "webp", "jpg", "jpeg"]);
|
|
45
|
+
|
|
46
|
+
function hasImageExt(name: string): boolean {
|
|
47
|
+
return IMAGE_EXTS.has(path.extname(name).slice(1).toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** URL-safe base64 of a cache-relative path (no `+`/`/`/`=` to break a URL). */
|
|
51
|
+
export function encodeCodexToken(relPath: string): string {
|
|
52
|
+
return Buffer.from(relPath, "utf8").toString("base64url");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decode a token back to a cache-relative path, returning null for anything
|
|
57
|
+
* unsafe up front: empty, undecodable to text, NUL-bearing, absolute, or
|
|
58
|
+
* containing a `..` segment. This is the FIRST of two traversal guards — the
|
|
59
|
+
* caller must still resolve against the cache root and re-check the boundary
|
|
60
|
+
* (see {@link resolveCodexImagePath}), because base64url decoding is lenient and
|
|
61
|
+
* a crafted token could still decode to a path that escapes the root.
|
|
62
|
+
*/
|
|
63
|
+
export function decodeCodexToken(token: string): string | null {
|
|
64
|
+
if (!token) return null;
|
|
65
|
+
let rel: string;
|
|
66
|
+
try {
|
|
67
|
+
rel = Buffer.from(token, "base64url").toString("utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!rel || rel.includes("\0")) return null;
|
|
72
|
+
if (path.isAbsolute(rel)) return null;
|
|
73
|
+
if (rel.split(/[\\/]/).some((seg) => seg === "..")) return null;
|
|
74
|
+
return rel;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a token to an absolute path that is GUARANTEED to sit inside the cache
|
|
79
|
+
* root, or null when the token is unsafe or escapes the root. Pure path math (no
|
|
80
|
+
* fs) so the boundary guard is unit-testable; the route layer adds a
|
|
81
|
+
* realpath/symlink re-check plus the image-content and size checks.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveCodexImagePath(
|
|
84
|
+
root: string,
|
|
85
|
+
token: string,
|
|
86
|
+
): { abs: string; relPath: string } | null {
|
|
87
|
+
const relPath = decodeCodexToken(token);
|
|
88
|
+
if (relPath == null) return null;
|
|
89
|
+
const rootResolved = path.resolve(root);
|
|
90
|
+
const abs = path.resolve(rootResolved, relPath);
|
|
91
|
+
// Boundary check on the resolved path — not a bare path.join — so a token that
|
|
92
|
+
// decodes to something escaping the root is rejected even if it slipped the
|
|
93
|
+
// up-front `..` check via odd separators.
|
|
94
|
+
if (abs !== rootResolved && !abs.startsWith(rootResolved + path.sep)) return null;
|
|
95
|
+
// A token decoding to "" resolves to the root dir itself, which is not a file.
|
|
96
|
+
if (abs === rootResolved) return null;
|
|
97
|
+
return { abs, relPath };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* List recent image files under the Codex generated-image cache, newest first.
|
|
102
|
+
* Read-only and best-effort: a missing root yields `[]`, unreadable subtrees are
|
|
103
|
+
* skipped, and both recursion depth and file count are bounded so a pathological
|
|
104
|
+
* cache tree cannot stall the request.
|
|
105
|
+
*/
|
|
106
|
+
export function listCodexImages(root: string, limit: number = CODEX_LIST_LIMIT): CodexImageEntry[] {
|
|
107
|
+
const rootResolved = path.resolve(root);
|
|
108
|
+
if (!fs.existsSync(rootResolved)) return [];
|
|
109
|
+
|
|
110
|
+
const found: { relPath: string; name: string; size: number; mtimeMs: number }[] = [];
|
|
111
|
+
let scanned = 0;
|
|
112
|
+
|
|
113
|
+
const walk = (dir: string, depth: number) => {
|
|
114
|
+
if (depth > MAX_SCAN_DEPTH || scanned >= MAX_SCAN_FILES) return;
|
|
115
|
+
let entries: fs.Dirent[];
|
|
116
|
+
try {
|
|
117
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
118
|
+
} catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const ent of entries) {
|
|
122
|
+
if (scanned >= MAX_SCAN_FILES) return;
|
|
123
|
+
const full = path.join(dir, ent.name);
|
|
124
|
+
if (ent.isDirectory()) {
|
|
125
|
+
walk(full, depth + 1);
|
|
126
|
+
} else if (ent.isFile() && hasImageExt(ent.name)) {
|
|
127
|
+
scanned++;
|
|
128
|
+
let st: fs.Stats;
|
|
129
|
+
try {
|
|
130
|
+
st = fs.statSync(full);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
found.push({
|
|
135
|
+
relPath: path.relative(rootResolved, full),
|
|
136
|
+
name: ent.name,
|
|
137
|
+
size: st.size,
|
|
138
|
+
mtimeMs: st.mtimeMs,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
walk(rootResolved, 0);
|
|
145
|
+
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
146
|
+
return found.slice(0, limit).map((f) => ({
|
|
147
|
+
token: encodeCodexToken(f.relPath),
|
|
148
|
+
name: f.name,
|
|
149
|
+
size: f.size,
|
|
150
|
+
mtimeMs: f.mtimeMs,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Read-only per-cut asset diagnostics (#427).
|
|
2
|
+
//
|
|
3
|
+
// After an agent generates clean images on disk (outside the React UI), the app
|
|
4
|
+
// needs a reliable way to NOTICE and EXPLAIN each cut's asset state — otherwise a
|
|
5
|
+
// writer sees "image missing" or a prose preview even though files exist (the
|
|
6
|
+
// god-cell pilot). `getCutStatus` in the UI trusts the recorded cuts.json fields
|
|
7
|
+
// and never checks disk, so a recorded-but-missing/typoed path looks "ready".
|
|
8
|
+
//
|
|
9
|
+
// This classifies each cut's REAL asset state against the local story folder and
|
|
10
|
+
// surfaces a precise per-cut reason when a recorded path doesn't resolve. Pure +
|
|
11
|
+
// framework-free (the disk check is injected) so it's unit-testable and works for
|
|
12
|
+
// genesis.cuts.json and plot-NN.cuts.json equally. Read-only: never mutates cuts.
|
|
13
|
+
|
|
14
|
+
import { isTextPanel, type Cut } from "./cuts";
|
|
15
|
+
|
|
16
|
+
export type CutAssetState =
|
|
17
|
+
| "planned" // no recorded image path yet (or a text panel awaiting export)
|
|
18
|
+
| "needs-conversion" // a PNG clean image exists but must be converted to WebP/JPEG (#441)
|
|
19
|
+
| "missing" // a path IS recorded in cuts.json but the file is absent/invalid on disk
|
|
20
|
+
| "clean-ready" // a valid clean image exists on disk
|
|
21
|
+
| "final-ready" // a valid exported final/lettered image exists on disk
|
|
22
|
+
| "uploaded"; // an uploaded URL/CID exists (content is on IPFS)
|
|
23
|
+
|
|
24
|
+
export interface CutAssetDiagnostic {
|
|
25
|
+
cutId: number;
|
|
26
|
+
/** "image" | "text" — text panels need no clean image. */
|
|
27
|
+
kind: "image" | "text";
|
|
28
|
+
state: CutAssetState;
|
|
29
|
+
/**
|
|
30
|
+
* Precise reason when state is "missing", OR the raw unsupported-extension
|
|
31
|
+
* detail for "needs-conversion" (the UI hides it under "Technical details").
|
|
32
|
+
* Null otherwise.
|
|
33
|
+
*/
|
|
34
|
+
issue: string | null;
|
|
35
|
+
/**
|
|
36
|
+
* For "needs-conversion": the relative path of the PNG clean image to convert
|
|
37
|
+
* (the client fetches + converts it to WebP). Null for every other state.
|
|
38
|
+
*/
|
|
39
|
+
convertiblePng: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AssetDiagnosticsSummary {
|
|
43
|
+
planned: number;
|
|
44
|
+
needsConversion: number;
|
|
45
|
+
missing: number;
|
|
46
|
+
cleanReady: number;
|
|
47
|
+
finalReady: number;
|
|
48
|
+
uploaded: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify one cut's asset state against disk. `assetIssue(relPath)` returns null
|
|
53
|
+
* when the recorded path is a valid local image asset, else a precise reason
|
|
54
|
+
* (missing file, wrong type, too large, traversal) — typically `imageAssetIssue`.
|
|
55
|
+
*
|
|
56
|
+
* Precedence mirrors the production pipeline AND the existing stale-path logic:
|
|
57
|
+
* an uploaded cut is "uploaded" regardless of local files (content is on IPFS);
|
|
58
|
+
* otherwise the most-advanced recorded path wins, and a recorded-but-broken path
|
|
59
|
+
* is surfaced as "missing" with the precise reason rather than silently trusted.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Classify one cut's asset state against disk. `assetIssue(relPath)` returns the
|
|
63
|
+
* publish-strict validity (WebP/JPEG, ≤1MB, magic-byte match). `pngClean(cut)`
|
|
64
|
+
* returns the relative path of a convertible PNG clean image for this cut (or
|
|
65
|
+
* null) — when a cut has no VALID clean image but a PNG one exists, that is a
|
|
66
|
+
* friendly "needs-conversion" step (#441), not a red unsupported-extension
|
|
67
|
+
* error. Defaults to no-PNG so existing callers/tests are unaffected.
|
|
68
|
+
*/
|
|
69
|
+
export function diagnoseCutAsset(
|
|
70
|
+
cut: Cut,
|
|
71
|
+
assetIssue: (relPath: string) => string | null,
|
|
72
|
+
pngClean: (cut: Cut) => string | null = () => null,
|
|
73
|
+
): CutAssetDiagnostic {
|
|
74
|
+
const kind: "image" | "text" = isTextPanel(cut) ? "text" : "image";
|
|
75
|
+
const label = `Cut ${cut.id}`;
|
|
76
|
+
// Text panels never need a clean image, so they are never "needs-conversion".
|
|
77
|
+
const png = kind === "image" ? pngClean(cut) : null;
|
|
78
|
+
|
|
79
|
+
if (cut.uploadedUrl || cut.uploadedCid) {
|
|
80
|
+
return { cutId: cut.id, kind, state: "uploaded", issue: null, convertiblePng: null };
|
|
81
|
+
}
|
|
82
|
+
if (cut.finalImagePath) {
|
|
83
|
+
const issue = assetIssue(cut.finalImagePath);
|
|
84
|
+
return issue
|
|
85
|
+
? { cutId: cut.id, kind, state: "missing", issue: `${label}: final image "${cut.finalImagePath}" — ${issue}`, convertiblePng: null }
|
|
86
|
+
: { cutId: cut.id, kind, state: "final-ready", issue: null, convertiblePng: null };
|
|
87
|
+
}
|
|
88
|
+
if (cut.cleanImagePath) {
|
|
89
|
+
const issue = assetIssue(cut.cleanImagePath);
|
|
90
|
+
if (!issue) return { cutId: cut.id, kind, state: "clean-ready", issue: null, convertiblePng: null };
|
|
91
|
+
// Recorded clean path is invalid. A real PNG (the usual cause) is a normal
|
|
92
|
+
// conversion step; keep the raw reason as a hide-able technical detail.
|
|
93
|
+
if (png) return { cutId: cut.id, kind, state: "needs-conversion", issue: `${label}: clean image "${cut.cleanImagePath}" — ${issue}`, convertiblePng: png };
|
|
94
|
+
return { cutId: cut.id, kind, state: "missing", issue: `${label}: clean image "${cut.cleanImagePath}" — ${issue}`, convertiblePng: null };
|
|
95
|
+
}
|
|
96
|
+
// No recorded path: a PNG clean image may still be sitting on disk (the agent
|
|
97
|
+
// wrote it but didn't record it) — offer conversion rather than "image missing".
|
|
98
|
+
if (png) return { cutId: cut.id, kind, state: "needs-conversion", issue: null, convertiblePng: png };
|
|
99
|
+
// Otherwise a not-yet-produced image cut or a text panel awaiting export.
|
|
100
|
+
return { cutId: cut.id, kind, state: "planned", issue: null, convertiblePng: null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function diagnoseCutAssets(
|
|
104
|
+
cuts: Cut[],
|
|
105
|
+
assetIssue: (relPath: string) => string | null,
|
|
106
|
+
pngClean: (cut: Cut) => string | null = () => null,
|
|
107
|
+
): CutAssetDiagnostic[] {
|
|
108
|
+
return cuts.map((cut) => diagnoseCutAsset(cut, assetIssue, pngClean));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function summarizeAssetDiagnostics(diags: CutAssetDiagnostic[]): AssetDiagnosticsSummary {
|
|
112
|
+
return {
|
|
113
|
+
planned: diags.filter((d) => d.state === "planned").length,
|
|
114
|
+
needsConversion: diags.filter((d) => d.state === "needs-conversion").length,
|
|
115
|
+
missing: diags.filter((d) => d.state === "missing").length,
|
|
116
|
+
cleanReady: diags.filter((d) => d.state === "clean-ready").length,
|
|
117
|
+
finalReady: diags.filter((d) => d.state === "final-ready").length,
|
|
118
|
+
uploaded: diags.filter((d) => d.state === "uploaded").length,
|
|
119
|
+
};
|
|
120
|
+
}
|