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.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. 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
+ }