@gentbajko/slopify 2.3.1 → 2.4.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 (90) hide show
  1. package/dist/adapter-registry.js +1 -1
  2. package/dist/adapters/image/fal.js +77 -6
  3. package/dist/adapters/image/replicate.js +41 -2
  4. package/dist/adapters/image/video.js +28 -0
  5. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  6. package/dist/assets/fonts/SOURCE.txt +6 -0
  7. package/dist/assets/models.yaml +82 -1
  8. package/dist/catalog/schema.js +10 -0
  9. package/dist/catalog/store.js +2 -2
  10. package/dist/catalog/validate.js +32 -4
  11. package/dist/edge/docker-launch.js +4 -0
  12. package/dist/edge/docker-projects/engine.js +57 -1
  13. package/dist/edge/docker-projects/install.js +10 -2
  14. package/dist/edge/docker-projects/prune.js +66 -0
  15. package/dist/edge/http/providers.js +8 -0
  16. package/dist/edge/http/timeouts.js +38 -0
  17. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  18. package/dist/kernel/runner/attempt.js +4 -1
  19. package/dist/kernel/runner/providers.js +20 -0
  20. package/dist/main.js +3 -0
  21. package/dist/slices/admission/rules.js +44 -2
  22. package/dist/slices/admission/schema.js +31 -0
  23. package/dist/slices/estimate/index.js +57 -2
  24. package/dist/slices/fonts/catalog.js +23 -0
  25. package/dist/slices/fonts/index.js +1 -1
  26. package/dist/slices/library/model.js +8 -1
  27. package/dist/slices/library/slots.js +6 -1
  28. package/dist/slices/play-drafts/convert.js +37 -1
  29. package/dist/slices/play-drafts/readiness.js +7 -3
  30. package/dist/slices/play-drafts/schema.js +24 -1
  31. package/dist/slices/project-templates/from-project.js +22 -0
  32. package/dist/slices/project-templates/setup.js +2 -0
  33. package/dist/slices/rebuild/preview-details.js +29 -1
  34. package/dist/slices/rebuild/preview-plan.js +2 -2
  35. package/dist/slices/rebuild/recipe-build.js +6 -2
  36. package/dist/slices/rebuild/recipe-edit.js +178 -0
  37. package/dist/slices/rebuild/recipe-exports.js +9 -4
  38. package/dist/slices/rebuild/recipe-input-schema.js +1 -0
  39. package/dist/slices/rebuild/recipe-legacy.js +9 -0
  40. package/dist/slices/rebuild/recipe-model.js +7 -1
  41. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  42. package/dist/slices/rebuild/recipe-save.js +25 -1
  43. package/dist/slices/rebuild/recipe-shorts.js +179 -0
  44. package/dist/slices/rebuild/recipe-validation.js +19 -2
  45. package/dist/slices/rebuild/recipe-visual.js +10 -1
  46. package/dist/slices/rebuild/recipe-work.js +83 -1
  47. package/dist/slices/rebuild/runtime-animate.js +110 -0
  48. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  49. package/dist/slices/rebuild/runtime-export.js +13 -1
  50. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  51. package/dist/slices/rebuild/runtime-piece-label.js +15 -0
  52. package/dist/slices/rebuild/runtime-publication.js +25 -4
  53. package/dist/slices/rebuild/runtime-run.js +11 -5
  54. package/dist/slices/rebuild/runtime-shorts.js +330 -0
  55. package/dist/slices/rebuild/runtime-store.js +7 -3
  56. package/dist/slices/rebuild/transition-repo.js +39 -0
  57. package/dist/slices/revisions/mutation-assets.js +18 -0
  58. package/dist/slices/revisions/mutation-prepare.js +9 -7
  59. package/dist/slices/revisions/mutations.js +2 -1
  60. package/dist/slices/revisions/publish.js +20 -2
  61. package/dist/slices/revisions/schema.js +11 -0
  62. package/dist/slices/shorts/captions.js +272 -0
  63. package/dist/slices/shorts/clips.js +89 -0
  64. package/dist/slices/shorts/model.js +186 -0
  65. package/dist/slices/shorts/pick.js +232 -0
  66. package/dist/slices/shorts/prompts.js +68 -0
  67. package/dist/slices/shorts/render.js +163 -0
  68. package/dist/slices/storage/asset-name.js +9 -2
  69. package/dist/slices/storage/layout.js +8 -0
  70. package/dist/slices/storage/model.js +6 -0
  71. package/dist/slices/storage/schema.js +3 -0
  72. package/dist/slices/telemetry/model.js +3 -0
  73. package/dist/slices/video/cards.js +72 -0
  74. package/dist/slices/video/chapters.js +89 -0
  75. package/dist/slices/video/cuts.js +141 -0
  76. package/dist/slices/video/edit-list.js +36 -6
  77. package/dist/slices/video/edit-settings.js +191 -0
  78. package/dist/slices/video/ffmpeg.js +155 -29
  79. package/dist/slices/video/look.js +164 -0
  80. package/dist/slices/video/plan.js +68 -11
  81. package/dist/slices/video/slideshow.js +22 -6
  82. package/dist/slices/video/transitions.js +51 -0
  83. package/dist/slices/youtube/transcript.js +54 -0
  84. package/dist/web/assets/index-BGgYMm2u.css +1 -0
  85. package/dist/web/assets/index-BVVgtntL.js +171 -0
  86. package/dist/web/assets/{pdf-DR97Byps.js → pdf-CAJNT254.js} +1 -1
  87. package/dist/web/index.html +2 -2
  88. package/package.json +1 -1
  89. package/dist/web/assets/index-DfXmBzCk.js +0 -168
  90. package/dist/web/assets/index-Y3RhfTEh.css +0 -1
@@ -66,7 +66,7 @@ export function buildRegistry(deps) {
66
66
  // Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
67
67
  // to be polled, and the wait is spent on the app's clock so a test never sits through one.
68
68
  const images = new Map([
69
- ["fal", falImage({ fetch: deps.fetch, key: keyOf("fal") })],
69
+ ["fal", falImage({ fetch: deps.fetch, key: keyOf("fal"), clock: deps.clock })],
70
70
  [
71
71
  "replicate",
72
72
  replicateImage({ fetch: deps.fetch, key: keyOf("replicate"), clock: deps.clock }),
@@ -1,13 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
- import { httpFailure, missingKey, noImage, unreadable } from "../explain.js";
4
+ import { httpFailure, internalError, missingKey, noImage, providerSaid, 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 fal.ai: `fetch` and the downloader beside this file, no SDK.
8
- // `@fal-ai/client` would buy queue polling this endpoint does not need, so the platform's
9
- // own `fetch` covers the whole call. The synchronous host runs the model on the open
10
- // connection, which is what the wrapper's 300 s measures.
9
+ // `@fal-ai/client` would buy queue polling, which only the video clips need and which is three
10
+ // plain requests, so the platform's own `fetch` covers every call. Images use the synchronous
11
+ // host, which runs the model on the open connection the wrapper's 300 s measures.
11
12
  export const falBase = "https://fal.run";
12
13
  // fal has no endpoint listing only its text-to-image models, so the list is this adapter's own
13
14
  // data and adding one is a line here plus its aspect shape below.
@@ -48,6 +49,26 @@ function aspectOf(model, aspect) {
48
49
  const shape = field ?? "image_size";
49
50
  return { [shape]: sizes[shape][aspect] };
50
51
  }
52
+ // Image-to-video runs on fal's queue, not the synchronous host: a clip takes one to five
53
+ // minutes, and fal documents the queue as the way to call anything that slow. A request is
54
+ // submitted, its status polled until it completes, and its answer fetched from the links the
55
+ // submission gave.
56
+ export const falQueueBase = "https://queue.fal.run";
57
+ export const falPollMs = 5000;
58
+ // The image-to-video models this adapter knows the inputs of. Each takes `prompt`,
59
+ // `image_url` and a `duration` string; the rest is per model. A model not listed gets only
60
+ // those three.
61
+ const videoInputs = {
62
+ "fal-ai/kling-video/v2.5-turbo/pro/image-to-video": () => ({}),
63
+ "fal-ai/wan-25-preview/image-to-video": () => ({ resolution: "720p" }),
64
+ "fal-ai/bytedance/seedance/v1/pro/fast/image-to-video": (aspect) => ({
65
+ resolution: "720p",
66
+ aspect_ratio: aspect,
67
+ }),
68
+ };
69
+ const queued = z.object({ status_url: z.string(), response_url: z.string() });
70
+ const queueStatus = z.object({ status: z.string(), error: z.string().nullish() });
71
+ const video = z.object({ video: z.object({ url: z.string() }) });
51
72
  // A wire payload is narrowed, never cast: everything unlisted is dropped at the seam.
52
73
  const generated = z.object({
53
74
  images: z.array(z.object({ url: z.string() })),
@@ -99,8 +120,58 @@ export function falImage(deps) {
99
120
  signal: req.signal,
100
121
  });
101
122
  },
123
+ animate: async (req) => {
124
+ const clock = deps.clock;
125
+ if (clock === undefined)
126
+ throw providerError({ kind: "other", message: internalError("fal.ai has no clock") });
127
+ const headers = { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" };
128
+ const submitted = await deps.fetch(`${falQueueBase}/${req.model}`, {
129
+ method: "POST",
130
+ signal: req.signal,
131
+ headers,
132
+ body: JSON.stringify({
133
+ prompt: req.prompt,
134
+ image_url: dataUri(req.image),
135
+ duration: String(Math.round(req.seconds)),
136
+ ...(Object.hasOwn(videoInputs, req.model) ? videoInputs[req.model]?.(req.aspect) : {}),
137
+ }),
138
+ });
139
+ if (!submitted.ok)
140
+ throw await failure(submitted, "video clip request");
141
+ const links = read(queued, await submitted.text());
142
+ for (;;) {
143
+ const status = await deps.fetch(links.status_url, { signal: req.signal, headers });
144
+ if (!status.ok)
145
+ throw await failure(status, "video clip request");
146
+ const current = read(queueStatus, await status.text());
147
+ if (current.error)
148
+ throw providerError({
149
+ kind: "other",
150
+ message: providerSaid("fal.ai", "could not make the video clip", redact(current.error), "Use Retry stage; if it keeps happening, choose another image-to-video model under Animate images in Edit project → Inputs → Look."),
151
+ });
152
+ if (current.status === "COMPLETED")
153
+ break;
154
+ await clock.sleep(falPollMs, req.signal);
155
+ }
156
+ const answer = await deps.fetch(links.response_url, { signal: req.signal, headers });
157
+ if (!answer.ok)
158
+ throw await failure(answer, "video clip request");
159
+ const made = read(video, await answer.text());
160
+ return await downloadVideo({
161
+ fetch: deps.fetch,
162
+ provider: "fal.ai",
163
+ url: made.video.url,
164
+ signal: req.signal,
165
+ });
166
+ },
102
167
  };
103
168
  }
169
+ function read(schema, text) {
170
+ const parsed = schema.safeParse(safeJson(text));
171
+ if (!parsed.success)
172
+ throw providerError({ kind: "other", message: unreadable("fal.ai") });
173
+ return parsed.data;
174
+ }
104
175
  function keyOf(deps) {
105
176
  // `missing_key`, not `auth`, because that rule makes it terminal.
106
177
  const key = deps.key();
@@ -134,7 +205,7 @@ function kindOf(status) {
134
205
  // has to reach the port's error contract first, which is not this adapter's to widen.
135
206
  return "other";
136
207
  }
137
- async function failure(response) {
208
+ async function failure(response, subject = "image request") {
138
209
  const text = await response.text().catch(() => "");
139
210
  // The provider's own words, through the redactor - an error body may quote the key back.
140
211
  const message = redact(detailOf(text) || response.statusText);
@@ -145,7 +216,7 @@ async function failure(response) {
145
216
  provider: "fal.ai",
146
217
  status: response.status,
147
218
  detail: message,
148
- subject: "image request",
219
+ subject,
149
220
  }),
150
221
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
151
222
  });
@@ -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.
@@ -71,8 +72,46 @@ export function replicateImage(deps) {
71
72
  signal: req.signal,
72
73
  });
73
74
  },
75
+ // The same prediction, polled the same way; a clip outlives `Prefer: wait` every time, so
76
+ // it is not asked for.
77
+ animate: async (req) => {
78
+ const response = await deps.fetch(`${replicateBase}/models/${req.model}/predictions`, {
79
+ method: "POST",
80
+ signal: req.signal,
81
+ headers: { ...auth(deps), "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ input: {
84
+ prompt: req.prompt,
85
+ [Object.hasOwn(videoImageField, req.model)
86
+ ? (videoImageField[req.model] ?? "image")
87
+ : "image"]: dataUri(req.image),
88
+ duration: Math.round(req.seconds),
89
+ ...(Object.hasOwn(videoResolution, req.model) ? { resolution: "720p" } : {}),
90
+ },
91
+ }),
92
+ });
93
+ if (!response.ok)
94
+ throw await failure(response, "video clip request");
95
+ const url = await settle(deps, parse(await response.text()), req.signal);
96
+ return await downloadVideo({
97
+ fetch: deps.fetch,
98
+ provider: "Replicate",
99
+ url,
100
+ signal: req.signal,
101
+ });
102
+ },
74
103
  };
75
104
  }
105
+ // Replicate's image-to-video models name the still differently: Kling calls it
106
+ // `start_image`, the others `image`. Wan and Seedance default to larger frames than the
107
+ // render needs, so they are asked for 720p, which is what the catalogue prices.
108
+ const videoImageField = {
109
+ "kwaivgi/kling-v2.5-turbo-pro": "start_image",
110
+ };
111
+ const videoResolution = {
112
+ "wan-video/wan-2.5-i2v": true,
113
+ "bytedance/seedance-1-pro-fast": true,
114
+ };
76
115
  // `Prefer: wait` answers `starting` when the model outlived its 60 s; Replicate's guidance
77
116
  // is to poll `urls.get` until the prediction settles.
78
117
  async function settle(deps, first, signal) {
@@ -144,7 +183,7 @@ function kindOf(status) {
144
183
  // error contract first, which is not this adapter's to widen.
145
184
  return "other";
146
185
  }
147
- async function failure(response) {
186
+ async function failure(response, subject = "image request") {
148
187
  const text = await response.text().catch(() => "");
149
188
  const parsed = errorBody.safeParse(safeJson(text));
150
189
  // The provider's own words, through the redactor - an error body may quote the key back.
@@ -157,7 +196,7 @@ async function failure(response) {
157
196
  provider: "Replicate",
158
197
  status: response.status,
159
198
  detail: message || response.statusText,
160
- subject: "image request",
199
+ subject,
161
200
  }),
162
201
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
163
202
  });
@@ -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
+ }
@@ -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
@@ -237,6 +237,87 @@ image:
237
237
  pricing:
238
238
  note: Price depends on your account or generated output; estimate unavailable.
239
239
  image: *a4
240
+ # Image-to-video models, marked by the `video` keyword: Animate images turns a still into a
241
+ # 5-second clip with one of these. perImage is the price of one 5-second clip.
242
+ - provider: fal
243
+ id: fal-ai/kling-video/v2.5-turbo/pro/image-to-video
244
+ name: Kling 2.5 Turbo Pro (image to video)
245
+ source: https://fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video
246
+ keywords:
247
+ - video
248
+ pricing:
249
+ perImage: 0.35
250
+ note: Public fal.ai rate verified 2026-09-26, one 5-second clip.
251
+ image:
252
+ aspectRatios:
253
+ - 16:9
254
+ - 9:16
255
+ - provider: fal
256
+ id: fal-ai/wan-25-preview/image-to-video
257
+ name: Wan 2.5 (image to video, 720p)
258
+ source: https://fal.ai/models/fal-ai/wan-25-preview/image-to-video
259
+ keywords:
260
+ - video
261
+ pricing:
262
+ perImage: 0.5
263
+ note: Public fal.ai rate verified 2026-09-26, $0.10 a second at 720p, one 5-second clip.
264
+ image:
265
+ aspectRatios:
266
+ - 16:9
267
+ - 9:16
268
+ - provider: fal
269
+ id: fal-ai/bytedance/seedance/v1/pro/fast/image-to-video
270
+ name: Seedance 1.0 Pro Fast (image to video, 720p)
271
+ source: https://fal.ai/models/fal-ai/bytedance/seedance/v1/pro/fast/image-to-video
272
+ keywords:
273
+ - video
274
+ pricing:
275
+ perImage: 0.12
276
+ note: fal.ai bills by video tokens; about $0.12 for a 5-second 720p clip (verified
277
+ 2026-09-26), more for larger frames.
278
+ image:
279
+ aspectRatios:
280
+ - 16:9
281
+ - 9:16
282
+ - provider: replicate
283
+ id: kwaivgi/kling-v2.5-turbo-pro
284
+ name: Kling 2.5 Turbo Pro (image to video)
285
+ source: https://replicate.com/kwaivgi/kling-v2.5-turbo-pro
286
+ keywords:
287
+ - video
288
+ pricing:
289
+ perImage: 0.35
290
+ note: Public Replicate rate verified 2026-09-26, $0.07 a second, one 5-second clip.
291
+ image:
292
+ aspectRatios:
293
+ - 16:9
294
+ - 9:16
295
+ - provider: replicate
296
+ id: wan-video/wan-2.5-i2v
297
+ name: Wan 2.5 (image to video, 720p)
298
+ source: https://replicate.com/wan-video/wan-2.5-i2v
299
+ keywords:
300
+ - video
301
+ pricing:
302
+ perImage: 0.5
303
+ note: Public Replicate rate verified 2026-09-26, $0.10 a second at 720p, one 5-second clip.
304
+ image:
305
+ aspectRatios:
306
+ - 16:9
307
+ - 9:16
308
+ - provider: replicate
309
+ id: bytedance/seedance-1-pro-fast
310
+ name: Seedance 1 Pro Fast (image to video, 720p)
311
+ source: https://replicate.com/bytedance/seedance-1-pro-fast
312
+ keywords:
313
+ - video
314
+ pricing:
315
+ perImage: 0.125
316
+ note: Public Replicate rate verified 2026-09-26, $0.025 a second at 720p, one 5-second clip.
317
+ image:
318
+ aspectRatios:
319
+ - 16:9
320
+ - 9:16
240
321
  tts:
241
322
  - provider: elevenlabs
242
323
  id: eleven_v3
@@ -89,3 +89,13 @@ export const catalogueSchema = z
89
89
  });
90
90
  }
91
91
  });
92
+ // Image-to-video models live in the image list, under the image providers that run them,
93
+ // marked by the `video` keyword; their perImage is the price of one clip. A catalogue that
94
+ // older Slopify versions read keeps its shape, and the image pickers leave these out.
95
+ export function isVideoModel(model) {
96
+ return model.keywords.includes("video");
97
+ }
98
+ // The image-to-video models a provider offers, as Animate images lists them.
99
+ export function videoModelsOf(catalogue, provider) {
100
+ return catalogue.image.filter((m) => m.provider === provider && m.enabled && !m.deprecated && isVideoModel(m));
101
+ }
@@ -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, 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 { 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) {
@@ -46,11 +50,35 @@ export function modelFields(draft, catalogue) {
46
50
  field,
47
51
  message: "This model cannot search the web for research. Choose another model or turn Research off.",
48
52
  });
49
- else if ("image" in model && !model.image.aspectRatios.includes(draft.format))
53
+ else if ("image" in model &&
54
+ (draft.sources.images === "generate" ||
55
+ ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail)) &&
56
+ !model.image.aspectRatios.includes(draft.format))
50
57
  fields.push({
51
58
  field,
52
59
  message: "This image model cannot make images in this video's shape. Choose another model.",
53
60
  });
61
+ // The shorts are always vertical, whatever shape the video is.
62
+ else if ("image" in model && usesShorts(draft) && !model.image.aspectRatios.includes("9:16"))
63
+ fields.push({
64
+ field,
65
+ message: "This image model cannot make the vertical (9:16) images Shorts need. Choose another image model, or turn Shorts off.",
66
+ });
67
+ }
68
+ // Animate images runs on the image provider's image-to-video models.
69
+ if (usesAnimation(draft) && draft.images !== undefined) {
70
+ const model = draft.videoEdit?.animateModel ?? "";
71
+ const offered = videoModelsOf(catalogue.read(), draft.images.provider);
72
+ if (offered.length === 0)
73
+ fields.push({
74
+ field: "videoEdit.animateModel",
75
+ 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.",
76
+ });
77
+ else if (model.trim() !== "" && !offered.some((one) => one.id === model))
78
+ fields.push({
79
+ field: "videoEdit.animateModel",
80
+ message: "This image-to-video model is no longer in Slopify's model list. Choose another under Animate images.",
81
+ });
54
82
  }
55
83
  return fields;
56
84
  }
@@ -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.");
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { readVersion } from "../../kernel/version.js";
5
5
  import { assertVolumeClaims } from "./claims.js";
6
6
  import { readCommittedJournal } from "./committed.js";
7
+ import { pruneRecoveryVolumes } from "./prune.js";
7
8
  import { assertSourceIdentity, recoverInstallation } from "./recover.js";
8
9
  import { journalSchema, privateDirectory, readState, receiptSchema, selectProjects, writeState, } from "./state.js";
9
10
  import { assertIdentity, assertWritableTree, identity, isMissing, privateTree, safePath, syncDirectory, treeDigest, } from "./tree.js";
@@ -91,7 +92,12 @@ export async function installProjects(c, e, recovery) {
91
92
  if (!old.running)
92
93
  await e.command(["start", old.id]);
93
94
  await e.health(old.id, j.token, expectedVersion);
94
- return { url: `http://${old.port}`, projects, recovery: j.backup };
95
+ return {
96
+ url: `http://${old.port}`,
97
+ projects,
98
+ recovery: j.backup,
99
+ pruned: { removed: [], problems: [] },
100
+ };
95
101
  }
96
102
  const id = randomUUID();
97
103
  const directory = join(c.directory, id);
@@ -229,7 +235,9 @@ export async function installProjects(c, e, recovery) {
229
235
  const ready = await e.inspect(candidate);
230
236
  if (!ready?.port)
231
237
  throw new Error("Slopify started but Docker did not report which port it is on. Run the same command again to finish.");
232
- return { url: `http://${ready.port}`, projects, recovery: j.backup };
238
+ // Only now, with the new container committed and healthy, are older snapshots redundant.
239
+ const pruned = await pruneRecoveryVolumes(c, e, j);
240
+ return { url: `http://${ready.port}`, projects, recovery: j.backup, pruned };
233
241
  }
234
242
  catch (cause) {
235
243
  const current = await readState(receiptPath, receiptSchema, c.uid);
@@ -0,0 +1,66 @@
1
+ import { join } from "node:path";
2
+ import { recoveryId } from "./engine.js";
3
+ import { journalSchema, readState } from "./state.js";
4
+ // Phases an update's record is left in once it is over, one way or the other. Anything else
5
+ // is an update that never finished, and its volume may be the only copy of the data.
6
+ const settled = new Set(["healthy", "committed", "restored", "rolled-back"]);
7
+ // After an update is committed and healthy, keep only its own recovery volume. An older one is
8
+ // removed only when all of these hold, so a volume some other installation, container or person
9
+ // made is never touched:
10
+ // - its name is exactly `<this data volume>-recovery-<uuid>`, never the data volume itself;
11
+ // - its io.slopify.transaction label is that same uuid (every launcher has set it);
12
+ // - the container label, which this launcher adds, names this container (earlier launchers
13
+ // set none, and for those the next check decides alone);
14
+ // - the update's own record, <install dir>/<uuid>/journal.json in this container's private
15
+ // folder, exists and names this container, data volume and Docker, the installation the
16
+ // volume's label names, and exactly this volume as its backup, and says that update is
17
+ // over. A first install that was rolled back has an installation id of its own, so the
18
+ // id is matched against the volume's label rather than the current update's.
19
+ // Docker refuses to remove a volume a container still uses; that refusal is reported, not
20
+ // forced. Nothing here can fail the update, which has already finished.
21
+ export async function pruneRecoveryVolumes(c, e, j) {
22
+ const removed = [];
23
+ const problems = [];
24
+ let volumes;
25
+ try {
26
+ volumes = await e.recoveryVolumes(c.volume);
27
+ }
28
+ catch (error) {
29
+ return {
30
+ removed,
31
+ problems: [
32
+ `Couldn't list older recovery volumes to tidy up, so all of them are kept. See them with docker volume ls --filter label=io.slopify.transaction and remove the ones you no longer need with docker volume rm <name>. ${reason(error)}`,
33
+ ],
34
+ };
35
+ }
36
+ for (const volume of volumes) {
37
+ const id = recoveryId(c.volume, volume.name);
38
+ if (id === null || id === j.id || volume.name === j.backup || volume.name === c.volume)
39
+ continue;
40
+ if (volume.transaction !== id)
41
+ continue;
42
+ if (volume.container !== null && volume.container !== c.name)
43
+ continue;
44
+ const record = await readState(join(c.directory, id, "journal.json"), journalSchema, c.uid).catch(() => null);
45
+ if (record === null ||
46
+ record.id !== id ||
47
+ record.name !== c.name ||
48
+ record.volume !== c.volume ||
49
+ record.daemon !== j.daemon ||
50
+ (volume.installation !== null && volume.installation !== record.installation) ||
51
+ record.backup !== volume.name ||
52
+ !settled.has(record.phase))
53
+ continue;
54
+ try {
55
+ await e.removeVolume(volume.name);
56
+ removed.push(volume.name);
57
+ }
58
+ catch (error) {
59
+ problems.push(`Couldn't remove the older recovery volume ${volume.name}, so it is kept. Once nothing uses it, remove it with docker volume rm ${volume.name}. ${reason(error)}`);
60
+ }
61
+ }
62
+ return { removed, problems };
63
+ }
64
+ function reason(error) {
65
+ return error instanceof Error ? `Why: ${error.message}` : "";
66
+ }
@@ -1,6 +1,7 @@
1
1
  import { zValidator } from "@hono/zod-validator";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
+ import { videoModelsOf } from "../../catalog/schema.js";
4
5
  import { cliPathMaxLength, saveCliPath } from "../../slices/settings/cli-paths.js";
5
6
  import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
6
7
  import { isLocalCliProvider, providerById, providerIds } from "../../slices/settings/model.js";
@@ -56,6 +57,13 @@ export function providerRoutes(deps) {
56
57
  .get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
57
58
  .get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
58
59
  const { id } = c.req.valid("param");
60
+ // Animate images lists the provider's image-to-video models, which only the catalogue
61
+ // names.
62
+ if (c.req.query("video") === "1")
63
+ return c.json({
64
+ models: deps.catalogue === undefined ? [] : videoModelsOf(deps.catalogue.read(), id),
65
+ allowsCustom: false,
66
+ });
59
67
  if (deps.catalogue && !isLocalCliProvider(id))
60
68
  return c.json({
61
69
  models: deps.catalogue.models(id, providerById(id).family).map((m) => ({