plotlink-ows 1.2.95 → 1.2.96
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/app/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- package/app/web/components/CutListPanel.tsx +1108 -436
- package/app/web/components/FinishEpisodePanel.tsx +57 -46
- package/app/web/components/LetteringEditor.tsx +845 -385
- package/app/web/components/PreviewPanel.tsx +1459 -845
- package/app/web/components/StoriesPage.tsx +981 -506
- package/app/web/dist/assets/{export-cut-che5mMWc.js → export-cut-BqZI0-Rv.js} +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +141 -0
- package/app/web/dist/index.html +1 -1
- package/package.json +1 -1
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +0 -143
package/app/lib/cuts.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
hasVisibleSpeechTail,
|
|
5
|
+
CARTOON_BUBBLE_RENDERER_VERSION,
|
|
6
|
+
type Overlay,
|
|
7
|
+
} from "./overlays";
|
|
4
8
|
|
|
5
|
-
export const SHOT_TYPES = [
|
|
9
|
+
export const SHOT_TYPES = [
|
|
10
|
+
"wide",
|
|
11
|
+
"medium",
|
|
12
|
+
"close-up",
|
|
13
|
+
"extreme-close-up",
|
|
14
|
+
] as const;
|
|
6
15
|
export type ShotType = (typeof SHOT_TYPES)[number];
|
|
7
16
|
|
|
8
17
|
export interface CutDialogue {
|
|
@@ -19,6 +28,19 @@ export interface CutDialogue {
|
|
|
19
28
|
*/
|
|
20
29
|
export type CutKind = "image" | "text";
|
|
21
30
|
|
|
31
|
+
/**
|
|
32
|
+
* AI draft lettering state (#494). `generated` means OWS created a first-pass
|
|
33
|
+
* overlay set from the cut script and it has not been user-tuned yet. `edited`
|
|
34
|
+
* means the writer has since adjusted or replaced that draft in the editor.
|
|
35
|
+
*/
|
|
36
|
+
export interface CutAiDraft {
|
|
37
|
+
status: "generated" | "edited";
|
|
38
|
+
/** Signature of the generated overlay set, for edit-detection on save. */
|
|
39
|
+
baseSig?: string;
|
|
40
|
+
generatedAt?: string;
|
|
41
|
+
updatedAt?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
export interface Cut {
|
|
23
45
|
id: number;
|
|
24
46
|
shotType: ShotType;
|
|
@@ -41,6 +63,8 @@ export interface Cut {
|
|
|
41
63
|
finalRendererVersion?: number;
|
|
42
64
|
/** Panel kind (#350). Absent ⇒ "image" (backward-compatible). */
|
|
43
65
|
kind?: CutKind;
|
|
66
|
+
/** AI draft lettering state (#494). Optional and backward-compatible. */
|
|
67
|
+
aiDraft?: CutAiDraft | null;
|
|
44
68
|
/** Text-panel background color (CSS color), e.g. "#101820". Optional (#350). */
|
|
45
69
|
background?: string;
|
|
46
70
|
/** Text-panel aspect ratio hint, e.g. "4:5". Optional (#350). */
|
|
@@ -89,7 +113,11 @@ export function cutNextAction(
|
|
|
89
113
|
if (cut.cleanImagePath || isTextPanel(cut)) {
|
|
90
114
|
return { key: "letter", label: "Letter this cut", opensEditor: true };
|
|
91
115
|
}
|
|
92
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
key: "add-art",
|
|
118
|
+
label: "Add clean art for this cut",
|
|
119
|
+
opensEditor: false,
|
|
120
|
+
};
|
|
93
121
|
}
|
|
94
122
|
|
|
95
123
|
/**
|
|
@@ -115,7 +143,9 @@ export function staleTailedCutIds(
|
|
|
115
143
|
cutsFile: Pick<CutsFile, "cuts">,
|
|
116
144
|
currentVersion: number = CARTOON_BUBBLE_RENDERER_VERSION,
|
|
117
145
|
): number[] {
|
|
118
|
-
return cutsFile.cuts
|
|
146
|
+
return cutsFile.cuts
|
|
147
|
+
.filter((c) => isStaleTailedExport(c, currentVersion))
|
|
148
|
+
.map((c) => c.id);
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
/** Base canvas width for a text panel sized from its aspect ratio (#351). */
|
|
@@ -127,14 +157,19 @@ export const TEXT_PANEL_BASE_WIDTH = 800;
|
|
|
127
157
|
* panel letters and exports at the SAME shape. Returns null for a missing or
|
|
128
158
|
* malformed ratio; callers fall back to 800×600.
|
|
129
159
|
*/
|
|
130
|
-
export function textPanelDimensions(
|
|
160
|
+
export function textPanelDimensions(
|
|
161
|
+
aspectRatio: string | undefined,
|
|
162
|
+
): { width: number; height: number } | null {
|
|
131
163
|
if (!aspectRatio) return null;
|
|
132
164
|
const m = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
|
|
133
165
|
if (!m) return null;
|
|
134
166
|
const w = parseFloat(m[1]);
|
|
135
167
|
const h = parseFloat(m[2]);
|
|
136
168
|
if (!(w > 0) || !(h > 0)) return null;
|
|
137
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
width: TEXT_PANEL_BASE_WIDTH,
|
|
171
|
+
height: Math.round((TEXT_PANEL_BASE_WIDTH * h) / w),
|
|
172
|
+
};
|
|
138
173
|
}
|
|
139
174
|
|
|
140
175
|
export interface CutsFile {
|
|
@@ -170,7 +205,9 @@ export function createDefaultCut(id: number, _plotFile: string): Cut {
|
|
|
170
205
|
}
|
|
171
206
|
|
|
172
207
|
export function createCutsFile(plotFile: string, cutCount = 1): CutsFile {
|
|
173
|
-
const cuts = Array.from({ length: cutCount }, (_, i) =>
|
|
208
|
+
const cuts = Array.from({ length: cutCount }, (_, i) =>
|
|
209
|
+
createDefaultCut(i + 1, plotFile),
|
|
210
|
+
);
|
|
174
211
|
return { version: 1, plotFile, cuts };
|
|
175
212
|
}
|
|
176
213
|
|
|
@@ -178,7 +215,10 @@ function cutsFilePath(storyDir: string, plotFile: string): string {
|
|
|
178
215
|
return path.join(storyDir, `${plotFile}.cuts.json`);
|
|
179
216
|
}
|
|
180
217
|
|
|
181
|
-
export function readCutsFile(
|
|
218
|
+
export function readCutsFile(
|
|
219
|
+
storyDir: string,
|
|
220
|
+
plotFile: string,
|
|
221
|
+
): CutsFile | null {
|
|
182
222
|
const filePath = cutsFilePath(storyDir, plotFile);
|
|
183
223
|
if (!fs.existsSync(filePath)) return null;
|
|
184
224
|
|
|
@@ -186,7 +226,9 @@ export function readCutsFile(storyDir: string, plotFile: string): CutsFile | nul
|
|
|
186
226
|
try {
|
|
187
227
|
raw = fs.readFileSync(filePath, "utf-8");
|
|
188
228
|
} catch (err) {
|
|
189
|
-
throw new Error(
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Cannot read ${plotFile}.cuts.json: ${(err as Error).message}`,
|
|
231
|
+
);
|
|
190
232
|
}
|
|
191
233
|
|
|
192
234
|
let data: unknown;
|
|
@@ -204,12 +246,19 @@ export function readCutsFile(storyDir: string, plotFile: string): CutsFile | nul
|
|
|
204
246
|
return data as CutsFile;
|
|
205
247
|
}
|
|
206
248
|
|
|
207
|
-
export function writeCutsFile(
|
|
249
|
+
export function writeCutsFile(
|
|
250
|
+
storyDir: string,
|
|
251
|
+
plotFile: string,
|
|
252
|
+
cutsFile: CutsFile,
|
|
253
|
+
): void {
|
|
208
254
|
const filePath = cutsFilePath(storyDir, plotFile);
|
|
209
255
|
fs.writeFileSync(filePath, JSON.stringify(cutsFile, null, 2) + "\n");
|
|
210
256
|
}
|
|
211
257
|
|
|
212
|
-
export function validateCutsFile(data: unknown): {
|
|
258
|
+
export function validateCutsFile(data: unknown): {
|
|
259
|
+
valid: boolean;
|
|
260
|
+
error?: string;
|
|
261
|
+
} {
|
|
213
262
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
214
263
|
return { valid: false, error: "Must be a JSON object" };
|
|
215
264
|
}
|
|
@@ -254,7 +303,10 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
|
|
|
254
303
|
}
|
|
255
304
|
for (let j = 0; j < (cut.characters as unknown[]).length; j++) {
|
|
256
305
|
if (typeof (cut.characters as unknown[])[j] !== "string") {
|
|
257
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
valid: false,
|
|
308
|
+
error: `Cut ${i} characters[${j}] must be a string`,
|
|
309
|
+
};
|
|
258
310
|
}
|
|
259
311
|
}
|
|
260
312
|
if (!Array.isArray(cut.dialogue)) {
|
|
@@ -262,8 +314,16 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
|
|
|
262
314
|
}
|
|
263
315
|
for (let j = 0; j < (cut.dialogue as unknown[]).length; j++) {
|
|
264
316
|
const d = (cut.dialogue as Record<string, unknown>[])[j];
|
|
265
|
-
if (
|
|
266
|
-
|
|
317
|
+
if (
|
|
318
|
+
typeof d !== "object" ||
|
|
319
|
+
d === null ||
|
|
320
|
+
typeof d.speaker !== "string" ||
|
|
321
|
+
typeof d.text !== "string"
|
|
322
|
+
) {
|
|
323
|
+
return {
|
|
324
|
+
valid: false,
|
|
325
|
+
error: `Cut ${i} dialogue[${j}] must have speaker and text strings`,
|
|
326
|
+
};
|
|
267
327
|
}
|
|
268
328
|
}
|
|
269
329
|
if (typeof cut.narration !== "string") {
|
|
@@ -272,10 +332,19 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
|
|
|
272
332
|
if (typeof cut.sfx !== "string") {
|
|
273
333
|
return { valid: false, error: `Cut ${i} missing sfx` };
|
|
274
334
|
}
|
|
275
|
-
const nullableStrings = [
|
|
335
|
+
const nullableStrings = [
|
|
336
|
+
"cleanImagePath",
|
|
337
|
+
"finalImagePath",
|
|
338
|
+
"exportedAt",
|
|
339
|
+
"uploadedCid",
|
|
340
|
+
"uploadedUrl",
|
|
341
|
+
] as const;
|
|
276
342
|
for (const field of nullableStrings) {
|
|
277
343
|
if (cut[field] !== null && typeof cut[field] !== "string") {
|
|
278
|
-
return {
|
|
344
|
+
return {
|
|
345
|
+
valid: false,
|
|
346
|
+
error: `Cut ${i} ${field} must be a string or null`,
|
|
347
|
+
};
|
|
279
348
|
}
|
|
280
349
|
}
|
|
281
350
|
if (cut.overlays !== undefined && !Array.isArray(cut.overlays)) {
|
|
@@ -285,6 +354,48 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
|
|
|
285
354
|
if (cut.kind !== undefined && cut.kind !== "image" && cut.kind !== "text") {
|
|
286
355
|
return { valid: false, error: `Cut ${i} kind must be "image" or "text"` };
|
|
287
356
|
}
|
|
357
|
+
if (cut.aiDraft !== undefined && cut.aiDraft !== null) {
|
|
358
|
+
if (typeof cut.aiDraft !== "object") {
|
|
359
|
+
return {
|
|
360
|
+
valid: false,
|
|
361
|
+
error: `Cut ${i} aiDraft must be an object or null`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const aiDraft = cut.aiDraft as Record<string, unknown>;
|
|
365
|
+
if (aiDraft.status !== "generated" && aiDraft.status !== "edited") {
|
|
366
|
+
return {
|
|
367
|
+
valid: false,
|
|
368
|
+
error: `Cut ${i} aiDraft.status must be "generated" or "edited"`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (
|
|
372
|
+
aiDraft.baseSig !== undefined &&
|
|
373
|
+
typeof aiDraft.baseSig !== "string"
|
|
374
|
+
) {
|
|
375
|
+
return {
|
|
376
|
+
valid: false,
|
|
377
|
+
error: `Cut ${i} aiDraft.baseSig must be a string`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (
|
|
381
|
+
aiDraft.generatedAt !== undefined &&
|
|
382
|
+
typeof aiDraft.generatedAt !== "string"
|
|
383
|
+
) {
|
|
384
|
+
return {
|
|
385
|
+
valid: false,
|
|
386
|
+
error: `Cut ${i} aiDraft.generatedAt must be a string`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
aiDraft.updatedAt !== undefined &&
|
|
391
|
+
typeof aiDraft.updatedAt !== "string"
|
|
392
|
+
) {
|
|
393
|
+
return {
|
|
394
|
+
valid: false,
|
|
395
|
+
error: `Cut ${i} aiDraft.updatedAt must be a string`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
288
399
|
if (cut.background !== undefined && typeof cut.background !== "string") {
|
|
289
400
|
return { valid: false, error: `Cut ${i} background must be a string` };
|
|
290
401
|
}
|
|
@@ -293,8 +404,14 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
|
|
|
293
404
|
}
|
|
294
405
|
// Bubble-renderer version stamp (#381) — optional, backward-compatible
|
|
295
406
|
// (absent ⇒ pre-versioning final image).
|
|
296
|
-
if (
|
|
297
|
-
|
|
407
|
+
if (
|
|
408
|
+
cut.finalRendererVersion !== undefined &&
|
|
409
|
+
typeof cut.finalRendererVersion !== "number"
|
|
410
|
+
) {
|
|
411
|
+
return {
|
|
412
|
+
valid: false,
|
|
413
|
+
error: `Cut ${i} finalRendererVersion must be a number`,
|
|
414
|
+
};
|
|
298
415
|
}
|
|
299
416
|
}
|
|
300
417
|
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// tested and shared between the editor checklist and the insert-from-script
|
|
4
4
|
// panel. None of this changes the export model or publish readiness rules.
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
comfortableOverlaySize,
|
|
8
|
+
createOverlay,
|
|
9
|
+
type Overlay,
|
|
10
|
+
type OverlayType,
|
|
11
|
+
} from "./overlays";
|
|
7
12
|
|
|
8
13
|
/** The cut fields the lettering guidance reads (a structural subset of Cut). */
|
|
9
14
|
export interface LetteringCut {
|
|
@@ -16,6 +21,7 @@ export interface LetteringCut {
|
|
|
16
21
|
sfx?: string;
|
|
17
22
|
dialogue?: { speaker: string; text: string }[];
|
|
18
23
|
overlays?: Overlay[];
|
|
24
|
+
kind?: "image" | "text";
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export interface LetteringChecklist {
|
|
@@ -45,12 +51,16 @@ export function cutLetteringChecklist(
|
|
|
45
51
|
cut: LetteringCut,
|
|
46
52
|
opts: { staleExport?: boolean } = {},
|
|
47
53
|
): LetteringChecklist {
|
|
48
|
-
const exported =
|
|
49
|
-
|
|
54
|
+
const exported =
|
|
55
|
+
!opts.staleExport && (!!cut.finalImagePath || !!cut.exportedAt);
|
|
56
|
+
const uploaded =
|
|
57
|
+
!opts.staleExport && (!!cut.uploadedUrl || !!cut.uploadedCid);
|
|
50
58
|
return {
|
|
51
59
|
hasCleanImage: !!cut.cleanImagePath,
|
|
52
60
|
hasScriptText:
|
|
53
|
-
(cut.dialogue?.length ?? 0) > 0 ||
|
|
61
|
+
(cut.dialogue?.length ?? 0) > 0 ||
|
|
62
|
+
!!cut.narration?.trim() ||
|
|
63
|
+
!!cut.sfx?.trim(),
|
|
54
64
|
bubblesPlaced: cut.overlays?.length ?? 0,
|
|
55
65
|
exported,
|
|
56
66
|
uploaded,
|
|
@@ -120,14 +130,62 @@ export function cutScriptLines(cut: LetteringCut): ScriptLine[] {
|
|
|
120
130
|
const lines: ScriptLine[] = [];
|
|
121
131
|
(cut.dialogue ?? []).forEach((d, i) => {
|
|
122
132
|
if (d?.text?.trim()) {
|
|
123
|
-
lines.push({
|
|
133
|
+
lines.push({
|
|
134
|
+
type: "speech",
|
|
135
|
+
speaker: d.speaker,
|
|
136
|
+
text: d.text.trim(),
|
|
137
|
+
key: `speech-${i}`,
|
|
138
|
+
});
|
|
124
139
|
}
|
|
125
140
|
});
|
|
126
141
|
if (cut.narration?.trim()) {
|
|
127
|
-
lines.push({
|
|
142
|
+
lines.push({
|
|
143
|
+
type: "narration",
|
|
144
|
+
text: cut.narration.trim(),
|
|
145
|
+
key: "narration",
|
|
146
|
+
});
|
|
128
147
|
}
|
|
129
148
|
if (cut.sfx?.trim()) {
|
|
130
149
|
lines.push({ type: "sfx", text: cut.sfx.trim(), key: "sfx" });
|
|
131
150
|
}
|
|
132
151
|
return lines;
|
|
133
152
|
}
|
|
153
|
+
|
|
154
|
+
function draftAnchorFor(
|
|
155
|
+
type: OverlayType,
|
|
156
|
+
index: number,
|
|
157
|
+
total: number,
|
|
158
|
+
): { x: number; y: number } {
|
|
159
|
+
if (type === "narration")
|
|
160
|
+
return { x: 0.08, y: 0.05 + Math.min(index, 2) * 0.18 };
|
|
161
|
+
if (type === "sfx") {
|
|
162
|
+
const col = index % 2;
|
|
163
|
+
const row = Math.floor(index / 2);
|
|
164
|
+
return { x: col === 0 ? 0.1 : 0.62, y: 0.68 + row * 0.12 };
|
|
165
|
+
}
|
|
166
|
+
const row = Math.floor(index / 2);
|
|
167
|
+
const left = index % 2 === 0;
|
|
168
|
+
const y = 0.08 + row * Math.max(0.15, Math.min(0.22, total > 4 ? 0.16 : 0.2));
|
|
169
|
+
return { x: left ? 0.05 : 0.45, y };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a first-pass editable overlay set from the cut script (#494). Pure,
|
|
174
|
+
* deterministic, and intentionally approximate: these are draft bubble/caption
|
|
175
|
+
* positions for the writer to review in the focused editor, not export-ready
|
|
176
|
+
* layout. Empty script pieces produce no overlays.
|
|
177
|
+
*/
|
|
178
|
+
export function buildDraftOverlays(cut: LetteringCut): Overlay[] {
|
|
179
|
+
const lines = cutScriptLines(cut);
|
|
180
|
+
return lines.map((line, index) => {
|
|
181
|
+
const { x, y } = draftAnchorFor(line.type, index, lines.length);
|
|
182
|
+
const overlay = createOverlay(line.type, x, y);
|
|
183
|
+
const comfortable = comfortableOverlaySize(line.type, x, y);
|
|
184
|
+
return {
|
|
185
|
+
...overlay,
|
|
186
|
+
...comfortable,
|
|
187
|
+
text: line.text,
|
|
188
|
+
...(line.type === "speech" ? { speaker: line.speaker ?? "" } : {}),
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|