@gentbajko/slopify 3.9.0 → 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.
Files changed (85) hide show
  1. package/dist/catalog/model-info.js +33 -0
  2. package/dist/edge/http/app.js +2 -0
  3. package/dist/edge/http/channels.js +6 -1
  4. package/dist/edge/http/document-themes.js +2 -2
  5. package/dist/edge/http/files.js +6 -2
  6. package/dist/edge/http/outputs.js +67 -0
  7. package/dist/edge/http/planning.js +18 -3
  8. package/dist/edge/http/projects.js +7 -4
  9. package/dist/edge/http/providers.js +4 -6
  10. package/dist/edge/http/revision-files.js +25 -7
  11. package/dist/edge/http/schedules.js +30 -0
  12. package/dist/edge/http/trash.js +51 -2
  13. package/dist/extension/slopify-studio-chrome.zip +0 -0
  14. package/dist/extension/slopify-studio-firefox.zip +0 -0
  15. package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
  16. package/dist/patch-notes/3.10.0.md +79 -0
  17. package/dist/patch-notes/3.9.1.md +12 -0
  18. package/dist/patch-notes/index.json +12 -0
  19. package/dist/slices/admission/heads.js +59 -0
  20. package/dist/slices/admission/rules.js +6 -1
  21. package/dist/slices/batch/index.js +22 -0
  22. package/dist/slices/channels/rebrand.js +7 -1
  23. package/dist/slices/channels/repo.js +2 -1
  24. package/dist/slices/channels/schema.js +5 -0
  25. package/dist/slices/channels/service.js +23 -3
  26. package/dist/slices/estimate/index.js +2 -0
  27. package/dist/slices/library/transfer.js +164 -0
  28. package/dist/slices/narration/aliases-table.js +82 -0
  29. package/dist/slices/outputs/add.js +192 -0
  30. package/dist/slices/outputs/model.js +36 -0
  31. package/dist/slices/outputs/service.js +136 -0
  32. package/dist/slices/rebuild/model.js +1 -1
  33. package/dist/slices/rebuild/recipe-text.js +9 -4
  34. package/dist/slices/rebuild/recipe-visual.js +6 -0
  35. package/dist/slices/rebuild/runtime-local.js +10 -3
  36. package/dist/slices/rebuild/runtime-provider.js +7 -0
  37. package/dist/slices/revisions/downloads.js +38 -17
  38. package/dist/slices/revisions/model.js +3 -1
  39. package/dist/slices/revisions/mutation-assets.js +29 -2
  40. package/dist/slices/revisions/mutation-prepare.js +44 -9
  41. package/dist/slices/revisions/schema.js +4 -0
  42. package/dist/slices/schedules/agenda.js +21 -16
  43. package/dist/slices/schedules/held-bulk.js +66 -0
  44. package/dist/slices/schedules/topics.js +1 -1
  45. package/dist/slices/storage/backup-import.js +8 -4
  46. package/dist/slices/storage/downloads.js +42 -4
  47. package/dist/slices/voices/adaptation.js +19 -0
  48. package/dist/slices/voices/model.js +4 -2
  49. package/dist/slices/youtube/model.js +33 -2
  50. package/dist/tutorials/AI-CLIs.md +1 -1
  51. package/dist/tutorials/Backups.md +5 -5
  52. package/dist/tutorials/Calendar.md +2 -2
  53. package/dist/tutorials/Channels.md +1 -1
  54. package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
  55. package/dist/tutorials/Document-Themes.md +2 -2
  56. package/dist/tutorials/Editing-a-Project.md +2 -2
  57. package/dist/tutorials/FAQ.md +1 -1
  58. package/dist/tutorials/Home-and-Projects.md +33 -22
  59. package/dist/tutorials/Intros-and-Outros.md +4 -4
  60. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
  61. package/dist/tutorials/Library-Overview.md +16 -15
  62. package/dist/tutorials/Multiple-Voices.md +3 -3
  63. package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
  64. package/dist/tutorials/PDF-Documents.md +5 -5
  65. package/dist/tutorials/Play-Narration.md +3 -3
  66. package/dist/tutorials/Play-Outputs.md +1 -1
  67. package/dist/tutorials/Play-Overview.md +4 -4
  68. package/dist/tutorials/Play-Title-and-Article.md +3 -3
  69. package/dist/tutorials/Project-Page.md +7 -6
  70. package/dist/tutorials/Prompts.md +1 -1
  71. package/dist/tutorials/Publishing-to-YouTube.md +2 -2
  72. package/dist/tutorials/Recovery-and-Retries.md +1 -1
  73. package/dist/tutorials/Schedules.md +1 -1
  74. package/dist/tutorials/Settings-Reference.md +35 -31
  75. package/dist/tutorials/Templates.md +1 -1
  76. package/dist/tutorials/Trash.md +5 -5
  77. package/dist/tutorials/Troubleshooting.md +2 -2
  78. package/dist/tutorials/Where-Your-Files-Live.md +4 -4
  79. package/dist/web/assets/index-CW_6Ho-M.js +325 -0
  80. package/dist/web/assets/index-DmPZKDb1.css +1 -0
  81. package/dist/web/assets/{pdf-D04vvdOD.js → pdf-C4K73cRQ.js} +1 -1
  82. package/dist/web/index.html +2 -2
  83. package/package.json +1 -1
  84. package/dist/web/assets/index-IWb_SctN.js +0 -310
  85. 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: !writesScript,
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(attributionMessages("", voices.speakers), config.language)),
172
+ llmInputFingerprint(context, withLanguage(spoken(""), config.language)),
168
173
  ],
169
174
  }
170
175
  : {
171
- ...llmInput(context, withLanguage(attributionMessages(endMatter.body, voices.speakers), config.language)),
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
- : piece.key === "thumbnail:image"
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 ? [] : [preparedResult(deps, context, piece, role, asset)], {}, asset);
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 images = outputsForRevision(deps.db, projectId, revisionId)
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
- (row.output.role === "image" ||
53
- // Every thumbnail the project makes; a second or third left from when it made three
54
- // stays out once it makes one.
55
- (row.output.role === "thumbnail" &&
56
- (row.output.meta.index ?? 1) <= thumbnailCountOf(revision.config))))
57
- .map((row) => positionedOutput(revision, row))
58
- .sort((a, b) => imageOrder(a.output.role, a.output.meta.index) -
59
- imageOrder(b.output.role, b.output.meta.index));
60
- for (const row of images) {
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
- entries[downloadName(slug, { ...row.output, path })] = [readFileSync(path), { level: 0 }];
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
- : { ok: true, filename: `${slug}-images.zip`, bytes: zipSync(entries) };
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 imageOrder(role, index) {
85
- return role === "thumbnail" ? Number.MAX_SAFE_INTEGER : typeof index === "number" ? index : 0;
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 +1,3 @@
1
- export {};
1
+ export function thumbnailOverrideOf(variant) {
2
+ return variant === 1 ? "1" : variant === 2 ? "2" : variant === 3 ? "3" : undefined;
3
+ }
@@ -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
- : "audio";
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 === "provided" && to.stage !== "audio"
20
- ? providedOutput[to.stage].role
21
- : "audio_body";
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" ? content.imageOrder.indexOf(to.imageKey) + 1 : 1,
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
- : providedOutput[to.stage].workKey;
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: to.kind === "image" || to.kind === "narration" || to.kind === "shortsMusic"
44
- ? workKey
45
- : `${result.output.stageKind}:${role}`,
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())