@gentbajko/slopify 2.4.0 → 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 (95) hide show
  1. package/dist/adapter-registry.js +8 -1
  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 +22 -2
  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 +7 -0
  11. package/dist/adapters/llm/codex-models.js +2 -1
  12. package/dist/adapters/llm/codex-server-models.js +2 -1
  13. package/dist/adapters/llm/run-cli.js +14 -2
  14. package/dist/assets/models.yaml +7 -0
  15. package/dist/catalog/registry.js +24 -3
  16. package/dist/catalog/runtime-models.js +1 -1
  17. package/dist/catalog/schema.js +10 -1
  18. package/dist/catalog/validate.js +17 -2
  19. package/dist/edge/events/hub.js +11 -0
  20. package/dist/edge/http/app.js +2 -0
  21. package/dist/edge/http/backups.js +40 -0
  22. package/dist/edge/http/host-cli.js +17 -2
  23. package/dist/edge/http/problem.js +1 -0
  24. package/dist/edge/http/schedules.js +1 -1
  25. package/dist/edge/http/settings.js +44 -0
  26. package/dist/host-cli/runtime.js +3 -2
  27. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  28. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  29. package/dist/kernel/ports/host-cli.js +17 -2
  30. package/dist/kernel/ports/image.js +3 -1
  31. package/dist/kernel/ports/llm.js +3 -1
  32. package/dist/kernel/runner/attempt.js +1 -1
  33. package/dist/kernel/runner/providers.js +22 -2
  34. package/dist/main.js +52 -7
  35. package/dist/slices/admission/model.js +8 -0
  36. package/dist/slices/admission/rules.js +47 -0
  37. package/dist/slices/admission/schema.js +11 -1
  38. package/dist/slices/admission/start.js +53 -15
  39. package/dist/slices/backups/files.js +135 -0
  40. package/dist/slices/backups/folder.js +35 -0
  41. package/dist/slices/backups/model.js +65 -0
  42. package/dist/slices/backups/repo.js +28 -0
  43. package/dist/slices/backups/schedule.js +60 -0
  44. package/dist/slices/backups/service.js +311 -0
  45. package/dist/slices/batch/index.js +2 -0
  46. package/dist/slices/estimate/index.js +9 -1
  47. package/dist/slices/library/slots.js +8 -2
  48. package/dist/slices/library/snapshot.js +8 -2
  49. package/dist/slices/notifications/notifier.js +76 -0
  50. package/dist/slices/notifications/rules.js +81 -0
  51. package/dist/slices/notifications/send.js +40 -0
  52. package/dist/slices/notifications/settings.js +34 -0
  53. package/dist/slices/play-drafts/convert.js +35 -3
  54. package/dist/slices/play-drafts/repo.js +7 -1
  55. package/dist/slices/play-drafts/review-inputs.js +2 -0
  56. package/dist/slices/play-drafts/schema.js +20 -2
  57. package/dist/slices/play-drafts/service.js +6 -0
  58. package/dist/slices/play-drafts/uploads.js +2 -1
  59. package/dist/slices/project-templates/from-project.js +20 -0
  60. package/dist/slices/project-templates/setup.js +11 -0
  61. package/dist/slices/rebuild/preview-details.js +24 -2
  62. package/dist/slices/rebuild/recipe-build.js +9 -3
  63. package/dist/slices/rebuild/recipe-input-schema.js +8 -1
  64. package/dist/slices/rebuild/recipe-legacy.js +3 -0
  65. package/dist/slices/rebuild/recipe-reference.js +44 -0
  66. package/dist/slices/rebuild/recipe-shorts.js +11 -4
  67. package/dist/slices/rebuild/recipe-validation.js +15 -1
  68. package/dist/slices/rebuild/recipe-visual.js +20 -8
  69. package/dist/slices/rebuild/runtime-actions.js +9 -2
  70. package/dist/slices/rebuild/runtime-image.js +50 -0
  71. package/dist/slices/rebuild/runtime-local.js +6 -3
  72. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  73. package/dist/slices/rebuild/runtime-provider.js +17 -10
  74. package/dist/slices/rebuild/runtime-shorts.js +4 -6
  75. package/dist/slices/rebuild/service-readiness.js +15 -1
  76. package/dist/slices/rebuild/transition-repo.js +1 -1
  77. package/dist/slices/revisions/adopt-content.js +7 -0
  78. package/dist/slices/revisions/adopt.js +7 -2
  79. package/dist/slices/revisions/mutation-assets.js +33 -9
  80. package/dist/slices/revisions/mutation-prepare.js +8 -10
  81. package/dist/slices/revisions/schema.js +5 -1
  82. package/dist/slices/schedules/repo.js +1 -1
  83. package/dist/slices/schedules/service.js +6 -1
  84. package/dist/slices/settings/model.js +5 -0
  85. package/dist/slices/storage/layout.js +10 -0
  86. package/dist/slices/storage/model.js +3 -0
  87. package/dist/slices/storage/portable.js +6 -3
  88. package/dist/slices/storage/reconcile.js +5 -1
  89. package/dist/web/assets/index-CktpxlJd.js +171 -0
  90. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  91. package/dist/web/assets/{pdf-CAJNT254.js → pdf-BLF2i8c2.js} +1 -1
  92. package/dist/web/index.html +2 -2
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-BGgYMm2u.css +0 -1
  95. package/dist/web/assets/index-BVVgtntL.js +0 -171
@@ -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
+ }
@@ -4,6 +4,14 @@ import { runDraftSchema } from "../admission/schema.js";
4
4
  import { draftDocumentThemeOf } from "../document/model.js";
5
5
  import { defaultShorts, shortsExtrasOf, shortsSettingsProblems, } from "../shorts/model.js";
6
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
+ }
7
15
  export function toAdmissionDraft(input) {
8
16
  const { form } = input.document;
9
17
  const fields = [];
@@ -49,15 +57,18 @@ export function toAdmissionDraft(input) {
49
57
  });
50
58
  return undefined;
51
59
  };
52
- 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
+ }) => {
53
64
  const attachment = input.attachments.find((item) => item.id === ref?.attachmentId && item.kind === kind && item.name === ref.name);
54
65
  if (attachment?.state === "ready" && attachment.stagedFileId !== null)
55
66
  return attachment.stagedFileId;
56
67
  fields.push({
57
68
  field,
58
69
  message: attachment?.state === "copying" || attachment?.state === "pending"
59
- ? "This file is still uploading. Wait for it to finish."
60
- : "That upload is no longer available. Choose the file again.",
70
+ ? messages.uploading
71
+ : messages.missing,
61
72
  });
62
73
  return undefined;
63
74
  };
@@ -119,6 +130,19 @@ export function toAdmissionDraft(input) {
119
130
  : form.audio
120
131
  : undefined,
121
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
+ : {}),
122
146
  articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
123
147
  ...(sources.audio === "generate" && form.narrationPrompt?.trim()
124
148
  ? { narrationPrompt: form.narrationPrompt }
@@ -154,12 +178,20 @@ export function toAdmissionDraft(input) {
154
178
  thumbnail: sources.thumbnail === "provide"
155
179
  ? file(form.provided.thumbnail, "thumbnail", "provided.thumbnail")
156
180
  : undefined,
181
+ reference: sources.images === "generate" && form.reference?.source === "provide"
182
+ ? file(form.provided.reference ?? null, "reference", "provided.reference")
183
+ : undefined,
157
184
  images: sources.images === "provide"
158
185
  ? form.provided.images.flatMap((ref, index) => {
159
186
  const id = file(ref, "images", `provided.images.${index}`);
160
187
  return id === undefined ? [] : [id];
161
188
  })
162
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
+ : {}),
163
195
  },
164
196
  chunking: {
165
197
  mode: chunkMode,
@@ -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,7 +1,7 @@
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
6
  import { runDraftSchema, videoEditSchema } from "../admission/schema.js";
7
7
  import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.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,6 +47,17 @@ 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.
@@ -105,6 +118,11 @@ export const playDraftFormSchema = z
105
118
  audio: file.nullable(),
106
119
  thumbnail: file.nullable(),
107
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(),
108
126
  })
109
127
  .strict()
110
128
  .readonly(),
@@ -150,7 +168,7 @@ export const discardDraftInputSchema = z
150
168
  export const draftAttachmentSchema = z
151
169
  .object({
152
170
  id,
153
- kind: z.enum(["audio", "images", "thumbnail"]),
171
+ kind: z.enum(draftAttachmentKinds),
154
172
  name: text,
155
173
  state: z.enum(["pending", "copying", "ready", "reattach"]),
156
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) {
@@ -103,6 +103,10 @@ function documentFromProject(deps, revision) {
103
103
  // and a snapshot holds one prompt per kind and name.
104
104
  if (!prompts.some((prompt) => prompt.kind === "image" && prompt.name === config.shorts?.imagePrompt))
105
105
  addPrompt("image", config.shorts?.imagePrompt, "shortsImage");
106
+ // The establishing image's prompt, the same way.
107
+ if (config.reference?.source === "prompt" &&
108
+ !prompts.some((prompt) => prompt.kind === "image" && prompt.name === config.reference?.prompt))
109
+ addPrompt("image", config.reference.prompt, "referencePrompt");
106
110
  for (const category of ["intro", "outro"]) {
107
111
  const choice = config[category];
108
112
  const body = revision.content.promptTemplates[category] ?? config.rendered[category];
@@ -125,9 +129,16 @@ function documentFromProject(deps, revision) {
125
129
  : "",
126
130
  audio: config.sources.audio === "provide" ? attachment("Audio from project") : null,
127
131
  thumbnail: config.sources.thumbnail === "provide" ? attachment("Thumbnail from project") : null,
132
+ ...(config.reference?.source === "provide"
133
+ ? { reference: attachment("Establishing image from project") }
134
+ : {}),
128
135
  images: config.sources.images === "provide"
129
136
  ? (definitions.length ? definitions : (config.provided.images ?? [])).map((_, index) => attachment(`Image ${index + 1}`))
130
137
  : [],
138
+ // Named like the project's other files, to be attached again on Play.
139
+ ...(config.shorts?.enabled === true && revision.content.shortsMusic !== undefined
140
+ ? { shortsMusic: attachment("Music from project") }
141
+ : {}),
131
142
  };
132
143
  return {
133
144
  schemaVersion: 1,
@@ -147,6 +158,15 @@ function documentFromProject(deps, revision) {
147
158
  llm: config.llm ?? { provider: "", model: "" },
148
159
  audio: config.audio ?? { provider: "", model: "", voice: "" },
149
160
  images: config.images ?? { provider: "", model: "" },
161
+ ...(config.reference === undefined
162
+ ? {}
163
+ : {
164
+ reference: {
165
+ source: config.reference.source,
166
+ prompt: config.reference.prompt ?? "",
167
+ thumbnail: config.reference.thumbnail !== false,
168
+ },
169
+ }),
150
170
  articlePrompt: config.articlePrompt ?? "",
151
171
  ...(config.narrationPrompt === undefined ? {} : { narrationPrompt: config.narrationPrompt }),
152
172
  ...(config.youtubeDescription === true ? { youtubeDescription: true } : {}),
@@ -10,6 +10,10 @@ export function templateSetup(deps, input) {
10
10
  { kind: "thumbnail", name: input.form.thumbnailPrompt },
11
11
  { kind: "shorts", name: input.form.shorts?.prompt ?? "" },
12
12
  { kind: "image", name: input.form.shorts?.imagePrompt ?? "" },
13
+ {
14
+ kind: "image",
15
+ name: input.form.reference?.source === "prompt" ? input.form.reference.prompt : "",
16
+ },
13
17
  ];
14
18
  for (const choice of choices) {
15
19
  if (choice.name === "")
@@ -53,6 +57,13 @@ export function freshTemplateDraft(deps, document, source) {
53
57
  audio: provided.audio === null ? null : fresh(provided.audio),
54
58
  thumbnail: provided.thumbnail === null ? null : fresh(provided.thumbnail),
55
59
  images: provided.images.map(fresh),
60
+ ...(provided.reference === undefined
61
+ ? {}
62
+ : { reference: provided.reference === null ? null : fresh(provided.reference) }),
63
+ // Like the images: the name is kept and the file is attached again on Play.
64
+ ...(provided.shortsMusic === undefined
65
+ ? {}
66
+ : { shortsMusic: provided.shortsMusic === null ? null : fresh(provided.shortsMusic) }),
56
67
  },
57
68
  },
58
69
  };
@@ -46,9 +46,19 @@ export function previewDetails(deps, view, allRecipes, selected) {
46
46
  return [
47
47
  {
48
48
  key: row.key,
49
- label: index < 0 ? "Thumbnail image" : `Image ${index + 1}`,
49
+ label: row.key === "reference:image"
50
+ ? "Establishing image"
51
+ : index < 0
52
+ ? "Thumbnail image"
53
+ : `Image ${index + 1}`,
50
54
  text: input.prompt,
51
- settings: [input.provider, input.model, input.aspect].join(" · "),
55
+ settings: [
56
+ input.provider,
57
+ input.model,
58
+ ...(input.thinking === undefined ? [] : [`effort ${input.thinking}`]),
59
+ input.aspect,
60
+ ...(input.reference === undefined ? [] : ["drawn from the establishing image"]),
61
+ ].join(" · "),
52
62
  },
53
63
  ];
54
64
  }
@@ -142,6 +152,12 @@ function inputChanges(parent, view) {
142
152
  : `${String(after.shorts.minSeconds)}-${String(after.shorts.maxSeconds)}`);
143
153
  add("Shorts prompt", before.shorts?.prompt, after.shorts?.prompt);
144
154
  add("Shorts image prompt", before.shorts?.imagePrompt, after.shorts?.imagePrompt);
155
+ add("Establishing image", referenceLabel(before), referenceLabel(after));
156
+ add("Thumbnail drawn from the establishing image", before.reference === undefined
157
+ ? undefined
158
+ : before.reference.thumbnail === false
159
+ ? "Off"
160
+ : "On", after.reference === undefined ? undefined : after.reference.thumbnail === false ? "Off" : "On");
145
161
  add("Title on the shorts", before.shorts === undefined ? undefined : before.shorts.titleOnScreen === true ? "On" : "Off", after.shorts === undefined ? undefined : after.shorts.titleOnScreen === true ? "On" : "Off");
146
162
  add("Shorts speed", before.shorts === undefined ? undefined : `${shortsSpeedOf(before.shorts).toFixed(2)}×`, after.shorts === undefined ? undefined : `${shortsSpeedOf(after.shorts).toFixed(2)}×`);
147
163
  add("Shorts music", old.shortsMusic === undefined ? "None" : "Uploaded file", next.shortsMusic === undefined
@@ -191,3 +207,9 @@ function inputChanges(parent, view) {
191
207
  add("Manual captions", old.subtitleCues?.cues, next.subtitleCues?.cues);
192
208
  return changes;
193
209
  }
210
+ function referenceLabel(config) {
211
+ const reference = config.reference;
212
+ if (reference === undefined)
213
+ return "Off";
214
+ return reference.source === "provide" ? "Uploaded image" : `Prompt: ${reference.prompt ?? ""}`;
215
+ }
@@ -1,7 +1,9 @@
1
+ import { referenceForThumbnail } from "../admission/rules.js";
1
2
  import { audioRecipes } from "./recipe-audio.js";
2
3
  import { documentRecipes } from "./recipe-document.js";
3
4
  import { editPlan } from "./recipe-edit.js";
4
5
  import { exportRecipes } from "./recipe-exports.js";
6
+ import { imageReference, referenceRecipe } from "./recipe-reference.js";
5
7
  import { shortsRecipes } from "./recipe-shorts.js";
6
8
  import { textRecipes } from "./recipe-text.js";
7
9
  import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
@@ -11,16 +13,20 @@ export function buildRecipes(context) {
11
13
  const audio = audioRecipes(context, text);
12
14
  const exports = exportRecipes(context, audio);
13
15
  const captions = exports.find((value) => value.key === "subtitles:files");
14
- const thumbnail = thumbnailRecipes(context, text.recipes);
16
+ // The establishing image, when it is on: every other image is drawn from it.
17
+ const reference = referenceRecipe(context);
18
+ const drawnFrom = imageReference(context, reference);
19
+ const thumbnail = thumbnailRecipes(context, text.recipes, referenceForThumbnail(context.config) ? drawnFrom : undefined);
15
20
  const youtube = youtubeRecipes(context, exports);
16
21
  return [
17
22
  ...text.recipes,
18
23
  ...audio.recipes,
19
24
  ...exports,
20
25
  ...youtube,
21
- ...shortsRecipes(context, exports),
26
+ ...(reference === undefined ? [] : [reference]),
27
+ ...shortsRecipes(context, exports, drawnFrom),
22
28
  ...thumbnail,
23
29
  ...documentRecipes(context, text, thumbnail),
24
- ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images))),
30
+ ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images), drawnFrom)),
25
31
  ];
26
32
  }
@@ -23,7 +23,9 @@ export const recipeInputSchema = z.discriminatedUnion("kind", [
23
23
  .object({
24
24
  budget: z.number().finite().optional(),
25
25
  level: z.enum(["minimal", "low", "medium", "high"]).optional(),
26
- effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
26
+ effort: z
27
+ .enum(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"])
28
+ .optional(),
27
29
  })
28
30
  .strict()
29
31
  .nullable(),
@@ -67,6 +69,11 @@ export const recipeInputSchema = z.discriminatedUnion("kind", [
67
69
  prompt: z.string(),
68
70
  aspect: z.enum(["16:9", "9:16"]),
69
71
  animate: z.object({ image: z.string(), seconds: z.number() }).strict().optional(),
72
+ thinking: z.enum(thinkingModes).optional(),
73
+ reference: z
74
+ .object({ fingerprint: z.string(), assetId: z.string().nullable() })
75
+ .strict()
76
+ .optional(),
70
77
  })
71
78
  .strict(),
72
79
  z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { referenceKey } from "../admission/model.js";
2
3
  import { buildRecipes } from "./recipe-build.js";
3
4
  export function baselineFingerprints(project, outputs, pieces, content, stages = []) {
4
5
  const recipes = buildRecipes({
@@ -34,6 +35,8 @@ export function legacyOutputWorkKey(output, config) {
34
35
  return `image:${output.id}`;
35
36
  case "thumbnail":
36
37
  return "thumbnail:image";
38
+ case "reference":
39
+ return referenceKey;
37
40
  case "notes":
38
41
  return "research:notes";
39
42
  case "article_md":
@@ -0,0 +1,44 @@
1
+ import { referenceKey } from "../admission/model.js";
2
+ import { usesReference } from "../admission/rules.js";
3
+ import { recipe, selectedAsset, } from "./recipe-model.js";
4
+ import { renderedPrompt } from "./recipe-text.js";
5
+ // The establishing image (`RunDraft.reference`): one step of the Images stage, made before
6
+ // every other image of the run and never part of the video. Made from its library prompt, whose
7
+ // keywords are filled like every other image prompt's, or the file the user uploaded.
8
+ export const referencePromptKey = "referencePrompt";
9
+ // The image provider's choice as an image request carries it: the effort only when one is set,
10
+ // so a request made without one keeps the fingerprint it always had.
11
+ export function imageChoice(config) {
12
+ return {
13
+ provider: config.images?.provider ?? "",
14
+ model: config.images?.model ?? "",
15
+ ...(config.images?.thinking === undefined ? {} : { thinking: config.images.thinking }),
16
+ };
17
+ }
18
+ export function referenceRecipe(context) {
19
+ const { config, content } = context;
20
+ if (!usesReference(config) || config.reference === undefined)
21
+ return undefined;
22
+ if (config.reference.source === "provide")
23
+ return recipe(context, referenceKey, "images", {
24
+ kind: "provided",
25
+ version: 1,
26
+ assetId: content.provided.reference ?? null,
27
+ semantic: "reference",
28
+ }, [], { unresolved: content.provided.reference === undefined });
29
+ const prompt = renderedPrompt(context, referencePromptKey);
30
+ return recipe(context, referenceKey, "images", { kind: "image", version: 1, ...imageChoice(config), aspect: config.format, prompt }, [], { unresolved: prompt.trim() === "" });
31
+ }
32
+ // Which image the others are drawn from: the step's fingerprint, so making it again or
33
+ // changing it marks every image drawn from it outdated, and the asset it made.
34
+ export function imageReference(context, reference) {
35
+ return reference === undefined
36
+ ? undefined
37
+ : {
38
+ key: reference.key,
39
+ input: {
40
+ fingerprint: reference.fingerprint,
41
+ assetId: selectedAsset(context, reference.key, reference.fingerprint),
42
+ },
43
+ };
44
+ }
@@ -5,6 +5,7 @@ import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
5
5
  import { defaultShortsImagePrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
6
6
  import { imagePromptMessages } from "../shorts/prompts.js";
7
7
  import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
8
+ import { imageChoice } from "./recipe-reference.js";
8
9
  import { llmInput, renderedPrompt } from "./recipe-text.js";
9
10
  // The Shorts step unfolds as its answers land, the way research's chapters unfold from the
10
11
  // plan. `shorts:pick` reads the word timing, so it waits for `subtitles:timing` and runs
@@ -19,7 +20,9 @@ import { llmInput, renderedPrompt } from "./recipe-text.js";
19
20
  // can be made again alone.
20
21
  export const shortsPickKey = "shorts:pick";
21
22
  export const shortsFutureKey = "shorts:future";
22
- export function shortsRecipes(context, exports) {
23
+ export function shortsRecipes(context, exports,
24
+ // The establishing image the shorts' images are drawn from, when it is on.
25
+ reference) {
23
26
  const { config } = context;
24
27
  const shorts = config.shorts;
25
28
  const timing = exports.find((value) => value.key === "subtitles:timing");
@@ -67,6 +70,10 @@ export function shortsRecipes(context, exports) {
67
70
  shorts.maxSeconds,
68
71
  config.llm?.provider ?? "",
69
72
  config.llm?.model ?? "",
73
+ // Only what is in use, so shorts planned before these existed keep their
74
+ // fingerprint; the estimate reads the fields above by position.
75
+ ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
76
+ ...(reference === undefined ? [] : [reference.input.fingerprint]),
70
77
  ],
71
78
  }, [pick.key], { tokenKey: shortsPickKey }),
72
79
  ];
@@ -94,11 +101,11 @@ export function shortsRecipes(context, exports) {
94
101
  const stills = written.map((prompt, index) => recipe(context, `${prefix}:image:${String(index + 1)}`, "video", {
95
102
  kind: "image",
96
103
  version: 1,
97
- provider: images.provider,
98
- model: images.model,
104
+ ...imageChoice(config),
99
105
  prompt,
100
106
  aspect: "9:16",
101
- }, [prompts.key], { token }));
107
+ ...(reference === undefined ? {} : { reference: reference.input }),
108
+ }, [prompts.key, ...(reference === undefined ? [] : [reference.key])], { token }));
102
109
  recipes.push(...stills);
103
110
  const extras = renderExtras(context, clip);
104
111
  recipes.push(recipe(context, `${prefix}:render`, "video", {
@@ -1,4 +1,4 @@
1
- import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
1
+ import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
2
2
  import { detectSlots, render } from "../admission/substitute.js";
3
3
  import { usesAnimation } from "../video/edit-settings.js";
4
4
  export function validateRecipeInputs(config, content) {
@@ -93,6 +93,20 @@ export function validateRecipeInputs(config, content) {
93
93
  raw: content.promptTemplates.thumbnailPrompt ?? null,
94
94
  literal: config.rendered.thumbnailPrompt,
95
95
  });
96
+ // The establishing image: its prompt is checked like the thumbnail's, an upload must be there.
97
+ if (usesReference(config) && config.reference?.source === "prompt")
98
+ prompts.push({
99
+ field: "rendered.referencePrompt",
100
+ raw: content.promptTemplates.referencePrompt ?? null,
101
+ literal: config.rendered.referencePrompt,
102
+ });
103
+ if (usesReference(config) &&
104
+ config.reference?.source === "provide" &&
105
+ content.provided.reference === undefined)
106
+ fields.push({
107
+ field: "content.provided.reference",
108
+ message: "Upload the establishing image, choose From a prompt instead, or set Establishing image to Off.",
109
+ });
96
110
  if (config.sources.images !== "off")
97
111
  for (const key of content.imageOrder) {
98
112
  const image = content.imageDefinitions[key];
@@ -1,9 +1,12 @@
1
1
  import { render } from "../admission/substitute.js";
2
2
  import { recipe, resourceIdentity, } from "./recipe-model.js";
3
+ import { imageChoice } from "./recipe-reference.js";
3
4
  import { matchingText, renderedPrompt } from "./recipe-text.js";
4
5
  export function visualRecipes(config, content, audioFingerprint, captionFingerprint,
5
6
  // What the Video stage's edit settings add (`recipe-edit.ts`), from the images planned here.
6
- edit) {
7
+ edit,
8
+ // The establishing image the generated images are drawn from, when it is on.
9
+ reference) {
7
10
  const recipes = [];
8
11
  const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
9
12
  for (const key of imageKeys) {
@@ -19,11 +22,11 @@ edit) {
19
22
  : {
20
23
  kind: "image",
21
24
  version: 1,
22
- provider: config.images?.provider ?? "",
23
- model: config.images?.model ?? "",
25
+ ...imageChoice(config),
24
26
  aspect: config.format,
25
27
  prompt: prompt ?? "",
26
- }, [], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
28
+ ...(reference === undefined ? {} : { reference: reference.input }),
29
+ }, image.source === "provide" || reference === undefined ? [] : [reference.key], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
27
30
  }
28
31
  if (config.sources.video !== "off") {
29
32
  const edited = edit?.([...recipes]) ?? { recipes: [], values: [], dependsOn: [] };
@@ -73,7 +76,9 @@ edit) {
73
76
  }
74
77
  return recipes;
75
78
  }
76
- export function thumbnailRecipes(context, textRecipes) {
79
+ export function thumbnailRecipes(context, textRecipes,
80
+ // The establishing image, when it is on and the thumbnail is drawn from it too.
81
+ reference) {
77
82
  const { config, content } = context;
78
83
  if (config.sources.thumbnail === "off")
79
84
  return [];
@@ -101,16 +106,23 @@ export function thumbnailRecipes(context, textRecipes) {
101
106
  config.images?.provider ?? null,
102
107
  config.images?.model ?? null,
103
108
  config.format,
109
+ // Only what is in use, so a thumbnail made before these existed keeps its
110
+ // fingerprint.
111
+ ...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
112
+ ...(reference === undefined ? [] : [reference.input.fingerprint]),
104
113
  ],
105
114
  }
106
115
  : {
107
116
  kind: "image",
108
117
  version: 1,
109
- provider: config.images?.provider ?? "",
110
- model: config.images?.model ?? "",
118
+ ...imageChoice(config),
111
119
  aspect: config.format,
112
120
  prompt,
113
- }, promptRecipe === undefined ? [] : [promptRecipe.key]),
121
+ ...(reference === undefined ? {} : { reference: reference.input }),
122
+ }, [
123
+ ...(promptRecipe === undefined ? [] : [promptRecipe.key]),
124
+ ...(reference === undefined ? [] : [reference.key]),
125
+ ]),
114
126
  ];
115
127
  }
116
128
  export function visualAssets(context, recipes) {
@@ -12,7 +12,14 @@ export async function revisionAction(deps, projectId, action) {
12
12
  const config = view.revision.config;
13
13
  const content = view.revision.content;
14
14
  const image = action.kind === "delete-image" || action.kind === "regenerate-image"
15
- ? view.outputs.find((row) => row.selected && row.output.id === action.outputId && row.output.role === "image")
15
+ ? view.outputs.find((row) => row.selected &&
16
+ row.output.id === action.outputId &&
17
+ (row.output.role === "image" ||
18
+ // A generated establishing image is made again like any image; it is never deleted
19
+ // on its own (Edit project sets it Off), and an uploaded one has nothing to redo.
20
+ (action.kind === "regenerate-image" &&
21
+ row.output.role === "reference" &&
22
+ config.reference?.source === "prompt")))
16
23
  : undefined;
17
24
  if ((action.kind === "delete-image" || action.kind === "regenerate-image") && image === undefined)
18
25
  return { ok: false, reason: "unknown-image" };
@@ -64,7 +71,7 @@ function keyStage(key) {
64
71
  const prefix = key.split(":")[0];
65
72
  if (prefix === "entry" || prefix === "article")
66
73
  return "article";
67
- if (prefix === "image")
74
+ if (prefix === "image" || prefix === "reference")
68
75
  return "images";
69
76
  if (prefix === "audio")
70
77
  return "audio";