@gentbajko/slopify 2.3.1 → 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 (146) hide show
  1. package/dist/adapter-registry.js +9 -2
  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 +99 -8
  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 +48 -2
  11. package/dist/adapters/image/video.js +28 -0
  12. package/dist/adapters/llm/codex-models.js +2 -1
  13. package/dist/adapters/llm/codex-server-models.js +2 -1
  14. package/dist/adapters/llm/run-cli.js +14 -2
  15. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  16. package/dist/assets/fonts/SOURCE.txt +6 -0
  17. package/dist/assets/models.yaml +89 -1
  18. package/dist/catalog/registry.js +24 -3
  19. package/dist/catalog/runtime-models.js +1 -1
  20. package/dist/catalog/schema.js +20 -1
  21. package/dist/catalog/store.js +2 -2
  22. package/dist/catalog/validate.js +47 -4
  23. package/dist/edge/docker-launch.js +4 -0
  24. package/dist/edge/docker-projects/engine.js +57 -1
  25. package/dist/edge/docker-projects/install.js +10 -2
  26. package/dist/edge/docker-projects/prune.js +66 -0
  27. package/dist/edge/events/hub.js +11 -0
  28. package/dist/edge/http/app.js +2 -0
  29. package/dist/edge/http/backups.js +40 -0
  30. package/dist/edge/http/host-cli.js +17 -2
  31. package/dist/edge/http/problem.js +1 -0
  32. package/dist/edge/http/providers.js +8 -0
  33. package/dist/edge/http/schedules.js +1 -1
  34. package/dist/edge/http/settings.js +44 -0
  35. package/dist/edge/http/timeouts.js +38 -0
  36. package/dist/host-cli/runtime.js +3 -2
  37. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  38. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  39. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  40. package/dist/kernel/ports/host-cli.js +17 -2
  41. package/dist/kernel/ports/image.js +3 -1
  42. package/dist/kernel/ports/llm.js +3 -1
  43. package/dist/kernel/runner/attempt.js +5 -2
  44. package/dist/kernel/runner/providers.js +42 -2
  45. package/dist/main.js +55 -7
  46. package/dist/slices/admission/model.js +8 -0
  47. package/dist/slices/admission/rules.js +91 -2
  48. package/dist/slices/admission/schema.js +42 -1
  49. package/dist/slices/admission/start.js +53 -15
  50. package/dist/slices/backups/files.js +135 -0
  51. package/dist/slices/backups/folder.js +35 -0
  52. package/dist/slices/backups/model.js +65 -0
  53. package/dist/slices/backups/repo.js +28 -0
  54. package/dist/slices/backups/schedule.js +60 -0
  55. package/dist/slices/backups/service.js +311 -0
  56. package/dist/slices/batch/index.js +2 -0
  57. package/dist/slices/estimate/index.js +65 -2
  58. package/dist/slices/fonts/catalog.js +23 -0
  59. package/dist/slices/fonts/index.js +1 -1
  60. package/dist/slices/library/model.js +8 -1
  61. package/dist/slices/library/slots.js +13 -2
  62. package/dist/slices/library/snapshot.js +8 -2
  63. package/dist/slices/notifications/notifier.js +76 -0
  64. package/dist/slices/notifications/rules.js +81 -0
  65. package/dist/slices/notifications/send.js +40 -0
  66. package/dist/slices/notifications/settings.js +34 -0
  67. package/dist/slices/play-drafts/convert.js +72 -4
  68. package/dist/slices/play-drafts/readiness.js +7 -3
  69. package/dist/slices/play-drafts/repo.js +7 -1
  70. package/dist/slices/play-drafts/review-inputs.js +2 -0
  71. package/dist/slices/play-drafts/schema.js +44 -3
  72. package/dist/slices/play-drafts/service.js +6 -0
  73. package/dist/slices/play-drafts/uploads.js +2 -1
  74. package/dist/slices/project-templates/from-project.js +42 -0
  75. package/dist/slices/project-templates/setup.js +13 -0
  76. package/dist/slices/rebuild/preview-details.js +53 -3
  77. package/dist/slices/rebuild/preview-plan.js +2 -2
  78. package/dist/slices/rebuild/recipe-build.js +13 -3
  79. package/dist/slices/rebuild/recipe-edit.js +178 -0
  80. package/dist/slices/rebuild/recipe-exports.js +9 -4
  81. package/dist/slices/rebuild/recipe-input-schema.js +9 -1
  82. package/dist/slices/rebuild/recipe-legacy.js +12 -0
  83. package/dist/slices/rebuild/recipe-model.js +7 -1
  84. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  85. package/dist/slices/rebuild/recipe-reference.js +44 -0
  86. package/dist/slices/rebuild/recipe-save.js +25 -1
  87. package/dist/slices/rebuild/recipe-shorts.js +186 -0
  88. package/dist/slices/rebuild/recipe-validation.js +33 -2
  89. package/dist/slices/rebuild/recipe-visual.js +29 -8
  90. package/dist/slices/rebuild/recipe-work.js +83 -1
  91. package/dist/slices/rebuild/runtime-actions.js +9 -2
  92. package/dist/slices/rebuild/runtime-animate.js +110 -0
  93. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  94. package/dist/slices/rebuild/runtime-export.js +13 -1
  95. package/dist/slices/rebuild/runtime-image.js +50 -0
  96. package/dist/slices/rebuild/runtime-local.js +6 -3
  97. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  98. package/dist/slices/rebuild/runtime-piece-label.js +17 -0
  99. package/dist/slices/rebuild/runtime-provider.js +17 -10
  100. package/dist/slices/rebuild/runtime-publication.js +25 -4
  101. package/dist/slices/rebuild/runtime-run.js +11 -5
  102. package/dist/slices/rebuild/runtime-shorts.js +328 -0
  103. package/dist/slices/rebuild/runtime-store.js +7 -3
  104. package/dist/slices/rebuild/service-readiness.js +15 -1
  105. package/dist/slices/rebuild/transition-repo.js +40 -1
  106. package/dist/slices/revisions/adopt-content.js +7 -0
  107. package/dist/slices/revisions/adopt.js +7 -2
  108. package/dist/slices/revisions/mutation-assets.js +51 -9
  109. package/dist/slices/revisions/mutation-prepare.js +14 -14
  110. package/dist/slices/revisions/mutations.js +2 -1
  111. package/dist/slices/revisions/publish.js +20 -2
  112. package/dist/slices/revisions/schema.js +16 -1
  113. package/dist/slices/schedules/repo.js +1 -1
  114. package/dist/slices/schedules/service.js +6 -1
  115. package/dist/slices/settings/model.js +5 -0
  116. package/dist/slices/shorts/captions.js +272 -0
  117. package/dist/slices/shorts/clips.js +89 -0
  118. package/dist/slices/shorts/model.js +186 -0
  119. package/dist/slices/shorts/pick.js +232 -0
  120. package/dist/slices/shorts/prompts.js +68 -0
  121. package/dist/slices/shorts/render.js +163 -0
  122. package/dist/slices/storage/asset-name.js +9 -2
  123. package/dist/slices/storage/layout.js +18 -0
  124. package/dist/slices/storage/model.js +9 -0
  125. package/dist/slices/storage/portable.js +6 -3
  126. package/dist/slices/storage/reconcile.js +5 -1
  127. package/dist/slices/storage/schema.js +3 -0
  128. package/dist/slices/telemetry/model.js +3 -0
  129. package/dist/slices/video/cards.js +72 -0
  130. package/dist/slices/video/chapters.js +89 -0
  131. package/dist/slices/video/cuts.js +141 -0
  132. package/dist/slices/video/edit-list.js +36 -6
  133. package/dist/slices/video/edit-settings.js +191 -0
  134. package/dist/slices/video/ffmpeg.js +155 -29
  135. package/dist/slices/video/look.js +164 -0
  136. package/dist/slices/video/plan.js +68 -11
  137. package/dist/slices/video/slideshow.js +22 -6
  138. package/dist/slices/video/transitions.js +51 -0
  139. package/dist/slices/youtube/transcript.js +54 -0
  140. package/dist/web/assets/index-CktpxlJd.js +171 -0
  141. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  142. package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
  143. package/dist/web/index.html +2 -2
  144. package/package.json +1 -1
  145. package/dist/web/assets/index-DfXmBzCk.js +0 -168
  146. package/dist/web/assets/index-Y3RhfTEh.css +0 -1
@@ -4,6 +4,7 @@ import { providerError } from "../../kernel/ports/model.js";
4
4
  import { httpFailure, missingKey, noImage, providerSaid, refusedImage, unreadable, } from "../explain.js";
5
5
  import { retryAfter } from "../retry-after.js";
6
6
  import { downloadImage } from "./bytes.js";
7
+ import { dataUri, downloadVideo } from "./video.js";
7
8
  // The HTTP gateway adapter for Replicate: `fetch`, the injected clock and the downloader
8
9
  // beside this file, no SDK. The `replicate` package wraps exactly the two requests below, so
9
10
  // the platform's own `fetch` covers it.
@@ -40,6 +41,13 @@ export function replicateImage(deps) {
40
41
  id: "replicate",
41
42
  models: () => Promise.resolve(replicateModels),
42
43
  generate: async (req) => {
44
+ // The Replicate models listed take no input image to draw from (the FLUX image inputs
45
+ // copy the picture's composition), so an establishing image is refused, not dropped.
46
+ if (req.reference !== undefined)
47
+ throw providerError({
48
+ kind: "unsupported",
49
+ message: "Replicate's image models can't use an establishing image as a reference. Choose Codex CLI, OpenAI, Google or fal.ai under Images → Provider on Play or in Edit project → Providers, or set Establishing image to Off in the Images section.",
50
+ });
43
51
  const response = await deps.fetch(`${replicateBase}/models/${req.model}/predictions`, {
44
52
  method: "POST",
45
53
  signal: req.signal,
@@ -71,8 +79,46 @@ export function replicateImage(deps) {
71
79
  signal: req.signal,
72
80
  });
73
81
  },
82
+ // The same prediction, polled the same way; a clip outlives `Prefer: wait` every time, so
83
+ // it is not asked for.
84
+ animate: async (req) => {
85
+ const response = await deps.fetch(`${replicateBase}/models/${req.model}/predictions`, {
86
+ method: "POST",
87
+ signal: req.signal,
88
+ headers: { ...auth(deps), "Content-Type": "application/json" },
89
+ body: JSON.stringify({
90
+ input: {
91
+ prompt: req.prompt,
92
+ [Object.hasOwn(videoImageField, req.model)
93
+ ? (videoImageField[req.model] ?? "image")
94
+ : "image"]: dataUri(req.image),
95
+ duration: Math.round(req.seconds),
96
+ ...(Object.hasOwn(videoResolution, req.model) ? { resolution: "720p" } : {}),
97
+ },
98
+ }),
99
+ });
100
+ if (!response.ok)
101
+ throw await failure(response, "video clip request");
102
+ const url = await settle(deps, parse(await response.text()), req.signal);
103
+ return await downloadVideo({
104
+ fetch: deps.fetch,
105
+ provider: "Replicate",
106
+ url,
107
+ signal: req.signal,
108
+ });
109
+ },
74
110
  };
75
111
  }
112
+ // Replicate's image-to-video models name the still differently: Kling calls it
113
+ // `start_image`, the others `image`. Wan and Seedance default to larger frames than the
114
+ // render needs, so they are asked for 720p, which is what the catalogue prices.
115
+ const videoImageField = {
116
+ "kwaivgi/kling-v2.5-turbo-pro": "start_image",
117
+ };
118
+ const videoResolution = {
119
+ "wan-video/wan-2.5-i2v": true,
120
+ "bytedance/seedance-1-pro-fast": true,
121
+ };
76
122
  // `Prefer: wait` answers `starting` when the model outlived its 60 s; Replicate's guidance
77
123
  // is to poll `urls.get` until the prediction settles.
78
124
  async function settle(deps, first, signal) {
@@ -144,7 +190,7 @@ function kindOf(status) {
144
190
  // error contract first, which is not this adapter's to widen.
145
191
  return "other";
146
192
  }
147
- async function failure(response) {
193
+ async function failure(response, subject = "image request") {
148
194
  const text = await response.text().catch(() => "");
149
195
  const parsed = errorBody.safeParse(safeJson(text));
150
196
  // The provider's own words, through the redactor - an error body may quote the key back.
@@ -157,7 +203,7 @@ async function failure(response) {
157
203
  provider: "Replicate",
158
204
  status: response.status,
159
205
  detail: message || response.statusText,
160
- subject: "image request",
206
+ subject,
161
207
  }),
162
208
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
163
209
  });
@@ -0,0 +1,28 @@
1
+ import { providerError } from "../../kernel/ports/model.js";
2
+ import { describeBytes } from "./bytes.js";
3
+ // What the two image-to-video adapters share: the still goes up inline as a data URI (the
4
+ // render sends a small JPEG, `slices/rebuild/runtime-animate.ts`), and the clip comes back
5
+ // as a link that is downloaded inside the attempt, like an image.
6
+ export function dataUri(image) {
7
+ return `data:${image.mime};base64,${Buffer.from(image.bytes).toString("base64")}`;
8
+ }
9
+ // An MP4 (or MOV) names its box type at byte 4.
10
+ export function sniffVideo(bytes) {
11
+ return bytes.length > 12 && Buffer.from(bytes.slice(4, 8)).toString("latin1") === "ftyp";
12
+ }
13
+ export async function downloadVideo(download) {
14
+ const response = await download.fetch(download.url, { signal: download.signal });
15
+ if (!response.ok)
16
+ // The link is not quoted back: a delivery link carries its own signature.
17
+ throw providerError({
18
+ kind: "other",
19
+ message: `${download.provider} made the video clip, but Slopify could not download it (error ${String(response.status)}). Check your internet connection, then use Retry stage.`,
20
+ });
21
+ const bytes = new Uint8Array(await response.arrayBuffer());
22
+ if (!sniffVideo(bytes))
23
+ throw providerError({
24
+ kind: "other",
25
+ message: `${download.provider} sent back something that is not an MP4 video clip (${describeBytes(bytes)}). Use Retry stage; if it keeps happening, choose another image-to-video model under Animate images in Edit project → Inputs → Look.`,
26
+ });
27
+ return { bytes, mime: "video/mp4" };
28
+ }
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
+ import { thinkingModes as knownEfforts } from "../../kernel/ports/llm.js";
4
5
  import { readCatalogueFile } from "./catalogue-files.js";
5
6
  import { codexModelName, nodeCodexServerModels } from "./codex-server-models.js";
6
7
  const safeText = z
@@ -40,7 +41,7 @@ export async function nodeCodexModels(env = process.env, binary = "codex", timeo
40
41
  if (!unique.has(item.slug)) {
41
42
  const thinkingModes = item.supported_reasoning_levels
42
43
  ?.map((level) => level.effort)
43
- .filter((level) => ["off", "low", "medium", "high", "xhigh"].includes(level));
44
+ .filter((level) => knownEfforts.includes(level));
44
45
  unique.set(item.slug, {
45
46
  id: item.slug,
46
47
  name: codexModelName(item.display_name ?? item.slug),
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { cliCommand } from "../../kernel/cli-command.js";
7
+ import { thinkingModes as knownEfforts } from "../../kernel/ports/llm.js";
7
8
  const text = z
8
9
  .string()
9
10
  .trim()
@@ -102,7 +103,7 @@ export async function nodeCodexServerModels(binary, env, timeoutMs = 15_000) {
102
103
  continue;
103
104
  const thinkingModes = row.supportedReasoningEfforts
104
105
  ?.map((item) => item.reasoningEffort)
105
- .filter((effort) => ["off", "low", "medium", "high", "xhigh"].includes(effort));
106
+ .filter((effort) => knownEfforts.includes(effort));
106
107
  models.set(row.model, {
107
108
  id: row.model,
108
109
  name: codexModelName(row.displayName),
@@ -87,12 +87,24 @@ export function nodeRunCli(binary, args, signal, options) {
87
87
  child.stderr?.destroy();
88
88
  }
89
89
  };
90
- const abort = () => terminate();
90
+ // An abort asks politely, then forces after the same grace stopCliRun gives. A reader
91
+ // waiting on stdout only reaches stopCliRun once stdout closes, so a CLI that traps
92
+ // SIGTERM and keeps its pipe open would otherwise outlive the run that was cancelled.
93
+ let forced;
94
+ const abort = () => {
95
+ terminate();
96
+ forced ??= setTimeout(() => terminate(true), cliTerminationGraceMs);
97
+ forced.unref();
98
+ };
91
99
  if (signal.aborted)
92
100
  abort();
93
101
  else
94
102
  signal.addEventListener("abort", abort, { once: true });
95
- void ended.then(() => signal.removeEventListener("abort", abort));
103
+ void ended.then(() => {
104
+ signal.removeEventListener("abort", abort);
105
+ if (forced !== undefined)
106
+ clearTimeout(forced);
107
+ });
96
108
  return {
97
109
  ...(inputWritten === undefined ? {} : { inputWritten }),
98
110
  pid: child.pid,
@@ -2,6 +2,12 @@ Barlow-Regular.ttf
2
2
  Source: https://raw.githubusercontent.com/google/fonts/6cdf01867df0813c2390f90dff7dc66c87f14cf7/ofl/barlow/Barlow-Regular.ttf
3
3
  SHA-256: 95aa02c7c43096e0dd44d787ba6216864a67157e402adab59b35572e0c1577ea
4
4
 
5
+ Barlow-Bold.ttf
6
+ Source: https://raw.githubusercontent.com/google/fonts/6cdf01867df0813c2390f90dff7dc66c87f14cf7/ofl/barlow/Barlow-Bold.ttf
7
+ SHA-256: 84e6a4d61e7c3e21f3c50ea6a4f7e5303a3467864c038be6ea3759bab8d547f9
8
+ Git blob: 2f20cbe667ca5cd871410dc50dc4b1a30c1d6267 (matches the google/fonts tree at that commit)
9
+ Licence: SIL Open Font License 1.1, OFL.txt below (the same licence and authors as Barlow-Regular)
10
+
5
11
  OFL.txt
6
12
  Source: https://raw.githubusercontent.com/google/fonts/6cdf01867df0813c2390f90dff7dc66c87f14cf7/ofl/barlow/OFL.txt
7
13
  SHA-256: 186d750eb496a4c17a76385f82be6aea2ac1cf2de074a811d63786cf374ea73f
@@ -2,7 +2,7 @@
2
2
  # A private copy in the Slopify data directory reloads automatically.
3
3
  # Omitted pricing means unknown, never free. Provider concurrency is capped at five.
4
4
  schemaVersion: 1
5
- updatedAt: 2026-09-24
5
+ updatedAt: 2026-09-26
6
6
  providers:
7
7
  openrouter:
8
8
  maxConcurrent: 3
@@ -141,6 +141,7 @@ image:
141
141
  source: https://developers.openai.com/api/docs/guides/image-generation
142
142
  keywords:
143
143
  - image
144
+ - reference
144
145
  pricing:
145
146
  note: Automatic quality and generated image tokens determine the charge; a fixed
146
147
  image price is unavailable.
@@ -154,6 +155,7 @@ image:
154
155
  source: https://developers.openai.com/api/docs/guides/image-generation
155
156
  keywords:
156
157
  - image
158
+ - reference
157
159
  pricing:
158
160
  note: Automatic quality and generated image tokens determine the charge; a fixed
159
161
  image price is unavailable.
@@ -164,6 +166,7 @@ image:
164
166
  source: https://developers.openai.com/api/docs/guides/image-generation
165
167
  keywords:
166
168
  - image
169
+ - reference
167
170
  pricing:
168
171
  note: Automatic quality and generated image tokens determine the charge; a fixed
169
172
  image price is unavailable.
@@ -174,6 +177,7 @@ image:
174
177
  source: https://ai.google.dev/gemini-api/docs/pricing
175
178
  keywords:
176
179
  - image
180
+ - reference
177
181
  pricing:
178
182
  perImage: 0.101
179
183
  note: 2K image output price; prompt, thinking and other input tokens are
@@ -187,6 +191,7 @@ image:
187
191
  source: https://ai.google.dev/gemini-api/docs/models
188
192
  keywords:
189
193
  - image
194
+ - reference
190
195
  pricing:
191
196
  note: Price depends on your account or generated output; estimate unavailable.
192
197
  image:
@@ -198,6 +203,7 @@ image:
198
203
  source: https://fal.ai/models/fal-ai/flux-2
199
204
  keywords:
200
205
  - image
206
+ - reference
201
207
  pricing:
202
208
  note: Price depends on your account or generated output; estimate unavailable.
203
209
  image: *a4
@@ -207,6 +213,7 @@ image:
207
213
  source: https://fal.ai/models/fal-ai/nano-banana-2
208
214
  keywords:
209
215
  - image
216
+ - reference
210
217
  pricing:
211
218
  note: Price depends on your account or generated output; estimate unavailable.
212
219
  image: *a4
@@ -237,6 +244,87 @@ image:
237
244
  pricing:
238
245
  note: Price depends on your account or generated output; estimate unavailable.
239
246
  image: *a4
247
+ # Image-to-video models, marked by the `video` keyword: Animate images turns a still into a
248
+ # 5-second clip with one of these. perImage is the price of one 5-second clip.
249
+ - provider: fal
250
+ id: fal-ai/kling-video/v2.5-turbo/pro/image-to-video
251
+ name: Kling 2.5 Turbo Pro (image to video)
252
+ source: https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video
253
+ keywords:
254
+ - video
255
+ pricing:
256
+ perImage: 0.35
257
+ note: Public fal.ai rate verified 2026-09-26, one 5-second clip.
258
+ image:
259
+ aspectRatios:
260
+ - 16:9
261
+ - 9:16
262
+ - provider: fal
263
+ id: fal-ai/wan-25-preview/image-to-video
264
+ name: Wan 2.5 (image to video, 720p)
265
+ source: https://fal.ai/models/fal-ai/wan-25-preview/image-to-video
266
+ keywords:
267
+ - video
268
+ pricing:
269
+ perImage: 0.5
270
+ note: Public fal.ai rate verified 2026-09-26, $0.10 a second at 720p, one 5-second clip.
271
+ image:
272
+ aspectRatios:
273
+ - 16:9
274
+ - 9:16
275
+ - provider: fal
276
+ id: fal-ai/bytedance/seedance/v1/pro/fast/image-to-video
277
+ name: Seedance 1.0 Pro Fast (image to video, 720p)
278
+ source: https://fal.ai/models/fal-ai/bytedance/seedance/v1/pro/fast/image-to-video
279
+ keywords:
280
+ - video
281
+ pricing:
282
+ perImage: 0.12
283
+ note: fal.ai bills by video tokens; about $0.12 for a 5-second 720p clip (verified
284
+ 2026-09-26), more for larger frames.
285
+ image:
286
+ aspectRatios:
287
+ - 16:9
288
+ - 9:16
289
+ - provider: replicate
290
+ id: kwaivgi/kling-v2.5-turbo-pro
291
+ name: Kling 2.5 Turbo Pro (image to video)
292
+ source: https://replicate.com/kwaivgi/kling-v2.5-turbo-pro
293
+ keywords:
294
+ - video
295
+ pricing:
296
+ perImage: 0.35
297
+ note: Public Replicate rate verified 2026-09-26, $0.07 a second, one 5-second clip.
298
+ image:
299
+ aspectRatios:
300
+ - 16:9
301
+ - 9:16
302
+ - provider: replicate
303
+ id: wan-video/wan-2.5-i2v
304
+ name: Wan 2.5 (image to video, 720p)
305
+ source: https://replicate.com/wan-video/wan-2.5-i2v
306
+ keywords:
307
+ - video
308
+ pricing:
309
+ perImage: 0.5
310
+ note: Public Replicate rate verified 2026-09-26, $0.10 a second at 720p, one 5-second clip.
311
+ image:
312
+ aspectRatios:
313
+ - 16:9
314
+ - 9:16
315
+ - provider: replicate
316
+ id: bytedance/seedance-1-pro-fast
317
+ name: Seedance 1 Pro Fast (image to video, 720p)
318
+ source: https://replicate.com/bytedance/seedance-1-pro-fast
319
+ keywords:
320
+ - video
321
+ pricing:
322
+ perImage: 0.125
323
+ note: Public Replicate rate verified 2026-09-26, $0.025 a second at 720p, one 5-second clip.
324
+ image:
325
+ aspectRatios:
326
+ - 16:9
327
+ - 9:16
240
328
  tts:
241
329
  - provider: elevenlabs
242
330
  id: eleven_v3
@@ -1,5 +1,7 @@
1
+ import { thinkingModes } from "../kernel/ports/llm.js";
1
2
  import { providerError } from "../kernel/ports/model.js";
2
3
  import { isLocalCliProvider } from "../slices/settings/model.js";
4
+ import { referenceRefusal, takesReferenceImage } from "./schema.js";
3
5
  export function curateRegistry(registry, catalogue) {
4
6
  const requireModel = (provider, family, id) => {
5
7
  const models = catalogue.models(provider, family);
@@ -67,7 +69,7 @@ export function curateRegistry(registry, catalogue) {
67
69
  ...m,
68
70
  ...("llm" in m && m.llm.thinking
69
71
  ? {
70
- thinkingModes: Object.keys(m.llm.thinking).filter((mode) => ["off", "low", "medium", "high", "xhigh"].includes(mode)),
72
+ thinkingModes: Object.keys(m.llm.thinking).filter((mode) => thinkingModes.includes(mode)),
71
73
  }
72
74
  : {}),
73
75
  })),
@@ -100,10 +102,19 @@ export function curateRegistry(registry, catalogue) {
100
102
  ...port,
101
103
  models: () => port.models(),
102
104
  generate: async (request) => {
103
- if (!(await port.models()).some((model) => model.id === request.model))
105
+ const selected = (await port.models()).find((model) => model.id === request.model);
106
+ if (selected === undefined)
104
107
  throw providerError({
105
108
  kind: "unsupported",
106
- message: "Image generation is not available in the Codex CLI on your computer. Update Codex CLI, or choose another image provider in the Edit tab under Edit project, then use Resume.",
109
+ message: request.model === "codex-imagegen"
110
+ ? "Image generation is not available in the Codex CLI on your computer. Update Codex CLI, or choose another image provider in the Edit tab under Edit project, then use Resume."
111
+ : "The chosen Codex model for images is not in the Codex CLI's model list on your computer. Update Codex CLI, or pick another model for images: in the Edit tab, choose Edit project and change it under Providers, then use Resume.",
112
+ });
113
+ if (request.thinking !== undefined &&
114
+ !selected.thinkingModes?.includes(request.thinking))
115
+ throw providerError({
116
+ kind: "unsupported",
117
+ message: "The chosen Codex model does not support this effort for images. In the Edit tab, choose Edit project and change the images' Effort under Providers, then use Resume.",
107
118
  });
108
119
  return port.generate(request);
109
120
  },
@@ -119,6 +130,16 @@ export function curateRegistry(registry, catalogue) {
119
130
  kind: "unsupported",
120
131
  message: "The chosen image model cannot make images in this video's shape. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
121
132
  });
133
+ if (request.thinking !== undefined)
134
+ throw providerError({
135
+ kind: "unsupported",
136
+ message: "The chosen image model has no effort setting. In the Edit tab, choose Edit project and set the images' Effort back to Model default under Providers, then use Resume.",
137
+ });
138
+ if (request.reference !== undefined && !takesReferenceImage(model))
139
+ throw providerError({
140
+ kind: "unsupported",
141
+ message: referenceRefusal,
142
+ });
122
143
  return port.generate(request);
123
144
  },
124
145
  };
@@ -6,7 +6,7 @@ export async function checkRuntimeModel(modelsFor, provider, family, id, thinkin
6
6
  const model = (await modelsFor(provider, family)).find((row) => row.id === id);
7
7
  if (model === undefined)
8
8
  return "missing";
9
- if (family === "llm" && thinking !== undefined && !model.thinkingModes?.includes(thinking))
9
+ if (family !== "tts" && thinking !== undefined && !model.thinkingModes?.includes(thinking))
10
10
  return "thinking";
11
11
  return "available";
12
12
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { thinkingModes } from "../kernel/ports/llm.js";
2
3
  import { providers } from "../slices/settings/model.js";
3
4
  const money = z.number().finite().nonnegative();
4
5
  const base = z.object({
@@ -29,7 +30,7 @@ export const llmModelSchema = base
29
30
  maxOutputTokens: z.number().int().positive().optional(),
30
31
  webSearch: z.boolean().default(false),
31
32
  thinking: z
32
- .partialRecord(z.enum(["off", "low", "medium", "high", "xhigh"]), z
33
+ .partialRecord(z.enum(thinkingModes), z
33
34
  .object({
34
35
  budget: z.number().int().min(-1).optional(),
35
36
  level: z.enum(["minimal", "low", "medium", "high"]).optional(),
@@ -89,3 +90,21 @@ export const catalogueSchema = z
89
90
  });
90
91
  }
91
92
  });
93
+ // Image-to-video models live in the image list, under the image providers that run them,
94
+ // marked by the `video` keyword; their perImage is the price of one clip. A catalogue that
95
+ // older Slopify versions read keeps its shape, and the image pickers leave these out.
96
+ export function isVideoModel(model) {
97
+ return model.keywords.includes("video");
98
+ }
99
+ // An image model that also takes an input image - so it can draw with the project's
100
+ // establishing image as its visual reference - carries the `reference` keyword, the same way
101
+ // and for the same reason the image-to-video models carry `video`.
102
+ export function takesReferenceImage(model) {
103
+ return model.keywords.includes("reference");
104
+ }
105
+ // What the run says when the chosen image model cannot take the establishing image.
106
+ export const referenceRefusal = "The chosen image model can't use an establishing image as a reference, so the images can't be drawn to match it. Choose an image model that can (Codex CLI, OpenAI, Google, or a fal.ai model listed with reference) under Images → Model on Play or in Edit project → Providers, or set Establishing image to Off in the Images section.";
107
+ // The image-to-video models a provider offers, as Animate images lists them.
108
+ export function videoModelsOf(catalogue, provider) {
109
+ return catalogue.image.filter((m) => m.provider === provider && m.enabled && !m.deprecated && isVideoModel(m));
110
+ }
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
2
2
  import { join } from "node:path";
3
3
  import { parse } from "yaml";
4
4
  import { isLocalCliProvider } from "../slices/settings/model.js";
5
- import { catalogueSchema } from "./schema.js";
5
+ import { catalogueSchema, isVideoModel } from "./schema.js";
6
6
  export const catalogueSource = "https://raw.githubusercontent.com/GentBajko/slopify/main/packages/app/src/assets/models.yaml";
7
7
  export function parseCatalogue(text) {
8
8
  if (Buffer.byteLength(text) > 1024 * 1024)
@@ -86,7 +86,7 @@ export function createCatalogueStore(deps) {
86
86
  }
87
87
  return {
88
88
  read,
89
- models: (provider, family) => read()[family].filter((m) => m.provider === provider && m.enabled && !m.deprecated),
89
+ models: (provider, family) => read()[family].filter((m) => m.provider === provider && m.enabled && !m.deprecated && !isVideoModel(m)),
90
90
  status: () => ({ updatedAt: read().updatedAt, path, warning, source: catalogueSource }),
91
91
  refresh: () => {
92
92
  refreshing ??= refresh().finally(() => {
@@ -1,5 +1,7 @@
1
- import { usesNarrationPreparation, usesYoutubeDescription, } from "../slices/admission/rules.js";
1
+ import { usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../slices/admission/rules.js";
2
2
  import { isLocalCliProvider } from "../slices/settings/model.js";
3
+ import { usesAnimation } from "../slices/video/edit-settings.js";
4
+ import { referenceRefusal, takesReferenceImage, videoModelsOf } from "./schema.js";
3
5
  export function modelFields(draft, catalogue) {
4
6
  if (!catalogue)
5
7
  return [];
@@ -10,7 +12,8 @@ export function modelFields(draft, catalogue) {
10
12
  draft.intro?.mode === "llm" ||
11
13
  draft.outro?.mode === "llm" ||
12
14
  usesNarrationPreparation(draft) ||
13
- usesYoutubeDescription(draft);
15
+ usesYoutubeDescription(draft) ||
16
+ usesShorts(draft);
14
17
  const checks = [
15
18
  { field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
16
19
  {
@@ -24,7 +27,8 @@ export function modelFields(draft, catalogue) {
24
27
  family: "image",
25
28
  choice: draft.images,
26
29
  needed: draft.sources.images === "generate" ||
27
- ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail),
30
+ ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail) ||
31
+ usesShorts(draft),
28
32
  },
29
33
  ];
30
34
  for (const { field, family, choice, needed } of checks) {
@@ -41,16 +45,55 @@ export function modelFields(draft, catalogue) {
41
45
  field,
42
46
  message: "This model does not support that thinking setting. Choose another.",
43
47
  });
48
+ else if ("image" in model && choice.thinking)
49
+ fields.push({
50
+ field: "images.thinking",
51
+ message: "This image model has no effort setting. Set Effort back to Model default.",
52
+ });
44
53
  else if ("llm" in model && draft.sources.research === "generate" && !model.llm.webSearch)
45
54
  fields.push({
46
55
  field,
47
56
  message: "This model cannot search the web for research. Choose another model or turn Research off.",
48
57
  });
49
- else if ("image" in model && !model.image.aspectRatios.includes(draft.format))
58
+ else if ("image" in model &&
59
+ (draft.sources.images === "generate" ||
60
+ ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail)) &&
61
+ !model.image.aspectRatios.includes(draft.format))
50
62
  fields.push({
51
63
  field,
52
64
  message: "This image model cannot make images in this video's shape. Choose another model.",
53
65
  });
66
+ // The shorts are always vertical, whatever shape the video is.
67
+ else if ("image" in model && usesShorts(draft) && !model.image.aspectRatios.includes("9:16"))
68
+ fields.push({
69
+ field,
70
+ message: "This image model cannot make the vertical (9:16) images Shorts need. Choose another image model, or turn Shorts off.",
71
+ });
72
+ }
73
+ // The establishing image needs a model that takes an input image; the catalogue says which.
74
+ // The Codex CLI always can, so only the keyed providers are looked up.
75
+ if (usesReference(draft) &&
76
+ draft.images !== undefined &&
77
+ !isLocalCliProvider(draft.images.provider)) {
78
+ const images = draft.images;
79
+ const model = catalogue.models(images.provider, "image").find((m) => m.id === images.model);
80
+ if (model !== undefined && !takesReferenceImage(model))
81
+ fields.push({ field: "reference.source", message: referenceRefusal });
82
+ }
83
+ // Animate images runs on the image provider's image-to-video models.
84
+ if (usesAnimation(draft) && draft.images !== undefined) {
85
+ const model = draft.videoEdit?.animateModel ?? "";
86
+ const offered = videoModelsOf(catalogue.read(), draft.images.provider);
87
+ if (offered.length === 0)
88
+ fields.push({
89
+ field: "videoEdit.animateModel",
90
+ message: "This image provider can't animate images. Choose fal.ai or Replicate as the image provider (on Play's Images rail, or in Edit project → Providers), or turn Animate images off.",
91
+ });
92
+ else if (model.trim() !== "" && !offered.some((one) => one.id === model))
93
+ fields.push({
94
+ field: "videoEdit.animateModel",
95
+ message: "This image-to-video model is no longer in Slopify's model list. Choose another under Animate images.",
96
+ });
54
97
  }
55
98
  return fields;
56
99
  }
@@ -62,6 +62,10 @@ async function main() {
62
62
  console.log(`Project files on this machine: ${result.projects}`);
63
63
  if (result.recovery)
64
64
  console.log(`Recovery volume retained: ${result.recovery}. Previous containers remain stopped.`);
65
+ for (const name of result.pruned.removed)
66
+ console.log(`Removed older recovery volume: ${name}`);
67
+ for (const problem of result.pruned.problems)
68
+ console.warn(problem);
65
69
  }
66
70
  finally {
67
71
  process.off("SIGINT", abort);
@@ -4,6 +4,24 @@ import { setTimeout as delay } from "node:timers/promises";
4
4
  import { z } from "zod";
5
5
  import { containerSchema, digestSchema, } from "./state.js";
6
6
  import { contains } from "./tree.js";
7
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
8
+ // A recovery volume says which update, installation and container made it. Earlier launchers
9
+ // set only the transaction label; `prune.ts` asks those for the update's own record instead.
10
+ export function recoveryLabels(j) {
11
+ return {
12
+ "io.slopify.transaction": j.id,
13
+ "io.slopify.installation": j.installation,
14
+ "io.slopify.container": j.name,
15
+ };
16
+ }
17
+ // The id a recovery volume of `volume` carries in its name, or null for any other volume.
18
+ export function recoveryId(volume, name) {
19
+ const prefix = `${volume}-recovery-`;
20
+ if (!name.startsWith(prefix))
21
+ return null;
22
+ const id = name.slice(prefix.length);
23
+ return uuid.test(id) ? id : null;
24
+ }
7
25
  // Docker's exit code alone says nothing, so its last error line decides the advice: the daemon
8
26
  // being stopped and the socket being off-limits are by far the most common first-run failures.
9
27
  export function dockerFailure(args, result) {
@@ -294,9 +312,47 @@ export function dockerEngine(runner, signal, env) {
294
312
  const all = (await command(["volume", "ls", "--format", "{{.Name}}"])).split(/\s+/);
295
313
  if (all.includes(j.backup))
296
314
  throw new Error("Recovery volume already exists; reconcile the journal before retrying.");
297
- await command(["volume", "create", "--label", `io.slopify.transaction=${j.id}`, j.backup]);
315
+ await command([
316
+ "volume",
317
+ "create",
318
+ ...Object.entries(recoveryLabels(j)).flatMap(([key, value]) => [
319
+ "--label",
320
+ `${key}=${value}`,
321
+ ]),
322
+ j.backup,
323
+ ]);
298
324
  return digestSchema.parse(await helper(j.image, [...source(j.volume, null), ...mount("volume", j.backup, "/backup")], "snapshot"));
299
325
  },
326
+ recoveryVolumes: async (volume) => {
327
+ const names = (await command([
328
+ "volume",
329
+ "ls",
330
+ "--filter",
331
+ "label=io.slopify.transaction",
332
+ "--format",
333
+ "{{.Name}}",
334
+ ]))
335
+ .split(/\s+/)
336
+ .filter((name) => recoveryId(volume, name) !== null);
337
+ const found = [];
338
+ for (const name of names) {
339
+ const labels = z
340
+ .record(z.string(), z.string())
341
+ .nullable()
342
+ .parse(JSON.parse(await command(["volume", "inspect", "--format", "{{json .Labels}}", name])));
343
+ found.push({
344
+ name,
345
+ transaction: labels?.["io.slopify.transaction"] ?? null,
346
+ installation: labels?.["io.slopify.installation"] ?? null,
347
+ container: labels?.["io.slopify.container"] ?? null,
348
+ });
349
+ }
350
+ return found;
351
+ },
352
+ // Never forced: Docker refuses a volume a container still mounts, and that refusal stands.
353
+ removeVolume: async (name) => {
354
+ await command(["volume", "rm", name]);
355
+ },
300
356
  restore: async (j) => {
301
357
  if (!j.backupDigest)
302
358
  throw new Error("No verified private snapshot; automatic restore is unsafe.");