@gentbajko/slopify 3.9.1 → 3.10.0
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/dist/catalog/model-info.js +33 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/channels.js +6 -1
- package/dist/edge/http/document-themes.js +2 -2
- package/dist/edge/http/files.js +6 -2
- package/dist/edge/http/outputs.js +67 -0
- package/dist/edge/http/planning.js +18 -3
- package/dist/edge/http/projects.js +7 -4
- package/dist/edge/http/providers.js +4 -6
- package/dist/edge/http/revision-files.js +25 -7
- package/dist/edge/http/schedules.js +30 -0
- package/dist/edge/http/trash.js +51 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
- package/dist/patch-notes/3.10.0.md +79 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/heads.js +59 -0
- package/dist/slices/admission/rules.js +6 -1
- package/dist/slices/batch/index.js +22 -0
- package/dist/slices/channels/rebrand.js +7 -1
- package/dist/slices/channels/repo.js +2 -1
- package/dist/slices/channels/schema.js +5 -0
- package/dist/slices/channels/service.js +23 -3
- package/dist/slices/estimate/index.js +2 -0
- package/dist/slices/library/transfer.js +164 -0
- package/dist/slices/narration/aliases-table.js +82 -0
- package/dist/slices/outputs/add.js +192 -0
- package/dist/slices/outputs/model.js +36 -0
- package/dist/slices/outputs/service.js +136 -0
- package/dist/slices/rebuild/model.js +1 -1
- package/dist/slices/rebuild/recipe-text.js +9 -4
- package/dist/slices/rebuild/recipe-visual.js +6 -0
- package/dist/slices/rebuild/runtime-local.js +10 -3
- package/dist/slices/rebuild/runtime-provider.js +7 -0
- package/dist/slices/revisions/downloads.js +38 -17
- package/dist/slices/revisions/model.js +3 -1
- package/dist/slices/revisions/mutation-assets.js +29 -2
- package/dist/slices/revisions/mutation-prepare.js +44 -9
- package/dist/slices/revisions/schema.js +4 -0
- package/dist/slices/schedules/agenda.js +21 -16
- package/dist/slices/schedules/held-bulk.js +66 -0
- package/dist/slices/schedules/topics.js +1 -1
- package/dist/slices/storage/backup-import.js +8 -4
- package/dist/slices/storage/downloads.js +42 -4
- package/dist/slices/voices/adaptation.js +19 -0
- package/dist/slices/voices/model.js +4 -2
- package/dist/slices/youtube/model.js +33 -2
- package/dist/tutorials/AI-CLIs.md +1 -1
- package/dist/tutorials/Backups.md +5 -5
- package/dist/tutorials/Calendar.md +2 -2
- package/dist/tutorials/Channels.md +1 -1
- package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
- package/dist/tutorials/Document-Themes.md +2 -2
- package/dist/tutorials/Editing-a-Project.md +2 -2
- package/dist/tutorials/FAQ.md +1 -1
- package/dist/tutorials/Home-and-Projects.md +33 -22
- package/dist/tutorials/Intros-and-Outros.md +4 -4
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
- package/dist/tutorials/Library-Overview.md +16 -15
- package/dist/tutorials/Multiple-Voices.md +3 -3
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
- package/dist/tutorials/PDF-Documents.md +5 -5
- package/dist/tutorials/Play-Narration.md +3 -3
- package/dist/tutorials/Play-Outputs.md +1 -1
- package/dist/tutorials/Play-Overview.md +4 -4
- package/dist/tutorials/Play-Title-and-Article.md +3 -3
- package/dist/tutorials/Project-Page.md +7 -6
- package/dist/tutorials/Prompts.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +2 -2
- package/dist/tutorials/Recovery-and-Retries.md +1 -1
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +35 -31
- package/dist/tutorials/Templates.md +1 -1
- package/dist/tutorials/Trash.md +5 -5
- package/dist/tutorials/Troubleshooting.md +2 -2
- package/dist/tutorials/Where-Your-Files-Live.md +4 -4
- package/dist/web/assets/index-CW_6Ho-M.js +325 -0
- package/dist/web/assets/index-DmPZKDb1.css +1 -0
- package/dist/web/assets/{pdf-D04vvdOD.js → pdf-C4K73cRQ.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-IWb_SctN.js +0 -310
- package/dist/web/assets/index-_D68Tk8q.css +0 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { runConfigSchema } from "../admission/schema.js";
|
|
3
|
+
import { costEstimateSchema } from "../rebuild/model.js";
|
|
4
|
+
import { revisionViewSchema } from "../revisions/schema.js";
|
|
5
|
+
import { addableOutputs } from "./add.js";
|
|
6
|
+
// The add-another-output requests and replies, shared by the route and the project page.
|
|
7
|
+
const revisionId = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(64)
|
|
11
|
+
.regex(/^[0-9A-Za-z_-]+$/);
|
|
12
|
+
export const addOutputPreviewRequestSchema = z
|
|
13
|
+
.object({ baseRevisionId: revisionId, kind: z.enum(addableOutputs) })
|
|
14
|
+
.strict();
|
|
15
|
+
export const addOutputRequestSchema = addOutputPreviewRequestSchema
|
|
16
|
+
.extend({ idempotencyKey: z.uuid(), adapt: z.boolean().optional() })
|
|
17
|
+
.strict();
|
|
18
|
+
const fieldSchema = z.object({ field: z.string(), message: z.string() });
|
|
19
|
+
export const addedOutputPreviewSchema = z.object({
|
|
20
|
+
kind: z.enum(addableOutputs),
|
|
21
|
+
label: z.string(),
|
|
22
|
+
reused: z.array(z.string()),
|
|
23
|
+
created: z.array(z.string()),
|
|
24
|
+
textUse: z.string(),
|
|
25
|
+
adapts: z.boolean(),
|
|
26
|
+
estimate: costEstimateSchema,
|
|
27
|
+
problems: z.array(fieldSchema),
|
|
28
|
+
// The settings with the output switched on, for Settings to open with when a choice is missing.
|
|
29
|
+
config: runConfigSchema,
|
|
30
|
+
});
|
|
31
|
+
export const addedOutputSchema = z.object({
|
|
32
|
+
ok: z.literal(true),
|
|
33
|
+
view: revisionViewSchema,
|
|
34
|
+
workKeys: z.array(z.string()),
|
|
35
|
+
duplicate: z.boolean(),
|
|
36
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
import { sourceOf } from "../admission/model.js";
|
|
3
|
+
import { admit } from "../admission/rules.js";
|
|
4
|
+
import { estimateRun } from "../estimate/index.js";
|
|
5
|
+
import { retainedPreviewPlan } from "../rebuild/preview-retained.js";
|
|
6
|
+
import { saveRevision } from "../revisions/mutations.js";
|
|
7
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
8
|
+
import { getRevisionView } from "../revisions/view.js";
|
|
9
|
+
import { addableOutputLabels, planAddedOutput, } from "./add.js";
|
|
10
|
+
function planned(deps, projectId, baseRevisionId, kind) {
|
|
11
|
+
const view = getRevisionView(deps, projectId, baseRevisionId);
|
|
12
|
+
if (view === undefined)
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
reason: currentRevisionId(deps.db, projectId) === undefined ? "no-project" : "no-revision",
|
|
16
|
+
};
|
|
17
|
+
const result = planAddedOutput(view.revision.config, kind);
|
|
18
|
+
if (!result.ok)
|
|
19
|
+
return result;
|
|
20
|
+
if (kind !== "images" && (view.articleMarkdown ?? "").trim() === "")
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
reason: "needs-article",
|
|
24
|
+
message: `${addableOutputLabels[kind]} is made from the article, which is not written yet. Wait for the Article step to finish, then add it.`,
|
|
25
|
+
};
|
|
26
|
+
return { ok: true, view, plan: result.plan };
|
|
27
|
+
}
|
|
28
|
+
export function previewAddedOutput(deps, input) {
|
|
29
|
+
const found = planned(deps, input.projectId, input.baseRevisionId, input.kind);
|
|
30
|
+
if (!found.ok)
|
|
31
|
+
return found;
|
|
32
|
+
if (!found.view.current)
|
|
33
|
+
return { ok: false, reason: "conflict" };
|
|
34
|
+
const { view, plan } = found;
|
|
35
|
+
const text = view.articleMarkdown ?? "";
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
value: {
|
|
39
|
+
kind: plan.kind,
|
|
40
|
+
label: addableOutputLabels[plan.kind],
|
|
41
|
+
reused: plan.reused,
|
|
42
|
+
created: plan.created,
|
|
43
|
+
textUse: plan.textUse,
|
|
44
|
+
adapts: plan.adapts,
|
|
45
|
+
estimate: estimateRun(addedOnly(view.revision.config, plan.config, text), plan.config.rendered, words(text), deps.catalogue),
|
|
46
|
+
problems: newProblems(view.revision.config, plan.config),
|
|
47
|
+
config: plan.config,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Saves the revision that switches the new output on, and names the work only it needs: the
|
|
52
|
+
// keys the previous revision did not have, minus anything the new revision can reuse. The
|
|
53
|
+
// caller previews and starts exactly those, so nothing already accepted runs again.
|
|
54
|
+
export async function addOutput(deps, input) {
|
|
55
|
+
const found = planned(deps, input.projectId, input.baseRevisionId, input.kind);
|
|
56
|
+
if (!found.ok)
|
|
57
|
+
return found;
|
|
58
|
+
const { view, plan } = found;
|
|
59
|
+
if (plan.adapts && input.adapt !== true)
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: "adaptation-not-accepted",
|
|
63
|
+
message: `${addableOutputLabels[plan.kind]} rewrites your article as a conversation. Tick "Adapt my article into a conversation" in Add another output, then add it.`,
|
|
64
|
+
};
|
|
65
|
+
const problems = newProblems(view.revision.config, plan.config);
|
|
66
|
+
if (problems.length > 0)
|
|
67
|
+
return { ok: false, reason: "invalid-edit", fields: problems };
|
|
68
|
+
const catalogue = catalogueOf(deps);
|
|
69
|
+
const before = new Set(retainedPreviewPlan(deps, view, catalogue).work.map((row) => row.key));
|
|
70
|
+
const saved = await saveRevision(deps, {
|
|
71
|
+
projectId: input.projectId,
|
|
72
|
+
baseRevisionId: input.baseRevisionId,
|
|
73
|
+
idempotencyKey: input.idempotencyKey,
|
|
74
|
+
edit: { config: plan.config, content: view.revision.content },
|
|
75
|
+
});
|
|
76
|
+
if (!saved.ok)
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: saved.reason,
|
|
80
|
+
...(saved.fields === undefined ? {} : { fields: saved.fields }),
|
|
81
|
+
};
|
|
82
|
+
const workKeys = retainedPreviewPlan(deps, saved.view, catalogue)
|
|
83
|
+
.work.filter((row) => !before.has(row.key) && row.disposition !== "reuse")
|
|
84
|
+
.map((row) => row.key);
|
|
85
|
+
return { ok: true, view: saved.view, workKeys, duplicate: saved.duplicate };
|
|
86
|
+
}
|
|
87
|
+
// The new output priced on its own: the accepted text as supplied, the stages already made
|
|
88
|
+
// switched off (narration as supplied when a video is added over it).
|
|
89
|
+
function addedOnly(before, after, text) {
|
|
90
|
+
const made = stageKinds.filter((kind) => sourceOf(before.sources, kind) !== "off");
|
|
91
|
+
const sources = {
|
|
92
|
+
...after.sources,
|
|
93
|
+
...Object.fromEntries(made.map((kind) => [kind, suppliedSource(kind)])),
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
...after,
|
|
97
|
+
sources,
|
|
98
|
+
provided: { ...after.provided, article: text },
|
|
99
|
+
...(sourceOf(before.sources, "video") === "off"
|
|
100
|
+
? {}
|
|
101
|
+
: { youtubeDescription: undefined, shorts: undefined }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function suppliedSource(kind) {
|
|
105
|
+
return kind === "audio" || kind === "images" || kind === "article" ? "provide" : "off";
|
|
106
|
+
}
|
|
107
|
+
// What admission refuses for the new configuration that it did not refuse before: the choices
|
|
108
|
+
// the new output needs and the project lacks. Problems the project already had stay its own.
|
|
109
|
+
function newProblems(before, after) {
|
|
110
|
+
const fieldsOf = (config) => {
|
|
111
|
+
const result = admit({ draft: config, staged: [], requiredSlots: [] });
|
|
112
|
+
return result.ok ? [] : result.fields;
|
|
113
|
+
};
|
|
114
|
+
const known = new Set(fieldsOf(before).map((one) => `${one.field}\n${one.message}`));
|
|
115
|
+
return fieldsOf(after)
|
|
116
|
+
.filter((one) => !known.has(`${one.field}\n${one.message}`))
|
|
117
|
+
.map((one) => ({ field: one.field, message: settingsHint(one) }));
|
|
118
|
+
}
|
|
119
|
+
function settingsHint(problem) {
|
|
120
|
+
return /Settings|Edit project/.test(problem.message)
|
|
121
|
+
? problem.message
|
|
122
|
+
: `${problem.message} Set it in this project's Settings, save, then add the output again.`;
|
|
123
|
+
}
|
|
124
|
+
function catalogueOf(deps) {
|
|
125
|
+
return (deps.catalogue?.read() ?? {
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
updatedAt: "",
|
|
128
|
+
providers: {},
|
|
129
|
+
llm: [],
|
|
130
|
+
tts: [],
|
|
131
|
+
image: [],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function words(text) {
|
|
135
|
+
return text.split(/\s+/u).filter((one) => one !== "").length;
|
|
136
|
+
}
|
|
@@ -34,7 +34,7 @@ const costRowSchema = z.object({
|
|
|
34
34
|
apiLow: z.number().finite().nonnegative().nullable().optional(),
|
|
35
35
|
apiHigh: z.number().finite().nonnegative().nullable().optional(),
|
|
36
36
|
});
|
|
37
|
-
const costEstimateSchema = z.object({
|
|
37
|
+
export const costEstimateSchema = z.object({
|
|
38
38
|
currency: z.literal("USD"),
|
|
39
39
|
rows: z.array(costRowSchema),
|
|
40
40
|
low: z.number().finite().nonnegative(),
|
|
@@ -16,6 +16,7 @@ import { researchDocuments } from "../research/documents.js";
|
|
|
16
16
|
import { plannerMessages, subAgentMessages } from "../research/planner.js";
|
|
17
17
|
import { synthesisMessages } from "../research/synthesis.js";
|
|
18
18
|
import { thumbnailMessages } from "../thumbnail/by-llm.js";
|
|
19
|
+
import { adaptationMessages } from "../voices/adaptation.js";
|
|
19
20
|
import { attributionMessages } from "../voices/attribution.js";
|
|
20
21
|
import { usesVoices } from "../voices/model.js";
|
|
21
22
|
import { scriptMessages } from "../voices/script.js";
|
|
@@ -123,7 +124,7 @@ export function textRecipes(context) {
|
|
|
123
124
|
? undefined
|
|
124
125
|
: {
|
|
125
126
|
speakers: voices.speakers.map((speaker) => ({ id: speaker.id, name: speaker.name })),
|
|
126
|
-
attribute:
|
|
127
|
+
attribute: voices.source === "attribute",
|
|
127
128
|
};
|
|
128
129
|
// A pasted article, or with Article Off an empty one: nothing is written.
|
|
129
130
|
const article = config.sources.article !== "generate" || content.articleEdited === true
|
|
@@ -156,7 +157,11 @@ export function textRecipes(context) {
|
|
|
156
157
|
if (writesScript)
|
|
157
158
|
script = { text: endMatter?.body ?? null, dependsOn: [article.key] };
|
|
158
159
|
else {
|
|
159
|
-
// An audiobook from a text: the text model hands the article's passages to speakers.
|
|
160
|
+
// An audiobook from a text: the text model hands the article's passages to speakers. Or,
|
|
161
|
+
// with `adapt`, it rewrites the article as a conversation; the article stays as written.
|
|
162
|
+
const spoken = (source) => voices.source === "adapt"
|
|
163
|
+
? adaptationMessages(source, voices.format, voices.speakers)
|
|
164
|
+
: attributionMessages(source, voices.speakers);
|
|
160
165
|
const attribute = recipe(context, "script:attribute", "article", endMatter === null
|
|
161
166
|
? {
|
|
162
167
|
kind: "deferred",
|
|
@@ -164,11 +169,11 @@ export function textRecipes(context) {
|
|
|
164
169
|
operation: "script-attribution",
|
|
165
170
|
template: [
|
|
166
171
|
article.fingerprint,
|
|
167
|
-
llmInputFingerprint(context, withLanguage(
|
|
172
|
+
llmInputFingerprint(context, withLanguage(spoken(""), config.language)),
|
|
168
173
|
],
|
|
169
174
|
}
|
|
170
175
|
: {
|
|
171
|
-
...llmInput(context, withLanguage(
|
|
176
|
+
...llmInput(context, withLanguage(spoken(endMatter.body), config.language)),
|
|
172
177
|
script: scriptCheck,
|
|
173
178
|
}, [article.key]);
|
|
174
179
|
recipes.push(attribute);
|
|
@@ -3,6 +3,7 @@ import { subjectOf, thumbnailCountOf, thumbnailKey } from "../admission/model.js
|
|
|
3
3
|
import { usesShortMode } from "../admission/short-mode.js";
|
|
4
4
|
import { render } from "../admission/substitute.js";
|
|
5
5
|
import { withoutScene, withScene } from "../images/scenes.js";
|
|
6
|
+
import { thumbnailOverrideOf } from "../revisions/model.js";
|
|
6
7
|
import { usesAmbientBed } from "../video/ambient-bed.js";
|
|
7
8
|
import { lookWait, withLooks } from "./recipe-appearance.js";
|
|
8
9
|
import { castFor } from "./recipe-cast.js";
|
|
@@ -225,6 +226,11 @@ appearance) {
|
|
|
225
226
|
const withScenes = scenes !== undefined;
|
|
226
227
|
const variants = Array.from({ length: thumbnailCountOf(config) }, (_, index) => index + 1);
|
|
227
228
|
return variants.map((variant) => {
|
|
229
|
+
// A thumbnail the person replaced with their own file uses that file, not a drawing.
|
|
230
|
+
const slot = thumbnailOverrideOf(variant);
|
|
231
|
+
const own = slot === undefined ? undefined : content.thumbnailOverrides?.[slot];
|
|
232
|
+
if (own !== undefined)
|
|
233
|
+
return recipe(context, thumbnailKey(variant), "thumbnail", { kind: "provided", version: 1, assetId: own, semantic: null }, [], { unresolved: false });
|
|
228
234
|
const scene = sceneFor(variant);
|
|
229
235
|
// With a scene the thumbnail waits for it; with the switch off a `{{Scene}}` line is left
|
|
230
236
|
// out, as the images do.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { rmSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { referenceKey } from "../admission/model.js";
|
|
4
|
+
import { referenceKey, thumbnailVariant } from "../admission/model.js";
|
|
5
5
|
import { plainText } from "../article/plain.js";
|
|
6
6
|
import { splitEndMatter } from "../article/split.js";
|
|
7
7
|
import { levelPieces } from "../loudness/level-pieces.js";
|
|
@@ -39,16 +39,23 @@ export async function executeLocalRecipe(deps, context, piece) {
|
|
|
39
39
|
createdAt: row.created_at,
|
|
40
40
|
bytes: statSync(outputPath(deps.paths, row.project_id, row.path)).size,
|
|
41
41
|
};
|
|
42
|
+
const variant = thumbnailVariant(piece.key);
|
|
42
43
|
const role = piece.key === "audio:provided"
|
|
43
44
|
? "audio_body"
|
|
44
|
-
:
|
|
45
|
+
: variant !== undefined
|
|
45
46
|
? "thumbnail"
|
|
46
47
|
: piece.key === referenceKey
|
|
47
48
|
? "reference"
|
|
48
49
|
: piece.key.startsWith("image:")
|
|
49
50
|
? "image"
|
|
50
51
|
: undefined;
|
|
51
|
-
await publishResult(deps, context, piece, role === undefined
|
|
52
|
+
await publishResult(deps, context, piece, role === undefined
|
|
53
|
+
? []
|
|
54
|
+
: [
|
|
55
|
+
preparedResult(deps, context, piece, role, asset, null,
|
|
56
|
+
// The second and third thumbnails carry their number, as when they are drawn.
|
|
57
|
+
variant !== undefined && variant > 1 ? { index: variant } : {}),
|
|
58
|
+
], {}, asset);
|
|
52
59
|
return "done";
|
|
53
60
|
}
|
|
54
61
|
if (input.kind !== "local")
|
|
@@ -220,6 +220,13 @@ function checkAnswer(piece, answer) {
|
|
|
220
220
|
const checked = parseAttribution(answer.text, attributionSource(piece.input.messages), piece.input.script.speakers);
|
|
221
221
|
return checked.ok ? undefined : checked.reason;
|
|
222
222
|
}
|
|
223
|
+
// A conversation adapted from the article is a rewrite: it has to read as a script.
|
|
224
|
+
if (piece.key === "script:attribute" && piece.input.kind === "llm" && piece.input.script) {
|
|
225
|
+
const checked = parseScript(answer.text, piece.input.script.speakers);
|
|
226
|
+
return checked.ok
|
|
227
|
+
? undefined
|
|
228
|
+
: `The conversation the text model wrote can't be read as a script: ${checked.reason}`;
|
|
229
|
+
}
|
|
223
230
|
return undefined;
|
|
224
231
|
}
|
|
225
232
|
function attributionSource(messages) {
|
|
@@ -4,7 +4,7 @@ import { zipSync } from "fflate";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
6
6
|
import { thumbnailCountOf } from "../admission/model.js";
|
|
7
|
-
import { contentTypeOf, downloadName, slugOf } from "../storage/downloads.js";
|
|
7
|
+
import { contentTypeOf, downloadName, fileVersion, slugOf, versionedName, } from "../storage/downloads.js";
|
|
8
8
|
import { outputPath } from "../storage/layout.js";
|
|
9
9
|
import { projectTitle } from "../storage/repo.js";
|
|
10
10
|
import { outputSchema } from "../storage/schema.js";
|
|
@@ -18,14 +18,14 @@ export function findRevisionDownload(deps, projectId, revisionId, recordId) {
|
|
|
18
18
|
if (revision === undefined)
|
|
19
19
|
return { ok: false, reason: "unknown-asset" };
|
|
20
20
|
const output = deps.db
|
|
21
|
-
.prepare(`SELECT o.descriptor, a.path FROM revision_outputs o
|
|
21
|
+
.prepare(`SELECT o.descriptor, o.slot, o.asset_id, a.path FROM revision_outputs o
|
|
22
22
|
JOIN project_assets a ON a.project_id=o.project_id AND a.id=o.asset_id
|
|
23
23
|
WHERE o.project_id=? AND o.revision_id=? AND o.id=?`)
|
|
24
24
|
.get(projectId, revisionId, recordId);
|
|
25
25
|
if (output !== undefined) {
|
|
26
26
|
const path = outputPath(deps.paths, projectId, z.string().parse(output.path));
|
|
27
27
|
const descriptor = outputSchema.parse(JSON.parse(z.string().parse(output.descriptor)));
|
|
28
|
-
return fileDownload(path, downloadName(slugOf(revision.config.title), { ...descriptor, path }));
|
|
28
|
+
return fileDownload(path, versionedName(downloadName(slugOf(revision.config.title), { ...descriptor, path }), fileVersion(deps, projectId, z.string().parse(output.slot), z.string().parse(output.asset_id))));
|
|
29
29
|
}
|
|
30
30
|
const piece = deps.db
|
|
31
31
|
.prepare(`SELECT p.descriptor, p.stage_kind, a.path FROM revision_pieces p
|
|
@@ -39,25 +39,38 @@ export function findRevisionDownload(deps, projectId, revisionId, recordId) {
|
|
|
39
39
|
const stageKind = z.enum(stageKinds).parse(piece.stage_kind);
|
|
40
40
|
return fileDownload(path, `${slugOf(revision.config.title)}-${stageKind}-${descriptor.kind}-${descriptor.idx}${extname(path)}`);
|
|
41
41
|
}
|
|
42
|
+
// The sets the project page downloads as one zip: every picture (Images' Download all), the
|
|
43
|
+
// thumbnails alone, or every rendered short. `only` narrows a set to the records the person
|
|
44
|
+
// picked, so the zip holds exactly those files and nothing else.
|
|
45
|
+
export const zipSets = ["images", "thumbnails", "shorts"];
|
|
42
46
|
export function revisionImagesZip(deps, projectId, revisionId) {
|
|
47
|
+
return revisionSetZip(deps, projectId, revisionId, "images");
|
|
48
|
+
}
|
|
49
|
+
export function revisionSetZip(deps, projectId, revisionId, set, only) {
|
|
43
50
|
if (projectTitle(deps.db, projectId) === undefined)
|
|
44
51
|
return { ok: false, reason: "unknown-project" };
|
|
45
52
|
const revision = revisionById(deps.db, projectId, revisionId);
|
|
46
53
|
if (revision === undefined)
|
|
47
54
|
return { ok: false, reason: "no-images" };
|
|
48
55
|
const slug = slugOf(revision.config.title);
|
|
56
|
+
const picked = only === undefined ? undefined : new Set(only);
|
|
49
57
|
const entries = {};
|
|
50
|
-
const
|
|
58
|
+
const thumbnail = (role, index) =>
|
|
59
|
+
// Every thumbnail the project makes; a second or third left from when it made three stays
|
|
60
|
+
// out once it makes one.
|
|
61
|
+
role === "thumbnail" &&
|
|
62
|
+
(typeof index === "number" ? index : 1) <= thumbnailCountOf(revision.config);
|
|
63
|
+
const members = outputsForRevision(deps.db, projectId, revisionId)
|
|
51
64
|
.filter((row) => row.selected &&
|
|
52
|
-
(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
(row.output.meta.index
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
for (const row of
|
|
65
|
+
(picked === undefined || picked.has(row.recordId)) &&
|
|
66
|
+
(set === "images"
|
|
67
|
+
? row.output.role === "image" || thumbnail(row.output.role, row.output.meta.index)
|
|
68
|
+
: set === "thumbnails"
|
|
69
|
+
? thumbnail(row.output.role, row.output.meta.index)
|
|
70
|
+
: row.output.role === "short_video"))
|
|
71
|
+
.map((row) => ({ ...positionedOutput(revision, row), slot: row.slot }))
|
|
72
|
+
.sort((a, b) => setOrder(a.output.role, a.output.meta) - setOrder(b.output.role, b.output.meta));
|
|
73
|
+
for (const row of members) {
|
|
61
74
|
const asset = deps.db
|
|
62
75
|
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
63
76
|
.get(projectId, row.assetId);
|
|
@@ -66,11 +79,16 @@ export function revisionImagesZip(deps, projectId, revisionId) {
|
|
|
66
79
|
const path = outputPath(deps.paths, projectId, z.string().parse(asset.path));
|
|
67
80
|
if (statSync(path, { throwIfNoEntry: false })?.isFile() !== true)
|
|
68
81
|
continue;
|
|
69
|
-
|
|
82
|
+
// Images and video are already compressed, so they are stored rather than deflated.
|
|
83
|
+
entries[versionedName(downloadName(slug, { ...row.output, path }), fileVersion(deps, projectId, row.slot, row.assetId))] = [readFileSync(path), { level: 0 }];
|
|
70
84
|
}
|
|
71
85
|
return Object.keys(entries).length === 0
|
|
72
86
|
? { ok: false, reason: "no-images" }
|
|
73
|
-
: {
|
|
87
|
+
: {
|
|
88
|
+
ok: true,
|
|
89
|
+
filename: `${slug}-${set}${picked === undefined ? "" : "-selected"}.zip`,
|
|
90
|
+
bytes: zipSync(entries),
|
|
91
|
+
};
|
|
74
92
|
}
|
|
75
93
|
function fileDownload(path, filename) {
|
|
76
94
|
const stats = statSync(path, { throwIfNoEntry: false });
|
|
@@ -81,6 +99,9 @@ function fileDownload(path, filename) {
|
|
|
81
99
|
download: { path, filename, bytes: stats.size, contentType: contentTypeOf(path) },
|
|
82
100
|
};
|
|
83
101
|
}
|
|
84
|
-
function
|
|
85
|
-
|
|
102
|
+
function setOrder(role, meta) {
|
|
103
|
+
if (role === "short_video")
|
|
104
|
+
return typeof meta.short === "number" ? meta.short : 0;
|
|
105
|
+
const index = typeof meta.index === "number" ? meta.index : 0;
|
|
106
|
+
return role === "thumbnail" ? 1_000_000 + index : index;
|
|
86
107
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { referenceKey } from "../admission/model.js";
|
|
3
|
+
import { referenceKey, thumbnailCountOf } from "../admission/model.js";
|
|
4
4
|
import { usesReference } from "../admission/rules.js";
|
|
5
5
|
import { outputPath, stagingPath } from "../storage/layout.js";
|
|
6
6
|
import { stagedFileById } from "../storage/repo.js";
|
|
7
|
+
import { thumbnailOverrideOf } from "./model.js";
|
|
7
8
|
export function bindUpload(content, upload, assetId) {
|
|
8
9
|
const to = upload.destination;
|
|
9
10
|
if (to.kind === "provided")
|
|
@@ -15,6 +16,15 @@ export function bindUpload(content, upload, assetId) {
|
|
|
15
16
|
};
|
|
16
17
|
if (to.kind === "shortsMusic")
|
|
17
18
|
return { ...content, shortsMusic: assetId };
|
|
19
|
+
if (to.kind === "thumbnail") {
|
|
20
|
+
const variant = thumbnailOverrideOf(to.variant);
|
|
21
|
+
if (variant === undefined)
|
|
22
|
+
throw new Error("Validated thumbnail destination disappeared.");
|
|
23
|
+
return {
|
|
24
|
+
...content,
|
|
25
|
+
thumbnailOverrides: { ...content.thumbnailOverrides, [variant]: assetId },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
18
28
|
const image = content.imageDefinitions[to.imageKey];
|
|
19
29
|
if (image === undefined)
|
|
20
30
|
throw new Error("Validated image destination disappeared.");
|
|
@@ -40,6 +50,12 @@ export function validateUploads(deps, edit) {
|
|
|
40
50
|
field,
|
|
41
51
|
message: "This section is off or not set to use your own file. Change the section's setting first, then add the file.",
|
|
42
52
|
});
|
|
53
|
+
if (to.kind === "thumbnail" &&
|
|
54
|
+
(!generatesThumbnail(edit.config) || to.variant > thumbnailCountOf(edit.config)))
|
|
55
|
+
fields.push({
|
|
56
|
+
field,
|
|
57
|
+
message: "This thumbnail is no longer one the project draws, so it can't take your file. Reload the project page and use Replace with my file on a thumbnail shown there.",
|
|
58
|
+
});
|
|
43
59
|
if (to.kind === "shortsMusic" && edit.config.shorts?.enabled !== true)
|
|
44
60
|
fields.push({
|
|
45
61
|
field,
|
|
@@ -60,7 +76,9 @@ export function validateUploads(deps, edit) {
|
|
|
60
76
|
: to.stage
|
|
61
77
|
: to.kind === "image"
|
|
62
78
|
? "images"
|
|
63
|
-
: "
|
|
79
|
+
: to.kind === "thumbnail"
|
|
80
|
+
? "thumbnail"
|
|
81
|
+
: "audio";
|
|
64
82
|
if (file === undefined ||
|
|
65
83
|
file.state !== "staged" ||
|
|
66
84
|
file.stageKind !== kind ||
|
|
@@ -96,6 +114,9 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
96
114
|
for (const [key, row] of Object.entries(content.narrationOverrides))
|
|
97
115
|
if (row.kind === "asset")
|
|
98
116
|
refs.push([`content.narrationOverrides.${key}.assetId`, row.assetId, "audio", true]);
|
|
117
|
+
for (const [variant, assetId] of Object.entries(content.thumbnailOverrides ?? {}))
|
|
118
|
+
if (assetId !== undefined)
|
|
119
|
+
refs.push([`content.thumbnailOverrides.${variant}`, assetId, "thumbnail", false]);
|
|
99
120
|
const roles = {
|
|
100
121
|
research: ["notes"],
|
|
101
122
|
article: ["article_md", "article_txt"],
|
|
@@ -171,6 +192,10 @@ export function assetPath(deps, projectId, id) {
|
|
|
171
192
|
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
172
193
|
.get(projectId, id)).path;
|
|
173
194
|
}
|
|
195
|
+
// A thumbnail step that draws its thumbnails, so each can be replaced by a file of one's own.
|
|
196
|
+
export function generatesThumbnail(config) {
|
|
197
|
+
return config.sources.thumbnail === "from_prompt" || config.sources.thumbnail === "prompt_by_llm";
|
|
198
|
+
}
|
|
174
199
|
// A stage whose own file the project uses: Provide for audio and the thumbnail, Upload for the
|
|
175
200
|
// establishing image.
|
|
176
201
|
export function providesOwnFile(config, kind) {
|
|
@@ -196,6 +221,7 @@ export function validateReplacementAvailability(deps, base, edit) {
|
|
|
196
221
|
...Object.values(base.revision.content.provided),
|
|
197
222
|
...Object.values(base.revision.content.imageDefinitions).map((row) => row.assetId),
|
|
198
223
|
...Object.values(base.revision.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
|
|
224
|
+
...Object.values(base.revision.content.thumbnailOverrides ?? {}),
|
|
199
225
|
]);
|
|
200
226
|
for (const kind of ["audio", "thumbnail", "reference"]) {
|
|
201
227
|
const assetId = edit.content.provided[kind];
|
|
@@ -209,6 +235,7 @@ export function validateReplacementAvailability(deps, base, edit) {
|
|
|
209
235
|
...Object.values(edit.content.provided),
|
|
210
236
|
...Object.values(edit.content.imageDefinitions).map((row) => row.assetId),
|
|
211
237
|
...Object.values(edit.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
|
|
238
|
+
...Object.values(edit.content.thumbnailOverrides ?? {}),
|
|
212
239
|
];
|
|
213
240
|
return next.flatMap((id) => id == null ||
|
|
214
241
|
previous.has(id) ||
|
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { thumbnailKey, thumbnailVariant } from "../admission/model.js";
|
|
2
3
|
import { plainText } from "../article/plain.js";
|
|
3
4
|
import { splitEndMatter } from "../article/split.js";
|
|
4
5
|
import { normalizeArticleIntent } from "../rebuild/recipe-save.js";
|
|
5
6
|
import { discardPreparedAssets } from "../storage/assets.js";
|
|
6
7
|
import { prepareStagedFile, prepareText } from "../storage/prepare.js";
|
|
7
8
|
import { outputSchema } from "../storage/schema.js";
|
|
9
|
+
import { thumbnailOverrideOf, } from "./model.js";
|
|
8
10
|
import { bindUpload, measureAudio, providedAssetSelected, providedOutput, providesOwnFile, } from "./mutation-assets.js";
|
|
9
11
|
export async function prepareEditAssets(deps, base, edit) {
|
|
10
12
|
const prepared = [];
|
|
11
13
|
const allocated = [];
|
|
12
14
|
try {
|
|
13
15
|
const projectId = base.revision.projectId;
|
|
14
|
-
let content = normalizeArticleIntent(base, edit);
|
|
16
|
+
let content = remadeThumbnails(normalizeArticleIntent(base, edit), edit);
|
|
15
17
|
for (const upload of edit.uploads ?? []) {
|
|
16
18
|
const to = upload.destination;
|
|
17
19
|
const role = to.kind === "image"
|
|
18
20
|
? "image"
|
|
19
|
-
: to.kind === "
|
|
20
|
-
?
|
|
21
|
-
: "
|
|
21
|
+
: to.kind === "thumbnail"
|
|
22
|
+
? "thumbnail"
|
|
23
|
+
: to.kind === "provided" && to.stage !== "audio"
|
|
24
|
+
? providedOutput[to.stage].role
|
|
25
|
+
: "audio_body";
|
|
22
26
|
const result = prepareStagedFile(deps, {
|
|
23
27
|
projectId,
|
|
24
28
|
stagedFileId: upload.stagedFileId,
|
|
25
29
|
role,
|
|
26
|
-
index: to.kind === "image"
|
|
30
|
+
index: to.kind === "image"
|
|
31
|
+
? content.imageOrder.indexOf(to.imageKey) + 1
|
|
32
|
+
: to.kind === "thumbnail"
|
|
33
|
+
? to.variant
|
|
34
|
+
: 1,
|
|
27
35
|
});
|
|
28
36
|
if (!result.ok)
|
|
29
37
|
throw new Error("A validated upload became unavailable.");
|
|
@@ -35,14 +43,25 @@ export async function prepareEditAssets(deps, base, edit) {
|
|
|
35
43
|
? to.key
|
|
36
44
|
: to.kind === "shortsMusic"
|
|
37
45
|
? "shorts:music"
|
|
38
|
-
:
|
|
46
|
+
: to.kind === "thumbnail"
|
|
47
|
+
? thumbnailKey(to.variant)
|
|
48
|
+
: providedOutput[to.stage].workKey;
|
|
49
|
+
// The first thumbnail keeps the slot a thumbnail always had; the second and third have
|
|
50
|
+
// their own, as when they are drawn (`rebuild/runtime-publication.ts`).
|
|
51
|
+
const ownSlot = to.kind === "image" ||
|
|
52
|
+
to.kind === "narration" ||
|
|
53
|
+
to.kind === "shortsMusic" ||
|
|
54
|
+
(to.kind === "thumbnail" && to.variant > 1);
|
|
39
55
|
const item = {
|
|
40
56
|
...result,
|
|
41
57
|
upload,
|
|
42
58
|
workKey,
|
|
43
|
-
slot:
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
slot: ownSlot ? workKey : `${result.output.stageKind}:${role}`,
|
|
60
|
+
// The second and third thumbnails carry their number; the first carries none, as when
|
|
61
|
+
// they are drawn.
|
|
62
|
+
...(to.kind === "thumbnail" && to.variant > 1
|
|
63
|
+
? { output: { ...result.output, meta: { ...result.output.meta, index: to.variant } } }
|
|
64
|
+
: {}),
|
|
46
65
|
};
|
|
47
66
|
allocated.push(result.asset);
|
|
48
67
|
const durationMs = result.output.stageKind === "audio"
|
|
@@ -160,6 +179,22 @@ export async function prepareEditAssets(deps, base, edit) {
|
|
|
160
179
|
throw error;
|
|
161
180
|
}
|
|
162
181
|
}
|
|
182
|
+
// Remaking a thumbnail that was replaced by the person's own file draws it again: the edit's
|
|
183
|
+
// regenerate list names it, and no new file for it comes with the edit.
|
|
184
|
+
function remadeThumbnails(content, edit) {
|
|
185
|
+
const overrides = { ...content.thumbnailOverrides };
|
|
186
|
+
for (const key of edit.regenerate ?? []) {
|
|
187
|
+
const variant = thumbnailVariant(key);
|
|
188
|
+
const slot = variant === undefined ? undefined : thumbnailOverrideOf(variant);
|
|
189
|
+
if (slot !== undefined &&
|
|
190
|
+
!edit.uploads?.some((one) => one.destination.kind === "thumbnail" && one.destination.variant === variant))
|
|
191
|
+
delete overrides[slot];
|
|
192
|
+
}
|
|
193
|
+
if (content.thumbnailOverrides === undefined)
|
|
194
|
+
return content;
|
|
195
|
+
const { thumbnailOverrides: _dropped, ...rest } = content;
|
|
196
|
+
return Object.keys(overrides).length === 0 ? rest : { ...rest, thumbnailOverrides: overrides };
|
|
197
|
+
}
|
|
163
198
|
function retainedAsset(deps, projectId, assetId) {
|
|
164
199
|
const row = z
|
|
165
200
|
.object({
|
|
@@ -95,6 +95,7 @@ export const revisionContentSchema = z
|
|
|
95
95
|
.strict())
|
|
96
96
|
.optional(),
|
|
97
97
|
ambientBed: id.optional(),
|
|
98
|
+
thumbnailOverrides: z.partialRecord(z.enum(["1", "2", "3"]), id).optional(),
|
|
98
99
|
})
|
|
99
100
|
.strict();
|
|
100
101
|
export const revisionEditSchema = z
|
|
@@ -116,6 +117,9 @@ export const revisionEditSchema = z
|
|
|
116
117
|
z.object({ kind: z.literal("image"), imageKey: id }).strict(),
|
|
117
118
|
z.object({ kind: z.literal("narration"), key: workKey }).strict(),
|
|
118
119
|
z.object({ kind: z.literal("shortsMusic") }).strict(),
|
|
120
|
+
z
|
|
121
|
+
.object({ kind: z.literal("thumbnail"), variant: z.number().int().min(1).max(3) })
|
|
122
|
+
.strict(),
|
|
119
123
|
]),
|
|
120
124
|
})
|
|
121
125
|
.strict())
|