@gentbajko/slopify 2.4.0 → 2.5.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 (95) hide show
  1. package/dist/adapter-registry.js +8 -1
  2. package/dist/adapters/host-cli/index.js +11 -0
  3. package/dist/adapters/host-cli/transport.js +6 -1
  4. package/dist/adapters/image/codex-output.js +59 -17
  5. package/dist/adapters/image/codex.js +92 -16
  6. package/dist/adapters/image/fal.js +22 -2
  7. package/dist/adapters/image/google.js +13 -1
  8. package/dist/adapters/image/openai.js +37 -15
  9. package/dist/adapters/image/reference.js +7 -0
  10. package/dist/adapters/image/replicate.js +7 -0
  11. package/dist/adapters/llm/codex-models.js +2 -1
  12. package/dist/adapters/llm/codex-server-models.js +2 -1
  13. package/dist/adapters/llm/run-cli.js +14 -2
  14. package/dist/assets/models.yaml +7 -0
  15. package/dist/catalog/registry.js +24 -3
  16. package/dist/catalog/runtime-models.js +1 -1
  17. package/dist/catalog/schema.js +10 -1
  18. package/dist/catalog/validate.js +17 -2
  19. package/dist/edge/events/hub.js +11 -0
  20. package/dist/edge/http/app.js +2 -0
  21. package/dist/edge/http/backups.js +40 -0
  22. package/dist/edge/http/host-cli.js +17 -2
  23. package/dist/edge/http/problem.js +1 -0
  24. package/dist/edge/http/schedules.js +1 -1
  25. package/dist/edge/http/settings.js +44 -0
  26. package/dist/host-cli/runtime.js +3 -2
  27. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  28. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  29. package/dist/kernel/ports/host-cli.js +17 -2
  30. package/dist/kernel/ports/image.js +3 -1
  31. package/dist/kernel/ports/llm.js +3 -1
  32. package/dist/kernel/runner/attempt.js +1 -1
  33. package/dist/kernel/runner/providers.js +22 -2
  34. package/dist/main.js +52 -7
  35. package/dist/slices/admission/model.js +8 -0
  36. package/dist/slices/admission/rules.js +47 -0
  37. package/dist/slices/admission/schema.js +11 -1
  38. package/dist/slices/admission/start.js +53 -15
  39. package/dist/slices/backups/files.js +135 -0
  40. package/dist/slices/backups/folder.js +35 -0
  41. package/dist/slices/backups/model.js +65 -0
  42. package/dist/slices/backups/repo.js +28 -0
  43. package/dist/slices/backups/schedule.js +60 -0
  44. package/dist/slices/backups/service.js +311 -0
  45. package/dist/slices/batch/index.js +2 -0
  46. package/dist/slices/estimate/index.js +9 -1
  47. package/dist/slices/library/slots.js +8 -2
  48. package/dist/slices/library/snapshot.js +8 -2
  49. package/dist/slices/notifications/notifier.js +76 -0
  50. package/dist/slices/notifications/rules.js +81 -0
  51. package/dist/slices/notifications/send.js +40 -0
  52. package/dist/slices/notifications/settings.js +34 -0
  53. package/dist/slices/play-drafts/convert.js +35 -3
  54. package/dist/slices/play-drafts/repo.js +7 -1
  55. package/dist/slices/play-drafts/review-inputs.js +2 -0
  56. package/dist/slices/play-drafts/schema.js +20 -2
  57. package/dist/slices/play-drafts/service.js +6 -0
  58. package/dist/slices/play-drafts/uploads.js +2 -1
  59. package/dist/slices/project-templates/from-project.js +20 -0
  60. package/dist/slices/project-templates/setup.js +11 -0
  61. package/dist/slices/rebuild/preview-details.js +24 -2
  62. package/dist/slices/rebuild/recipe-build.js +9 -3
  63. package/dist/slices/rebuild/recipe-input-schema.js +8 -1
  64. package/dist/slices/rebuild/recipe-legacy.js +3 -0
  65. package/dist/slices/rebuild/recipe-reference.js +44 -0
  66. package/dist/slices/rebuild/recipe-shorts.js +11 -4
  67. package/dist/slices/rebuild/recipe-validation.js +15 -1
  68. package/dist/slices/rebuild/recipe-visual.js +20 -8
  69. package/dist/slices/rebuild/runtime-actions.js +9 -2
  70. package/dist/slices/rebuild/runtime-image.js +50 -0
  71. package/dist/slices/rebuild/runtime-local.js +6 -3
  72. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  73. package/dist/slices/rebuild/runtime-provider.js +17 -10
  74. package/dist/slices/rebuild/runtime-shorts.js +4 -6
  75. package/dist/slices/rebuild/service-readiness.js +15 -1
  76. package/dist/slices/rebuild/transition-repo.js +1 -1
  77. package/dist/slices/revisions/adopt-content.js +7 -0
  78. package/dist/slices/revisions/adopt.js +7 -2
  79. package/dist/slices/revisions/mutation-assets.js +33 -9
  80. package/dist/slices/revisions/mutation-prepare.js +8 -10
  81. package/dist/slices/revisions/schema.js +5 -1
  82. package/dist/slices/schedules/repo.js +1 -1
  83. package/dist/slices/schedules/service.js +6 -1
  84. package/dist/slices/settings/model.js +5 -0
  85. package/dist/slices/storage/layout.js +10 -0
  86. package/dist/slices/storage/model.js +3 -0
  87. package/dist/slices/storage/portable.js +6 -3
  88. package/dist/slices/storage/reconcile.js +5 -1
  89. package/dist/web/assets/index-CktpxlJd.js +171 -0
  90. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  91. package/dist/web/assets/{pdf-CAJNT254.js → pdf-BLF2i8c2.js} +1 -1
  92. package/dist/web/index.html +2 -2
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-BGgYMm2u.css +0 -1
  95. package/dist/web/assets/index-BVVgtntL.js +0 -171
@@ -0,0 +1,50 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { outputPath } from "../storage/layout.js";
4
+ // The provider call an image step makes: its request, the effort when one is chosen and, while
5
+ // the project's Establishing image is on, that image's bytes as the visual reference.
6
+ export function imageCall(deps, projectId, input, previewLabel) {
7
+ const reference = referenceImage(deps, projectId, input);
8
+ return {
9
+ provider: input.provider,
10
+ model: input.model,
11
+ prompt: input.prompt,
12
+ aspect: input.aspect,
13
+ ...(input.thinking === undefined ? {} : { thinking: input.thinking }),
14
+ ...(reference === undefined ? {} : { reference }),
15
+ ...(previewLabel === undefined ? {} : { previewLabel }),
16
+ };
17
+ }
18
+ function referenceImage(deps, projectId, input) {
19
+ if (input.reference === undefined)
20
+ return undefined;
21
+ // The step waits for the establishing image, so a missing one went away since.
22
+ const missing = "The establishing image this image is drawn from is missing. Use Regenerate on the establishing image in the Images section (or upload it again in Edit project → Images), then use Retry stage.";
23
+ if (input.reference.assetId === null)
24
+ throw new Error(missing);
25
+ const row = z
26
+ .object({ path: z.string() })
27
+ .optional()
28
+ .parse(deps.db
29
+ .prepare("SELECT path FROM project_assets WHERE id=? AND project_id=?")
30
+ .get(input.reference.assetId, projectId));
31
+ if (row === undefined)
32
+ throw new Error(missing);
33
+ let bytes;
34
+ try {
35
+ bytes = new Uint8Array(readFileSync(outputPath(deps.paths, projectId, row.path)));
36
+ }
37
+ catch {
38
+ throw new Error(missing);
39
+ }
40
+ const mime = imageMime(bytes);
41
+ if (mime === undefined)
42
+ throw new Error("The establishing image is not a PNG or JPEG file, so it can't be sent as a reference. Upload a PNG or JPEG in Edit project → Images → Establishing image, then use Retry stage.");
43
+ return { bytes, mime };
44
+ }
45
+ function imageMime(bytes) {
46
+ const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
47
+ if (png.every((value, index) => bytes[index] === value))
48
+ return "image/png";
49
+ return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff ? "image/jpeg" : undefined;
50
+ }
@@ -1,6 +1,7 @@
1
1
  import { 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
5
  import { plainText } from "../article/plain.js";
5
6
  import { splitEndMatter } from "../article/split.js";
6
7
  import { joinNarration } from "../narration/concat.js";
@@ -36,9 +37,11 @@ export async function executeLocalRecipe(deps, context, piece) {
36
37
  ? "audio_body"
37
38
  : piece.key === "thumbnail:image"
38
39
  ? "thumbnail"
39
- : piece.key.startsWith("image:")
40
- ? "image"
41
- : undefined;
40
+ : piece.key === referenceKey
41
+ ? "reference"
42
+ : piece.key.startsWith("image:")
43
+ ? "image"
44
+ : undefined;
42
45
  await publishResult(deps, context, piece, role === undefined ? [] : [preparedResult(deps, context, piece, role, asset)], {}, asset);
43
46
  return "done";
44
47
  }
@@ -6,6 +6,8 @@ export function pieceLabel(deps, context, piece) {
6
6
  const { key } = piece;
7
7
  if (key === "thumbnail:image" || key.startsWith("thumbnail:"))
8
8
  return "Thumbnail";
9
+ if (key === "reference:image")
10
+ return "Establishing image";
9
11
  if (key === "article:body")
10
12
  return "Article";
11
13
  if (key === "document:pdf")
@@ -1,3 +1,4 @@
1
+ import { referenceKey } from "../admission/model.js";
1
2
  import { plainText } from "../article/plain.js";
2
3
  import { splitEndMatter } from "../article/split.js";
3
4
  import { observeNarration } from "../narration/live.js";
@@ -9,6 +10,7 @@ import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
9
10
  import { outputPath } from "../storage/layout.js";
10
11
  import { probeDurationMs } from "../video/ffmpeg.js";
11
12
  import { executeArticleRequests } from "./runtime-article.js";
13
+ import { imageCall } from "./runtime-image.js";
12
14
  import { frozenInstructions } from "./runtime-instructions.js";
13
15
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
14
16
  import { preparedResult, preparedTexts, publishResult } from "./runtime-publication.js";
@@ -89,20 +91,25 @@ export async function executeProviderRecipe(deps, context, providers, piece) {
89
91
  return "done";
90
92
  }
91
93
  if (input.kind === "image") {
92
- const image = await wrapped.image({
93
- provider: input.provider,
94
- model: input.model,
95
- prompt: input.prompt,
96
- aspect: input.aspect,
97
- });
98
- if (!image.ok)
99
- return "held";
100
- const asset = writeAsset(deps, context.work.projectId, image.value.mime === "image/jpeg" ? "image.jpg" : "image.png", image.value.bytes);
101
94
  const view = executionView(deps, context.work.projectId, context.work.revisionId);
102
95
  const index = piece.key.startsWith("image:")
103
96
  ? (view?.revision.content.imageOrder.indexOf(piece.key.slice(6)) ?? 0) + 1
104
97
  : undefined;
105
- const output = preparedResult(deps, context, piece, piece.key === "thumbnail:image" ? "thumbnail" : "image", asset, null, {
98
+ const image = await wrapped.image(imageCall(deps, context.work.projectId, input, piece.key === "thumbnail:image"
99
+ ? "Thumbnail"
100
+ : piece.key === referenceKey
101
+ ? "Establishing image"
102
+ : index === undefined
103
+ ? "Image"
104
+ : `Image ${String(index)}`));
105
+ if (!image.ok)
106
+ return "held";
107
+ const asset = writeAsset(deps, context.work.projectId, image.value.mime === "image/jpeg" ? "image.jpg" : "image.png", image.value.bytes);
108
+ const output = preparedResult(deps, context, piece, piece.key === "thumbnail:image"
109
+ ? "thumbnail"
110
+ : piece.key === referenceKey
111
+ ? "reference"
112
+ : "image", asset, null, {
106
113
  prompt: input.prompt,
107
114
  provider: input.provider,
108
115
  model: input.model,
@@ -14,6 +14,7 @@ import { probeDurationMs } from "../video/ffmpeg.js";
14
14
  import { sentencesText, transcriptSentences } from "../youtube/transcript.js";
15
15
  import { shortsPickKey } from "./recipe-shorts.js";
16
16
  import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
17
+ import { imageCall } from "./runtime-image.js";
17
18
  import { savedCatalogue } from "./runtime-plan.js";
18
19
  import { preparedResult, preparedTexts, publishResult } from "./runtime-publication.js";
19
20
  import { wordsSchema } from "./runtime-subtitles.js";
@@ -163,12 +164,9 @@ async function image(deps, context, providers, piece, clip, index) {
163
164
  const input = piece.input;
164
165
  if (input.kind !== "image")
165
166
  throw setupError();
166
- const made = await providers.forPiece(piece.id).image({
167
- provider: input.provider,
168
- model: input.model,
169
- prompt: input.prompt,
170
- aspect: input.aspect,
171
- });
167
+ const made = await providers
168
+ .forPiece(piece.id)
169
+ .image(imageCall(deps, context.work.projectId, input, `Short ${String(clip.number)} image ${String(index)}`));
172
170
  if (!made.ok)
173
171
  return "held";
174
172
  const asset = writeAsset(deps, context.work.projectId, made.value.mime === "image/jpeg" ? "short-image.jpg" : "short-image.png", made.value.bytes);
@@ -1,4 +1,5 @@
1
1
  import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
+ import { referenceRefusal, takesReferenceImage } from "../../catalog/schema.js";
2
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
3
4
  import { cliPathChanged } from "../settings/cli-paths.js";
4
5
  import { isLocalCliProvider } from "../settings/model.js";
@@ -38,7 +39,7 @@ export async function checkReadiness(deps, snapshot, view) {
38
39
  });
39
40
  continue;
40
41
  }
41
- const result = await checkRuntimeModel(deps.modelsFor, choice.provider, choice.family, choice.model, choice.family === "llm" ? (choice.thinking ?? undefined) : undefined);
42
+ const result = await checkRuntimeModel(deps.modelsFor, choice.provider, choice.family, choice.model, choice.family === "tts" ? undefined : (choice.thinking ?? undefined));
42
43
  if (result === "missing")
43
44
  fields.push({
44
45
  field: `${choice.family}.model`,
@@ -112,6 +113,19 @@ export function localReadiness(deps, snapshot, view, providers, catalogue) {
112
113
  field: `work.${recipe.key}.aspect`,
113
114
  message: "The model does not support this output shape.",
114
115
  });
116
+ // Only the keyed providers' models are listed; the Codex CLI always takes a reference.
117
+ if (model !== undefined &&
118
+ input.reference !== undefined &&
119
+ !takesReferenceImage(model) &&
120
+ !fields.some((row) => row.field === "reference.source"))
121
+ fields.push({ field: "reference.source", message: referenceRefusal });
122
+ if (model !== undefined &&
123
+ input.thinking !== undefined &&
124
+ !fields.some((row) => row.field === "images.thinking"))
125
+ fields.push({
126
+ field: "images.thinking",
127
+ message: "This image model has no effort setting. Set the images' Effort back to Model default under Providers.",
128
+ });
115
129
  }
116
130
  }
117
131
  return fields;
@@ -137,7 +137,7 @@ function stageForKey(key) {
137
137
  return "article";
138
138
  if (prefix === "audio" || prefix === "narration")
139
139
  return "audio";
140
- if (prefix === "image")
140
+ if (prefix === "image" || prefix === "reference")
141
141
  return "images";
142
142
  if (prefix === "thumbnail")
143
143
  return "thumbnail";
@@ -30,6 +30,13 @@ export function baselineContent(deps, project, outputs, pieces, assets, template
30
30
  if (asset !== undefined)
31
31
  provided[stage] = asset.id;
32
32
  }
33
+ // An uploaded establishing image, attached when the run started.
34
+ if (project.config.reference?.source === "provide") {
35
+ const output = outputs.find((row) => row.role === "reference");
36
+ const asset = output === undefined ? undefined : assets.get(output.path);
37
+ if (asset !== undefined)
38
+ provided.reference = asset.id;
39
+ }
33
40
  const imageDefinitions = {};
34
41
  const order = [];
35
42
  const imagePieces = pieces.filter((row) => row.kind === "image");
@@ -13,7 +13,9 @@ import { getRevisionView } from "./view.js";
13
13
  export function ensureBaseline(deps, projectId) {
14
14
  return withProjectControl(deps.db, projectId, () => adoptBaseline(deps, projectId));
15
15
  }
16
- export function adoptBaseline(deps, projectId, templates) {
16
+ export function adoptBaseline(deps, projectId, templates,
17
+ // The Shorts step's background music a new run was started with, already a project asset.
18
+ supplied = {}) {
17
19
  return transact(deps.db, () => {
18
20
  const project = projectById(deps.db, projectId);
19
21
  if (project === undefined)
@@ -29,7 +31,10 @@ export function adoptBaseline(deps, projectId, templates) {
29
31
  const stages = stagesOf(deps.db, projectId);
30
32
  const pieces = stages.flatMap((stage) => allPiecesOf(deps.db, stage.id));
31
33
  const assets = adoptAssets(deps, projectId, outputs, pieces);
32
- const content = baselineContent(deps, project, outputs, pieces, assets, templates);
34
+ const content = {
35
+ ...baselineContent(deps, project, outputs, pieces, assets, templates),
36
+ ...(supplied.shortsMusic === undefined ? {} : { shortsMusic: supplied.shortsMusic }),
37
+ };
33
38
  const fingerprints = baselineFingerprints(project, outputs, pieces, content, stages);
34
39
  const revision = {
35
40
  id: deps.ids.next(),
@@ -1,5 +1,7 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { z } from "zod";
3
+ import { referenceKey } from "../admission/model.js";
4
+ import { usesReference } from "../admission/rules.js";
3
5
  import { outputPath, stagingPath } from "../storage/layout.js";
4
6
  import { stagedFileById } from "../storage/repo.js";
5
7
  export function bindUpload(content, upload, assetId) {
@@ -31,7 +33,7 @@ export function validateUploads(deps, edit) {
31
33
  for (const [index, upload] of (edit.uploads ?? []).entries()) {
32
34
  const field = `uploads.${index}`;
33
35
  const to = upload.destination;
34
- if ((to.kind === "provided" && edit.config.sources[to.stage] !== "provide") ||
36
+ if ((to.kind === "provided" && !providesOwnFile(edit.config, to.stage)) ||
35
37
  (to.kind === "narration" && edit.config.sources.audio !== "generate") ||
36
38
  (to.kind === "image" && edit.config.sources.images === "off"))
37
39
  fields.push({
@@ -52,7 +54,13 @@ export function validateUploads(deps, edit) {
52
54
  staged.add(upload.stagedFileId);
53
55
  destinations.add(destination);
54
56
  const file = stagedFileById(deps.db, upload.stagedFileId);
55
- const kind = to.kind === "provided" ? to.stage : to.kind === "image" ? "images" : "audio";
57
+ const kind = to.kind === "provided"
58
+ ? to.stage === "reference"
59
+ ? "images"
60
+ : to.stage
61
+ : to.kind === "image"
62
+ ? "images"
63
+ : "audio";
56
64
  if (file === undefined ||
57
65
  file.state !== "staged" ||
58
66
  file.stageKind !== kind ||
@@ -77,7 +85,7 @@ export function validateUploads(deps, edit) {
77
85
  export function validateAssetReferences(deps, projectId, content, prepared) {
78
86
  const fields = [];
79
87
  const refs = [];
80
- for (const key of ["research", "article", "audio", "thumbnail"]) {
88
+ for (const key of ["research", "article", "audio", "thumbnail", "reference"]) {
81
89
  const id = content.provided[key];
82
90
  if (id !== undefined)
83
91
  refs.push([`content.provided.${key}`, id, key, false]);
@@ -94,7 +102,10 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
94
102
  audio: ["audio_body", "audio_intro", "audio_outro", "audio_export"],
95
103
  images: ["image"],
96
104
  thumbnail: ["thumbnail"],
105
+ reference: ["reference"],
97
106
  };
107
+ // The establishing image is the Images stage's.
108
+ const stageOf = (kind) => (kind === "reference" ? "images" : kind);
98
109
  for (const [field, id, kind, allowPiece] of refs) {
99
110
  if (!prepared.some((row) => row.id === id && row.projectId === projectId) &&
100
111
  deps.db
@@ -107,7 +118,7 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
107
118
  if (!prepared.some((row) => row.id === id) &&
108
119
  deps.db
109
120
  .prepare("SELECT 1 FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? AND json_extract(descriptor,'$.role') IN (SELECT value FROM json_each(?))")
110
- .get(projectId, id, kind, JSON.stringify(roles[kind])) === undefined &&
121
+ .get(projectId, id, stageOf(kind), JSON.stringify(roles[kind])) === undefined &&
111
122
  (!allowPiece ||
112
123
  deps.db
113
124
  .prepare("SELECT 1 FROM revision_pieces WHERE project_id=? AND asset_id=? AND stage_kind=? AND json_extract(descriptor,'$.kind') IN (SELECT value FROM json_each(?))")
@@ -150,12 +161,25 @@ export function assetPath(deps, projectId, id) {
150
161
  .prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
151
162
  .get(projectId, id)).path;
152
163
  }
164
+ // A stage whose own file the project uses: Provide for audio and the thumbnail, Upload for the
165
+ // establishing image.
166
+ export function providesOwnFile(config, kind) {
167
+ return kind === "reference"
168
+ ? usesReference(config) && config.reference?.source === "provide"
169
+ : config.sources[kind] === "provide";
170
+ }
171
+ // Where each provided file is published: its work key and output role.
172
+ export const providedOutput = {
173
+ audio: { workKey: "audio:provided", role: "audio_body" },
174
+ thumbnail: { workKey: "thumbnail:image", role: "thumbnail" },
175
+ reference: { workKey: referenceKey, role: "reference" },
176
+ };
153
177
  export function providedAssetSelected(base, kind, assetId) {
154
- return (base.revision.config.sources[kind] === "provide" &&
178
+ return (providesOwnFile(base.revision.config, kind) &&
155
179
  base.outputs.some((row) => row.selected &&
156
180
  row.assetId === assetId &&
157
- row.workKey === (kind === "audio" ? "audio:provided" : "thumbnail:image") &&
158
- row.output.role === (kind === "audio" ? "audio_body" : "thumbnail")));
181
+ row.workKey === providedOutput[kind].workKey &&
182
+ row.output.role === providedOutput[kind].role));
159
183
  }
160
184
  export function validateReplacementAvailability(deps, base, edit) {
161
185
  const previous = new Set([
@@ -163,10 +187,10 @@ export function validateReplacementAvailability(deps, base, edit) {
163
187
  ...Object.values(base.revision.content.imageDefinitions).map((row) => row.assetId),
164
188
  ...Object.values(base.revision.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
165
189
  ]);
166
- for (const kind of ["audio", "thumbnail"]) {
190
+ for (const kind of ["audio", "thumbnail", "reference"]) {
167
191
  const assetId = edit.content.provided[kind];
168
192
  if (assetId !== undefined &&
169
- edit.config.sources[kind] === "provide" &&
193
+ providesOwnFile(edit.config, kind) &&
170
194
  !providedAssetSelected(base, kind, assetId) &&
171
195
  !edit.uploads?.some((row) => row.destination.kind === "provided" && row.destination.stage === kind))
172
196
  previous.delete(assetId);
@@ -5,7 +5,7 @@ import { normalizeArticleIntent } from "../rebuild/recipe-save.js";
5
5
  import { discardPreparedAssets } from "../storage/assets.js";
6
6
  import { prepareStagedFile, prepareText } from "../storage/prepare.js";
7
7
  import { outputSchema } from "../storage/schema.js";
8
- import { bindUpload, measureAudio, providedAssetSelected, } from "./mutation-assets.js";
8
+ import { bindUpload, measureAudio, providedAssetSelected, providedOutput, providesOwnFile, } from "./mutation-assets.js";
9
9
  export async function prepareEditAssets(deps, base, edit) {
10
10
  const prepared = [];
11
11
  const allocated = [];
@@ -16,8 +16,8 @@ export async function prepareEditAssets(deps, base, edit) {
16
16
  const to = upload.destination;
17
17
  const role = to.kind === "image"
18
18
  ? "image"
19
- : to.kind === "provided" && to.stage === "thumbnail"
20
- ? "thumbnail"
19
+ : to.kind === "provided" && to.stage !== "audio"
20
+ ? providedOutput[to.stage].role
21
21
  : "audio_body";
22
22
  const result = prepareStagedFile(deps, {
23
23
  projectId,
@@ -35,9 +35,7 @@ export async function prepareEditAssets(deps, base, edit) {
35
35
  ? to.key
36
36
  : to.kind === "shortsMusic"
37
37
  ? "shorts:music"
38
- : to.stage === "audio"
39
- ? "audio:provided"
40
- : "thumbnail:image";
38
+ : providedOutput[to.stage].workKey;
41
39
  const item = {
42
40
  ...result,
43
41
  upload,
@@ -93,21 +91,21 @@ export async function prepareEditAssets(deps, base, edit) {
93
91
  slot: workKey,
94
92
  });
95
93
  }
96
- for (const kind of ["audio", "thumbnail"]) {
94
+ for (const kind of ["audio", "thumbnail", "reference"]) {
97
95
  const assetId = content.provided[kind];
98
- if (edit.config.sources[kind] !== "provide" ||
96
+ if (!providesOwnFile(edit.config, kind) ||
99
97
  assetId === undefined ||
100
98
  providedAssetSelected(base, kind, assetId) ||
101
99
  prepared.some((row) => row.asset.id === assetId))
102
100
  continue;
103
101
  const existing = deps.db
104
102
  .prepare("SELECT descriptor FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? LIMIT 1")
105
- .get(projectId, assetId, kind);
103
+ .get(projectId, assetId, kind === "reference" ? "images" : kind);
106
104
  if (existing === undefined)
107
105
  throw new Error("Validated replacement asset has no descriptor.");
108
106
  const descriptor = outputSchema.parse(JSON.parse(z.string().parse(existing.descriptor)));
109
107
  const asset = retainedAsset(deps, projectId, assetId);
110
- const role = kind === "audio" ? "audio_body" : "thumbnail";
108
+ const role = providedOutput[kind].role;
111
109
  const item = {
112
110
  asset,
113
111
  output: {
@@ -32,6 +32,7 @@ export const revisionContentSchema = z
32
32
  article: id.optional(),
33
33
  audio: id.optional(),
34
34
  thumbnail: id.optional(),
35
+ reference: id.optional(),
35
36
  })
36
37
  .strict(),
37
38
  imageOrder: z.array(id).max(60),
@@ -91,7 +92,10 @@ export const revisionEditSchema = z
91
92
  stagedFileId: id,
92
93
  destination: z.discriminatedUnion("kind", [
93
94
  z
94
- .object({ kind: z.literal("provided"), stage: z.enum(["audio", "thumbnail"]) })
95
+ .object({
96
+ kind: z.literal("provided"),
97
+ stage: z.enum(["audio", "thumbnail", "reference"]),
98
+ })
95
99
  .strict(),
96
100
  z.object({ kind: z.literal("image"), imageKey: id }).strict(),
97
101
  z.object({ kind: z.literal("narration"), key: workKey }).strict(),
@@ -308,7 +308,7 @@ function parseRun(row) {
308
308
  const internalRunError = "Slopify hit an internal error while starting this run. The next run will try again; if it keeps happening, use Download diagnostics in Settings and report it.";
309
309
  const runErrors = {
310
310
  "missing-template": "The template this schedule uses was deleted or changed. Edit the schedule and choose a template again.",
311
- "unsupported-media": "This template uses audio, images or a thumbnail you supplied, which scheduled runs cannot use. Pick a template that generates these instead.",
311
+ "unsupported-media": "This template uses audio, images, a thumbnail or shorts background music you supplied, which scheduled runs cannot use. Pick a template that generates these instead, or remove the music under Outputs → Export → More shorts options on Play and save the template again.",
312
312
  "spend-limit": "Not started: the estimated cost was above this schedule's spend limit, or some prices were unknown. Raise the spend limit or choose models with known prices.",
313
313
  readiness: "Not started: a provider was not ready, for example a missing API key, model or voice. Check Settings → Providers; the next run will try again.",
314
314
  "not-found": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
@@ -152,7 +152,12 @@ function checkTemplate(deps, id, version) {
152
152
  return { ok: false, reason: "missing-template" };
153
153
  const sources = template.document.form.sources;
154
154
  const mediaSources = ["audio", "images", "thumbnail"];
155
- if (mediaSources.some((kind) => sources[kind] === "provide"))
155
+ const { form } = template.document;
156
+ // A supplied file is attached again on Play, which a scheduled run cannot do; the shorts'
157
+ // background music is one too.
158
+ if (mediaSources.some((kind) => sources[kind] === "provide") ||
159
+ (form.shorts?.enabled === true && form.provided.shortsMusic) ||
160
+ (sources.images === "generate" && form.reference?.source === "provide"))
156
161
  return { ok: false, reason: "unsupported-media" };
157
162
  return { ok: true, value: true };
158
163
  }
@@ -22,6 +22,11 @@ export const providerIds = [
22
22
  export function isLocalCliProvider(id) {
23
23
  return id === "claude-code" || id === "codex" || id === "gemini" || id === "codex-image";
24
24
  }
25
+ // How many calls to a command-line tool run at once. Each Codex image is its own job - its
26
+ // own folder and thread - so four are drawn side by side; the text CLIs keep three.
27
+ export function localCliConcurrency(id) {
28
+ return id === "codex-image" ? 4 : 3;
29
+ }
25
30
  export const providers = [
26
31
  { id: "openrouter", family: "llm", displayName: "OpenRouter", auth: "key" },
27
32
  {
@@ -5,6 +5,14 @@ export function projectDir(paths, projectId) {
5
5
  export function outputPath(paths, projectId, relativePath) {
6
6
  return contained(projectDir(paths, projectId), relativePath);
7
7
  }
8
+ // Scheduled backups (slices/backups) land here unless the user picks another folder. It sits
9
+ // inside the projects root because that is the one folder a Docker install shares with the
10
+ // host, so the archives end up on the user's disk and not only in the container's volume.
11
+ // Project folders are ULIDs, so the name never collides with one; reconcile leaves it alone.
12
+ export const backupsFolderName = "Backups";
13
+ export function defaultBackupsDir(paths) {
14
+ return contained(paths.projects, backupsFolderName);
15
+ }
8
16
  export function stagingPath(paths, stagedFileId) {
9
17
  return contained(paths.staging, stagedFileId);
10
18
  }
@@ -78,6 +86,8 @@ export function outputFileName(role, index, extension, stageKind) {
78
86
  return `audio-outro${extension}`;
79
87
  case "thumbnail":
80
88
  return `thumbnail${extension}`;
89
+ case "reference":
90
+ return `reference${extension}`;
81
91
  case "image":
82
92
  return `images/${String(index).padStart(3, "0")}${extension}`;
83
93
  }
@@ -33,5 +33,8 @@ export const outputRoles = [
33
33
  "short_video",
34
34
  // An image of the slideshow brought to life by an image-to-video model (Animate images).
35
35
  "animated_image",
36
+ // The Images stage's establishing image: made or uploaded first, drawn from by the other
37
+ // images, never shown in the video.
38
+ "reference",
36
39
  ];
37
40
  export const stagedFileStates = ["copying", "staged"];
@@ -12,12 +12,13 @@ import { readFontMetadata } from "../fonts/sfnt.js";
12
12
  import { lintEntry, lintPrompt } from "../library/lint.js";
13
13
  import { entryCategories, entryModes, promptKinds } from "../library/model.js";
14
14
  import { listEntries, listPrompts } from "../library/repo.js";
15
+ import { notificationUrlKey } from "../notifications/settings.js";
15
16
  import { projectTemplateSchema } from "../project-templates/schema.js";
16
17
  import { cliPathMaxLength } from "../settings/cli-paths.js";
17
18
  import { appearances, providerById, providerIds } from "../settings/model.js";
18
19
  import { listVoices } from "../settings/repo.js";
19
20
  import { voiceIdMax, voiceNameMax } from "../settings/voices.js";
20
- import { projectDir, stagingPath } from "./layout.js";
21
+ import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
21
22
  import { stageKinds } from "./model.js";
22
23
  import { insertStagedFile, stagedFiles } from "./repo.js";
23
24
  const record = z.record(z.string(), z.string());
@@ -326,7 +327,8 @@ const storedAppearance = z.enum(appearances);
326
327
  function portableSettings(settings) {
327
328
  const portable = {};
328
329
  for (const [key, value] of Object.entries(settings)) {
329
- if (key === "tutorial.session")
330
+ // The Notification URL stays on this machine: an ntfy topic in it is as good as a password.
331
+ if (key === "tutorial.session" || key === notificationUrlKey)
330
332
  continue;
331
333
  const parsed = storedJson(value);
332
334
  if (key === "silenceGapSeconds")
@@ -862,7 +864,8 @@ function planStagedImport(deps, archive, files) {
862
864
  });
863
865
  }
864
866
  export function storageBytes(paths) {
865
- const projects = directoryBytes(paths.projects);
867
+ // Scheduled backups default to a folder inside the projects root; they are not project files.
868
+ const projects = directoryBytes(paths.projects) - directoryBytes(defaultBackupsDir(paths));
866
869
  const staging = directoryBytes(paths.staging);
867
870
  return { data: directoryBytes(paths.dataDir), projects, staging };
868
871
  }
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
- import { outputPath, stagingPath } from "./layout.js";
3
+ import { backupsFolderName, outputPath, stagingPath } from "./layout.js";
4
4
  import { stagedFiles as stagedRows } from "./repo.js";
5
5
  import { stagedFileReferenced } from "./staging-refs.js";
6
6
  export function reconcileStorage(db, paths) {
@@ -33,6 +33,10 @@ export function reconcileStorage(db, paths) {
33
33
  let orphanFiles = 0;
34
34
  for (const entry of readdirSync(paths.projects, { withFileTypes: true })) {
35
35
  const path = join(paths.projects, entry.name);
36
+ // The scheduled backups' default folder: its archives belong to slices/backups, which
37
+ // prunes them by its own rules.
38
+ if (entry.name === backupsFolderName && entry.isDirectory())
39
+ continue;
36
40
  if (!entry.isDirectory()) {
37
41
  unlinkSync(path);
38
42
  orphanFiles += 1;