@gentbajko/slopify 0.8.4 → 1.0.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 (204) hide show
  1. package/README.md +28 -6
  2. package/dist/adapters/llm/claude-code.js +6 -3
  3. package/dist/adapters/llm/codex.js +102 -12
  4. package/dist/adapters/llm/gemini.js +11 -8
  5. package/dist/adapters/llm/run-cli.js +61 -6
  6. package/dist/adapters/tts/inworld-async.js +4 -4
  7. package/dist/adapters/tts/inworld.js +73 -68
  8. package/dist/catalog/registry.js +13 -73
  9. package/dist/edge/cli.js +5 -2
  10. package/dist/edge/events/hub.js +7 -2
  11. package/dist/edge/events/preview-cache.js +7 -4
  12. package/dist/edge/events/visibility.js +33 -0
  13. package/dist/edge/http/actions.js +74 -79
  14. package/dist/edge/http/app.js +39 -6
  15. package/dist/edge/http/audio-preview.js +9 -1
  16. package/dist/edge/http/checkpoints.js +95 -0
  17. package/dist/edge/http/diagnostics.js +31 -0
  18. package/dist/edge/http/draft-files.js +69 -0
  19. package/dist/edge/http/draft-problem.js +15 -0
  20. package/dist/edge/http/drafts.js +95 -0
  21. package/dist/edge/http/multipart.js +86 -0
  22. package/dist/edge/http/mutations.js +49 -0
  23. package/dist/edge/http/planning.js +12 -1
  24. package/dist/edge/http/project-templates.js +74 -0
  25. package/dist/edge/http/projects.js +19 -2
  26. package/dist/edge/http/revision-files.js +84 -0
  27. package/dist/edge/http/revisions.js +103 -0
  28. package/dist/edge/http/schedules.js +100 -0
  29. package/dist/edge/http/staging.js +11 -52
  30. package/dist/edge/http/storage.js +110 -0
  31. package/dist/edge/http/subtitles.js +9 -79
  32. package/dist/edge/http/tutorial.js +26 -0
  33. package/dist/edge/signal-shutdown.js +23 -0
  34. package/dist/kernel/audio-preview.js +11 -5
  35. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  36. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  37. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  38. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  39. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  40. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  41. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  42. package/dist/kernel/ports/model.js +5 -0
  43. package/dist/kernel/runner/attempt-repo.js +15 -0
  44. package/dist/kernel/runner/attempt.js +14 -2
  45. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  46. package/dist/kernel/runner/index.js +35 -11
  47. package/dist/kernel/runner/providers.js +18 -8
  48. package/dist/kernel/runner/work-attempt.js +47 -0
  49. package/dist/kernel/runner/work-authority.js +37 -0
  50. package/dist/kernel/runner/work.js +23 -0
  51. package/dist/main.js +227 -69
  52. package/dist/slices/admission/repo.js +5 -56
  53. package/dist/slices/admission/schema.js +64 -0
  54. package/dist/slices/admission/start.js +13 -4
  55. package/dist/slices/article/continuation.js +9 -3
  56. package/dist/slices/article/provided-entries.js +7 -3
  57. package/dist/slices/article/run.js +20 -7
  58. package/dist/slices/article/segments.js +13 -4
  59. package/dist/slices/batch/index.js +8 -11
  60. package/dist/slices/cancel/index.js +78 -34
  61. package/dist/slices/checkpoints/change.js +250 -0
  62. package/dist/slices/checkpoints/fingerprint.js +33 -0
  63. package/dist/slices/checkpoints/index.js +2 -0
  64. package/dist/slices/checkpoints/model.js +1 -0
  65. package/dist/slices/checkpoints/recovery.js +116 -0
  66. package/dist/slices/checkpoints/repo.js +132 -0
  67. package/dist/slices/checkpoints/rules.js +30 -0
  68. package/dist/slices/checkpoints/schema.js +59 -0
  69. package/dist/slices/control/index.js +88 -22
  70. package/dist/slices/control/providers.js +3 -2
  71. package/dist/slices/control/revision-control-schema.js +7 -0
  72. package/dist/slices/control/revision-control.js +59 -0
  73. package/dist/slices/estimate/index.js +77 -59
  74. package/dist/slices/estimate/requests.js +102 -0
  75. package/dist/slices/images/run.js +14 -3
  76. package/dist/slices/library/slots.js +11 -11
  77. package/dist/slices/library/snapshot.js +33 -0
  78. package/dist/slices/narration/legacy-plan.js +58 -0
  79. package/dist/slices/narration/live.js +2 -2
  80. package/dist/slices/narration/plan.js +48 -54
  81. package/dist/slices/narration/run.js +21 -8
  82. package/dist/slices/play-drafts/convert.js +120 -0
  83. package/dist/slices/play-drafts/model.js +1 -0
  84. package/dist/slices/play-drafts/readiness.js +80 -0
  85. package/dist/slices/play-drafts/repo.js +76 -0
  86. package/dist/slices/play-drafts/review-inputs.js +150 -0
  87. package/dist/slices/play-drafts/review.js +109 -0
  88. package/dist/slices/play-drafts/schema.js +218 -0
  89. package/dist/slices/play-drafts/service.js +242 -0
  90. package/dist/slices/play-drafts/start-repo.js +113 -0
  91. package/dist/slices/play-drafts/start.js +148 -0
  92. package/dist/slices/play-drafts/uploads.js +67 -0
  93. package/dist/slices/project-templates/from-project.js +159 -0
  94. package/dist/slices/project-templates/model.js +1 -0
  95. package/dist/slices/project-templates/repo.js +41 -0
  96. package/dist/slices/project-templates/schema.js +37 -0
  97. package/dist/slices/project-templates/service.js +136 -0
  98. package/dist/slices/project-templates/setup.js +55 -0
  99. package/dist/slices/rebuild/admission-repo.js +120 -0
  100. package/dist/slices/rebuild/dependencies.js +33 -0
  101. package/dist/slices/rebuild/model.js +94 -0
  102. package/dist/slices/rebuild/narration-history.js +43 -0
  103. package/dist/slices/rebuild/narration-reuse.js +20 -0
  104. package/dist/slices/rebuild/preview-details.js +144 -0
  105. package/dist/slices/rebuild/preview-plan.js +223 -0
  106. package/dist/slices/rebuild/preview-retained.js +128 -0
  107. package/dist/slices/rebuild/provided-review.js +32 -0
  108. package/dist/slices/rebuild/recipe-audio.js +172 -0
  109. package/dist/slices/rebuild/recipe-build.js +17 -0
  110. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  111. package/dist/slices/rebuild/recipe-exports.js +65 -0
  112. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  113. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  114. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  115. package/dist/slices/rebuild/recipe-model.js +77 -0
  116. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  117. package/dist/slices/rebuild/recipe-save.js +221 -0
  118. package/dist/slices/rebuild/recipe-text.js +170 -0
  119. package/dist/slices/rebuild/recipe-validation.js +86 -0
  120. package/dist/slices/rebuild/recipe-visual.js +111 -0
  121. package/dist/slices/rebuild/recipe-work.js +212 -0
  122. package/dist/slices/rebuild/recipes.js +4 -0
  123. package/dist/slices/rebuild/repo.js +64 -0
  124. package/dist/slices/rebuild/runtime-actions.js +76 -0
  125. package/dist/slices/rebuild/runtime-admission.js +71 -0
  126. package/dist/slices/rebuild/runtime-article.js +117 -0
  127. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  128. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  129. package/dist/slices/rebuild/runtime-export.js +139 -0
  130. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  131. package/dist/slices/rebuild/runtime-local.js +137 -0
  132. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  133. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  134. package/dist/slices/rebuild/runtime-plan.js +103 -0
  135. package/dist/slices/rebuild/runtime-provider.js +184 -0
  136. package/dist/slices/rebuild/runtime-publication.js +172 -0
  137. package/dist/slices/rebuild/runtime-run.js +35 -0
  138. package/dist/slices/rebuild/runtime-store.js +166 -0
  139. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  140. package/dist/slices/rebuild/service-readiness.js +110 -0
  141. package/dist/slices/rebuild/service.js +149 -0
  142. package/dist/slices/rebuild/transition-repo.js +108 -0
  143. package/dist/slices/rebuild/work-records.js +44 -0
  144. package/dist/slices/reruns/index.js +5 -0
  145. package/dist/slices/research/run.js +33 -12
  146. package/dist/slices/revisions/adopt-assets.js +31 -0
  147. package/dist/slices/revisions/adopt-content.js +112 -0
  148. package/dist/slices/revisions/adopt.js +100 -0
  149. package/dist/slices/revisions/downloads.js +80 -0
  150. package/dist/slices/revisions/index.js +5 -0
  151. package/dist/slices/revisions/manifest-repo.js +125 -0
  152. package/dist/slices/revisions/model.js +1 -0
  153. package/dist/slices/revisions/mutation-assets.js +143 -0
  154. package/dist/slices/revisions/mutation-cues.js +95 -0
  155. package/dist/slices/revisions/mutation-prepare.js +182 -0
  156. package/dist/slices/revisions/mutation-request.js +58 -0
  157. package/dist/slices/revisions/mutation-work.js +47 -0
  158. package/dist/slices/revisions/mutations.js +194 -0
  159. package/dist/slices/revisions/projection.js +82 -0
  160. package/dist/slices/revisions/publication-model.js +1 -0
  161. package/dist/slices/revisions/publication-rules.js +166 -0
  162. package/dist/slices/revisions/publish.js +115 -0
  163. package/dist/slices/revisions/repo.js +83 -0
  164. package/dist/slices/revisions/restore.js +60 -0
  165. package/dist/slices/revisions/rules.js +99 -0
  166. package/dist/slices/revisions/schema.js +160 -0
  167. package/dist/slices/revisions/view.js +44 -0
  168. package/dist/slices/schedules/calendar.js +47 -0
  169. package/dist/slices/schedules/model.js +1 -0
  170. package/dist/slices/schedules/repo.js +281 -0
  171. package/dist/slices/schedules/scheduler.js +144 -0
  172. package/dist/slices/schedules/schema.js +80 -0
  173. package/dist/slices/schedules/service.js +178 -0
  174. package/dist/slices/settings/cli-paths.js +1 -1
  175. package/dist/slices/settings/cli-status.js +45 -5
  176. package/dist/slices/settings/tutorial-schema.js +39 -0
  177. package/dist/slices/settings/tutorial.js +66 -0
  178. package/dist/slices/storage/assets.js +108 -0
  179. package/dist/slices/storage/delete-project.js +2 -1
  180. package/dist/slices/storage/downloads.js +4 -1
  181. package/dist/slices/storage/portable.js +871 -0
  182. package/dist/slices/storage/prepare.js +67 -0
  183. package/dist/slices/storage/reconcile.js +25 -5
  184. package/dist/slices/storage/repo.js +2 -15
  185. package/dist/slices/storage/schema.js +35 -0
  186. package/dist/slices/storage/staging-refs.js +26 -0
  187. package/dist/slices/storage/staging.js +16 -11
  188. package/dist/slices/thumbnail/run.js +26 -9
  189. package/dist/slices/video/audio-export-args.js +33 -0
  190. package/dist/slices/video/audio-export.js +13 -36
  191. package/dist/slices/video/audio-inputs.js +4 -3
  192. package/dist/slices/video/ffmpeg.js +6 -0
  193. package/dist/slices/video/reuse-audio.js +11 -4
  194. package/dist/updater/install-flow.js +27 -2
  195. package/dist/updater/plan.js +28 -2
  196. package/dist/updater/service.js +27 -2
  197. package/dist/updater/worker.js +2 -1
  198. package/dist/web/assets/index-CO7DnVX9.js +137 -0
  199. package/dist/web/assets/index-DX1_chKs.css +1 -0
  200. package/dist/web/index.html +2 -2
  201. package/package.json +2 -2
  202. package/dist/adapters/tts/inworld-text.js +0 -32
  203. package/dist/web/assets/index-Ce2HApus.css +0 -1
  204. package/dist/web/assets/index-xbD0MZOY.js +0 -130
@@ -0,0 +1,67 @@
1
+ import { constants, copyFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { allocateAsset, discardPreparedAssets, sealAsset, writeAsset } from "./assets.js";
4
+ import { extensionOf, outputFileName, stagingPath } from "./layout.js";
5
+ import { stagedFileById } from "./repo.js";
6
+ export function prepareStagedFile(deps, input) {
7
+ const staged = stagedFileById(deps.db, input.stagedFileId);
8
+ if (staged === undefined)
9
+ return { ok: false, reason: "unknown-staged-file" };
10
+ if (staged.state !== "staged")
11
+ return { ok: false, reason: "still-copying" };
12
+ const index = input.index ?? 1;
13
+ const filename = basename(outputFileName(input.role, index, extensionOf(staged.originalFilename), staged.stageKind));
14
+ const stagedSource = stagingPath(deps.paths, staged.path);
15
+ const pending = allocateAsset(deps, input.projectId, filename);
16
+ try {
17
+ copyFileSync(stagedSource, pending.absolutePath, constants.COPYFILE_EXCL);
18
+ const asset = sealAsset(deps, pending);
19
+ return {
20
+ ok: true,
21
+ asset,
22
+ stagedFileId: staged.id,
23
+ stagedSource,
24
+ output: {
25
+ id: deps.ids.next(),
26
+ projectId: input.projectId,
27
+ stageKind: staged.stageKind,
28
+ role: input.role,
29
+ path: asset.path,
30
+ originalFilename: staged.originalFilename,
31
+ bytes: asset.bytes,
32
+ durationMs: null,
33
+ meta: input.role === "image" ? { index } : {},
34
+ createdAt: asset.createdAt,
35
+ },
36
+ };
37
+ }
38
+ catch (error) {
39
+ discardPreparedAssets(deps, [pending]);
40
+ throw error;
41
+ }
42
+ }
43
+ export function prepareText(deps, input) {
44
+ const filename = basename(outputFileName(input.role, 1, ".txt", input.stageKind));
45
+ const asset = writeAsset(deps, input.projectId, filename, Buffer.from(input.text, "utf8"));
46
+ try {
47
+ return {
48
+ asset,
49
+ output: {
50
+ id: deps.ids.next(),
51
+ projectId: input.projectId,
52
+ stageKind: input.stageKind,
53
+ role: input.role,
54
+ path: asset.path,
55
+ originalFilename: null,
56
+ bytes: asset.bytes,
57
+ durationMs: null,
58
+ meta: {},
59
+ createdAt: asset.createdAt,
60
+ },
61
+ };
62
+ }
63
+ catch (error) {
64
+ discardPreparedAssets(deps, [asset]);
65
+ throw error;
66
+ }
67
+ }
@@ -1,14 +1,18 @@
1
- import { readdirSync, rmSync, unlinkSync } from "node:fs";
1
+ import { readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
- // Files the database does not know about, and staged uploads that never reached a
4
- // project, are removed at start.
3
+ import { outputPath, stagingPath } from "./layout.js";
4
+ import { stagedFiles as stagedRows } from "./repo.js";
5
+ import { stagedFileReferenced } from "./staging-refs.js";
5
6
  export function reconcileStorage(db, paths) {
6
7
  const projects = idsOf(db, "SELECT id FROM projects", "id");
7
8
  const kept = new Set();
8
- for (const row of db.prepare("SELECT project_id, path FROM outputs").all()) {
9
+ for (const row of db
10
+ .prepare("SELECT project_id, path FROM outputs UNION SELECT project_id, path FROM project_assets")
11
+ .all()) {
9
12
  const projectId = row.project_id;
10
13
  const path = row.path;
11
14
  if (typeof projectId === "string" && typeof path === "string") {
15
+ outputPath(paths, projectId, path);
12
16
  kept.add(`${projectId}/${slashed(path)}`);
13
17
  }
14
18
  }
@@ -47,12 +51,28 @@ export function reconcileStorage(db, paths) {
47
51
  orphanFiles += 1;
48
52
  }
49
53
  }
54
+ const retained = new Set();
55
+ for (const file of stagedRows(db)) {
56
+ if (!stagedFileReferenced(db, file.id))
57
+ continue;
58
+ const stat = statSync(stagingPath(paths, file.path), { throwIfNoEntry: false });
59
+ if (file.state === "staged" && stat?.isFile() && stat.size === file.bytes) {
60
+ retained.add(file.path);
61
+ db.prepare("UPDATE play_draft_attachments SET status='ready',error=NULL WHERE staged_file_id=?").run(file.id);
62
+ }
63
+ else {
64
+ db.prepare("UPDATE play_draft_attachments SET staged_file_id=NULL,status='reattach',error=? WHERE staged_file_id=?").run("Upload is missing or incomplete. Reattach the file.", file.id);
65
+ }
66
+ }
67
+ db.prepare("UPDATE play_draft_attachments SET status='reattach',error=? WHERE status IN ('ready','pending') AND staged_file_id IS NULL").run("Upload is missing or incomplete. Reattach the file.");
50
68
  let stagedFiles = 0;
51
69
  for (const file of filesUnder(paths.staging)) {
70
+ if (retained.has(file))
71
+ continue;
52
72
  unlinkSync(join(paths.staging, file));
53
73
  stagedFiles += 1;
54
74
  }
55
- db.exec("DELETE FROM staged_files");
75
+ db.exec("DELETE FROM staged_files WHERE NOT EXISTS (SELECT 1 FROM play_draft_attachments WHERE staged_file_id=staged_files.id)");
56
76
  return { orphanFiles, stagedFiles };
57
77
  }
58
78
  // The one field read inside a payload whose shape belongs to the stage that wrote it: a
@@ -1,17 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { outputRoles, stagedFileStates, stageKinds } from "./model.js";
3
- const metaSchema = z.object({
4
- subtitleOmissions: z
5
- .array(z.object({ start: z.number().finite().nonnegative(), text: z.string() }))
6
- .optional(),
7
- subtitlesMode: z.enum(["off", "files", "burn-in"]).optional(),
8
- promptName: z.string().optional(),
9
- prompt: z.string().optional(),
10
- index: z.number().optional(),
11
- provider: z.string().optional(),
12
- model: z.string().optional(),
13
- voice: z.string().optional(),
14
- });
3
+ import { metaSchema } from "./schema.js";
15
4
  const outputRow = z.object({
16
5
  id: z.string(),
17
6
  project_id: z.string(),
@@ -46,8 +35,6 @@ export function outputById(db, id) {
46
35
  const row = db.prepare("SELECT * FROM outputs WHERE id = ?").get(id);
47
36
  return row === undefined ? undefined : toOutput(outputRow.parse(row));
48
37
  }
49
- // An action replaces an output rather than versioning it. The row goes
50
- // here; the file it names is the caller's to unlink once this has committed.
51
38
  export function deleteOutput(db, id) {
52
39
  db.prepare("DELETE FROM outputs WHERE id = ?").run(id);
53
40
  }
@@ -68,7 +55,7 @@ export function stagedFiles(db) {
68
55
  .map((row) => toStagedFile(stagedFileRow.parse(row)));
69
56
  }
70
57
  export function deleteStagedFile(db, id) {
71
- db.prepare("DELETE FROM staged_files WHERE id = ?").run(id);
58
+ db.prepare("DELETE FROM staged_files WHERE id = ? AND NOT EXISTS (SELECT 1 FROM play_draft_attachments WHERE staged_file_id = ?)").run(id, id);
72
59
  }
73
60
  export function projectTitle(db, projectId) {
74
61
  const row = db.prepare("SELECT title FROM projects WHERE id = ?").get(projectId);
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { outputRoles, stagedFileStates, stageKinds } from "./model.js";
3
+ export const metaSchema = z.object({
4
+ subtitleOmissions: z
5
+ .array(z.object({ start: z.number().finite().nonnegative(), text: z.string() }))
6
+ .optional(),
7
+ subtitlesMode: z.enum(["off", "files", "burn-in"]).optional(),
8
+ promptName: z.string().optional(),
9
+ prompt: z.string().optional(),
10
+ index: z.number().optional(),
11
+ provider: z.string().optional(),
12
+ model: z.string().optional(),
13
+ voice: z.string().optional(),
14
+ });
15
+ export const outputSchema = z.object({
16
+ id: z.string(),
17
+ projectId: z.string(),
18
+ stageKind: z.enum(stageKinds),
19
+ role: z.enum(outputRoles),
20
+ path: z.string(),
21
+ originalFilename: z.string().nullable(),
22
+ bytes: z.number().nonnegative(),
23
+ durationMs: z.number().nonnegative().nullable(),
24
+ meta: metaSchema,
25
+ createdAt: z.string(),
26
+ });
27
+ export const stagedFileSchema = z.object({
28
+ id: z.string(),
29
+ stageKind: z.enum(stageKinds),
30
+ path: z.string(),
31
+ originalFilename: z.string(),
32
+ bytes: z.number().nonnegative(),
33
+ state: z.enum(stagedFileStates),
34
+ createdAt: z.string(),
35
+ });
@@ -0,0 +1,26 @@
1
+ import { rmSync } from "node:fs";
2
+ import { stagingPath } from "./layout.js";
3
+ import { deleteStagedFile, stagedFileById } from "./repo.js";
4
+ export function stagedFileReferenced(db, id) {
5
+ return (db.prepare("SELECT 1 FROM play_draft_attachments WHERE staged_file_id=? LIMIT 1").get(id) !==
6
+ undefined);
7
+ }
8
+ export function releaseStagedFile(deps, id) {
9
+ // A nested savepoint cannot prove that its caller will commit. Boot collects deferred releases.
10
+ if (deps.db.isTransaction || stagedFileReferenced(deps.db, id))
11
+ return;
12
+ const file = stagedFileById(deps.db, id);
13
+ // An active stream owns its open file until completion, including on Windows.
14
+ if (file?.state === "copying")
15
+ return;
16
+ try {
17
+ rmSync(stagingPath(deps.paths, file?.path ?? id), { force: true });
18
+ }
19
+ catch (error) {
20
+ deps.log.write("warn", "staging.release", {
21
+ detail: `${id}: ${error instanceof Error ? error.message : String(error)}`,
22
+ });
23
+ return;
24
+ }
25
+ deleteStagedFile(deps.db, id);
26
+ }
@@ -1,20 +1,22 @@
1
1
  import { copyFileSync, createWriteStream, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
2
+ import { basename, dirname } from "node:path";
3
3
  import { pipeline } from "node:stream/promises";
4
4
  import { z } from "zod";
5
5
  import { transact } from "../../kernel/db/tx.js";
6
6
  import { extensionOf, outputFileName, outputPath, stagingPath } from "./layout.js";
7
7
  import { deleteStagedFile, insertOutput, insertStagedFile, markStagedFileCopied, stagedFileById, } from "./repo.js";
8
+ import { releaseStagedFile, stagedFileReferenced } from "./staging-refs.js";
9
+ export { prepareStagedFile, prepareText } from "./prepare.js";
8
10
  // The upload's own name is data, never a path component: the file is stored under a
9
11
  // generated id, and anything with a separator, a traversal, or a control character is
10
12
  // refused rather than sanitised, so nothing silently lands under a name nobody chose.
11
13
  const filename = z
12
14
  .string()
13
- .trim()
15
+ .refine((name) => name.trim().length > 0)
14
16
  .min(1)
15
17
  .max(255)
16
18
  .refine((name) => !name.includes("/") && !name.includes("\\"))
17
- .refine((name) => name !== "." && name !== "..")
19
+ .refine((name) => name.trim() !== "." && name.trim() !== "..")
18
20
  .refine((name) => ![...name].some((character) => (character.codePointAt(0) ?? 0) < 0x20));
19
21
  // ceiling: progress is coalesced to one event per 200 ms per upload. A page that wants
20
22
  // a smoother bar reads bytes from the staging list instead of asking for more events.
@@ -24,7 +26,7 @@ export async function stageUpload(deps, input) {
24
26
  if (!named.success) {
25
27
  return { ok: false, reason: "unsafe-filename" };
26
28
  }
27
- const originalFilename = named.data;
29
+ const originalFilename = input.originalFilename;
28
30
  const id = deps.ids.next();
29
31
  const target = stagingPath(deps.paths, id);
30
32
  const file = {
@@ -38,7 +40,10 @@ export async function stageUpload(deps, input) {
38
40
  };
39
41
  // The row exists before the first byte, so a process that dies mid-copy leaves a
40
42
  // record reconcile collects rather than an untracked file.
41
- insertStagedFile(deps.db, file);
43
+ transact(deps.db, () => {
44
+ insertStagedFile(deps.db, file);
45
+ input.onAllocated?.(file);
46
+ });
42
47
  let bytes = 0;
43
48
  let lastEmit = 0;
44
49
  const notify = (event) => {
@@ -157,12 +162,7 @@ export function attachStagedFile(deps, input) {
157
162
  // has committed. A failure here is not worth failing the run over, because the file is a
158
163
  // duplicate of one the project already holds and the next boot's reconcile clears it.
159
164
  export function dropStagedSource(deps, source) {
160
- try {
161
- rmSync(source, { force: true });
162
- }
163
- catch (error) {
164
- deps.log.write("warn", "staging.source", { detail: `${source}: ${messageOf(error)}` });
165
- }
165
+ releaseStagedFile(deps, basename(source));
166
166
  }
167
167
  // A stage set to Provide can carry pasted text instead of a file, and the project holds it as
168
168
  // an output like any other. Unlike an attached upload this needs no two-phase dance: the text
@@ -196,10 +196,15 @@ export function discardStagedFile(deps, id) {
196
196
  if (staged === undefined) {
197
197
  return { ok: false, reason: "unknown-staged-file" };
198
198
  }
199
+ if (stagedFileReferenced(deps.db, id))
200
+ return { ok: false, reason: "in-use" };
199
201
  discard(deps, id, stagingPath(deps.paths, staged.path), "staging.discarded");
200
202
  return { ok: true };
201
203
  }
202
204
  function discard(deps, id, target, event) {
205
+ deps.db
206
+ .prepare("UPDATE play_draft_attachments SET staged_file_id=NULL,status='reattach',error=? WHERE staged_file_id=?")
207
+ .run("Upload is missing or incomplete. Reattach the file.", id);
203
208
  try {
204
209
  rmSync(target, { force: true });
205
210
  }
@@ -30,9 +30,13 @@ export async function runThumbnail(deps, context, providers) {
30
30
  // here is a bug in admission rather than the user's.
31
31
  throw new Error("the run has no image provider or model");
32
32
  }
33
- const written = source === "from_prompt" ? undefined : await byLlm(deps, context, providers, project);
33
+ const result = source === "from_prompt" ? undefined : await byLlm(deps, context, providers, project);
34
+ if (result !== undefined && !result.ok)
35
+ return "held";
36
+ const written = result?.value;
34
37
  const prompt = written?.prompt ?? fromTemplate(project);
35
- await make(deps, context, providers, project, choice, prompt);
38
+ if ((await make(deps, context, providers, project, choice, prompt)) === "held")
39
+ return "held";
36
40
  // Tokens are counted with a provider and model name per stage, and a payload names one
37
41
  // provider. This stage can use two - the LLM that writes the prompt and the image model
38
42
  // that draws it - so the event names whichever one's usage it reports, because both are
@@ -50,6 +54,7 @@ export async function runThumbnail(deps, context, providers) {
50
54
  stage: "thumbnail",
51
55
  detail: source === "from_prompt" ? "from the picked prompt" : "from the prompt the LLM wrote",
52
56
  });
57
+ return "done";
53
58
  }
54
59
  // The rendered thumbnail template goes to the image provider as it is.
55
60
  function fromTemplate(project) {
@@ -68,9 +73,12 @@ async function byLlm(deps, context, providers, project) {
68
73
  // The wording the user is looking at does not change under them. No call was
69
74
  // made, so this run counts none - the run that wrote the prompt already did.
70
75
  return {
71
- prompt: payloadOf(kept).prompt,
72
- ...(llm === undefined ? {} : named(llm)),
73
- tokens: noTokens,
76
+ ok: true,
77
+ value: {
78
+ prompt: payloadOf(kept).prompt,
79
+ ...(llm === undefined ? {} : named(llm)),
80
+ tokens: noTokens,
81
+ },
74
82
  };
75
83
  }
76
84
  if (llm === undefined) {
@@ -87,7 +95,9 @@ async function byLlm(deps, context, providers, project) {
87
95
  check: (given) => writtenPrompt(given.text),
88
96
  });
89
97
  // The image prompt is exactly the LLM's output, never edited by the app.
90
- const prompt = answer.text.trim();
98
+ if (!answer.ok)
99
+ return answer;
100
+ const prompt = answer.value.text.trim();
91
101
  keepWritten(deps, context, kept?.id, prompt, messages);
92
102
  // The messages sent are stored beside the article stage's, per stage.
93
103
  storeText(deps, {
@@ -96,7 +106,10 @@ async function byLlm(deps, context, providers, project) {
96
106
  role: "instructions",
97
107
  text: instructionsText(messages),
98
108
  });
99
- return { prompt, ...named(llm), tokens: plusUsage(noTokens, answer.usage) };
109
+ return {
110
+ ok: true,
111
+ value: { prompt, ...named(llm), tokens: plusUsage(noTokens, answer.value.usage) },
112
+ };
100
113
  }
101
114
  function named(choice) {
102
115
  return { provider: choice.provider, model: choice.model };
@@ -147,15 +160,18 @@ async function make(deps, context, providers, project, choice, prompt) {
147
160
  const { projectId } = context.stage;
148
161
  if (outputsOf(deps.db, projectId).some((output) => output.role === "thumbnail")) {
149
162
  // The `image-done` sub-step: a retry that failed after the image landed keeps it.
150
- return;
163
+ return "done";
151
164
  }
152
- const made = await providers.image({
165
+ const result = await providers.image({
153
166
  provider: choice.provider,
154
167
  model: choice.model,
155
168
  ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
156
169
  prompt,
157
170
  aspect: project.format,
158
171
  });
172
+ if (!result.ok)
173
+ return "held";
174
+ const made = result.value;
159
175
  const name = outputFileName("thumbnail", 1, made.mime === "image/png" ? ".png" : ".jpg", "thumbnail");
160
176
  write(deps, projectId, name, made);
161
177
  // A cancel protects an output already stored, not one about to be: one landing between
@@ -184,6 +200,7 @@ async function make(deps, context, providers, project, choice, prompt) {
184
200
  // No `image.landed`: the thumbnail stays out of the slideshow, and that event's `index`
185
201
  // is a place in it. The stage reaching `done` is what tells the page.
186
202
  insertOutput(deps.db, output);
203
+ return "done";
187
204
  }
188
205
  function write(deps, projectId, name, made) {
189
206
  const target = outputPath(deps.paths, projectId, name);
@@ -0,0 +1,33 @@
1
+ const rate = 48000;
2
+ export function audioExportArgs(audio, output) {
3
+ const inputs = audio.flatMap((segment) => segment.path === null
4
+ ? ["-f", "lavfi", "-t", segment.seconds.toFixed(6), "-i", `anullsrc=r=${rate}:cl=stereo`]
5
+ : ["-i", segment.path]);
6
+ const chains = audio.map((_segment, index) => `[${index}:a]aformat=sample_fmts=s16:sample_rates=${rate}:channel_layouts=stereo,asetpts=PTS-STARTPTS[a${index}]`);
7
+ chains.push(`${audio.map((_segment, index) => `[a${index}]`).join("")}concat=n=${audio.length}:v=0:a=1[a]`);
8
+ return [
9
+ "-hide_banner",
10
+ "-nostdin",
11
+ "-loglevel",
12
+ "error",
13
+ "-progress",
14
+ "pipe:1",
15
+ "-nostats",
16
+ "-y",
17
+ ...inputs,
18
+ "-filter_complex",
19
+ chains.join(";"),
20
+ "-map",
21
+ "[a]",
22
+ "-vn",
23
+ "-c:a",
24
+ "pcm_s16le",
25
+ "-ar",
26
+ String(rate),
27
+ "-ac",
28
+ "2",
29
+ "-f",
30
+ "wav",
31
+ output,
32
+ ];
33
+ }
@@ -1,12 +1,16 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
1
3
  import { relative } from "node:path";
2
- import { projectDir } from "../storage/layout.js";
4
+ import { outputPath, projectDir } from "../storage/layout.js";
3
5
  import { outputsOf } from "../storage/repo.js";
4
6
  import { prepareSubtitles } from "../subtitles/prepare.js";
7
+ import { audioExportArgs } from "./audio-export-args.js";
5
8
  import { audioInputs } from "./audio-inputs.js";
6
9
  import { audioTimeline } from "./plan.js";
7
10
  import { reusableAudioExport } from "./reuse-audio.js";
8
11
  import { writeExport } from "./write-export.js";
9
12
  import { writeSubtitles } from "./write-subtitles.js";
13
+ export { audioExportArgs } from "./audio-export-args.js";
10
14
  const rate = 48000;
11
15
  export async function exportAudioWav(deps, context, gapSeconds) {
12
16
  const { projectId } = context.stage;
@@ -15,6 +19,7 @@ export async function exportAudioWav(deps, context, gapSeconds) {
15
19
  const audio = audioTimeline({ ...input, gapSeconds }, 1 / rate);
16
20
  const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
17
21
  const dir = projectDir(deps.paths, projectId);
22
+ const sources = outputs.filter((output) => ["audio_body", "audio_intro", "audio_outro"].includes(output.role));
18
23
  const plan = {
19
24
  sampleRate: rate,
20
25
  channels: 2,
@@ -26,9 +31,13 @@ export async function exportAudioWav(deps, context, gapSeconds) {
26
31
  path: segment.path === null ? null : relative(dir, segment.path),
27
32
  })),
28
33
  output: "audio.wav",
29
- sourceIds: outputs
30
- .filter((output) => ["audio_body", "audio_intro", "audio_outro"].includes(output.role))
31
- .map((output) => output.id),
34
+ sourceIds: sources.map((output) => output.id),
35
+ sourceHashes: sources.map((output) => ({
36
+ id: output.id,
37
+ sha256: createHash("sha256")
38
+ .update(readFileSync(outputPath(deps.paths, projectId, output.path)))
39
+ .digest("hex"),
40
+ })),
32
41
  };
33
42
  const existing = reusableAudioExport(deps.paths, projectId, outputs, plan);
34
43
  const subtitles = await prepareSubtitles(deps, context, audio, { width: 1920, height: 1080 });
@@ -46,35 +55,3 @@ export async function exportAudioWav(deps, context, gapSeconds) {
46
55
  args: (part) => audioExportArgs(audio, part),
47
56
  });
48
57
  }
49
- export function audioExportArgs(audio, output) {
50
- const inputs = audio.flatMap((segment) => segment.path === null
51
- ? ["-f", "lavfi", "-t", segment.seconds.toFixed(6), "-i", `anullsrc=r=${rate}:cl=stereo`]
52
- : ["-i", segment.path]);
53
- const chains = audio.map((_segment, index) => `[${index}:a]aformat=sample_fmts=s16:sample_rates=${rate}:channel_layouts=stereo,asetpts=PTS-STARTPTS[a${index}]`);
54
- chains.push(`${audio.map((_segment, index) => `[a${index}]`).join("")}concat=n=${audio.length}:v=0:a=1[a]`);
55
- return [
56
- "-hide_banner",
57
- "-nostdin",
58
- "-loglevel",
59
- "error",
60
- "-progress",
61
- "pipe:1",
62
- "-nostats",
63
- "-y",
64
- ...inputs,
65
- "-filter_complex",
66
- chains.join(";"),
67
- "-map",
68
- "[a]",
69
- "-vn",
70
- "-c:a",
71
- "pcm_s16le",
72
- "-ar",
73
- String(rate),
74
- "-ac",
75
- "2",
76
- "-f",
77
- "wav",
78
- output,
79
- ];
80
- }
@@ -18,10 +18,11 @@ async function audio(deps, outputs, role, dir, signal) {
18
18
  return undefined;
19
19
  }
20
20
  const path = outputPath(deps.paths, output.projectId, output.path);
21
- const durationMs = output.durationMs ?? (await probeDurationMs(deps.ffmpeg, path, signal, deps.log));
21
+ const durationMs = output.durationMs ??
22
+ (deps.measureAudio === undefined
23
+ ? await probeDurationMs(deps.ffmpeg, path, signal, deps.log)
24
+ : await deps.measureAudio(path, signal));
22
25
  if (output.durationMs === null) {
23
- // A provided file arrives with no duration; measuring it once is worth recording.
24
- deps.db.prepare("UPDATE outputs SET duration_ms = ? WHERE id = ?").run(durationMs, output.id);
25
26
  deps.log.write("info", "video.probe", {
26
27
  projectId: output.projectId,
27
28
  stage: "video",
@@ -217,6 +217,12 @@ export async function probeDurationMs(bin, file, signal, log) {
217
217
  "-nostats",
218
218
  "-i",
219
219
  file,
220
+ // Older null muxers report the last packet's start, giving short clips zero
221
+ // duration. One discarded padding sample places that packet at the real end;
222
+ // current muxers include it, adding less than 1ms at supported audio rates.
223
+ // Retire when all supported FFmpeg builds report packet ends.
224
+ "-af",
225
+ "apad=pad_len=1",
220
226
  "-f",
221
227
  "null",
222
228
  "-",
@@ -10,6 +10,9 @@ const planSchema = z.object({
10
10
  audio: z.array(z.object({ kind: z.string(), path: z.string().nullable(), seconds: z.number() })),
11
11
  output: z.string(),
12
12
  sourceIds: z.array(z.string()).optional(),
13
+ sourceHashes: z
14
+ .array(z.object({ id: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/) }))
15
+ .optional(),
13
16
  });
14
17
  // Old releases did not record source IDs. Their relative timeline plus source dates and
15
18
  // file metadata let subtitle-only edits reuse those exports without a one-time re-encode.
@@ -21,10 +24,12 @@ export function reusableAudioExport(paths, projectId, outputs, plan) {
21
24
  let saved;
22
25
  try {
23
26
  saved = planSchema.parse(JSON.parse(readFileSync(outputPath(paths, projectId, record.path), "utf8")));
24
- const { sourceIds: previousIds, ...previousPlan } = saved;
25
- const { sourceIds, ...currentPlan } = plan;
27
+ const { sourceIds: previousIds, sourceHashes: previousHashes, ...previousPlan } = saved;
28
+ const { sourceIds, sourceHashes, ...currentPlan } = plan;
26
29
  if (JSON.stringify(previousPlan) !== JSON.stringify(currentPlan) ||
27
- (previousIds !== undefined && JSON.stringify(previousIds) !== JSON.stringify(sourceIds)))
30
+ (previousIds !== undefined && JSON.stringify(previousIds) !== JSON.stringify(sourceIds)) ||
31
+ (previousHashes !== undefined &&
32
+ JSON.stringify(previousHashes) !== JSON.stringify(sourceHashes)))
28
33
  return undefined;
29
34
  const wav = statSync(outputPath(paths, projectId, media.path));
30
35
  if (!wav.isFile() ||
@@ -41,7 +46,9 @@ export function reusableAudioExport(paths, projectId, outputs, plan) {
41
46
  Date.parse(source.createdAt) > Date.parse(media.createdAt))
42
47
  return undefined;
43
48
  const file = statSync(outputPath(paths, projectId, source.path));
44
- if (!file.isFile() || file.size !== source.bytes || file.mtimeMs > wav.mtimeMs)
49
+ if (!file.isFile() ||
50
+ file.size !== source.bytes ||
51
+ (previousHashes === undefined && file.mtimeMs > wav.mtimeMs))
45
52
  return undefined;
46
53
  }
47
54
  return media;
@@ -8,6 +8,29 @@ export async function runUpdateFlow(plan, deps) {
8
8
  backedUp = true;
9
9
  await deps.start(entry);
10
10
  await deps.healthy(plan.version);
11
+ }
12
+ catch {
13
+ await deps.stopCandidate();
14
+ if (backedUp)
15
+ await deps.restore();
16
+ await deps.start(plan.oldEntry);
17
+ await deps.healthy(plan.previousVersion);
18
+ deps.report("The update did not start; the previous version and database were restored.");
19
+ throw new Error("The previous Slopify version was restored.");
20
+ }
21
+ // Prune before publishing the activation pointer. The candidate's recovery watcher uses
22
+ // that pointer as its unlock signal, so publishing it first could admit a second update
23
+ // while this worker is still deleting artifacts. The pruner retains both runnable
24
+ // versions and the fresh rollback backup, so a later activation failure remains safe.
25
+ let pruneFailed = false;
26
+ try {
27
+ await deps.prune();
28
+ }
29
+ catch {
30
+ // Cleanup is maintenance and must never prevent a healthy candidate from activating.
31
+ pruneFailed = true;
32
+ }
33
+ try {
11
34
  await deps.activate();
12
35
  }
13
36
  catch {
@@ -19,8 +42,10 @@ export async function runUpdateFlow(plan, deps) {
19
42
  deps.report("The update did not start; the previous version and database were restored.");
20
43
  throw new Error("The previous Slopify version was restored.");
21
44
  }
22
- // The pointer rename commits the new database. The candidate may already observe
23
- // it and accept writes, so a lost unlock acknowledgement can never trigger rollback.
45
+ if (pruneFailed)
46
+ deps.report("The update started, but obsolete update files could not be removed.");
47
+ // The pointer rename commits the new database. A lost unlock acknowledgement cannot
48
+ // safely roll it back; the candidate's watcher observes the same pointer and recovers.
24
49
  await deps.release();
25
50
  deps.report(`Slopify ${plan.version} started successfully.`);
26
51
  }
@@ -1,6 +1,6 @@
1
1
  import { constants } from "node:fs";
2
- import { access, readFile, realpath, rename, writeFile } from "node:fs/promises";
3
- import { delimiter, dirname, join } from "node:path";
2
+ import { access, lstat, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { cliCommand } from "../kernel/cli-command.js";
6
6
  import { isStableVersion, isUpdateToken, newerVersion, npmRegistry, updatePackage, } from "./model.js";
@@ -93,6 +93,32 @@ export async function activateUpdate(dataDir, version, token) {
93
93
  await writeFile(temporary, JSON.stringify({ version, token }), { mode: 0o600 });
94
94
  await rename(temporary, pointer);
95
95
  }
96
+ const updateBackup = /^before-(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-\d+\.db$/;
97
+ export async function pruneUpdateArtifacts(dataDir, currentVersion, rollbackVersion, rollbackBackup) {
98
+ if (!isStableVersion(currentVersion) || !isStableVersion(rollbackVersion))
99
+ throw new Error("Invalid retained update version.");
100
+ const updates = resolve(dataDir, "updates");
101
+ const backup = resolve(rollbackBackup);
102
+ const backupName = basename(backup);
103
+ if (dirname(backup) !== updates || !updateBackup.test(backupName))
104
+ throw new Error("Invalid retained update backup.");
105
+ let backupIsFile = false;
106
+ try {
107
+ backupIsFile = (await lstat(backup)).isFile();
108
+ }
109
+ catch {
110
+ // Refuse all cleanup unless the rollback database is present as a regular file.
111
+ }
112
+ if (!backupIsFile)
113
+ throw new Error("The retained update backup is missing.");
114
+ for (const entry of await readdir(updates, { withFileTypes: true })) {
115
+ const retainedVersion = entry.name === currentVersion || entry.name === rollbackVersion;
116
+ const obsoleteInstall = isStableVersion(entry.name) && !retainedVersion;
117
+ const obsoleteBackup = updateBackup.test(entry.name) && entry.name !== backupName;
118
+ if (obsoleteInstall || obsoleteBackup)
119
+ await rm(join(updates, entry.name), { recursive: true, force: true });
120
+ }
121
+ }
96
122
  export async function npmCommand() {
97
123
  const nodeDir = dirname(process.execPath);
98
124
  for (const candidate of [