@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
@@ -2,7 +2,7 @@
2
2
  // the picked templates, the slot names they ask for, and the rendered text stored on the
3
3
  // project. The bodies are read at the click, not at selection, so an edit made in between
4
4
  // is the one that runs.
5
- import { normaliseDraft, usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
5
+ import { normaliseDraft, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
6
6
  import { collectFields, render } from "../admission/substitute.js";
7
7
  import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
8
8
  export function pickTemplates(db, input, snapshot) {
@@ -25,6 +25,15 @@ export function pickTemplates(db, input, snapshot) {
25
25
  // None picked is the built-in prompt, which asks for no keyword.
26
26
  if (usesYoutubeDescription(draft))
27
27
  body(db, "description", draft.descriptionPrompt, "descriptionPrompt", missing, text, "description", snapshot);
28
+ // The Shorts step's two prompts; none picked is the built-in one, which asks for no keyword.
29
+ if (usesShorts(draft)) {
30
+ body(db, "shorts", draft.shorts?.prompt, "shorts.prompt", missing, text, "shorts", snapshot);
31
+ body(db, "image", draft.shorts?.imagePrompt, "shorts.imagePrompt", missing, image, "shortsImage", snapshot);
32
+ }
33
+ // The establishing image's prompt: an image prompt like the others, its keywords filled the
34
+ // same way.
35
+ if (usesReference(draft) && draft.reference?.source === "prompt")
36
+ body(db, "image", draft.reference.prompt, "reference.prompt", missing, image, "referencePrompt", snapshot);
28
37
  if (sources.images === "generate") {
29
38
  for (const [index, picked] of draft.imagePrompts.entries()) {
30
39
  const field = `imagePrompts.${String(index)}.name`;
@@ -36,7 +45,9 @@ export function pickTemplates(db, input, snapshot) {
36
45
  if (sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") {
37
46
  body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt", snapshot);
38
47
  }
39
- const fields = collectFields(text.map((picked) => picked.body), image.map((picked) => picked.body));
48
+ const fields = collectFields(
49
+ // The title's keywords are asked for like a prompt's: it is filled when the run starts.
50
+ [draft.title, ...text.map((picked) => picked.body)], image.map((picked) => picked.body));
40
51
  return {
41
52
  draft: withSavedModes(draft, intro, outro),
42
53
  requiredSlots: fields.map((field) => field.name),
@@ -25,9 +25,15 @@ export const librarySnapshotSchema = z
25
25
  })
26
26
  .strict()
27
27
  .readonly();
28
+ // A template names its prompts and intros/outros; the Library's current one of that name is
29
+ // what a run from it uses, so editing a prompt reaches every template and schedule that names
30
+ // it. The copy the template saved is only the fallback for one since deleted or renamed, so a
31
+ // template never stops working because its Library changed.
28
32
  export function snapshotPrompt(db, snapshot, kind, name) {
29
- return (snapshot?.prompts.find((row) => row.kind === kind && row.name.toLowerCase() === name.toLowerCase()) ?? promptByName(db, kind, name));
33
+ return (promptByName(db, kind, name) ??
34
+ snapshot?.prompts.find((row) => row.kind === kind && row.name.toLowerCase() === name.toLowerCase()));
30
35
  }
31
36
  export function snapshotEntry(db, snapshot, category, name) {
32
- return (snapshot?.entries.find((row) => row.category === category && row.name.toLowerCase() === name.toLowerCase()) ?? entryByName(db, category, name));
37
+ return (entryByName(db, category, name) ??
38
+ snapshot?.entries.find((row) => row.category === category && row.name.toLowerCase() === name.toLowerCase()));
33
39
  }
@@ -0,0 +1,76 @@
1
+ import { redact } from "../../kernel/log.js";
2
+ import { noticeOf, noticeText, webhookBody } from "./rules.js";
3
+ import { sendFailureText } from "./send.js";
4
+ export function createRunNotifier(deps) {
5
+ // ceiling: one entry per project seen since boot, a few dozen bytes each.
6
+ const states = new Map();
7
+ const reasons = new Map();
8
+ const inflight = new Set();
9
+ let closed = false;
10
+ const notify = (projectId, kind) => {
11
+ const url = deps.url();
12
+ if (url === null)
13
+ return;
14
+ const subject = deps.subject(projectId);
15
+ if (subject === undefined)
16
+ return;
17
+ const text = noticeText(kind, { ...subject, reason: reasons.get(projectId) });
18
+ const body = redact(webhookBody(text));
19
+ const sending = deps
20
+ .send(url, body)
21
+ .then((result) => {
22
+ if (!result.ok)
23
+ deps.log.write("warn", "notification.failed", {
24
+ projectId,
25
+ detail: `${kind} notification not delivered: ${sendFailureText(result)}`,
26
+ });
27
+ })
28
+ .catch((error) => {
29
+ deps.log.write("warn", "notification.failed", {
30
+ projectId,
31
+ detail: `${kind} notification not delivered: ${error instanceof Error ? error.message : String(error)}`,
32
+ });
33
+ })
34
+ .finally(() => {
35
+ inflight.delete(sending);
36
+ });
37
+ inflight.add(sending);
38
+ };
39
+ return {
40
+ observe: (event) => {
41
+ if (closed)
42
+ return;
43
+ try {
44
+ if (event.type === "stage.state") {
45
+ if (event.state === "failed" && event.failureReason !== undefined)
46
+ reasons.set(event.projectId, event.failureReason);
47
+ else if (event.state === "running")
48
+ reasons.delete(event.projectId);
49
+ return;
50
+ }
51
+ if (event.type !== "project.state")
52
+ return;
53
+ const previous = states.get(event.projectId);
54
+ if (previous === event.state)
55
+ return;
56
+ states.set(event.projectId, event.state);
57
+ const kind = noticeOf(previous, event.state);
58
+ if (kind !== undefined)
59
+ notify(event.projectId, kind);
60
+ }
61
+ catch (error) {
62
+ deps.log.write("warn", "notification.failed", {
63
+ projectId: event.projectId,
64
+ detail: `notification skipped: ${error instanceof Error ? error.message : String(error)}`,
65
+ });
66
+ }
67
+ },
68
+ close: () => {
69
+ closed = true;
70
+ },
71
+ settled: async () => {
72
+ while (inflight.size > 0)
73
+ await Promise.all([...inflight]);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,81 @@
1
+ // Only a run seen running can finish, fail or stop to wait: a project first seen already done
2
+ // was done before anyone was watching. Paused and canceled are what the user just pressed, so
3
+ // they say nothing. `pending` after `running` is the runner finding nothing it may start: a
4
+ // review checkpoint holding the next step, or held work waiting for Resume.
5
+ export function noticeOf(previous, next) {
6
+ if (previous !== "running")
7
+ return undefined;
8
+ switch (next) {
9
+ case "done":
10
+ return "ready";
11
+ case "failed":
12
+ return "failed";
13
+ case "pending":
14
+ return "waiting";
15
+ default:
16
+ return undefined;
17
+ }
18
+ }
19
+ const reasonMax = 140;
20
+ // A provider's failure can run to paragraphs; a notification has a line.
21
+ export function shortReason(reason) {
22
+ const line = reason
23
+ ?.split(/\r?\n/)
24
+ .map((part) => part.trim())
25
+ .find((part) => part !== "");
26
+ if (line === undefined)
27
+ return undefined;
28
+ return line.length <= reasonMax ? line : `${line.slice(0, reasonMax - 1).trimEnd()}…`;
29
+ }
30
+ export function noticeText(kind, subject) {
31
+ const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
32
+ switch (kind) {
33
+ case "ready":
34
+ return subject.makesVideo
35
+ ? { headline: `Video ready: ${title}`, detail: "Open the project to watch it." }
36
+ : { headline: `Run finished: ${title}`, detail: "Open the project to see its files." };
37
+ case "failed": {
38
+ const reason = shortReason(subject.reason);
39
+ return {
40
+ headline: reason === undefined ? `Run failed: ${title}` : `Run failed: ${title} — ${reason}`,
41
+ detail: "Open the project to see what stopped it and press Retry.",
42
+ };
43
+ }
44
+ case "waiting":
45
+ return {
46
+ headline: `Waiting for you: ${title}`,
47
+ detail: "Open the project to review the held step or press Resume.",
48
+ };
49
+ }
50
+ }
51
+ export const testNotice = {
52
+ headline: "Slopify test notification",
53
+ detail: "Notifications work. You'll get one when a run finishes, fails or waits for you.",
54
+ };
55
+ // The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
56
+ // but the project's title, what happened and the provider's own short reason goes in it.
57
+ export function webhookBody(text) {
58
+ return `${text.headline}\n${text.detail}\n`;
59
+ }
60
+ export const notificationUrlMax = 2048;
61
+ // Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
62
+ // fetch refuses those, and a password in a URL would sit in the database in plain sight.
63
+ export function notificationUrlProblem(value) {
64
+ const trimmed = value.trim();
65
+ if (trimmed === "")
66
+ return undefined;
67
+ if (trimmed.length > notificationUrlMax)
68
+ return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(trimmed);
72
+ }
73
+ catch {
74
+ return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
75
+ }
76
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
77
+ return "The Notification URL must start with https:// or http://.";
78
+ if (parsed.username !== "" || parsed.password !== "")
79
+ return "Remove the user name and password from the Notification URL; Slopify can't send them.";
80
+ return undefined;
81
+ }
@@ -0,0 +1,40 @@
1
+ // One POST to the user's Notification URL. Short, single-shot and never retried: a
2
+ // notification that did not arrive is logged, and a run never waits on one.
3
+ export const notificationTimeoutMs = 5_000;
4
+ export function createNotificationSender(fetchImpl, timeoutMs = notificationTimeoutMs) {
5
+ return async (url, body) => {
6
+ try {
7
+ const response = await fetchImpl(url, {
8
+ method: "POST",
9
+ headers: { "content-type": "text/plain; charset=utf-8" },
10
+ body,
11
+ redirect: "manual",
12
+ signal: AbortSignal.timeout(timeoutMs),
13
+ });
14
+ // The answer's body is never read; cancelling it frees the socket.
15
+ await response.body?.cancel().catch(() => { });
16
+ return response.ok ? { ok: true } : { ok: false, reason: "refused", status: response.status };
17
+ }
18
+ catch (error) {
19
+ return {
20
+ ok: false,
21
+ reason: error instanceof DOMException &&
22
+ (error.name === "TimeoutError" || error.name === "AbortError")
23
+ ? "timeout"
24
+ : "unreachable",
25
+ };
26
+ }
27
+ };
28
+ }
29
+ // What went wrong, in words fit for both the log and the Settings screen. The URL itself is
30
+ // never repeated: an ntfy topic in it is as good as a password.
31
+ export function sendFailureText(result) {
32
+ switch (result.reason) {
33
+ case "timeout":
34
+ return `the Notification URL didn't answer within ${String(notificationTimeoutMs / 1000)} seconds`;
35
+ case "unreachable":
36
+ return "the Notification URL couldn't be reached (wrong address, no network, or the server is down)";
37
+ case "refused":
38
+ return `the Notification URL answered HTTP ${String(result.status)}`;
39
+ }
40
+ }
@@ -0,0 +1,34 @@
1
+ import { readSetting, writeSetting } from "../settings/repo.js";
2
+ import { notificationUrlProblem } from "./rules.js";
3
+ // Its own row in the key/value `settings` table. Backups leave it out
4
+ // (`slices/storage/portable.ts`): an ntfy topic in the URL is as good as a password.
5
+ export const notificationUrlKey = "notificationUrl";
6
+ export function readNotificationUrl(db) {
7
+ const stored = readSetting(db, notificationUrlKey);
8
+ if (stored === undefined)
9
+ return null;
10
+ let value;
11
+ try {
12
+ value = JSON.parse(stored);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ // A hand-edited row that no longer passes the rules is treated as not set rather than
18
+ // POSTed to.
19
+ return typeof value === "string" && value !== "" && notificationUrlProblem(value) === undefined
20
+ ? value
21
+ : null;
22
+ }
23
+ export function saveNotificationUrl(db, raw) {
24
+ const problem = notificationUrlProblem(raw);
25
+ if (problem !== undefined)
26
+ return { ok: false, message: problem };
27
+ const url = raw.trim();
28
+ if (url === "") {
29
+ db.prepare("DELETE FROM settings WHERE key = ?").run(notificationUrlKey);
30
+ return { ok: true, url: null };
31
+ }
32
+ writeSetting(db, notificationUrlKey, JSON.stringify(url));
33
+ return { ok: true, url };
34
+ }
@@ -2,7 +2,16 @@ import { z } from "zod";
2
2
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
3
3
  import { runDraftSchema } from "../admission/schema.js";
4
4
  import { draftDocumentThemeOf } from "../document/model.js";
5
+ import { defaultShorts, shortsExtrasOf, shortsSettingsProblems, } from "../shorts/model.js";
5
6
  import { defaultSubtitles } from "../subtitles/model.js";
7
+ // Said where the control is, since the music sits in a closed disclosure on the Export rail.
8
+ const musicMessages = {
9
+ uploading: "The shorts' background music is still uploading, so the run can't start yet. Wait for it to finish, or remove it under Outputs → Export → More shorts options → Background music.",
10
+ missing: "The shorts' background music file is no longer available (it was not uploaded again after the draft was copied, or the upload failed). Choose the file again under Outputs → Export → More shorts options → Background music, or remove it.",
11
+ };
12
+ function pickMusic(id) {
13
+ return id === undefined ? {} : { shortsMusic: id };
14
+ }
6
15
  export function toAdmissionDraft(input) {
7
16
  const { form } = input.document;
8
17
  const fields = [];
@@ -48,15 +57,18 @@ export function toAdmissionDraft(input) {
48
57
  });
49
58
  return undefined;
50
59
  };
51
- const file = (ref, kind, field) => {
60
+ const file = (ref, kind, field, messages = {
61
+ uploading: "This file is still uploading. Wait for it to finish.",
62
+ missing: "That upload is no longer available. Choose the file again.",
63
+ }) => {
52
64
  const attachment = input.attachments.find((item) => item.id === ref?.attachmentId && item.kind === kind && item.name === ref.name);
53
65
  if (attachment?.state === "ready" && attachment.stagedFileId !== null)
54
66
  return attachment.stagedFileId;
55
67
  fields.push({
56
68
  field,
57
69
  message: attachment?.state === "copying" || attachment?.state === "pending"
58
- ? "This file is still uploading. Wait for it to finish."
59
- : "That upload is no longer available. Choose the file again.",
70
+ ? messages.uploading
71
+ : messages.missing,
60
72
  });
61
73
  return undefined;
62
74
  };
@@ -66,6 +78,36 @@ export function toAdmissionDraft(input) {
66
78
  ? "files"
67
79
  : form.subtitles.mode;
68
80
  const chunkMode = sources.audio === "generate" ? form.chunking.mode : "whole";
81
+ const shortsOn = sources.audio !== "off" && form.shorts?.enabled === true;
82
+ // Refused in admission's words, so Play and the run say the same sentence.
83
+ const shortsOf = (raw) => {
84
+ const whole = (value) => (value.trim() === "" ? Number.NaN : Number(value));
85
+ const settings = {
86
+ enabled: true,
87
+ count: whole(raw.count),
88
+ minSeconds: whole(raw.minSeconds),
89
+ maxSeconds: whole(raw.maxSeconds),
90
+ ...(raw.prompt.trim() ? { prompt: raw.prompt } : {}),
91
+ ...(raw.imagePrompt.trim() ? { imagePrompt: raw.imagePrompt } : {}),
92
+ ...shortsExtrasOf(raw),
93
+ };
94
+ for (const problem of shortsSettingsProblems(settings))
95
+ fields.push({ field: `shorts.${problem.field}`, message: problem.message });
96
+ const { musicVolume, speed, ...rest } = settings;
97
+ return {
98
+ ...rest,
99
+ // A number already refused above is left out rather than saved as NaN.
100
+ ...(musicVolume !== undefined && Number.isFinite(musicVolume) ? { musicVolume } : {}),
101
+ ...(speed !== undefined && Number.isFinite(speed) ? { speed } : {}),
102
+ count: Number.isFinite(settings.count) ? settings.count : defaultShorts.count,
103
+ minSeconds: Number.isFinite(settings.minSeconds)
104
+ ? settings.minSeconds
105
+ : defaultShorts.minSeconds,
106
+ maxSeconds: Number.isFinite(settings.maxSeconds)
107
+ ? settings.maxSeconds
108
+ : defaultShorts.maxSeconds,
109
+ };
110
+ };
69
111
  const draft = {
70
112
  ...(form.checkpoints === undefined ? {} : { checkpoints: form.checkpoints }),
71
113
  title: form.title,
@@ -88,6 +130,19 @@ export function toAdmissionDraft(input) {
88
130
  : form.audio
89
131
  : undefined,
90
132
  images: form.images,
133
+ // The establishing image belongs to generated images; Off, or images not generated,
134
+ // leaves it out, which is what every draft saved before it was.
135
+ ...(sources.images === "generate" &&
136
+ form.reference !== undefined &&
137
+ form.reference.source !== "off"
138
+ ? {
139
+ reference: {
140
+ source: form.reference.source,
141
+ ...(form.reference.source === "prompt" ? { prompt: form.reference.prompt } : {}),
142
+ thumbnail: form.reference.thumbnail,
143
+ },
144
+ }
145
+ : {}),
91
146
  articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
92
147
  ...(sources.audio === "generate" && form.narrationPrompt?.trim()
93
148
  ? { narrationPrompt: form.narrationPrompt }
@@ -99,6 +154,9 @@ export function toAdmissionDraft(input) {
99
154
  ...(form.descriptionPrompt?.trim() ? { descriptionPrompt: form.descriptionPrompt } : {}),
100
155
  }
101
156
  : {}),
157
+ // Cut from the narration too, so the same rule: a switch left on with narration Off asks
158
+ // for nothing.
159
+ ...(shortsOn && form.shorts !== undefined ? { shorts: shortsOf(form.shorts) } : {}),
102
160
  imagePrompts: sources.images === "generate"
103
161
  ? form.imagePrompts.map((prompt, index) => ({
104
162
  name: prompt.name,
@@ -120,12 +178,20 @@ export function toAdmissionDraft(input) {
120
178
  thumbnail: sources.thumbnail === "provide"
121
179
  ? file(form.provided.thumbnail, "thumbnail", "provided.thumbnail")
122
180
  : undefined,
181
+ reference: sources.images === "generate" && form.reference?.source === "provide"
182
+ ? file(form.provided.reference ?? null, "reference", "provided.reference")
183
+ : undefined,
123
184
  images: sources.images === "provide"
124
185
  ? form.provided.images.flatMap((ref, index) => {
125
186
  const id = file(ref, "images", `provided.images.${index}`);
126
187
  return id === undefined ? [] : [id];
127
188
  })
128
189
  : [],
190
+ // Optional, so only a file that was attached is checked; with Shorts off it is kept on
191
+ // the draft for when Shorts is turned back on, and never reaches the run.
192
+ ...(shortsOn && form.provided.shortsMusic
193
+ ? pickMusic(file(form.provided.shortsMusic, "audio", "shorts.music", musicMessages))
194
+ : {}),
129
195
  },
130
196
  chunking: {
131
197
  mode: chunkMode,
@@ -144,11 +210,13 @@ export function toAdmissionDraft(input) {
144
210
  fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
145
211
  },
146
212
  silenceGapSeconds: input.silenceGapSeconds,
147
- imageSeconds: measure("imageSeconds", sources.video === "generate", defaultImageSeconds, imageSecondsProblem),
213
+ imageSeconds: measure("imageSeconds", sources.video === "generate" || shortsOn, defaultImageSeconds, imageSecondsProblem),
148
214
  zoomPercent: measure("zoomPercent", sources.video === "generate", defaultZoomPercent, zoomPercentProblem),
149
215
  // A choice from a list, so there is nothing to refuse; a run without a video keeps it
150
216
  // for when the video is turned back on.
151
217
  motionStyle: form.motionStyle,
218
+ // Kept the same way; admission checks it only while the video renders.
219
+ ...(form.videoEdit === undefined ? {} : { videoEdit: form.videoEdit }),
152
220
  edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
153
221
  };
154
222
  const parsed = runDraftSchema.safeParse(draft);
@@ -1,9 +1,10 @@
1
1
  import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
2
  import { modelFields } from "../../catalog/validate.js";
3
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
4
- import { usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
4
+ import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
5
5
  import { cliPathChanged } from "../settings/cli-paths.js";
6
6
  import { hasKey, listVoices } from "../settings/repo.js";
7
+ import { usesAnimation } from "../video/edit-settings.js";
7
8
  export function choices(runs) {
8
9
  const selected = runs
9
10
  .flatMap(({ draft: d }) => [
@@ -17,7 +18,8 @@ export function choices(runs) {
17
18
  d.intro?.mode === "llm" ||
18
19
  d.outro?.mode === "llm" ||
19
20
  usesNarrationPreparation(d) ||
20
- usesYoutubeDescription(d),
21
+ usesYoutubeDescription(d) ||
22
+ usesShorts(d),
21
23
  },
22
24
  {
23
25
  field: "audio",
@@ -30,7 +32,9 @@ export function choices(runs) {
30
32
  family: "image",
31
33
  choice: d.images,
32
34
  needed: d.sources.images === "generate" ||
33
- ["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail),
35
+ ["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail) ||
36
+ usesShorts(d) ||
37
+ usesAnimation(d),
34
38
  },
35
39
  ])
36
40
  .flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { fingerprint } from "../../kernel/runner/work.js";
3
+ import { draftAttachmentKinds } from "./schema.js";
3
4
  const rowSchema = z.object({
4
5
  id: z.string(),
5
6
  schema_version: z.number(),
@@ -21,7 +22,7 @@ const attachmentSchema = z.object({
21
22
  id: z.string(),
22
23
  draft_id: z.string(),
23
24
  staged_file_id: z.string().nullable(),
24
- kind: z.enum(["audio", "images", "thumbnail"]),
25
+ kind: z.enum(draftAttachmentKinds),
25
26
  original_filename: z.string(),
26
27
  status: z.enum(["pending", "ready", "reattach"]),
27
28
  error: z.string().nullable(),
@@ -52,6 +53,11 @@ export function attachmentRefs(document) {
52
53
  ...(p.audio === null ? [] : [{ ...p.audio, kind: "audio" }]),
53
54
  ...p.images.map((file) => ({ ...file, kind: "images" })),
54
55
  ...(p.thumbnail === null ? [] : [{ ...p.thumbnail, kind: "thumbnail" }]),
56
+ ...(p.reference === undefined || p.reference === null
57
+ ? []
58
+ : [{ ...p.reference, kind: "reference" }]),
59
+ // The shorts' music is staged like narration audio, so it is an audio attachment.
60
+ ...(p.shortsMusic == null ? [] : [{ ...p.shortsMusic, kind: "audio" }]),
55
61
  ];
56
62
  }
57
63
  export function requestHash(value) {
@@ -110,7 +110,9 @@ export function resolveReviewInputs(deps, view, font) {
110
110
  const activeIds = new Set(runs.flatMap((run) => [
111
111
  run.draft.provided.audio,
112
112
  run.draft.provided.thumbnail,
113
+ run.draft.provided.reference,
113
114
  ...(run.draft.provided.images ?? []),
115
+ run.draft.provided.shortsMusic,
114
116
  ]));
115
117
  const attachmentIdentity = fresh.value.attachments.flatMap((file) => file.stagedFileId !== null && activeIds.has(file.stagedFileId)
116
118
  ? [{ id: file.id, stagedFileId: file.stagedFileId, bytes: file.bytes }]
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { formats, stageKinds } from "../../kernel/pipeline.js";
3
3
  import { thinkingModes } from "../../kernel/ports/llm.js";
4
- import { motionStyles, stageSources } from "../admission/model.js";
4
+ import { motionStyles, referenceSources, stageSources } from "../admission/model.js";
5
5
  import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
6
- import { runDraftSchema } from "../admission/schema.js";
6
+ import { runDraftSchema, videoEditSchema } from "../admission/schema.js";
7
7
  import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
8
8
  import { documentSettingsSchema } from "../document/theme-schema.js";
9
9
  import { librarySnapshotSchema } from "../library/snapshot.js";
@@ -15,6 +15,8 @@ const values = z.record(text, text).readonly();
15
15
  const provider = z
16
16
  .object({ provider: text, model: text, thinking: z.enum(thinkingModes).optional() })
17
17
  .strict();
18
+ // What a draft's uploads are for: a stage's files, or the Images stage's establishing image.
19
+ export const draftAttachmentKinds = ["audio", "images", "thumbnail", "reference"];
18
20
  const file = z.object({ attachmentId: id, name: text }).strict().readonly();
19
21
  export const playDraftFormSchema = z
20
22
  .object({
@@ -45,11 +47,42 @@ export const playDraftFormSchema = z
45
47
  })
46
48
  .readonly(),
47
49
  images: provider.readonly(),
50
+ // Absent on drafts and templates saved before the establishing image: Off. The prompt is
51
+ // an image prompt's name, "" when none is picked.
52
+ reference: z
53
+ .object({
54
+ source: z.enum(["off", ...referenceSources]),
55
+ prompt: text,
56
+ thumbnail: z.boolean(),
57
+ })
58
+ .strict()
59
+ .readonly()
60
+ .optional(),
48
61
  articlePrompt: text,
49
62
  narrationPrompt: text.optional(),
50
63
  // Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
51
64
  youtubeDescription: z.boolean().optional(),
52
65
  descriptionPrompt: text.optional(),
66
+ // Absent on drafts and templates saved before Shorts: off. The numbers are raw text, like
67
+ // every other number on Play; the prompts are names, "" being the built-in ones.
68
+ shorts: z
69
+ .object({
70
+ enabled: z.boolean(),
71
+ count: text,
72
+ minSeconds: text,
73
+ maxSeconds: text,
74
+ prompt: text,
75
+ imagePrompt: text,
76
+ // Absent on drafts saved before these: the title off, no link, the music at its
77
+ // default level, normal speed. The two numbers are raw text too.
78
+ titleOnScreen: z.boolean().optional(),
79
+ fullVideoLink: text.optional(),
80
+ musicVolume: text.optional(),
81
+ speed: text.optional(),
82
+ })
83
+ .strict()
84
+ .readonly()
85
+ .optional(),
53
86
  imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
54
87
  thumbnailPrompt: text,
55
88
  intro: text,
@@ -74,6 +107,9 @@ export const playDraftFormSchema = z
74
107
  edgeSilenceSeconds: text.default(String(defaultEdgeSilenceSeconds)),
75
108
  zoomPercent: text.default(String(defaultZoomPercent)),
76
109
  motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
110
+ // Absent on drafts and templates saved before the edit settings: today's slideshow. Every
111
+ // field is a pick from a list, so it is kept as the settings themselves.
112
+ videoEdit: videoEditSchema.strict().readonly().optional(),
77
113
  values,
78
114
  provided: z
79
115
  .object({
@@ -82,6 +118,11 @@ export const playDraftFormSchema = z
82
118
  audio: file.nullable(),
83
119
  thumbnail: file.nullable(),
84
120
  images: z.array(file).readonly(),
121
+ // The uploaded establishing image; absent on drafts saved before it.
122
+ reference: file.nullable().optional(),
123
+ // The Shorts step's background music, uploaded as an audio attachment. Absent on
124
+ // drafts and templates saved before Play offered it: no music.
125
+ shortsMusic: file.nullable().optional(),
85
126
  })
86
127
  .strict()
87
128
  .readonly(),
@@ -127,7 +168,7 @@ export const discardDraftInputSchema = z
127
168
  export const draftAttachmentSchema = z
128
169
  .object({
129
170
  id,
130
- kind: z.enum(["audio", "images", "thumbnail"]),
171
+ kind: z.enum(draftAttachmentKinds),
131
172
  name: text,
132
173
  state: z.enum(["pending", "copying", "ready", "reattach"]),
133
174
  stagedFileId: text.nullable(),
@@ -208,6 +208,12 @@ export function forkDraft(deps, input) {
208
208
  audio: p.audio === null ? null : rewrite(p.audio),
209
209
  thumbnail: p.thumbnail === null ? null : rewrite(p.thumbnail),
210
210
  images: p.images.map(rewrite),
211
+ ...(p.reference === undefined
212
+ ? {}
213
+ : { reference: p.reference === null ? null : rewrite(p.reference) }),
214
+ ...(p.shortsMusic === undefined
215
+ ? {}
216
+ : { shortsMusic: p.shortsMusic === null ? null : rewrite(p.shortsMusic) }),
211
217
  },
212
218
  },
213
219
  };
@@ -29,7 +29,8 @@ export async function uploadDraftAttachment(deps, input) {
29
29
  return refuse("conflict");
30
30
  const storage = { ...deps, emit: () => undefined };
31
31
  const result = await stageUpload(storage, {
32
- stageKind: attachment.kind,
32
+ // The establishing image is the Images stage's upload.
33
+ stageKind: attachment.kind === "reference" ? "images" : attachment.kind,
33
34
  originalFilename: attachment.original_filename,
34
35
  content: input.content,
35
36
  onAllocated(file) {