@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 { 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
+ }
@@ -87,6 +87,17 @@ export function createHub(deps) {
87
87
  },
88
88
  };
89
89
  }
90
+ // Every project event also goes to `observe` (the run notifier), after the open pages have
91
+ // it, whether or not a page is open to receive it.
92
+ export function observedHub(hub, observe) {
93
+ return {
94
+ ...hub,
95
+ emit: (projectId, event) => {
96
+ hub.emit(projectId, event);
97
+ observe(event);
98
+ },
99
+ };
100
+ }
90
101
  function messageOf(error) {
91
102
  return error instanceof Error ? error.message : String(error);
92
103
  }
@@ -4,6 +4,7 @@ import { Hono } from "hono";
4
4
  import { streamSSE } from "hono/streaming";
5
5
  import { actionRoutes } from "./actions.js";
6
6
  import { audioPreviewRoutes } from "./audio-preview.js";
7
+ import { backupRoutes } from "./backups.js";
7
8
  import { checkpointRoutes } from "./checkpoints.js";
8
9
  import { diagnosticsRoutes } from "./diagnostics.js";
9
10
  import { documentThemeRoutes } from "./document-themes.js";
@@ -41,6 +42,7 @@ function apiRoutes(deps, startedAt) {
41
42
  }))
42
43
  .route("/staging", stagingRoutes(deps))
43
44
  .route("/storage", storageRoutes(deps))
45
+ .route("/backups", backupRoutes(deps.backups))
44
46
  .route("/drafts", draftRoutes(deps.drafts))
45
47
  .route("/diagnostics", diagnosticsRoutes(deps))
46
48
  .route("/project-templates", projectTemplateRoutes(deps.drafts))
@@ -0,0 +1,40 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { HTTPException } from "hono/http-exception";
4
+ import { backupConfigInputSchema } from "../../slices/backups/model.js";
5
+ import { onInvalid, problem, titleOf } from "./problem.js";
6
+ // Settings → Backups: the schedule, the folder, the last result and Back up now. Writing an
7
+ // archive takes minutes, so Back up now answers at once and the screen polls this view.
8
+ export function backupRoutes(service) {
9
+ const backups = () => {
10
+ if (service === undefined)
11
+ throw new HTTPException(404);
12
+ return service;
13
+ };
14
+ return new Hono()
15
+ .get("/", async (c) => {
16
+ c.header("Cache-Control", "no-store");
17
+ return c.json(await backups().view());
18
+ })
19
+ .put("/", zValidator("json", backupConfigInputSchema, onInvalid), async (c) => {
20
+ const saved = backups().save(c.req.valid("json"));
21
+ if (!saved.ok)
22
+ return problem(c, {
23
+ status: 400,
24
+ title: titleOf(400),
25
+ detail: saved.detail,
26
+ extensions: { field: "folder" },
27
+ });
28
+ return c.json(await backups().view());
29
+ })
30
+ .post("/run", async (c) => {
31
+ const started = backups().runNow();
32
+ if (!started.ok)
33
+ return problem(c, {
34
+ status: started.status,
35
+ title: titleOf(started.status),
36
+ detail: started.detail,
37
+ });
38
+ return c.json(await backups().view(), 202);
39
+ });
40
+ }
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { sniffImage } from "../../adapters/image/bytes.js";
7
7
  import { redact } from "../../kernel/log.js";
8
8
  import { bridgeLimits, HostFolderRefused, hostCliIds, hostCliProtocol, hostFrameSchema, hostImageSchema, hostLlmIds, hostLlmSchema, hostModelsSchema, hostOpenFolderSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
9
+ import { agentImageTimeoutMs } from "../../kernel/ports/image.js";
9
10
  import { isProviderError, providerError } from "../../kernel/ports/model.js";
10
11
  export function hostGate() {
11
12
  let accepting = true;
@@ -226,9 +227,23 @@ export function hostCliRoutes(options) {
226
227
  const job = c.get("job");
227
228
  const controller = new AbortController();
228
229
  const signal = AbortSignal.any([job.signal, controller.signal]);
229
- const timer = setTimeout(() => controller.abort(), 300_000);
230
+ const timer = setTimeout(() => controller.abort(), agentImageTimeoutMs);
230
231
  try {
231
- const result = await ports.image.generate({ ...body.data, signal });
232
+ const { reference, thinking, ...rest } = body.data;
233
+ const image = reference === undefined
234
+ ? undefined
235
+ : {
236
+ bytes: new Uint8Array(Buffer.from(reference.base64, "base64")),
237
+ mime: reference.mime,
238
+ };
239
+ if (image !== undefined && sniffImage(image.bytes) !== image.mime)
240
+ return invalid(c);
241
+ const result = await ports.image.generate({
242
+ ...rest,
243
+ ...(thinking === undefined ? {} : { thinking }),
244
+ ...(image === undefined ? {} : { reference: image }),
245
+ signal,
246
+ });
232
247
  if (signal.aborted)
233
248
  throw providerError({ kind: "unavailable", message: unavailable });
234
249
  if (result.bytes.byteLength > bridgeLimits.image || sniffImage(result.bytes) !== result.mime)
@@ -4,6 +4,7 @@ const titles = {
4
4
  404: "Not Found",
5
5
  409: "Conflict",
6
6
  415: "Unsupported Media Type",
7
+ 502: "Bad Gateway",
7
8
  500: "Internal Server Error",
8
9
  };
9
10
  export function problem(c, init) {
@@ -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) => ({
@@ -18,7 +18,7 @@ function refused(c, result) {
18
18
  conflict: "This schedule changed while you were editing, possibly because a run just started. Reload the page to see the latest, then make your change again.",
19
19
  "cancel-required": "This schedule is still active. Cancel it first, then delete it. Completed schedules can be deleted straight away.",
20
20
  "missing-template": "The template you picked was deleted or changed. Choose a template again, then save.",
21
- "unsupported-media": "This template uses audio, images or a thumbnail you supplied, so it cannot run on a schedule. Pick a template that generates these instead.",
21
+ "unsupported-media": "This template uses audio, images, a thumbnail or shorts background music you supplied, so it cannot run on a schedule: a scheduled run has no one to attach the file again. Pick a template that generates these instead, or remove the music under Outputs → Export → More shorts options on Play and save the template again.",
22
22
  "not-due": "This one-off time has already passed. Choose a time in the future.",
23
23
  "spend-limit": "The estimated cost is above this schedule's spend limit, or some prices are unknown. Raise the spend limit or choose models with known prices.",
24
24
  readiness: "A provider this template needs is not ready, for example a missing API key, model or voice. Check Settings → Providers, then try again.",
@@ -1,6 +1,9 @@
1
1
  import { zValidator } from "@hono/zod-validator";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
+ import { notificationUrlMax, notificationUrlProblem, testNotice, webhookBody, } from "../../slices/notifications/rules.js";
5
+ import { createNotificationSender, sendFailureText } from "../../slices/notifications/send.js";
6
+ import { readNotificationUrl, saveNotificationUrl } from "../../slices/notifications/settings.js";
4
7
  import { appearances, providerIds } from "../../slices/settings/model.js";
5
8
  import { readSettings, saveSettings } from "../../slices/settings/playback.js";
6
9
  import { addVoice, removeVoice, voiceIdMax, voiceNameMax, voices, } from "../../slices/settings/voices.js";
@@ -13,6 +16,9 @@ const voiceBody = z.object({
13
16
  name: z.string(),
14
17
  voiceId: z.string(),
15
18
  });
19
+ // Length and scheme are the slice's rules, so their sentences reach the field; this bound only
20
+ // keeps a pasted novel off the parser.
21
+ const notificationBody = z.object({ url: z.string().max(notificationUrlMax * 2) });
16
22
  const playbackBody = z.object({
17
23
  silenceGapSeconds: z.number(),
18
24
  appearance: z.enum(appearances),
@@ -22,6 +28,7 @@ const playbackBody = z.object({
22
28
  export function settingsRoutes(deps) {
23
29
  const playback = { db: deps.db, log: deps.log };
24
30
  const voiceDeps = { db: deps.db, ids: deps.ids };
31
+ const send = deps.sendNotification ?? createNotificationSender(globalThis.fetch);
25
32
  return new Hono()
26
33
  .get("/", (c) => c.json(readSettings(playback)))
27
34
  .put("/", zValidator("json", playbackBody, onInvalid), (c) => {
@@ -35,6 +42,43 @@ export function settingsRoutes(deps) {
35
42
  });
36
43
  }
37
44
  return c.json(result.settings);
45
+ })
46
+ .get("/notifications", (c) => c.json({ url: readNotificationUrl(deps.db) }))
47
+ .put("/notifications", zValidator("json", notificationBody, onInvalid), (c) => {
48
+ const result = saveNotificationUrl(deps.db, c.req.valid("json").url);
49
+ if (!result.ok) {
50
+ return problem(c, {
51
+ status: 400,
52
+ title: titleOf(400),
53
+ detail: `The Notification URL wasn't saved: ${result.message} Fix it in Settings → Notifications → Notification URL, then press Save.`,
54
+ extensions: { fields: [{ field: "url", message: result.message }] },
55
+ });
56
+ }
57
+ return c.json({ url: result.url });
58
+ })
59
+ .post("/notifications/test", zValidator("json", notificationBody, onInvalid), async (c) => {
60
+ const url = c.req.valid("json").url.trim();
61
+ const invalid = url === ""
62
+ ? "Enter a Notification URL first, for example https://ntfy.sh/your-topic."
63
+ : notificationUrlProblem(url);
64
+ if (invalid !== undefined) {
65
+ return problem(c, {
66
+ status: 400,
67
+ title: titleOf(400),
68
+ detail: `The test notification wasn't sent: ${invalid}`,
69
+ extensions: { fields: [{ field: "url", message: invalid }] },
70
+ });
71
+ }
72
+ const result = await send(url, webhookBody(testNotice));
73
+ if (!result.ok) {
74
+ deps.log.write("warn", "notification.test", { detail: sendFailureText(result) });
75
+ return problem(c, {
76
+ status: 502,
77
+ title: titleOf(502),
78
+ detail: `The test notification wasn't delivered: ${sendFailureText(result)}. Check the address in Settings → Notifications → Notification URL (for ntfy, https://ntfy.sh/your-topic), then press Send test notification again.`,
79
+ });
80
+ }
81
+ return c.json({ sent: true });
38
82
  })
39
83
  .get("/voices", (c) => c.json({ voices: voices(voiceDeps) }))
40
84
  .post("/voices", zValidator("json", voiceBody, onInvalid), (c) => {
@@ -0,0 +1,38 @@
1
+ export const requestLimits = { requestMs: 300_000, uploadIdleMs: 300_000 };
2
+ // Node's `requestTimeout` (5 minutes) is one limit for the whole server: every
3
+ // `connectionsCheckingInterval` it answers 408 and closes any connection whose current request
4
+ // started longer ago than that and has not fully arrived, however steadily its body is still
5
+ // coming in. A multi-GB backup over a slow link needs longer, so the server's limit is switched
6
+ // off and the same limit is kept here per request, with one exception: Import everything
7
+ // (PUT /api/storage/import) may take as long as it needs, and is only cut off when it sends
8
+ // nothing at all for `uploadIdleMs`. `headersTimeout` (1 minute) is Node's and still applies to
9
+ // every request.
10
+ export function limitRequestTimes(server, limits = requestLimits) {
11
+ if (!("requestTimeout" in server))
12
+ return;
13
+ server.requestTimeout = 0;
14
+ server.on("request", (request) => {
15
+ if (isBackupUpload(request)) {
16
+ // Idle only while the file is arriving; once it is in, the import may take its time.
17
+ request.setTimeout(limits.uploadIdleMs);
18
+ request.once("end", () => request.setTimeout(0));
19
+ return;
20
+ }
21
+ const timer = setTimeout(() => {
22
+ if (request.complete)
23
+ return;
24
+ // What Node itself does: a 408 if nothing has been answered on this connection yet.
25
+ const socket = request.socket;
26
+ if (socket.writable && socket.bytesWritten === 0)
27
+ socket.write("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n");
28
+ socket.destroy();
29
+ }, limits.requestMs);
30
+ timer.unref();
31
+ const clear = () => clearTimeout(timer);
32
+ request.once("end", clear);
33
+ request.once("close", clear);
34
+ });
35
+ }
36
+ function isBackupUpload(request) {
37
+ return request.method === "PUT" && (request.url ?? "").split("?")[0] === "/api/storage/import";
38
+ }
@@ -1,7 +1,7 @@
1
1
  import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { codexImage } from "../adapters/image/codex.js";
4
+ import { codexImage, codexImageTimeoutMs } from "../adapters/image/codex.js";
5
5
  import { claudeCodeLlm } from "../adapters/llm/claude-code.js";
6
6
  import { nodeClaudeCodeModels } from "../adapters/llm/claude-code-models.js";
7
7
  import { codexLlm } from "../adapters/llm/codex.js";
@@ -75,7 +75,8 @@ export function createHostRuntime(deps) {
75
75
  }),
76
76
  image: {
77
77
  id: "codex-image",
78
- models: () => codexImage({ run: deps.run }).models(),
78
+ timeoutMs: codexImageTimeoutMs,
79
+ models: () => codexImage({ run: deps.run, readModels: () => models("codex") }).models(),
79
80
  generate: async (request) => {
80
81
  const binary = await command("codex-image");
81
82
  return codexImage({ run: deps.run, binary, env: deps.env }).generate(request);
@@ -0,0 +1,16 @@
1
+ -- Library prompts of the new "shorts" kind: what the Video stage's Shorts step asks the text
2
+ -- model for when it picks the clips. SQLite cannot change a CHECK in place, so the table is
3
+ -- copied into one whose CHECK lists the new kind, like 0015 did for "description".
4
+ CREATE TABLE prompts_shorts_upgrade (
5
+ id TEXT PRIMARY KEY,
6
+ kind TEXT NOT NULL CHECK (kind IN ('article','image','thumbnail','narration','description','shorts')),
7
+ name TEXT NOT NULL,
8
+ body TEXT NOT NULL,
9
+ slots TEXT NOT NULL,
10
+ updated_at TEXT NOT NULL
11
+ );
12
+ INSERT INTO prompts_shorts_upgrade (id,kind,name,body,slots,updated_at)
13
+ SELECT id,kind,name,body,slots,updated_at FROM prompts;
14
+ DROP TABLE prompts;
15
+ ALTER TABLE prompts_shorts_upgrade RENAME TO prompts;
16
+ CREATE UNIQUE INDEX prompts_name ON prompts(kind,lower(name));
@@ -0,0 +1,56 @@
1
+ -- Before 2.4.0 every save made while a step waited for Resume minted a new held row for it,
2
+ -- even when the project already had a row for exactly that step (same key and fingerprint).
3
+ -- Save now reserves the existing one instead (existingWork in
4
+ -- slices/rebuild/transition-repo.ts); this removes the copies left behind by older saves.
5
+ --
6
+ -- A row counts for a step the way Save counts it: a finished row (work and piece done), or a
7
+ -- held row nothing ran for (pending, held, unsent, no planning context, no attempt). Per
8
+ -- project, key and fingerprint, the row Save would pick is kept: a finished one first,
9
+ -- otherwise the newest held one. Only held rows that are not that pick are removed, and only
10
+ -- when they carry one piece and no review checkpoint. A revision that reserved a removed
11
+ -- copy is pointed at the kept held row, which is the same unstarted step. A copy some
12
+ -- revision reserved while a finished row exists is left alone: pointing that revision at
13
+ -- the finished result would change what it shows. Finished, running, failed and admitted
14
+ -- work, and assets, are never touched.
15
+ CREATE TEMP TABLE m0022_eligible AS
16
+ SELECT w.id AS work_id, p.id AS piece_id, w.project_id, p.work_key, p.fingerprint,
17
+ w.rowid AS rid, w.state = 'done' AS done
18
+ FROM revision_work w JOIN revision_work_pieces p ON p.work_id = w.id
19
+ WHERE (w.state = 'done' AND p.state = 'done')
20
+ OR (w.state = 'pending' AND w.dispatch_state = 'held' AND p.state = 'held'
21
+ AND p.submitted_at IS NULL AND w.recipe_context IS NULL
22
+ AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.work_id = w.id));
23
+
24
+ CREATE TEMP TABLE m0022_kept AS
25
+ SELECT project_id, work_key, fingerprint, work_id, piece_id, done FROM (
26
+ SELECT e.*, row_number() OVER (
27
+ PARTITION BY project_id, work_key, fingerprint ORDER BY done DESC, rid DESC
28
+ ) AS pick
29
+ FROM m0022_eligible e
30
+ ) WHERE pick = 1;
31
+
32
+ CREATE TEMP TABLE m0022_copies AS
33
+ SELECT e.work_id, k.work_id AS kept_work_id, k.piece_id AS kept_piece_id
34
+ FROM m0022_eligible e
35
+ JOIN m0022_kept k ON k.project_id = e.project_id AND k.work_key = e.work_key
36
+ AND k.fingerprint = e.fingerprint
37
+ WHERE e.done = 0 AND e.work_id <> k.work_id
38
+ AND (SELECT count(*) FROM revision_work_pieces q WHERE q.work_id = e.work_id) = 1
39
+ AND NOT EXISTS (SELECT 1 FROM review_checkpoints c WHERE c.work_id = e.work_id)
40
+ AND (k.done = 0
41
+ OR NOT EXISTS (SELECT 1 FROM revision_work_reservations r WHERE r.work_id = e.work_id));
42
+
43
+ UPDATE revision_work_reservations
44
+ SET piece_id = CASE WHEN piece_id IS NULL THEN NULL ELSE (
45
+ SELECT c.kept_piece_id FROM m0022_copies c WHERE c.work_id = revision_work_reservations.work_id
46
+ ) END,
47
+ work_id = (
48
+ SELECT c.kept_work_id FROM m0022_copies c WHERE c.work_id = revision_work_reservations.work_id
49
+ )
50
+ WHERE work_id IN (SELECT work_id FROM m0022_copies);
51
+
52
+ DELETE FROM revision_work WHERE id IN (SELECT work_id FROM m0022_copies);
53
+
54
+ DROP TABLE m0022_eligible;
55
+ DROP TABLE m0022_kept;
56
+ DROP TABLE m0022_copies;
@@ -0,0 +1,18 @@
1
+ -- A Play draft can carry an uploaded establishing image (the Images stage's reference). SQLite
2
+ -- cannot widen a CHECK constraint in place, so the attachments table is rebuilt with the new
3
+ -- kind. Nothing references it, so enforcement stays on.
4
+ CREATE TABLE play_draft_attachments_reference_upgrade (
5
+ id TEXT PRIMARY KEY,
6
+ draft_id TEXT NOT NULL REFERENCES play_drafts(id) ON DELETE CASCADE,
7
+ staged_file_id TEXT REFERENCES staged_files(id) ON DELETE SET NULL,
8
+ kind TEXT NOT NULL CHECK(kind IN ('audio','images','thumbnail','reference')),
9
+ original_filename TEXT NOT NULL,
10
+ status TEXT NOT NULL CHECK(status IN ('pending','ready','reattach')),
11
+ error TEXT
12
+ );
13
+ INSERT INTO play_draft_attachments_reference_upgrade (id,draft_id,staged_file_id,kind,original_filename,status,error)
14
+ SELECT id,draft_id,staged_file_id,kind,original_filename,status,error FROM play_draft_attachments ORDER BY rowid;
15
+ DROP TABLE play_draft_attachments;
16
+ ALTER TABLE play_draft_attachments_reference_upgrade RENAME TO play_draft_attachments;
17
+ CREATE INDEX play_draft_attachment_file ON play_draft_attachments(staged_file_id);
18
+ CREATE INDEX play_draft_attachment_owner ON play_draft_attachments(draft_id);
@@ -13,6 +13,8 @@ export const bridgeLimits = {
13
13
  frame: 4 * 1024 * 1024,
14
14
  stream: 64 * 1024 * 1024,
15
15
  image: 32 * 1024 * 1024,
16
+ // An establishing image sent along with an image request; base64 keeps it inside `request`.
17
+ reference: 10 * 1024 * 1024,
16
18
  text: 2 * 1024 * 1024,
17
19
  generation: 5,
18
20
  metadata: 8,
@@ -39,9 +41,22 @@ export const hostLlmSchema = z
39
41
  .strict();
40
42
  export const hostImageSchema = z
41
43
  .object({
42
- model: z.literal("codex-imagegen"),
44
+ // "codex-imagegen" is the Codex default; any other is one of the Codex CLI's models.
45
+ model: short.refine((v) => !v.startsWith("-") && !/\s/.test(v)),
43
46
  prompt: z.string().min(1).max(bridgeLimits.text),
44
47
  aspect: z.enum(["16:9", "9:16"]),
48
+ thinking: z.enum(thinkingModes).optional(),
49
+ // The establishing image, as base64 (at most `bridgeLimits.reference` bytes decoded).
50
+ reference: z
51
+ .object({
52
+ mime: z.enum(["image/png", "image/jpeg"]),
53
+ base64: z
54
+ .string()
55
+ .max(Math.ceil(bridgeLimits.reference / 3) * 4)
56
+ .regex(/^[A-Za-z0-9+/]*={0,2}$/),
57
+ })
58
+ .strict()
59
+ .optional(),
45
60
  })
46
61
  .strict();
47
62
  export const hostOpenFolderSchema = z
@@ -73,7 +88,7 @@ export const hostModelsSchema = z
73
88
  id: short,
74
89
  name: short,
75
90
  group: short.optional(),
76
- thinkingModes: z.array(z.enum(thinkingModes)).max(5).optional(),
91
+ thinkingModes: z.array(z.enum(thinkingModes)).max(thinkingModes.length).optional(),
77
92
  })
78
93
  .strict())
79
94
  .max(1000),
@@ -1 +1,3 @@
1
- export {};
1
+ // How long an agent-driven image job (the Codex CLI reviewing and redrawing its image) may
2
+ // run, on this machine or through the host helper.
3
+ export const agentImageTimeoutMs = 30 * 60_000;
@@ -1,2 +1,4 @@
1
1
  export const messageRoles = ["system", "user", "assistant"];
2
- export const thinkingModes = ["off", "low", "medium", "high", "xhigh"];
2
+ // "max" and "ultra" are the Codex CLI's two highest reasoning efforts (its model list reports
3
+ // them per model); the catalogue's models never offer them.
4
+ export const thinkingModes = ["off", "low", "medium", "high", "xhigh", "max", "ultra"];
@@ -5,11 +5,13 @@ import { isProviderError, providerError } from "../ports/model.js";
5
5
  // Four attempts - the first plus 3 retries - waiting 2 s, 8 s, 30 s.
6
6
  export const attemptLimit = 4;
7
7
  export const backoffMs = [2000, 8000, 30_000];
8
- // Each attempt times out at 120 s, image calls at 300 s.
8
+ // Each attempt times out at 120 s, image calls at 300 s, and an image animated into a clip at
9
+ // 900 s: image-to-video models queue and take one to five minutes when they run.
9
10
  export const timeoutMs = {
10
11
  llm: 120_000,
11
12
  tts: 120_000,
12
13
  image: 300_000,
14
+ video: 900_000,
13
15
  };
14
16
  // A refusal and an unsupported capability are the provider's final answer; retrying only
15
17
  // spends the user's money again. The third is a key removed mid-run, which leaves nothing
@@ -23,7 +25,7 @@ const terminalKinds = [
23
25
  ];
24
26
  export async function attempt(ctx, call, opts) {
25
27
  ctx.signal.throwIfAborted();
26
- const limit = timeoutMs[opts.kind];
28
+ const limit = opts.timeoutMs ?? timeoutMs[opts.kind];
27
29
  for (let n = 1;; n += 1) {
28
30
  ctx.signal.throwIfAborted();
29
31
  const retrieving = ctx.continuation?.read() !== undefined;
@@ -131,6 +133,7 @@ const callee = {
131
133
  llm: "AI model",
132
134
  tts: "narration provider",
133
135
  image: "image provider",
136
+ video: "image-to-video model",
134
137
  };
135
138
  // Node's `fetch` rejects a request that never reached the server with this TypeError.
136
139
  function unreachable(error) {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { providerError } from "../ports/model.js";
2
3
  import { attempt } from "./attempt.js";
3
4
  export function stageProviders(deps, context, pieceId) {
4
5
  const ctx = {
@@ -140,14 +141,53 @@ export function stageProviders(deps, context, pieceId) {
140
141
  },
141
142
  image: (call) => {
142
143
  const port = deps.registry.image(call.provider);
144
+ const callId = randomUUID();
145
+ // A long job (the Codex CLI reviewing and redrawing its image) says how far it has got
146
+ // on the stage's live panel; the other providers report nothing and show no panel.
147
+ const onProgress = (text) => context.emit({
148
+ type: "llm.preview",
149
+ ...(pieceId === undefined ? {} : { workPieceId: pieceId }),
150
+ projectId: context.stage.projectId,
151
+ stage: context.stage.kind,
152
+ callId,
153
+ label: call.previewLabel ?? "Image",
154
+ text,
155
+ reset: true,
156
+ });
143
157
  return schedule(call.provider, () => attempt(ctx, (signal) => port.generate({
144
158
  model: call.model,
145
159
  prompt: call.prompt,
146
160
  aspect: call.aspect,
161
+ ...(call.thinking === undefined ? {} : { thinking: call.thinking }),
162
+ ...(call.reference === undefined ? {} : { reference: call.reference }),
163
+ onProgress,
164
+ signal,
165
+ }),
166
+ // One request, one answer: the limit runs over the whole call - 300 s, or the
167
+ // provider's own when it needs longer.
168
+ {
169
+ kind: "image",
170
+ ...(port.timeoutMs === undefined ? {} : { timeoutMs: port.timeoutMs }),
171
+ }));
172
+ },
173
+ animate: (call) => {
174
+ const port = deps.registry.image(call.provider);
175
+ const animate = port.animate;
176
+ if (animate === undefined)
177
+ return Promise.reject(providerError({
178
+ kind: "unsupported",
179
+ message: `${call.provider} can't turn images into video clips. Choose fal.ai or Replicate as the image provider in Edit project → Providers, or turn Animate images off in Edit project → Inputs → Look.`,
180
+ }));
181
+ return schedule(call.provider, () => attempt(ctx, (signal) => animate({
182
+ model: call.model,
183
+ prompt: call.prompt,
184
+ image: call.image,
185
+ aspect: call.aspect,
186
+ seconds: call.seconds,
147
187
  signal,
148
188
  }),
149
- // One request, one answer: the 300 s runs over the whole call.
150
- { kind: "image" }));
189
+ // Queued and slow: the 900 s runs over the whole call, polling included.
190
+ { kind: "video" }));
151
191
  },
152
192
  forPiece: (piece) => stageProviders(deps, context, piece),
153
193
  };