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
package/app/routes/publish.ts
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { streamSSE } from "hono/streaming";
|
|
3
|
-
import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
|
|
3
|
+
import { publishStoryline, publishPlot, getEthBalance, getCreationFee, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
|
|
4
4
|
import { keccak256, toBytes } from "viem";
|
|
5
5
|
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { STORIES_DIR } from "../lib/paths";
|
|
8
|
+
import { readCutsFile } from "../lib/cuts";
|
|
9
|
+
import { checkMarkdownReadiness } from "../lib/cartoon-readiness";
|
|
10
|
+
import { readStoryMeta } from "./stories";
|
|
11
|
+
import { sniffImageType, findStaleAssetPaths } from "../lib/clean-image-sync";
|
|
12
|
+
import { isValidImageAsset } from "../lib/image-asset-validate";
|
|
13
|
+
import { extractOgTitle, leadingTitleSegment } from "../lib/public-title";
|
|
14
|
+
import { canonicalizeGenre, GENRES } from "../../lib/genres";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a request's genre to a canonical PlotLink value (#412). Returns the
|
|
18
|
+
* canonical string for a valid/aliased genre, `undefined` for an absent/blank
|
|
19
|
+
* genre (no metadata change), or an `{ error }` for a non-empty genre that can't
|
|
20
|
+
* be mapped — so the route fails locally with a clear message instead of letting
|
|
21
|
+
* PlotLink reject it and leave the public story UNCATEGORIZED.
|
|
22
|
+
*/
|
|
23
|
+
function resolveGenre(input: string | undefined): { genre?: string } | { error: string } {
|
|
24
|
+
if (!input || !input.trim()) return { genre: undefined };
|
|
25
|
+
const canonical = canonicalizeGenre(input);
|
|
26
|
+
if (!canonical) {
|
|
27
|
+
return { error: `Invalid genre "${input}". Use one of: ${GENRES.join(", ")}.` };
|
|
28
|
+
}
|
|
29
|
+
return { genre: canonical };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate that an uploaded image's actual magic bytes match its claimed
|
|
34
|
+
* WebP/JPEG MIME type, so a renamed PNG/text file labeled image/webp cannot be
|
|
35
|
+
* forwarded to the plotlink backend. Mirrors the byte check the cartoon
|
|
36
|
+
* clean-image upload uses (#266). Returns a user-facing error string, or null
|
|
37
|
+
* when the bytes are valid.
|
|
38
|
+
*/
|
|
39
|
+
async function imageBytesError(file: File): Promise<string | null> {
|
|
40
|
+
const expected = file.type === "image/webp" ? "webp" : "jpeg";
|
|
41
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
42
|
+
if (sniffImageType(bytes) !== expected) {
|
|
43
|
+
return "File content is not a valid WebP/JPEG image (bytes do not match the image type)";
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
6
47
|
|
|
7
48
|
const publish = new Hono();
|
|
8
49
|
|
|
@@ -21,41 +62,61 @@ publish.get("/preflight", async (c) => {
|
|
|
21
62
|
return c.json({ ready: false, error: "No EVM address on wallet" });
|
|
22
63
|
}
|
|
23
64
|
|
|
24
|
-
// Check ETH balance against real estimated cost
|
|
25
65
|
const balance = await getEthBalance(address);
|
|
66
|
+
|
|
67
|
+
// The MCV2 Bond creation fee is a plain contract read and is the only hard
|
|
68
|
+
// on-chain cost we can always count on. Read it independently of gas
|
|
69
|
+
// estimation: a flaky gas estimate must not masquerade as an unreadable-chain
|
|
70
|
+
// failure. If the fee itself cannot be read, the RPC/contract config is
|
|
71
|
+
// genuinely broken — that is a real blocker.
|
|
72
|
+
let creationFee: bigint;
|
|
73
|
+
try {
|
|
74
|
+
creationFee = await getCreationFee();
|
|
75
|
+
} catch {
|
|
76
|
+
return c.json({
|
|
77
|
+
ready: false,
|
|
78
|
+
address,
|
|
79
|
+
ethBalance: balance.toString(),
|
|
80
|
+
error: "Could not read creation fee — check RPC and contract config",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Gas estimation is best-effort. PlotLink owns Filebase/IPFS server-side and
|
|
85
|
+
// OWS uploads images/content through the PlotLink API, so there is NO local
|
|
86
|
+
// Filebase env requirement to gate on (#287). And gas estimation simulates
|
|
87
|
+
// createStoryline with dummy content the contract can reject — which is why
|
|
88
|
+
// the real #211 pilot publish succeeded while this estimate failed. So a
|
|
89
|
+
// failed estimate is a warning, not an absolute publish blocker.
|
|
26
90
|
let totalCost: bigint | null = null;
|
|
27
|
-
let
|
|
28
|
-
let estimationFailed = false;
|
|
91
|
+
let estimateWarning: string | null = null;
|
|
29
92
|
try {
|
|
30
93
|
const dummyCid = "QmDummy";
|
|
31
94
|
const dummyHash = keccak256(toBytes("estimation"));
|
|
32
95
|
const estimate = await estimatePublishCost(address, "Test", dummyCid, dummyHash);
|
|
33
96
|
totalCost = estimate.totalCost;
|
|
34
|
-
creationFee = estimate.creationFee;
|
|
35
97
|
} catch {
|
|
36
|
-
|
|
98
|
+
estimateWarning =
|
|
99
|
+
"Could not estimate gas; using the creation fee as the minimum required balance — actual publish may still succeed";
|
|
37
100
|
}
|
|
38
|
-
// Fail closed: if estimation fails, block publishing
|
|
39
|
-
const requiredBalance = totalCost ?? BigInt(0);
|
|
40
|
-
const hasEnoughEth = !estimationFailed && totalCost !== null && balance >= requiredBalance;
|
|
41
101
|
|
|
42
|
-
//
|
|
43
|
-
|
|
102
|
+
// Require enough ETH for at least the creation fee; when a full gas estimate
|
|
103
|
+
// is available, require that instead. Never block solely on a missing
|
|
104
|
+
// estimate — but a genuinely insufficient balance is still a real blocker.
|
|
105
|
+
const requiredBalance = totalCost ?? creationFee;
|
|
106
|
+
const hasEnoughEth = balance >= requiredBalance;
|
|
44
107
|
|
|
45
108
|
return c.json({
|
|
46
|
-
ready: hasEnoughEth
|
|
109
|
+
ready: hasEnoughEth,
|
|
47
110
|
address,
|
|
48
111
|
ethBalance: balance.toString(),
|
|
49
112
|
creationFee: creationFee.toString(),
|
|
50
113
|
requiredBalance: requiredBalance.toString(),
|
|
51
114
|
hasEnoughEth,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
error:
|
|
55
|
-
?
|
|
56
|
-
:
|
|
57
|
-
? `Insufficient ETH. Need ~${(Number(requiredBalance) / 1e18).toFixed(6)} ETH (creation fee + gas)`
|
|
58
|
-
: !hasFilebase ? "Filebase not configured" : null,
|
|
115
|
+
estimationFailed: totalCost === null,
|
|
116
|
+
estimateWarning,
|
|
117
|
+
error: !hasEnoughEth
|
|
118
|
+
? `Insufficient ETH. Need at least ~${(Number(requiredBalance) / 1e18).toFixed(6)} ETH (creation fee${totalCost !== null ? " + gas" : ""})`
|
|
119
|
+
: null,
|
|
59
120
|
});
|
|
60
121
|
} catch (err: unknown) {
|
|
61
122
|
const message = err instanceof Error ? err.message : "Preflight check failed";
|
|
@@ -63,6 +124,58 @@ publish.get("/preflight", async (c) => {
|
|
|
63
124
|
}
|
|
64
125
|
});
|
|
65
126
|
|
|
127
|
+
/**
|
|
128
|
+
* GET /api/publish/public-title — read the INDEXED public title from PlotLink
|
|
129
|
+
* after a publish (#379). There is no public JSON read endpoint, so this fetches
|
|
130
|
+
* the rendered public page server-side (no CORS) and extracts its og:title:
|
|
131
|
+
* - genesis/storyline (`/story/<id>`) → { storylineTitle }
|
|
132
|
+
* - plot (`/story/<id>/<plotIndex>`) → { plotTitle } (leading title segment)
|
|
133
|
+
* `fetched:false` when the page is unreachable / has no title, so the caller
|
|
134
|
+
* treats it as inconclusive rather than a false pass. The client then runs the
|
|
135
|
+
* pure title verifier on the result.
|
|
136
|
+
*/
|
|
137
|
+
publish.get("/public-title", async (c) => {
|
|
138
|
+
const storylineId = c.req.query("storylineId");
|
|
139
|
+
const plotIndex = c.req.query("plotIndex");
|
|
140
|
+
if (!storylineId || !/^\d+$/.test(storylineId)) {
|
|
141
|
+
return c.json({ ok: false, error: "Valid storylineId required" }, 400);
|
|
142
|
+
}
|
|
143
|
+
const isPlot = plotIndex != null && plotIndex !== "" && /^\d+$/.test(plotIndex);
|
|
144
|
+
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
145
|
+
const url = isPlot
|
|
146
|
+
? `${PLOTLINK_URL}/story/${storylineId}/${plotIndex}`
|
|
147
|
+
: `${PLOTLINK_URL}/story/${storylineId}`;
|
|
148
|
+
const storylineUrl = `${PLOTLINK_URL}/story/${storylineId}`;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
if (!isPlot) {
|
|
152
|
+
const res = await fetch(url);
|
|
153
|
+
if (!res.ok) return c.json({ ok: true, fetched: false });
|
|
154
|
+
const og = extractOgTitle(await res.text());
|
|
155
|
+
if (!og) return c.json({ ok: true, fetched: false });
|
|
156
|
+
return c.json({ ok: true, fetched: true, storylineTitle: og });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const plotRes = await fetch(url);
|
|
160
|
+
if (!plotRes.ok) return c.json({ ok: true, fetched: false });
|
|
161
|
+
const plotOg = extractOgTitle(await plotRes.text());
|
|
162
|
+
if (!plotOg) return c.json({ ok: true, fetched: false });
|
|
163
|
+
let storylineOg: string | null = null;
|
|
164
|
+
try {
|
|
165
|
+
const storylineRes = await fetch(storylineUrl);
|
|
166
|
+
storylineOg = storylineRes.ok ? extractOgTitle(await storylineRes.text()) : null;
|
|
167
|
+
} catch {
|
|
168
|
+
// Best-effort read only. If the storyline page fetch rejects, keep the
|
|
169
|
+
// successful plot-page title and fall back to last-segment stripping.
|
|
170
|
+
storylineOg = null;
|
|
171
|
+
}
|
|
172
|
+
return c.json({ ok: true, fetched: true, plotTitle: leadingTitleSegment(plotOg, storylineOg) });
|
|
173
|
+
} catch {
|
|
174
|
+
// Network/parse failure → inconclusive; never block on a flaky read.
|
|
175
|
+
return c.json({ ok: true, fetched: false });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
66
179
|
/** POST /api/publish/file — publish a story file on-chain (streams progress) */
|
|
67
180
|
publish.post("/file", async (c) => {
|
|
68
181
|
const body = await c.req.json<{
|
|
@@ -74,12 +187,22 @@ publish.post("/file", async (c) => {
|
|
|
74
187
|
language?: string;
|
|
75
188
|
isNsfw?: boolean;
|
|
76
189
|
storylineId?: number;
|
|
190
|
+
contentType?: string;
|
|
77
191
|
}>();
|
|
78
192
|
|
|
79
193
|
if (!body.title || !body.content) {
|
|
80
194
|
return c.json({ error: "title and content required" }, 400);
|
|
81
195
|
}
|
|
82
196
|
|
|
197
|
+
// Canonicalize the genre up-front (#412) so it's the canonical PlotLink value in
|
|
198
|
+
// the content metadata, and a non-mappable genre fails here with a clear message
|
|
199
|
+
// instead of after the on-chain publish.
|
|
200
|
+
const genreResult = resolveGenre(body.genre);
|
|
201
|
+
if ("error" in genreResult) {
|
|
202
|
+
return c.json({ error: genreResult.error }, 400);
|
|
203
|
+
}
|
|
204
|
+
const canonicalGenre = genreResult.genre;
|
|
205
|
+
|
|
83
206
|
// Enforce character limits
|
|
84
207
|
const isGenesis = body.fileName === "genesis.md";
|
|
85
208
|
const isPlot = /^plot-\d+\.md$/.test(body.fileName);
|
|
@@ -90,6 +213,52 @@ publish.post("/file", async (c) => {
|
|
|
90
213
|
}, 400);
|
|
91
214
|
}
|
|
92
215
|
|
|
216
|
+
// Cartoon plot readiness — block invalid/incomplete publish markdown.
|
|
217
|
+
// Derive cartoon status from server-side .story.json metadata (NOT the
|
|
218
|
+
// request body) so a direct API caller cannot bypass by omitting/faking
|
|
219
|
+
// contentType.
|
|
220
|
+
if (isPlot) {
|
|
221
|
+
const storyDir = path.join(STORIES_DIR, body.storyName);
|
|
222
|
+
const isCartoon = readStoryMeta(storyDir).contentType === "cartoon";
|
|
223
|
+
if (isCartoon) {
|
|
224
|
+
const plotFile = body.fileName.replace(/\.md$/, "");
|
|
225
|
+
let cutsFile;
|
|
226
|
+
try {
|
|
227
|
+
cutsFile = readCutsFile(storyDir, plotFile);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return c.json({ error: `Cannot publish: ${(err as Error).message}` }, 400);
|
|
230
|
+
}
|
|
231
|
+
if (!cutsFile) {
|
|
232
|
+
return c.json({ error: `Cannot publish: ${plotFile}.cuts.json not found. Generate cuts and upload final images first.` }, 400);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Block on stale recorded asset paths — a cut whose cleanImagePath /
|
|
236
|
+
// finalImagePath points to a missing/invalid local file is not actually
|
|
237
|
+
// ready, and the generic markdown issues would obscure why (#302). Skip
|
|
238
|
+
// already-uploaded cuts: their content is on IPFS (uploadedUrl), so a
|
|
239
|
+
// missing LOCAL asset must not block re-publish.
|
|
240
|
+
const stale = findStaleAssetPaths(
|
|
241
|
+
cutsFile.cuts,
|
|
242
|
+
(rel) => isValidImageAsset(storyDir, rel),
|
|
243
|
+
).filter((issue) => {
|
|
244
|
+
const cut = cutsFile.cuts.find((ct) => ct.id === issue.cutId);
|
|
245
|
+
return !cut?.uploadedUrl;
|
|
246
|
+
});
|
|
247
|
+
if (stale.length > 0) {
|
|
248
|
+
const messages = stale.map((s) => s.message);
|
|
249
|
+
return c.json(
|
|
250
|
+
{ error: `Cartoon plot not ready to publish: ${messages.join("; ")}`, issues: messages },
|
|
251
|
+
400,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const { ready, issues } = checkMarkdownReadiness(body.content, cutsFile.cuts);
|
|
256
|
+
if (!ready) {
|
|
257
|
+
return c.json({ error: `Cartoon plot not ready to publish: ${issues.join("; ")}`, issues }, 400);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
93
262
|
// Get wallet
|
|
94
263
|
let wallets;
|
|
95
264
|
try {
|
|
@@ -116,7 +285,7 @@ publish.post("/file", async (c) => {
|
|
|
116
285
|
body.storylineId,
|
|
117
286
|
body.title,
|
|
118
287
|
body.content,
|
|
119
|
-
|
|
288
|
+
canonicalGenre,
|
|
120
289
|
async (progress) => {
|
|
121
290
|
await stream.writeSSE({ data: JSON.stringify(progress) });
|
|
122
291
|
},
|
|
@@ -128,12 +297,13 @@ publish.post("/file", async (c) => {
|
|
|
128
297
|
wallet.name,
|
|
129
298
|
body.title,
|
|
130
299
|
body.content,
|
|
131
|
-
|
|
300
|
+
canonicalGenre,
|
|
132
301
|
async (progress) => {
|
|
133
302
|
await stream.writeSSE({ data: JSON.stringify(progress) });
|
|
134
303
|
},
|
|
135
304
|
body.language,
|
|
136
305
|
body.isNsfw,
|
|
306
|
+
body.contentType,
|
|
137
307
|
);
|
|
138
308
|
}
|
|
139
309
|
|
|
@@ -212,9 +382,9 @@ publish.post("/upload-cover", async (c) => {
|
|
|
212
382
|
return c.json({ error: "No image file provided" }, 400);
|
|
213
383
|
}
|
|
214
384
|
|
|
215
|
-
// Validate file size (
|
|
216
|
-
if (file.size >
|
|
217
|
-
return c.json({ error: "Image exceeds
|
|
385
|
+
// Validate file size (1MB max)
|
|
386
|
+
if (file.size > 1024 * 1024) {
|
|
387
|
+
return c.json({ error: "Image exceeds 1MB limit" }, 400);
|
|
218
388
|
}
|
|
219
389
|
|
|
220
390
|
// Validate file type — only WebP and JPEG accepted by the plotlink server
|
|
@@ -223,6 +393,9 @@ publish.post("/upload-cover", async (c) => {
|
|
|
223
393
|
return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
|
|
224
394
|
}
|
|
225
395
|
|
|
396
|
+
const bytesError = await imageBytesError(file);
|
|
397
|
+
if (bytesError) return c.json({ error: bytesError }, 400);
|
|
398
|
+
|
|
226
399
|
const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
|
|
227
400
|
return c.json({ cid });
|
|
228
401
|
} catch (err: unknown) {
|
|
@@ -247,9 +420,9 @@ publish.post("/upload-plot-image", async (c) => {
|
|
|
247
420
|
return c.json({ error: "No image file provided" }, 400);
|
|
248
421
|
}
|
|
249
422
|
|
|
250
|
-
// Validate file size (
|
|
251
|
-
if (file.size >
|
|
252
|
-
return c.json({ error: "Image exceeds
|
|
423
|
+
// Validate file size (1MB max)
|
|
424
|
+
if (file.size > 1024 * 1024) {
|
|
425
|
+
return c.json({ error: "Image exceeds 1MB limit" }, 400);
|
|
253
426
|
}
|
|
254
427
|
|
|
255
428
|
// Validate file type — only WebP and JPEG accepted by the plotlink server
|
|
@@ -288,13 +461,21 @@ publish.post("/update-storyline", async (c) => {
|
|
|
288
461
|
return c.json({ error: "storylineId required" }, 400);
|
|
289
462
|
}
|
|
290
463
|
|
|
464
|
+
// Canonicalize the genre before the signed on-chain metadata update so a
|
|
465
|
+
// natural label (e.g. "Sci-Fi") becomes "Science Fiction" rather than being
|
|
466
|
+
// rejected by PlotLink and leaving the public story UNCATEGORIZED (#412).
|
|
467
|
+
const genreResult = resolveGenre(body.genre);
|
|
468
|
+
if ("error" in genreResult) {
|
|
469
|
+
return c.json({ error: genreResult.error }, 400);
|
|
470
|
+
}
|
|
471
|
+
|
|
291
472
|
await updateStoryline(
|
|
292
473
|
wallet.name,
|
|
293
474
|
address as `0x${string}`,
|
|
294
475
|
body.storylineId,
|
|
295
476
|
{
|
|
296
477
|
coverCid: body.coverCid,
|
|
297
|
-
genre:
|
|
478
|
+
genre: genreResult.genre,
|
|
298
479
|
language: body.language,
|
|
299
480
|
isNsfw: body.isNsfw,
|
|
300
481
|
},
|