@gentbajko/slopify 2.0.3 → 2.1.1
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/SUBTITLES.md +1 -1
- package/dist/adapters/alignment/threads.js +11 -0
- package/dist/adapters/alignment/worker.js +38 -4
- package/dist/adapters/host-cli/index.js +25 -1
- package/dist/assets/document/SOURCE.txt +7 -0
- package/dist/assets/document/background.jpg +0 -0
- package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
- package/dist/assets/document/fonts/OFL.txt +93 -0
- package/dist/assets/document/fonts/SOURCE.txt +11 -0
- package/dist/catalog/validate.js +3 -2
- package/dist/edge/docker-projects/state.js +5 -1
- package/dist/edge/host-cli.js +14 -0
- package/dist/edge/http/folder-location-schema.js +5 -8
- package/dist/edge/http/folder-location.js +10 -5
- package/dist/edge/http/host-cli.js +27 -2
- package/dist/edge/http/revision-files.js +5 -1
- package/dist/host-cli/open-folder.js +95 -0
- package/dist/host-cli/runtime.js +2 -0
- package/dist/kernel/db/migrate.js +25 -1
- package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
- package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
- package/dist/kernel/pipeline.js +11 -1
- package/dist/kernel/ports/host-cli.js +14 -0
- package/dist/kernel/runner/graph.js +6 -1
- package/dist/main.js +6 -2
- package/dist/slices/admission/model.js +7 -1
- package/dist/slices/admission/rules.js +67 -3
- package/dist/slices/admission/schema.js +16 -3
- package/dist/slices/admission/start.js +3 -2
- package/dist/slices/document/blocks.js +129 -0
- package/dist/slices/document/flow.js +117 -0
- package/dist/slices/document/fonts.js +54 -0
- package/dist/slices/document/model.js +11 -0
- package/dist/slices/document/pages.js +198 -0
- package/dist/slices/document/render.js +242 -0
- package/dist/slices/document/sources.js +35 -0
- package/dist/slices/document/theme.js +158 -0
- package/dist/slices/document/writer.js +66 -0
- package/dist/slices/estimate/index.js +14 -2
- package/dist/slices/library/model.js +1 -1
- package/dist/slices/library/slots.js +4 -1
- package/dist/slices/play-drafts/convert.js +30 -1
- package/dist/slices/play-drafts/readiness.js +3 -2
- package/dist/slices/play-drafts/schema.js +22 -5
- package/dist/slices/project-templates/from-project.js +10 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/preview-details.js +11 -1
- package/dist/slices/rebuild/recipe-audio.js +3 -0
- package/dist/slices/rebuild/recipe-build.js +6 -1
- package/dist/slices/rebuild/recipe-document.js +36 -0
- package/dist/slices/rebuild/recipe-exports.js +14 -2
- package/dist/slices/rebuild/recipe-fixture.js +4 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +2 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
- package/dist/slices/rebuild/recipe-save.js +15 -3
- package/dist/slices/rebuild/recipe-validation.js +15 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +15 -0
- package/dist/slices/rebuild/recipe-youtube.js +33 -0
- package/dist/slices/rebuild/recovery-selection.js +2 -0
- package/dist/slices/rebuild/recovery.js +10 -5
- package/dist/slices/rebuild/runtime-actions.js +2 -0
- package/dist/slices/rebuild/runtime-document.js +85 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +1 -0
- package/dist/slices/rebuild/runtime-export.js +35 -19
- package/dist/slices/rebuild/runtime-piece-label.js +4 -0
- package/dist/slices/rebuild/runtime-publication.js +1 -0
- package/dist/slices/rebuild/runtime-run.js +11 -3
- package/dist/slices/rebuild/runtime-store.js +7 -2
- package/dist/slices/rebuild/runtime-subtitles.js +6 -3
- package/dist/slices/rebuild/runtime-youtube.js +91 -0
- package/dist/slices/rebuild/transition-repo.js +2 -0
- package/dist/slices/reruns/cascade.js +6 -4
- package/dist/slices/revisions/mutation-cues.js +3 -1
- package/dist/slices/revisions/projection.js +2 -1
- package/dist/slices/storage/downloads.js +1 -0
- package/dist/slices/storage/layout.js +6 -0
- package/dist/slices/storage/model.js +3 -0
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/telemetry/model.js +4 -1
- package/dist/slices/video/audio-export.js +11 -3
- package/dist/slices/video/edit-list.js +73 -0
- package/dist/slices/video/ffmpeg.js +123 -57
- package/dist/slices/video/motion.js +51 -0
- package/dist/slices/video/plan.js +61 -27
- package/dist/slices/video/reuse-audio.js +2 -0
- package/dist/slices/video/run.js +21 -10
- package/dist/slices/video/slideshow.js +48 -0
- package/dist/slices/video/write-export.js +17 -25
- package/dist/slices/youtube/answer.js +199 -0
- package/dist/slices/youtube/model.js +27 -0
- package/dist/slices/youtube/timestamps.js +24 -0
- package/dist/slices/youtube/transcript.js +47 -0
- package/dist/web/assets/index-B6NbhpjD.css +1 -0
- package/dist/web/assets/index-DV01Cyjx.js +142 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-Bwtmu9ei.css +0 -1
- package/dist/web/assets/index-DjhMJqMN.js +0 -141
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
CREATE TABLE prompts_description_upgrade (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
kind TEXT NOT NULL CHECK (kind IN ('article','image','thumbnail','narration','description')),
|
|
4
|
+
name TEXT NOT NULL,
|
|
5
|
+
body TEXT NOT NULL,
|
|
6
|
+
slots TEXT NOT NULL,
|
|
7
|
+
updated_at TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
INSERT INTO prompts_description_upgrade (id,kind,name,body,slots,updated_at)
|
|
10
|
+
SELECT id,kind,name,body,slots,updated_at FROM prompts;
|
|
11
|
+
DROP TABLE prompts;
|
|
12
|
+
ALTER TABLE prompts_description_upgrade RENAME TO prompts;
|
|
13
|
+
CREATE UNIQUE INDEX prompts_name ON prompts(kind,lower(name));
|
package/dist/kernel/pipeline.js
CHANGED
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
// runner may not import a slice to learn them.
|
|
4
4
|
// The frame a run is made in, and the aspect an image is asked for: one set, named once.
|
|
5
5
|
export const formats = ["16:9", "9:16"];
|
|
6
|
-
|
|
6
|
+
// Document is last so every screen that walks this list in order shows the six older
|
|
7
|
+
// stages where they always were.
|
|
8
|
+
export const stageKinds = [
|
|
9
|
+
"research",
|
|
10
|
+
"article",
|
|
11
|
+
"audio",
|
|
12
|
+
"images",
|
|
13
|
+
"thumbnail",
|
|
14
|
+
"video",
|
|
15
|
+
"document",
|
|
16
|
+
];
|
|
7
17
|
export const stageStates = [
|
|
8
18
|
"pending",
|
|
9
19
|
"running",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { messageRoles, thinkingModes } from "./llm.js";
|
|
3
4
|
import { llmDocumentsSchema } from "./llm-documents.js";
|
|
@@ -16,6 +17,9 @@ export const bridgeLimits = {
|
|
|
16
17
|
generation: 5,
|
|
17
18
|
metadata: 8,
|
|
18
19
|
};
|
|
20
|
+
/** A folder the helper refuses to open on purpose; the route answers it with a 403. */
|
|
21
|
+
export class HostFolderRefused extends Error {
|
|
22
|
+
}
|
|
19
23
|
const short = z
|
|
20
24
|
.string()
|
|
21
25
|
.min(1)
|
|
@@ -40,6 +44,16 @@ export const hostImageSchema = z
|
|
|
40
44
|
aspect: z.enum(["16:9", "9:16"]),
|
|
41
45
|
})
|
|
42
46
|
.strict();
|
|
47
|
+
export const hostOpenFolderSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
path: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(4096)
|
|
53
|
+
.refine((v) => isAbsolute(v) && !/[\p{Cc}]/u.test(v)),
|
|
54
|
+
})
|
|
55
|
+
.strict();
|
|
56
|
+
export const hostOpenedSchema = z.object({ opened: z.literal(true) }).strict();
|
|
43
57
|
export const hostStatusSchema = z
|
|
44
58
|
.object({
|
|
45
59
|
id: z.enum(hostCliIds),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Images use saved prompts and can run immediately. A thumbnail's source and an
|
|
2
|
-
// audio-only export narrow these dependencies through dependenciesOf().
|
|
2
|
+
// audio-only export narrow these dependencies through dependenciesOf(). The document is
|
|
3
|
+
// laid out from the article alone, with the thumbnail as its cover when there is one, so it
|
|
4
|
+
// runs beside narration and images rather than after them.
|
|
3
5
|
export const deps = {
|
|
4
6
|
research: [],
|
|
5
7
|
article: ["research"],
|
|
@@ -7,6 +9,7 @@ export const deps = {
|
|
|
7
9
|
images: [],
|
|
8
10
|
thumbnail: ["article"],
|
|
9
11
|
video: ["article", "audio", "images", "thumbnail"],
|
|
12
|
+
document: ["article", "thumbnail"],
|
|
10
13
|
};
|
|
11
14
|
// `provided` and `skipped` release a dependency exactly as `done` does.
|
|
12
15
|
export function satisfied(state) {
|
|
@@ -37,6 +40,8 @@ export function dependenciesOf(kind, sources) {
|
|
|
37
40
|
return [];
|
|
38
41
|
if (kind === "video" && sources.video === "off")
|
|
39
42
|
return ["article", "audio"];
|
|
43
|
+
if (kind === "document" && sources.thumbnail === "off")
|
|
44
|
+
return ["article"];
|
|
40
45
|
return deps[kind];
|
|
41
46
|
}
|
|
42
47
|
// The thin meter under a running row on Projects, averaging stage progress. Every stage
|
package/dist/main.js
CHANGED
|
@@ -27,6 +27,7 @@ import { ulidIds } from "./kernel/ids.js";
|
|
|
27
27
|
import { acquireInstanceLock } from "./kernel/lock.js";
|
|
28
28
|
import { openLog } from "./kernel/log.js";
|
|
29
29
|
import { ensureDirs, layout, subtitleModelDir } from "./kernel/paths.js";
|
|
30
|
+
import { stageKinds } from "./kernel/pipeline.js";
|
|
30
31
|
import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
|
|
31
32
|
import { createCheckpointAuthority, } from "./kernel/runner/checkpoint-authority.js";
|
|
32
33
|
import { createRunner } from "./kernel/runner/index.js";
|
|
@@ -323,7 +324,10 @@ export async function boot(config, options = {}) {
|
|
|
323
324
|
schedules: scheduleDeps,
|
|
324
325
|
...(rebuild.measureAudio === undefined ? {} : { measureAudio: rebuild.measureAudio }),
|
|
325
326
|
openFolder,
|
|
326
|
-
folderLocation:
|
|
327
|
+
folderLocation: {
|
|
328
|
+
...(await dockerFolderConfiguration(process.env, paths.projects)),
|
|
329
|
+
...(hostCli === undefined ? {} : { openOnHost: hostCli.openFolder }),
|
|
330
|
+
},
|
|
327
331
|
...(dockerState === undefined ? {} : { installationPending: () => updater.locked() }),
|
|
328
332
|
db,
|
|
329
333
|
paths,
|
|
@@ -484,7 +488,7 @@ export function wireRunner({ audioPreviews, db, paths, clock, ids, log, hub, tel
|
|
|
484
488
|
});
|
|
485
489
|
},
|
|
486
490
|
},
|
|
487
|
-
runs: Object.fromEntries(
|
|
491
|
+
runs: Object.fromEntries(stageKinds.map((kind) => [
|
|
488
492
|
kind,
|
|
489
493
|
(context) => runRevisionInvocation(execution, context, stageProviders(providers, context)),
|
|
490
494
|
])),
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// The format is the kernel's: the image port asks for the same two aspects.
|
|
2
2
|
export { formats } from "../../kernel/pipeline.js";
|
|
3
3
|
// Generate, Provide or Off for most stages, plus the thumbnail's two Generate modes.
|
|
4
|
-
// Article stays Generate or Provide; Video can be Generate or Off.
|
|
4
|
+
// Article stays Generate or Provide; Video and Document can be Generate or Off.
|
|
5
5
|
export const stageSources = ["generate", "provide", "off", "from_prompt", "prompt_by_llm"];
|
|
6
|
+
export function sourceOf(sources, kind) {
|
|
7
|
+
return sources[kind] ?? "off";
|
|
8
|
+
}
|
|
9
|
+
// How each slideshow image moves while it is on screen: Zoom in and out, Pan across, a
|
|
10
|
+
// Mix of both taking turns, or Still. `video/motion.ts` has the rules.
|
|
11
|
+
export const motionStyles = ["zoom", "pan", "mixed", "still"];
|
|
6
12
|
export const entryModes = ["text", "llm"];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
import { sourceOf } from "./model.js";
|
|
2
3
|
export const titleMax = 200;
|
|
3
4
|
export const valueMax = 200;
|
|
4
5
|
export const numberPerPromptMax = 20;
|
|
@@ -6,6 +7,41 @@ export const imagesPerRunMax = 60;
|
|
|
6
7
|
// ceiling: the default gap is 3 s and nothing fixes an upper or lower bound, so the range
|
|
7
8
|
// is this module's. A wider gap is a settings change, not a schema change.
|
|
8
9
|
export const silenceGapSecondsMax = 30;
|
|
10
|
+
// ceiling: the owner's numbers. A still held past ten minutes is a separate kind of video.
|
|
11
|
+
export const imageSecondsMin = 1;
|
|
12
|
+
export const imageSecondsMax = 600;
|
|
13
|
+
export const defaultImageSeconds = 15;
|
|
14
|
+
// ceiling: the same 30 s as the gap; half seconds are enough precision for a pause.
|
|
15
|
+
export const edgeSilenceSecondsMax = 30;
|
|
16
|
+
export const defaultEdgeSilenceSeconds = 2;
|
|
17
|
+
// ceiling: past 50% the crop loses more of the picture than it shows.
|
|
18
|
+
export const zoomPercentMax = 50;
|
|
19
|
+
export const defaultZoomPercent = 22.5;
|
|
20
|
+
export const defaultMotionStyle = "zoom";
|
|
21
|
+
// The words Play, Edit project and the change preview use for each motion style.
|
|
22
|
+
export const motionStyleLabels = {
|
|
23
|
+
zoom: "Zoom in and out",
|
|
24
|
+
pan: "Pan across",
|
|
25
|
+
mixed: "Mix of both",
|
|
26
|
+
still: "Still",
|
|
27
|
+
};
|
|
28
|
+
// Shared by admission, Play's draft conversion and Edit project, so all three say the same
|
|
29
|
+
// sentence about the same value.
|
|
30
|
+
export function imageSecondsProblem(value) {
|
|
31
|
+
return Number.isInteger(value) && value >= imageSecondsMin && value <= imageSecondsMax
|
|
32
|
+
? undefined
|
|
33
|
+
: `Enter a whole number of seconds between ${imageSecondsMin} and ${imageSecondsMax}.`;
|
|
34
|
+
}
|
|
35
|
+
export function zoomPercentProblem(value) {
|
|
36
|
+
return Number.isInteger(value * 2) && value >= 0 && value <= zoomPercentMax
|
|
37
|
+
? undefined
|
|
38
|
+
: `Enter a zoom between 0 and ${zoomPercentMax} percent, in steps of 0.5.`;
|
|
39
|
+
}
|
|
40
|
+
export function edgeSilenceSecondsProblem(value) {
|
|
41
|
+
return Number.isInteger(value * 2) && value >= 0 && value <= edgeSilenceSecondsMax
|
|
42
|
+
? undefined
|
|
43
|
+
: `Enter a number of seconds between 0 and ${edgeSilenceSecondsMax}, in steps of 0.5.`;
|
|
44
|
+
}
|
|
9
45
|
// The legal source for each stage. Exported because Play's
|
|
10
46
|
// source switches offer exactly these and nothing else, so the segmented control and the
|
|
11
47
|
// refusal below it are the same list and a stage whose legal set changes cannot leave a
|
|
@@ -18,6 +54,7 @@ export const allowedSources = {
|
|
|
18
54
|
images: ["off", "generate", "provide"],
|
|
19
55
|
thumbnail: ["off", "from_prompt", "prompt_by_llm", "provide"],
|
|
20
56
|
video: ["off", "generate"],
|
|
57
|
+
document: ["off", "generate"],
|
|
21
58
|
};
|
|
22
59
|
export function admit(input) {
|
|
23
60
|
const fields = [];
|
|
@@ -30,10 +67,10 @@ export function admit(input) {
|
|
|
30
67
|
fields.push({ field: "title", message: `Keep the title to ${titleMax} characters or fewer.` });
|
|
31
68
|
}
|
|
32
69
|
for (const kind of stageKinds) {
|
|
33
|
-
if (!allowedSources[kind].includes(sources
|
|
70
|
+
if (!allowedSources[kind].includes(sourceOf(sources, kind))) {
|
|
34
71
|
fields.push({
|
|
35
72
|
field: `sources.${kind}`,
|
|
36
|
-
message: `"${sources
|
|
73
|
+
message: `"${sourceOf(sources, kind)}" is not an option for ${kind}. Reload the page and choose again.`,
|
|
37
74
|
});
|
|
38
75
|
}
|
|
39
76
|
}
|
|
@@ -43,7 +80,8 @@ export function admit(input) {
|
|
|
43
80
|
sources.thumbnail === "prompt_by_llm" ||
|
|
44
81
|
draft.intro?.mode === "llm" ||
|
|
45
82
|
draft.outro?.mode === "llm" ||
|
|
46
|
-
usesNarrationPreparation(draft)
|
|
83
|
+
usesNarrationPreparation(draft) ||
|
|
84
|
+
usesYoutubeDescription(draft);
|
|
47
85
|
if (needsLlm && !chosen(draft.llm)) {
|
|
48
86
|
fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
|
|
49
87
|
}
|
|
@@ -79,6 +117,7 @@ export function admit(input) {
|
|
|
79
117
|
message: "Subtitles need narration. Turn narration on, or turn subtitles off.",
|
|
80
118
|
});
|
|
81
119
|
}
|
|
120
|
+
fields.push(...youtubeDescriptionFields(draft));
|
|
82
121
|
checkProvided(draft, input.staged, fields);
|
|
83
122
|
checkValues(draft, input.requiredSlots, fields);
|
|
84
123
|
if (!Number.isFinite(draft.silenceGapSeconds) ||
|
|
@@ -89,6 +128,16 @@ export function admit(input) {
|
|
|
89
128
|
message: `Enter a silence gap between 0 and ${silenceGapSecondsMax} seconds.`,
|
|
90
129
|
});
|
|
91
130
|
}
|
|
131
|
+
// Checked only where they are used: a hidden control cannot hold up a run.
|
|
132
|
+
const imageProblem = sources.video === "generate" ? imageSecondsProblem(draft.imageSeconds) : undefined;
|
|
133
|
+
if (imageProblem !== undefined)
|
|
134
|
+
fields.push({ field: "imageSeconds", message: imageProblem });
|
|
135
|
+
const zoomProblem = sources.video === "generate" ? zoomPercentProblem(draft.zoomPercent) : undefined;
|
|
136
|
+
if (zoomProblem !== undefined)
|
|
137
|
+
fields.push({ field: "zoomPercent", message: zoomProblem });
|
|
138
|
+
const edgeProblem = sources.audio === "off" ? undefined : edgeSilenceSecondsProblem(draft.edgeSilenceSeconds);
|
|
139
|
+
if (edgeProblem !== undefined)
|
|
140
|
+
fields.push({ field: "edgeSilenceSeconds", message: edgeProblem });
|
|
92
141
|
return fields.length === 0 ? { ok: true, draft } : { ok: false, fields };
|
|
93
142
|
}
|
|
94
143
|
// Research only feeds article writing, so a provided article hides it.
|
|
@@ -241,6 +290,21 @@ export function narrationPreparationFields(draft) {
|
|
|
241
290
|
]
|
|
242
291
|
: [];
|
|
243
292
|
}
|
|
293
|
+
// The YouTube description is written from the narration's word timings, so it needs
|
|
294
|
+
// narration; it runs whether the video renders or only the WAV is exported.
|
|
295
|
+
export function usesYoutubeDescription(draft) {
|
|
296
|
+
return draft.youtubeDescription === true && draft.sources.audio !== "off";
|
|
297
|
+
}
|
|
298
|
+
export function youtubeDescriptionFields(draft) {
|
|
299
|
+
return draft.youtubeDescription === true && draft.sources.audio === "off"
|
|
300
|
+
? [
|
|
301
|
+
{
|
|
302
|
+
field: "youtubeDescription",
|
|
303
|
+
message: "The YouTube description is timed from the narration. Turn narration on, or turn the YouTube description off.",
|
|
304
|
+
},
|
|
305
|
+
]
|
|
306
|
+
: [];
|
|
307
|
+
}
|
|
244
308
|
function chosen(choice) {
|
|
245
309
|
return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
|
|
246
310
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
3
3
|
import { checkpointStageSchema } from "../checkpoints/schema.js";
|
|
4
|
+
import { documentThemes } from "../document/model.js";
|
|
4
5
|
import { chunkModes } from "../narration/chunk.js";
|
|
5
6
|
import { subtitleConfigSchema } from "../subtitles/model.js";
|
|
6
|
-
import { entryModes, formats, stageSources } from "./model.js";
|
|
7
|
+
import { entryModes, formats, motionStyles, stageSources } from "./model.js";
|
|
8
|
+
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "./rules.js";
|
|
7
9
|
const providerChoice = z.object({
|
|
8
10
|
provider: z.string(),
|
|
9
11
|
model: z.string(),
|
|
@@ -21,8 +23,9 @@ export const runDraftSchema = z.object({
|
|
|
21
23
|
.optional(),
|
|
22
24
|
title: z.string(),
|
|
23
25
|
format: z.enum(formats),
|
|
24
|
-
// Spelled out rather than built from stageKinds, so the inferred type carries the
|
|
25
|
-
// keys and this schema stays assignable to the domain type without a cast.
|
|
26
|
+
// Spelled out rather than built from stageKinds, so the inferred type carries the
|
|
27
|
+
// keys and this schema stays assignable to the domain type without a cast. Document is
|
|
28
|
+
// optional: everything saved before it existed has no such key, and absent means Off.
|
|
26
29
|
sources: z.object({
|
|
27
30
|
research: z.enum(stageSources),
|
|
28
31
|
article: z.enum(stageSources),
|
|
@@ -30,6 +33,7 @@ export const runDraftSchema = z.object({
|
|
|
30
33
|
images: z.enum(stageSources),
|
|
31
34
|
thumbnail: z.enum(stageSources),
|
|
32
35
|
video: z.enum(stageSources),
|
|
36
|
+
document: z.enum(stageSources).optional(),
|
|
33
37
|
}),
|
|
34
38
|
llm: providerChoice.optional(),
|
|
35
39
|
audio: providerChoice
|
|
@@ -60,7 +64,16 @@ export const runDraftSchema = z.object({
|
|
|
60
64
|
})
|
|
61
65
|
.optional(),
|
|
62
66
|
silenceGapSeconds: z.number(),
|
|
67
|
+
// Defaulted rather than required, so a config, revision, draft review or backup saved
|
|
68
|
+
// before these existed still parses. The range is `rules.ts`'s, not the schema's.
|
|
69
|
+
imageSeconds: z.number().default(defaultImageSeconds),
|
|
70
|
+
zoomPercent: z.number().default(defaultZoomPercent),
|
|
71
|
+
motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
|
|
72
|
+
edgeSilenceSeconds: z.number().default(defaultEdgeSilenceSeconds),
|
|
63
73
|
subtitles: subtitleConfigSchema.optional(),
|
|
74
|
+
document: z.object({ theme: z.enum(documentThemes) }).optional(),
|
|
75
|
+
youtubeDescription: z.boolean().optional(),
|
|
76
|
+
descriptionPrompt: z.string().optional(),
|
|
64
77
|
});
|
|
65
78
|
export const runConfigSchema = runDraftSchema.extend({
|
|
66
79
|
rendered: z.record(z.string(), z.string()),
|
|
@@ -5,6 +5,7 @@ import { admitInitialRevision } from "../rebuild/runtime-admission.js";
|
|
|
5
5
|
import { adoptBaseline } from "../revisions/adopt.js";
|
|
6
6
|
import { attachStagedFile, storeText } from "../storage/staging.js";
|
|
7
7
|
import { releaseStagedFile } from "../storage/staging-refs.js";
|
|
8
|
+
import { sourceOf, } from "./model.js";
|
|
8
9
|
import { insertProject, insertStage } from "./repo.js";
|
|
9
10
|
// Provide → `provided` with its output attached; Off → `skipped`;
|
|
10
11
|
// everything else → `pending`.
|
|
@@ -32,10 +33,10 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
|
|
|
32
33
|
id: deps.ids.next(),
|
|
33
34
|
projectId: id,
|
|
34
35
|
kind,
|
|
35
|
-
source: draft.sources
|
|
36
|
+
source: sourceOf(draft.sources, kind),
|
|
36
37
|
state: kind === "video" && draft.sources.video === "off" && draft.sources.audio !== "off"
|
|
37
38
|
? "pending"
|
|
38
|
-
: initialState(draft.sources
|
|
39
|
+
: initialState(sourceOf(draft.sources, kind)),
|
|
39
40
|
failureReason: null,
|
|
40
41
|
attemptCount: 0,
|
|
41
42
|
progressCurrent: null,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { remark } from "remark";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
const plainStyle = { strong: false, emphasis: false, href: null };
|
|
4
|
+
export function markdownBlocks(markdown) {
|
|
5
|
+
const tree = remark().use(remarkGfm).parse(markdown);
|
|
6
|
+
return tree.children.flatMap((node) => blocksOf(node, 0));
|
|
7
|
+
}
|
|
8
|
+
export function runsText(runs) {
|
|
9
|
+
return runs.map((run) => run.text).join("");
|
|
10
|
+
}
|
|
11
|
+
// Every link in the markdown, bare URLs included (remark-gfm makes those links too).
|
|
12
|
+
export function markdownLinks(markdown) {
|
|
13
|
+
const links = [];
|
|
14
|
+
const visit = (node) => {
|
|
15
|
+
if (node.type === "link") {
|
|
16
|
+
const text = collapse(inlineRuns(node.children, plainStyle)
|
|
17
|
+
.map((run) => run.text)
|
|
18
|
+
.join(""));
|
|
19
|
+
links.push({ text: text === "" ? node.url : text, href: node.url });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if ("children" in node)
|
|
23
|
+
for (const child of node.children)
|
|
24
|
+
visit(child);
|
|
25
|
+
};
|
|
26
|
+
visit(remark().use(remarkGfm).parse(markdown));
|
|
27
|
+
return links;
|
|
28
|
+
}
|
|
29
|
+
function blocksOf(node, depth) {
|
|
30
|
+
switch (node.type) {
|
|
31
|
+
case "heading": {
|
|
32
|
+
const text = collapse(runsText(inlineRuns(node.children, plainStyle)));
|
|
33
|
+
return text === ""
|
|
34
|
+
? []
|
|
35
|
+
: [{ kind: "heading", level: node.depth === 1 ? 1 : node.depth === 2 ? 2 : 3, text }];
|
|
36
|
+
}
|
|
37
|
+
case "paragraph":
|
|
38
|
+
return paragraph(inlineRuns(node.children, plainStyle));
|
|
39
|
+
case "list":
|
|
40
|
+
return node.children.flatMap((item, index) => {
|
|
41
|
+
const marker = node.ordered ? `${(node.start ?? 1) + index}.` : "•";
|
|
42
|
+
let first = true;
|
|
43
|
+
return item.children.flatMap((child) => {
|
|
44
|
+
if (child.type === "list")
|
|
45
|
+
return blocksOf(child, depth + 1);
|
|
46
|
+
const runs = child.type === "paragraph"
|
|
47
|
+
? inlineRuns(child.children, plainStyle)
|
|
48
|
+
: blocksOf(child, depth).flatMap((block) => ("runs" in block ? block.runs : []));
|
|
49
|
+
if (tidy(runs).length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const block = {
|
|
52
|
+
kind: "item",
|
|
53
|
+
depth,
|
|
54
|
+
marker: first ? marker : "",
|
|
55
|
+
runs: tidy(runs),
|
|
56
|
+
};
|
|
57
|
+
first = false;
|
|
58
|
+
return [block];
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
case "blockquote":
|
|
62
|
+
return node.children.flatMap((child) => blocksOf(child, depth).flatMap((block) => "runs" in block ? [{ kind: "quote", runs: block.runs }] : [block]));
|
|
63
|
+
case "thematicBreak":
|
|
64
|
+
return [{ kind: "rule" }];
|
|
65
|
+
case "code":
|
|
66
|
+
return paragraph([{ ...plainStyle, text: node.value }]);
|
|
67
|
+
case "table":
|
|
68
|
+
return node.children.flatMap((row) => paragraph(row.children.flatMap((cell, index) => [
|
|
69
|
+
...(index === 0 ? [] : [{ ...plainStyle, text: " | " }]),
|
|
70
|
+
...inlineRuns(cell.children, plainStyle),
|
|
71
|
+
])));
|
|
72
|
+
// Raw HTML, footnote definitions, front matter and images have no printed form here.
|
|
73
|
+
default:
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function paragraph(runs) {
|
|
78
|
+
const tidied = tidy(runs);
|
|
79
|
+
return tidied.length === 0 ? [] : [{ kind: "paragraph", runs: tidied }];
|
|
80
|
+
}
|
|
81
|
+
function inlineRuns(nodes, style) {
|
|
82
|
+
return nodes.flatMap((node) => {
|
|
83
|
+
switch (node.type) {
|
|
84
|
+
case "text":
|
|
85
|
+
case "inlineCode":
|
|
86
|
+
return [{ ...style, text: node.value }];
|
|
87
|
+
case "strong":
|
|
88
|
+
return inlineRuns(node.children, { ...style, strong: true });
|
|
89
|
+
case "emphasis":
|
|
90
|
+
return inlineRuns(node.children, { ...style, emphasis: true });
|
|
91
|
+
case "delete":
|
|
92
|
+
return inlineRuns(node.children, style);
|
|
93
|
+
case "link":
|
|
94
|
+
return inlineRuns(node.children, { ...style, href: node.url });
|
|
95
|
+
case "break":
|
|
96
|
+
return [{ ...style, text: "\n" }];
|
|
97
|
+
case "footnoteReference":
|
|
98
|
+
return [{ ...style, text: `[${node.label ?? node.identifier}]` }];
|
|
99
|
+
case "image":
|
|
100
|
+
return node.alt ? [{ ...style, text: node.alt }] : [];
|
|
101
|
+
default:
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Soft line breaks and runs of spaces become one space; a hard break stays a line break.
|
|
107
|
+
function tidy(runs) {
|
|
108
|
+
const tidied = runs
|
|
109
|
+
.map((run) => (run.text === "\n" ? run : { ...run, text: run.text.replace(/\s+/g, " ") }))
|
|
110
|
+
.filter((run) => run.text !== "");
|
|
111
|
+
const first = tidied[0];
|
|
112
|
+
const last = tidied.at(-1);
|
|
113
|
+
if (first === undefined || last === undefined)
|
|
114
|
+
return [];
|
|
115
|
+
const trimmed = tidied.map((run, index) => ({
|
|
116
|
+
...run,
|
|
117
|
+
text: index === 0 && index === tidied.length - 1
|
|
118
|
+
? run.text.trim()
|
|
119
|
+
: index === 0
|
|
120
|
+
? run.text.trimStart()
|
|
121
|
+
: index === tidied.length - 1
|
|
122
|
+
? run.text.trimEnd()
|
|
123
|
+
: run.text,
|
|
124
|
+
}));
|
|
125
|
+
return trimmed.filter((run) => run.text !== "");
|
|
126
|
+
}
|
|
127
|
+
function collapse(text) {
|
|
128
|
+
return text.replace(/\s+/g, " ").trim();
|
|
129
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { faceWidth, useFace } from "./fonts.js";
|
|
2
|
+
// `room(index)` answers where line `index` starts, relative to the paragraph's left edge,
|
|
3
|
+
// and how wide it may be: a drop cap narrows the first few.
|
|
4
|
+
export function wrapRuns(doc, runs, style, room) {
|
|
5
|
+
const spaceWidth = faceWidth(doc, style.body, style.size, " ");
|
|
6
|
+
const lines = [];
|
|
7
|
+
let line = [];
|
|
8
|
+
let x = 0;
|
|
9
|
+
const limit = () => room(lines.length);
|
|
10
|
+
const finish = () => {
|
|
11
|
+
lines.push(line);
|
|
12
|
+
line = [];
|
|
13
|
+
x = 0;
|
|
14
|
+
};
|
|
15
|
+
const place = (atom) => {
|
|
16
|
+
const { indent } = limit();
|
|
17
|
+
line.push({
|
|
18
|
+
text: atom.text,
|
|
19
|
+
face: faceOf(atom.run, style),
|
|
20
|
+
color: atom.run.href === null ? style.color : style.linkColor,
|
|
21
|
+
href: atom.run.href,
|
|
22
|
+
x: indent + x,
|
|
23
|
+
width: atom.width,
|
|
24
|
+
});
|
|
25
|
+
x += atom.width;
|
|
26
|
+
};
|
|
27
|
+
for (const token of tokens(doc, runs, style)) {
|
|
28
|
+
if (token.kind === "break") {
|
|
29
|
+
finish();
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const width = token.atoms.reduce((sum, atom) => sum + atom.width, 0);
|
|
33
|
+
const gap = line.length > 0 && token.space ? spaceWidth : 0;
|
|
34
|
+
if (line.length > 0 && x + gap + width > limit().width)
|
|
35
|
+
finish();
|
|
36
|
+
else
|
|
37
|
+
x += gap;
|
|
38
|
+
if (width <= limit().width) {
|
|
39
|
+
for (const atom of token.atoms)
|
|
40
|
+
place(atom);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const atom of token.atoms)
|
|
44
|
+
for (const character of [...atom.text]) {
|
|
45
|
+
const piece = { ...atom, text: character, width: measure(doc, atom.run, style, character) };
|
|
46
|
+
if (line.length > 0 && x + piece.width > limit().width)
|
|
47
|
+
finish();
|
|
48
|
+
place(piece);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (line.length > 0)
|
|
52
|
+
finish();
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
export function drawLine(doc, line, left, y, size) {
|
|
56
|
+
for (const piece of line) {
|
|
57
|
+
useFace(doc, piece.face, size);
|
|
58
|
+
doc.setTextColor(piece.color.r, piece.color.g, piece.color.b);
|
|
59
|
+
doc.text(piece.text, left + piece.x, y);
|
|
60
|
+
}
|
|
61
|
+
// One clickable box per run of pieces sharing a link, rather than one per word.
|
|
62
|
+
let start;
|
|
63
|
+
let end;
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (start?.href != null && end !== undefined)
|
|
66
|
+
doc.link(left + start.x, y - size * 0.3528, end.x + end.width - start.x, size * 0.45, {
|
|
67
|
+
url: start.href,
|
|
68
|
+
});
|
|
69
|
+
start = undefined;
|
|
70
|
+
end = undefined;
|
|
71
|
+
};
|
|
72
|
+
for (const piece of line) {
|
|
73
|
+
if (piece.href !== start?.href)
|
|
74
|
+
flush();
|
|
75
|
+
if (piece.href !== null) {
|
|
76
|
+
start ??= piece;
|
|
77
|
+
end = piece;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
flush();
|
|
81
|
+
doc.setCharSpace(0);
|
|
82
|
+
}
|
|
83
|
+
function tokens(doc, runs, style) {
|
|
84
|
+
const result = [];
|
|
85
|
+
let space = false;
|
|
86
|
+
for (const run of runs) {
|
|
87
|
+
for (const part of run.text.split(/( +|\n)/)) {
|
|
88
|
+
if (part === "")
|
|
89
|
+
continue;
|
|
90
|
+
if (part === "\n") {
|
|
91
|
+
result.push({ kind: "break" });
|
|
92
|
+
space = false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (/^ +$/.test(part)) {
|
|
96
|
+
space = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const atom = { text: part, run, width: measure(doc, run, style, part) };
|
|
100
|
+
const last = result.at(-1);
|
|
101
|
+
// No space before this part: it continues the previous word, as the comma after a
|
|
102
|
+
// bold word does, and must not be wrapped away from it.
|
|
103
|
+
if (!space && last?.kind === "word")
|
|
104
|
+
result[result.length - 1] = { ...last, atoms: [...last.atoms, atom] };
|
|
105
|
+
else
|
|
106
|
+
result.push({ kind: "word", atoms: [atom], space });
|
|
107
|
+
space = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
function measure(doc, run, style, text) {
|
|
113
|
+
return faceWidth(doc, faceOf(run, style), style.size, text);
|
|
114
|
+
}
|
|
115
|
+
function faceOf(run, style) {
|
|
116
|
+
return run.strong ? style.strong : run.emphasis ? style.emphasis : style.body;
|
|
117
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// The bundled Cinzel weights (SIL OFL 1.1, assets/document/fonts/OFL.txt) and the page
|
|
3
|
+
// texture. Read on every render rather than kept in memory: four fonts and one image are
|
|
4
|
+
// under a megabyte, and a document is made once per article.
|
|
5
|
+
const assets = new URL("../../assets/document/", import.meta.url);
|
|
6
|
+
const cinzelFiles = {
|
|
7
|
+
normal: "Cinzel-Regular.ttf",
|
|
8
|
+
medium: "Cinzel-Medium.ttf",
|
|
9
|
+
bold: "Cinzel-Bold.ttf",
|
|
10
|
+
black: "Cinzel-Black.ttf",
|
|
11
|
+
};
|
|
12
|
+
export function readDocumentAssets() {
|
|
13
|
+
try {
|
|
14
|
+
const cinzel = Object.fromEntries(Object.entries(cinzelFiles).map(([style, file]) => [
|
|
15
|
+
style,
|
|
16
|
+
readFileSync(new URL(`fonts/${file}`, assets)).toString("base64"),
|
|
17
|
+
]));
|
|
18
|
+
return { cinzel, parchment: new Uint8Array(readFileSync(new URL("background.jpg", assets))) };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new Error(`Slopify couldn't read the fonts and page texture it makes documents with (${error instanceof Error ? error.message : String(error)}). Its installation is incomplete: reinstall or update Slopify, then use Retry stage on Document.`, { cause: error });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function registerFonts(doc, fonts) {
|
|
25
|
+
for (const [style, file] of Object.entries(cinzelFiles)) {
|
|
26
|
+
doc.addFileToVFS(file, fonts[style]);
|
|
27
|
+
doc.addFont(file, "Cinzel", style);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Cinzel exists only in the four bundled weights; the standard fonts only in normal, bold,
|
|
31
|
+
// italic and bold italic. A face asking for anything else gets the nearest one.
|
|
32
|
+
export function useFace(doc, face, size) {
|
|
33
|
+
doc.setFont(face.family, styleFor(face));
|
|
34
|
+
doc.setFontSize(size);
|
|
35
|
+
doc.setCharSpace(face.letterSpacing);
|
|
36
|
+
}
|
|
37
|
+
export function faceWidth(doc, face, size, text) {
|
|
38
|
+
useFace(doc, face, size);
|
|
39
|
+
return doc.getTextWidth(text) + face.letterSpacing * [...text].length;
|
|
40
|
+
}
|
|
41
|
+
function styleFor(face) {
|
|
42
|
+
if (face.family === "Cinzel") {
|
|
43
|
+
if (face.style === "italic")
|
|
44
|
+
return "normal";
|
|
45
|
+
if (face.style === "bolditalic")
|
|
46
|
+
return "bold";
|
|
47
|
+
return face.style;
|
|
48
|
+
}
|
|
49
|
+
if (face.style === "medium")
|
|
50
|
+
return "normal";
|
|
51
|
+
if (face.style === "black")
|
|
52
|
+
return "bold";
|
|
53
|
+
return face.style;
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The built-in looks a project's PDF can take. Browser-safe: Play, Edit project and the
|
|
2
|
+
// server all offer and accept the same names from here.
|
|
3
|
+
export const documentThemes = ["dicemaster", "plain"];
|
|
4
|
+
export const defaultDocumentTheme = "dicemaster";
|
|
5
|
+
export const documentThemeLabels = {
|
|
6
|
+
dicemaster: "DiceMaster",
|
|
7
|
+
plain: "Plain",
|
|
8
|
+
};
|
|
9
|
+
export function documentThemeOf(settings) {
|
|
10
|
+
return settings?.theme ?? defaultDocumentTheme;
|
|
11
|
+
}
|