@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.
- package/dist/adapter-registry.js +9 -2
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +99 -8
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +48 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +65 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +13 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +44 -3
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { usesShorts } from "../admission/rules.js";
|
|
4
|
+
import { resolveBoldFont } from "../fonts/index.js";
|
|
5
|
+
import { clipWords } from "../shorts/captions.js";
|
|
6
|
+
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
7
|
+
import { defaultShortsPrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
|
|
8
|
+
import { checkPicks, keepNumbers, noPicksMessage, pickMessages, pickRetryMessages, } from "../shorts/pick.js";
|
|
9
|
+
import { checkImagePrompts } from "../shorts/prompts.js";
|
|
10
|
+
import { renderShort } from "../shorts/render.js";
|
|
11
|
+
import { allocateAsset, discardPreparedAssets, sealAsset, writeAsset } from "../storage/assets.js";
|
|
12
|
+
import { outputPath, projectDir } from "../storage/layout.js";
|
|
13
|
+
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
14
|
+
import { sentencesText, transcriptSentences } from "../youtube/transcript.js";
|
|
15
|
+
import { shortsPickKey } from "./recipe-shorts.js";
|
|
16
|
+
import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
|
|
17
|
+
import { imageCall } from "./runtime-image.js";
|
|
18
|
+
import { savedCatalogue } from "./runtime-plan.js";
|
|
19
|
+
import { preparedResult, preparedTexts, publishResult } from "./runtime-publication.js";
|
|
20
|
+
import { wordsSchema } from "./runtime-subtitles.js";
|
|
21
|
+
// The Shorts step's four kinds of work (`recipe-shorts.ts`): the pick, each clip's image
|
|
22
|
+
// prompts, each image, and each clip's render. Each is one piece of its own, so a failure
|
|
23
|
+
// names the clip it belongs to and Retry stage redoes only what did not finish.
|
|
24
|
+
export async function executeShortsRecipe(deps, context, providers, piece) {
|
|
25
|
+
if (deps.db
|
|
26
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
27
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
28
|
+
return "done";
|
|
29
|
+
if (!context.maySubmit(piece.id))
|
|
30
|
+
return "held";
|
|
31
|
+
context.signal.throwIfAborted();
|
|
32
|
+
const snapshot = exportSnapshot(deps, context, piece);
|
|
33
|
+
if (!usesShorts(snapshot.view.revision.config))
|
|
34
|
+
throw setupError();
|
|
35
|
+
if (piece.key === "shorts:pick")
|
|
36
|
+
return pick(deps, context, providers, piece, snapshot);
|
|
37
|
+
const matched = /^shorts:(\d+):(prompts|image:(\d+)|render)$/.exec(piece.key);
|
|
38
|
+
const number = Number(matched?.[1]);
|
|
39
|
+
if (matched === null || !Number.isInteger(number))
|
|
40
|
+
throw setupError();
|
|
41
|
+
const clip = savedClips(snapshot).find((one) => one.number === number);
|
|
42
|
+
if (clip === undefined)
|
|
43
|
+
throw new Error("The clip this short was cut from is no longer among the picked shorts. Use Pick different moments in Edit project → Shorts, then Retry stage.");
|
|
44
|
+
if (matched[2] === "prompts")
|
|
45
|
+
return prompts(deps, context, providers, piece, snapshot, clip);
|
|
46
|
+
if (matched[2] === "render")
|
|
47
|
+
return render(deps, context, piece, snapshot, clip);
|
|
48
|
+
return image(deps, context, providers, piece, clip, Number(matched[3]));
|
|
49
|
+
}
|
|
50
|
+
async function pick(deps, context, providers, piece, snapshot) {
|
|
51
|
+
const input = piece.input;
|
|
52
|
+
if (input.kind !== "local" || input.operation !== "shorts-pick-v1")
|
|
53
|
+
throw setupError();
|
|
54
|
+
const { view } = snapshot;
|
|
55
|
+
const config = view.revision.config;
|
|
56
|
+
const shorts = config.shorts;
|
|
57
|
+
if (shorts === undefined)
|
|
58
|
+
throw setupError();
|
|
59
|
+
const llm = config.llm;
|
|
60
|
+
if (llm === undefined || llm.provider.trim() === "" || llm.model.trim() === "")
|
|
61
|
+
throw new Error("Shorts need an AI text model to pick the clips, and none is chosen. Choose one in Edit project → Providers, then Retry stage.");
|
|
62
|
+
const sentences = transcriptSentences(timingWords(deps, context, view));
|
|
63
|
+
if (sentences.length === 0)
|
|
64
|
+
throw new Error("The narration's word timing holds no words, so there is nothing to cut shorts from. Use Re-run section on Video, then Retry stage.");
|
|
65
|
+
const audio = await revisionAudio(deps, context, view);
|
|
66
|
+
const durationSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
67
|
+
const limits = {
|
|
68
|
+
count: shorts.count,
|
|
69
|
+
minSeconds: shorts.minSeconds,
|
|
70
|
+
maxSeconds: shorts.maxSeconds,
|
|
71
|
+
durationSeconds,
|
|
72
|
+
};
|
|
73
|
+
// Nothing to ask the model for, and nothing to pay for.
|
|
74
|
+
if (durationSeconds < shorts.minSeconds)
|
|
75
|
+
throw new Error(noPicksMessage([], limits));
|
|
76
|
+
const saved = Array.isArray(input.values) ? input.values[4] : undefined;
|
|
77
|
+
const brief = {
|
|
78
|
+
instruction: typeof saved === "string" && saved.trim() !== "" ? saved : defaultShortsPrompt,
|
|
79
|
+
title: config.title,
|
|
80
|
+
durationSeconds,
|
|
81
|
+
count: shorts.count,
|
|
82
|
+
minSeconds: shorts.minSeconds,
|
|
83
|
+
maxSeconds: shorts.maxSeconds,
|
|
84
|
+
sentences: sentencesText(sentences),
|
|
85
|
+
};
|
|
86
|
+
const ask = llmCall(deps, context, piece, view, (text) => {
|
|
87
|
+
const checked = checkPicks(text, sentences, limits);
|
|
88
|
+
return checked.ok ? undefined : checked.reason;
|
|
89
|
+
});
|
|
90
|
+
const first = await providers.forPiece(piece.id).llm(ask(pickMessages(brief)));
|
|
91
|
+
if (!first.ok)
|
|
92
|
+
return "held";
|
|
93
|
+
let checked = checkPicks(first.value.text, sentences, limits);
|
|
94
|
+
if (!checked.ok)
|
|
95
|
+
throw new Error(checked.reason);
|
|
96
|
+
// One more ask when clips were missing or broke the rules, with what was wrong; then
|
|
97
|
+
// whichever answer held more usable clips.
|
|
98
|
+
if (checked.picks.length < shorts.count && checked.problems.length > 0) {
|
|
99
|
+
if (!context.maySubmit(piece.id))
|
|
100
|
+
return "held";
|
|
101
|
+
const second = await providers
|
|
102
|
+
.forPiece(piece.id)
|
|
103
|
+
.llm(ask(pickRetryMessages(brief, first.value.text, checked.problems)));
|
|
104
|
+
if (!second.ok)
|
|
105
|
+
return "held";
|
|
106
|
+
const again = checkPicks(second.value.text, sentences, limits);
|
|
107
|
+
if (again.ok && again.picks.length >= checked.picks.length)
|
|
108
|
+
checked = again;
|
|
109
|
+
}
|
|
110
|
+
if (checked.picks.length === 0)
|
|
111
|
+
throw new Error(noPicksMessage(checked.problems, limits));
|
|
112
|
+
// A clip picked again with the same sentences keeps its number and seed, so its prompts,
|
|
113
|
+
// images and render are reused; the new ones carry this pick's token.
|
|
114
|
+
const previous = pickedShortsOf(view.pieces.find((row) => row.key === shortsPickKey && row.selected)?.piece.payload);
|
|
115
|
+
const picks = keepNumbers(checked.picks, previous?.shorts ?? [], view.revision.content.regenerationTokens[shortsPickKey] ?? null);
|
|
116
|
+
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
117
|
+
[
|
|
118
|
+
"shorts",
|
|
119
|
+
"shorts.json",
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
shorts: picks.map(({ text: _text, seed: _seed, ...clip }) => clip),
|
|
122
|
+
}, null, 2),
|
|
123
|
+
],
|
|
124
|
+
]), {
|
|
125
|
+
shorts: picks,
|
|
126
|
+
durationSeconds,
|
|
127
|
+
// The numbered transcript's times, which a range set by hand is measured against.
|
|
128
|
+
sentences: sentences.map(({ start, end, text }) => ({ start, end, text })),
|
|
129
|
+
}, null,
|
|
130
|
+
// The shorts past the new count, from a pick that made more.
|
|
131
|
+
(key) => Number(/^shorts:(\d+):/.exec(key)?.[1] ?? 0) > picks.length);
|
|
132
|
+
return "done";
|
|
133
|
+
}
|
|
134
|
+
async function prompts(deps, context, providers, piece, snapshot, clip) {
|
|
135
|
+
const input = piece.input;
|
|
136
|
+
if (input.kind !== "llm")
|
|
137
|
+
throw setupError();
|
|
138
|
+
const count = shortImageCount(clip.end - clip.start, snapshot.view.revision.config.imageSeconds);
|
|
139
|
+
const answer = await providers.forPiece(piece.id).llm({
|
|
140
|
+
provider: input.provider,
|
|
141
|
+
model: input.model,
|
|
142
|
+
...(input.thinking === null ? {} : { thinking: input.thinking }),
|
|
143
|
+
thinkingConfig: input.thinkingConfig,
|
|
144
|
+
messages: input.messages,
|
|
145
|
+
webSearch: false,
|
|
146
|
+
previewLabel: piece.key,
|
|
147
|
+
check: (value) => {
|
|
148
|
+
const checked = checkImagePrompts(value.text, count);
|
|
149
|
+
return checked.ok ? undefined : checked.reason;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
if (!answer.ok)
|
|
153
|
+
return "held";
|
|
154
|
+
const checked = checkImagePrompts(answer.value.text, count);
|
|
155
|
+
if (!checked.ok)
|
|
156
|
+
throw new Error(checked.reason);
|
|
157
|
+
const images = new RegExp(`^shorts:${String(clip.number)}:image:(\\d+)$`);
|
|
158
|
+
await publishResult(deps, context, piece, [], { prompts: checked.prompts }, null,
|
|
159
|
+
// The images past this count, from a longer clip made before.
|
|
160
|
+
(key) => Number(images.exec(key)?.[1] ?? 0) > count);
|
|
161
|
+
return "done";
|
|
162
|
+
}
|
|
163
|
+
async function image(deps, context, providers, piece, clip, index) {
|
|
164
|
+
const input = piece.input;
|
|
165
|
+
if (input.kind !== "image")
|
|
166
|
+
throw setupError();
|
|
167
|
+
const made = await providers
|
|
168
|
+
.forPiece(piece.id)
|
|
169
|
+
.image(imageCall(deps, context.work.projectId, input, `Short ${String(clip.number)} image ${String(index)}`));
|
|
170
|
+
if (!made.ok)
|
|
171
|
+
return "held";
|
|
172
|
+
const asset = writeAsset(deps, context.work.projectId, made.value.mime === "image/jpeg" ? "short-image.jpg" : "short-image.png", made.value.bytes);
|
|
173
|
+
const output = preparedResult(deps, context, piece, "short_image", asset, null, {
|
|
174
|
+
short: clip.number,
|
|
175
|
+
sentences: [clip.first, clip.last],
|
|
176
|
+
index,
|
|
177
|
+
prompt: input.prompt,
|
|
178
|
+
provider: input.provider,
|
|
179
|
+
model: input.model,
|
|
180
|
+
});
|
|
181
|
+
await publishResult(deps, context, piece, [output], { prompt: input.prompt }, asset);
|
|
182
|
+
return "done";
|
|
183
|
+
}
|
|
184
|
+
async function render(deps, context, piece, snapshot, clip) {
|
|
185
|
+
const { view, plan } = snapshot;
|
|
186
|
+
const config = view.revision.config;
|
|
187
|
+
const prefix = `shorts:${String(clip.number)}:image:`;
|
|
188
|
+
const images = plan.recipes
|
|
189
|
+
.filter((recipe) => recipe.key.startsWith(prefix))
|
|
190
|
+
.toSorted((left, right) => imageIndex(left.key) - imageIndex(right.key))
|
|
191
|
+
.map((recipe) => {
|
|
192
|
+
const row = view.outputs.find((one) => one.workKey === recipe.key && one.selected && one.available && one.state === "ready");
|
|
193
|
+
if (row === undefined)
|
|
194
|
+
throw new Error(`Short ${String(clip.number)} is missing one of its images. Retry stage to make it again; if it keeps happening, use Make this short again in Edit project → Shorts.`);
|
|
195
|
+
return outputPath(deps.paths, context.work.projectId, row.output.path);
|
|
196
|
+
});
|
|
197
|
+
const words = clipWords(timingWords(deps, context, view), clip.start, clip.end);
|
|
198
|
+
const timeline = await revisionAudio(deps, context, view);
|
|
199
|
+
// The bundled Barlow is drawn in its own Bold face; any other font is emboldened.
|
|
200
|
+
const font = await resolveBoldFont(deps.paths, config.subtitles?.fontId ?? "default");
|
|
201
|
+
const shorts = config.shorts;
|
|
202
|
+
const speed = shorts === undefined ? 1 : shortsSpeedOf(shorts);
|
|
203
|
+
const music = await backgroundMusic(deps, context, view);
|
|
204
|
+
if (!context.maySubmit(piece.id))
|
|
205
|
+
return "held";
|
|
206
|
+
const pending = allocateAsset(deps, context.work.projectId, "short.mp4");
|
|
207
|
+
const prepared = [];
|
|
208
|
+
const seconds = (clip.end - clip.start) / speed;
|
|
209
|
+
try {
|
|
210
|
+
await renderShort({
|
|
211
|
+
bin: deps.ffmpeg,
|
|
212
|
+
timeline,
|
|
213
|
+
start: clip.start,
|
|
214
|
+
end: clip.end,
|
|
215
|
+
images,
|
|
216
|
+
imageSeconds: config.imageSeconds,
|
|
217
|
+
motionStyle: config.motionStyle,
|
|
218
|
+
zoomPercent: config.zoomPercent,
|
|
219
|
+
words,
|
|
220
|
+
font,
|
|
221
|
+
...(shorts?.titleOnScreen === true ? { title: clip.title } : {}),
|
|
222
|
+
speed,
|
|
223
|
+
music,
|
|
224
|
+
output: pending.absolutePath,
|
|
225
|
+
scratch: projectDir(deps.paths, context.work.projectId),
|
|
226
|
+
signal: context.signal,
|
|
227
|
+
log: deps.log,
|
|
228
|
+
onProgress: (elapsedMs) => context.emit({
|
|
229
|
+
type: "stage.progress",
|
|
230
|
+
projectId: context.work.projectId,
|
|
231
|
+
stage: "video",
|
|
232
|
+
current: Math.min(100, Math.round(elapsedMs / (seconds * 10))),
|
|
233
|
+
total: 100,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
context.signal.throwIfAborted();
|
|
237
|
+
const durationMs = await probeDurationMs(deps.ffmpeg, pending.absolutePath, context.signal, deps.log);
|
|
238
|
+
const asset = sealAsset(deps, pending);
|
|
239
|
+
prepared.push(preparedResult(deps, context, piece, "short_video", asset, durationMs, {
|
|
240
|
+
short: clip.number,
|
|
241
|
+
sentences: [clip.first, clip.last],
|
|
242
|
+
}));
|
|
243
|
+
await publishResult(deps, context, piece, prepared, { short: clip.number, start: clip.start, end: clip.end, durationMs }, asset);
|
|
244
|
+
// One per rendered short, with no stage: stage "video" would read as a finished video.
|
|
245
|
+
// Its pick and prompt tokens are not counted here.
|
|
246
|
+
deps.count?.("stage.completed", { shorts: 1 });
|
|
247
|
+
return "done";
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
discardPreparedAssets(deps, [pending, ...prepared.map((one) => one.asset)]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// The clips the pick saved for this plan, with any range set by hand in place: the
|
|
254
|
+
// selected pick whose fingerprint is the one the plan asks for.
|
|
255
|
+
function savedClips(snapshot) {
|
|
256
|
+
const recipe = snapshot.plan.recipes.find((one) => one.key === shortsPickKey);
|
|
257
|
+
const row = snapshot.view.pieces.find((one) => one.key === shortsPickKey &&
|
|
258
|
+
one.selected &&
|
|
259
|
+
one.piece.state === "done" &&
|
|
260
|
+
one.fingerprint === recipe?.fingerprint);
|
|
261
|
+
const picked = pickedShortsOf(row?.piece.payload);
|
|
262
|
+
const shorts = snapshot.view.revision.config.shorts;
|
|
263
|
+
if (picked === undefined || recipe === undefined || shorts === undefined)
|
|
264
|
+
return [];
|
|
265
|
+
return effectiveClips(picked, snapshot.view.revision.content.shortsRanges, recipe.fingerprint, shorts);
|
|
266
|
+
}
|
|
267
|
+
// The project's background music for the shorts, checked readable before the render so a
|
|
268
|
+
// bad file is named as the music rather than as a failed render.
|
|
269
|
+
async function backgroundMusic(deps, context, view) {
|
|
270
|
+
const assetId = view.revision.content.shortsMusic;
|
|
271
|
+
const shorts = view.revision.config.shorts;
|
|
272
|
+
if (assetId === undefined || shorts === undefined)
|
|
273
|
+
return undefined;
|
|
274
|
+
const fix = "Choose another file under Background music in Edit project → Shorts, or remove the music, then Retry stage.";
|
|
275
|
+
const row = z
|
|
276
|
+
.object({ path: z.string() })
|
|
277
|
+
.safeParse(deps.db
|
|
278
|
+
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
279
|
+
.get(context.work.projectId, assetId));
|
|
280
|
+
if (!row.success)
|
|
281
|
+
throw new Error(`The shorts' background music file is no longer in the project. ${fix}`);
|
|
282
|
+
const path = outputPath(deps.paths, context.work.projectId, row.data.path);
|
|
283
|
+
let durationMs;
|
|
284
|
+
try {
|
|
285
|
+
durationMs = await probeDurationMs(deps.ffmpeg, path, context.signal, deps.log);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
context.signal.throwIfAborted();
|
|
289
|
+
throw new Error(`The shorts' background music couldn't be read as audio (${error instanceof Error ? error.message : String(error)}). ${fix}`);
|
|
290
|
+
}
|
|
291
|
+
if (durationMs <= 0)
|
|
292
|
+
throw new Error(`The shorts' background music file holds no sound. ${fix}`);
|
|
293
|
+
return { path, volume: musicVolumeOf(shorts) };
|
|
294
|
+
}
|
|
295
|
+
function timingWords(deps, context, view) {
|
|
296
|
+
const timing = view.outputs.find((row) => row.selected &&
|
|
297
|
+
row.available &&
|
|
298
|
+
row.state === "ready" &&
|
|
299
|
+
row.workKey === "subtitles:timing" &&
|
|
300
|
+
row.output.role === "subtitle_words");
|
|
301
|
+
if (timing === undefined)
|
|
302
|
+
throw new Error("The narration's word timing, which the shorts are cut and captioned from, is missing. Use Re-run section on Video, then Retry stage.");
|
|
303
|
+
return wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8"))).words;
|
|
304
|
+
}
|
|
305
|
+
// The project's text model, with the thinking settings saved for this step's catalogue.
|
|
306
|
+
function llmCall(deps, context, piece, view, check) {
|
|
307
|
+
const llm = view.revision.config.llm ?? { provider: "", model: "" };
|
|
308
|
+
const row = deps.db
|
|
309
|
+
.prepare("SELECT recipe_context FROM revision_work WHERE id=?")
|
|
310
|
+
.get(context.work.workId);
|
|
311
|
+
const model = savedCatalogue(row?.recipe_context).llm.find((one) => one.provider === llm.provider && one.id === llm.model);
|
|
312
|
+
return (messages) => ({
|
|
313
|
+
provider: llm.provider,
|
|
314
|
+
model: llm.model,
|
|
315
|
+
...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
|
|
316
|
+
thinkingConfig: llm.thinking === undefined ? null : (model?.llm.thinking?.[llm.thinking] ?? null),
|
|
317
|
+
messages,
|
|
318
|
+
webSearch: false,
|
|
319
|
+
previewLabel: piece.key,
|
|
320
|
+
check: (value) => check(value.text),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
function imageIndex(key) {
|
|
324
|
+
return Number(key.split(":").at(-1));
|
|
325
|
+
}
|
|
326
|
+
function setupError() {
|
|
327
|
+
return new Error("Slopify hit an internal error (a Shorts step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
328
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
3
|
import { sourceOf } from "../admission/model.js";
|
|
4
|
-
import { usesYoutubeDescription } from "../admission/rules.js";
|
|
4
|
+
import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
|
|
5
5
|
import { currentRevisionId } from "../revisions/repo.js";
|
|
6
6
|
import { getRevisionView } from "../revisions/view.js";
|
|
7
7
|
import { settleScheduleRunsForProject } from "../schedules/repo.js";
|
|
8
|
+
import { editNeedsTiming } from "../video/edit-settings.js";
|
|
8
9
|
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
9
10
|
import { workPieces } from "./work-records.js";
|
|
10
11
|
const workRow = z.object({
|
|
@@ -33,10 +34,13 @@ export function executionStages(deps, projectId) {
|
|
|
33
34
|
.filter((row) => workPieces(deps.db, String(row.id)).some((piece) => {
|
|
34
35
|
if (!keys.has(piece.key))
|
|
35
36
|
return false;
|
|
36
|
-
// Hand-edited captions need no timing, unless the YouTube description
|
|
37
|
+
// Hand-edited captions need no timing, unless the YouTube description, the shorts
|
|
38
|
+
// or the video's edit read it.
|
|
37
39
|
if (piece.key === "subtitles:timing" &&
|
|
38
40
|
view?.revision.content.subtitleCues !== undefined &&
|
|
39
|
-
!usesYoutubeDescription(view.revision.config)
|
|
41
|
+
!usesYoutubeDescription(view.revision.config) &&
|
|
42
|
+
!usesShorts(view.revision.config) &&
|
|
43
|
+
!editNeedsTiming(view.revision.config))
|
|
40
44
|
return false;
|
|
41
45
|
if (piece.input.kind === "deferred" && piece.key.endsWith(":future")) {
|
|
42
46
|
const cacheKey = String(row.id);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkRuntimeModel } from "../../catalog/runtime-models.js";
|
|
2
|
+
import { referenceRefusal, takesReferenceImage } from "../../catalog/schema.js";
|
|
2
3
|
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
3
4
|
import { cliPathChanged } from "../settings/cli-paths.js";
|
|
4
5
|
import { isLocalCliProvider } from "../settings/model.js";
|
|
@@ -38,7 +39,7 @@ export async function checkReadiness(deps, snapshot, view) {
|
|
|
38
39
|
});
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
41
|
-
const result = await checkRuntimeModel(deps.modelsFor, choice.provider, choice.family, choice.model, choice.family === "
|
|
42
|
+
const result = await checkRuntimeModel(deps.modelsFor, choice.provider, choice.family, choice.model, choice.family === "tts" ? undefined : (choice.thinking ?? undefined));
|
|
42
43
|
if (result === "missing")
|
|
43
44
|
fields.push({
|
|
44
45
|
field: `${choice.family}.model`,
|
|
@@ -112,6 +113,19 @@ export function localReadiness(deps, snapshot, view, providers, catalogue) {
|
|
|
112
113
|
field: `work.${recipe.key}.aspect`,
|
|
113
114
|
message: "The model does not support this output shape.",
|
|
114
115
|
});
|
|
116
|
+
// Only the keyed providers' models are listed; the Codex CLI always takes a reference.
|
|
117
|
+
if (model !== undefined &&
|
|
118
|
+
input.reference !== undefined &&
|
|
119
|
+
!takesReferenceImage(model) &&
|
|
120
|
+
!fields.some((row) => row.field === "reference.source"))
|
|
121
|
+
fields.push({ field: "reference.source", message: referenceRefusal });
|
|
122
|
+
if (model !== undefined &&
|
|
123
|
+
input.thinking !== undefined &&
|
|
124
|
+
!fields.some((row) => row.field === "images.thinking"))
|
|
125
|
+
fields.push({
|
|
126
|
+
field: "images.thinking",
|
|
127
|
+
message: "This image model has no effort setting. Set the images' Effort back to Model default under Providers.",
|
|
128
|
+
});
|
|
115
129
|
}
|
|
116
130
|
}
|
|
117
131
|
return fields;
|
|
@@ -33,12 +33,24 @@ export function transitionRevisionWork(deps, input) {
|
|
|
33
33
|
const logicalKey = input.logicalKeys?.[row.work_key] ?? row.logical_key ?? row.work_key;
|
|
34
34
|
const before = baseFingerprints[logicalKey];
|
|
35
35
|
const after = input.fingerprints[logicalKey];
|
|
36
|
+
// Work unfolded under another step (a short's prompts, images and render under the
|
|
37
|
+
// pick) is planned under its own key too once it is known. It carries over only while
|
|
38
|
+
// that key still wants the same work; when it changed (a short made again), the new
|
|
39
|
+
// step is reserved afresh below. A short's step the new plan doesn't list yet waits on
|
|
40
|
+
// an answer that changed, so it is unfolded again once that answer lands.
|
|
41
|
+
const own = logicalKey === row.work_key ? undefined : input.fingerprints[row.work_key];
|
|
42
|
+
if (own !== undefined && own !== row.fingerprint)
|
|
43
|
+
continue;
|
|
44
|
+
if (own === undefined && logicalKey !== row.work_key && row.work_key.startsWith("shorts:"))
|
|
45
|
+
continue;
|
|
36
46
|
if (before !== undefined &&
|
|
37
47
|
before === after &&
|
|
38
48
|
(input.baseFingerprints !== undefined ||
|
|
39
49
|
before === (row.desired_fingerprint ?? row.fingerprint))) {
|
|
40
50
|
db.prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint,logical_key,desired_fingerprint) VALUES (?,?,?,?,?,?,?,?)`).run(input.projectId, input.revisionId, row.work_key, row.work_id, row.piece_id, row.fingerprint, logicalKey, after);
|
|
41
51
|
carried.add(logicalKey);
|
|
52
|
+
if (own !== undefined)
|
|
53
|
+
carried.add(row.work_key);
|
|
42
54
|
}
|
|
43
55
|
}
|
|
44
56
|
const live = db.prepare("SELECT id FROM revision_work WHERE project_id=?").all(input.projectId);
|
|
@@ -58,6 +70,15 @@ export function transitionRevisionWork(deps, input) {
|
|
|
58
70
|
const exact = input.recipes?.find((row) => row.key === key);
|
|
59
71
|
if (exact !== undefined && exact.fingerprint !== fp)
|
|
60
72
|
throw new Error("Slopify hit an internal error (a planned step doesn't match the saved project version). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
73
|
+
// An undo, a restore or a save repeated while a step waits for Resume asks for a
|
|
74
|
+
// step the project already has a row for. A finished one is the result itself; a
|
|
75
|
+
// held copy nobody started is the same placeholder. Minting another each time left a
|
|
76
|
+
// new held row per save, and an undo re-queued work that had already finished.
|
|
77
|
+
const existing = existingWork(db, input.projectId, key, fp);
|
|
78
|
+
if (existing !== undefined) {
|
|
79
|
+
db.prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint) VALUES (?,?,?,?,?,?)`).run(input.projectId, input.revisionId, key, existing.workId, existing.pieceId, fp);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
61
82
|
const kind = exact?.stage ?? stageForKey(key);
|
|
62
83
|
const stage = db
|
|
63
84
|
.prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
|
|
@@ -90,6 +111,24 @@ export function transitionRevisionWork(deps, input) {
|
|
|
90
111
|
}
|
|
91
112
|
});
|
|
92
113
|
}
|
|
114
|
+
// A finished row wins over a held one. A held row counts only while nothing ran for it:
|
|
115
|
+
// no attempt, nothing submitted, and no planning context (admission gives a row that).
|
|
116
|
+
function existingWork(db, projectId, key, fingerprint) {
|
|
117
|
+
const row = db
|
|
118
|
+
.prepare(`SELECT w.id AS work_id,p.id AS piece_id FROM revision_work w
|
|
119
|
+
JOIN revision_work_pieces p ON p.work_id=w.id
|
|
120
|
+
WHERE w.project_id=? AND p.work_key=? AND p.fingerprint=? AND (
|
|
121
|
+
(w.state='done' AND p.state='done') OR
|
|
122
|
+
(w.state='pending' AND w.dispatch_state='held' AND p.state='held'
|
|
123
|
+
AND p.submitted_at IS NULL AND w.recipe_context IS NULL
|
|
124
|
+
AND NOT EXISTS(SELECT 1 FROM attempts a WHERE a.work_id=w.id))
|
|
125
|
+
)
|
|
126
|
+
ORDER BY CASE w.state WHEN 'done' THEN 0 ELSE 1 END, w.rowid DESC LIMIT 1`)
|
|
127
|
+
.get(projectId, key, fingerprint);
|
|
128
|
+
return row === undefined
|
|
129
|
+
? undefined
|
|
130
|
+
: { workId: z.string().parse(row.work_id), pieceId: z.string().parse(row.piece_id) };
|
|
131
|
+
}
|
|
93
132
|
function stageForKey(key) {
|
|
94
133
|
const prefix = key.split(":")[0];
|
|
95
134
|
if (prefix === "research")
|
|
@@ -98,7 +137,7 @@ function stageForKey(key) {
|
|
|
98
137
|
return "article";
|
|
99
138
|
if (prefix === "audio" || prefix === "narration")
|
|
100
139
|
return "audio";
|
|
101
|
-
if (prefix === "image")
|
|
140
|
+
if (prefix === "image" || prefix === "reference")
|
|
102
141
|
return "images";
|
|
103
142
|
if (prefix === "thumbnail")
|
|
104
143
|
return "thumbnail";
|
|
@@ -30,6 +30,13 @@ export function baselineContent(deps, project, outputs, pieces, assets, template
|
|
|
30
30
|
if (asset !== undefined)
|
|
31
31
|
provided[stage] = asset.id;
|
|
32
32
|
}
|
|
33
|
+
// An uploaded establishing image, attached when the run started.
|
|
34
|
+
if (project.config.reference?.source === "provide") {
|
|
35
|
+
const output = outputs.find((row) => row.role === "reference");
|
|
36
|
+
const asset = output === undefined ? undefined : assets.get(output.path);
|
|
37
|
+
if (asset !== undefined)
|
|
38
|
+
provided.reference = asset.id;
|
|
39
|
+
}
|
|
33
40
|
const imageDefinitions = {};
|
|
34
41
|
const order = [];
|
|
35
42
|
const imagePieces = pieces.filter((row) => row.kind === "image");
|
|
@@ -13,7 +13,9 @@ import { getRevisionView } from "./view.js";
|
|
|
13
13
|
export function ensureBaseline(deps, projectId) {
|
|
14
14
|
return withProjectControl(deps.db, projectId, () => adoptBaseline(deps, projectId));
|
|
15
15
|
}
|
|
16
|
-
export function adoptBaseline(deps, projectId, templates
|
|
16
|
+
export function adoptBaseline(deps, projectId, templates,
|
|
17
|
+
// The Shorts step's background music a new run was started with, already a project asset.
|
|
18
|
+
supplied = {}) {
|
|
17
19
|
return transact(deps.db, () => {
|
|
18
20
|
const project = projectById(deps.db, projectId);
|
|
19
21
|
if (project === undefined)
|
|
@@ -29,7 +31,10 @@ export function adoptBaseline(deps, projectId, templates) {
|
|
|
29
31
|
const stages = stagesOf(deps.db, projectId);
|
|
30
32
|
const pieces = stages.flatMap((stage) => allPiecesOf(deps.db, stage.id));
|
|
31
33
|
const assets = adoptAssets(deps, projectId, outputs, pieces);
|
|
32
|
-
const content =
|
|
34
|
+
const content = {
|
|
35
|
+
...baselineContent(deps, project, outputs, pieces, assets, templates),
|
|
36
|
+
...(supplied.shortsMusic === undefined ? {} : { shortsMusic: supplied.shortsMusic }),
|
|
37
|
+
};
|
|
33
38
|
const fingerprints = baselineFingerprints(project, outputs, pieces, content, stages);
|
|
34
39
|
const revision = {
|
|
35
40
|
id: deps.ids.next(),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { referenceKey } from "../admission/model.js";
|
|
4
|
+
import { usesReference } from "../admission/rules.js";
|
|
3
5
|
import { outputPath, stagingPath } from "../storage/layout.js";
|
|
4
6
|
import { stagedFileById } from "../storage/repo.js";
|
|
5
7
|
export function bindUpload(content, upload, assetId) {
|
|
@@ -11,6 +13,8 @@ export function bindUpload(content, upload, assetId) {
|
|
|
11
13
|
...content,
|
|
12
14
|
narrationOverrides: { ...content.narrationOverrides, [to.key]: { kind: "asset", assetId } },
|
|
13
15
|
};
|
|
16
|
+
if (to.kind === "shortsMusic")
|
|
17
|
+
return { ...content, shortsMusic: assetId };
|
|
14
18
|
const image = content.imageDefinitions[to.imageKey];
|
|
15
19
|
if (image === undefined)
|
|
16
20
|
throw new Error("Validated image destination disappeared.");
|
|
@@ -29,13 +33,18 @@ export function validateUploads(deps, edit) {
|
|
|
29
33
|
for (const [index, upload] of (edit.uploads ?? []).entries()) {
|
|
30
34
|
const field = `uploads.${index}`;
|
|
31
35
|
const to = upload.destination;
|
|
32
|
-
if ((to.kind === "provided" && edit.config
|
|
36
|
+
if ((to.kind === "provided" && !providesOwnFile(edit.config, to.stage)) ||
|
|
33
37
|
(to.kind === "narration" && edit.config.sources.audio !== "generate") ||
|
|
34
38
|
(to.kind === "image" && edit.config.sources.images === "off"))
|
|
35
39
|
fields.push({
|
|
36
40
|
field,
|
|
37
41
|
message: "This section is off or not set to use your own file. Change the section's setting first, then add the file.",
|
|
38
42
|
});
|
|
43
|
+
if (to.kind === "shortsMusic" && edit.config.shorts?.enabled !== true)
|
|
44
|
+
fields.push({
|
|
45
|
+
field,
|
|
46
|
+
message: "Background music is only used by the shorts. Turn Shorts on in Edit project → Shorts, then add the music.",
|
|
47
|
+
});
|
|
39
48
|
const destination = JSON.stringify(to);
|
|
40
49
|
if (staged.has(upload.stagedFileId) || destinations.has(destination))
|
|
41
50
|
fields.push({
|
|
@@ -45,7 +54,13 @@ export function validateUploads(deps, edit) {
|
|
|
45
54
|
staged.add(upload.stagedFileId);
|
|
46
55
|
destinations.add(destination);
|
|
47
56
|
const file = stagedFileById(deps.db, upload.stagedFileId);
|
|
48
|
-
const kind = to.kind === "provided"
|
|
57
|
+
const kind = to.kind === "provided"
|
|
58
|
+
? to.stage === "reference"
|
|
59
|
+
? "images"
|
|
60
|
+
: to.stage
|
|
61
|
+
: to.kind === "image"
|
|
62
|
+
? "images"
|
|
63
|
+
: "audio";
|
|
49
64
|
if (file === undefined ||
|
|
50
65
|
file.state !== "staged" ||
|
|
51
66
|
file.stageKind !== kind ||
|
|
@@ -70,7 +85,7 @@ export function validateUploads(deps, edit) {
|
|
|
70
85
|
export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
71
86
|
const fields = [];
|
|
72
87
|
const refs = [];
|
|
73
|
-
for (const key of ["research", "article", "audio", "thumbnail"]) {
|
|
88
|
+
for (const key of ["research", "article", "audio", "thumbnail", "reference"]) {
|
|
74
89
|
const id = content.provided[key];
|
|
75
90
|
if (id !== undefined)
|
|
76
91
|
refs.push([`content.provided.${key}`, id, key, false]);
|
|
@@ -87,7 +102,10 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
87
102
|
audio: ["audio_body", "audio_intro", "audio_outro", "audio_export"],
|
|
88
103
|
images: ["image"],
|
|
89
104
|
thumbnail: ["thumbnail"],
|
|
105
|
+
reference: ["reference"],
|
|
90
106
|
};
|
|
107
|
+
// The establishing image is the Images stage's.
|
|
108
|
+
const stageOf = (kind) => (kind === "reference" ? "images" : kind);
|
|
91
109
|
for (const [field, id, kind, allowPiece] of refs) {
|
|
92
110
|
if (!prepared.some((row) => row.id === id && row.projectId === projectId) &&
|
|
93
111
|
deps.db
|
|
@@ -100,7 +118,7 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
100
118
|
if (!prepared.some((row) => row.id === id) &&
|
|
101
119
|
deps.db
|
|
102
120
|
.prepare("SELECT 1 FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? AND json_extract(descriptor,'$.role') IN (SELECT value FROM json_each(?))")
|
|
103
|
-
.get(projectId, id, kind, JSON.stringify(roles[kind])) === undefined &&
|
|
121
|
+
.get(projectId, id, stageOf(kind), JSON.stringify(roles[kind])) === undefined &&
|
|
104
122
|
(!allowPiece ||
|
|
105
123
|
deps.db
|
|
106
124
|
.prepare("SELECT 1 FROM revision_pieces WHERE project_id=? AND asset_id=? AND stage_kind=? AND json_extract(descriptor,'$.kind') IN (SELECT value FROM json_each(?))")
|
|
@@ -115,6 +133,17 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
|
|
|
115
133
|
message: "This file cannot be used in this section. Choose a file of the right kind.",
|
|
116
134
|
});
|
|
117
135
|
}
|
|
136
|
+
// The shorts' music is uploaded with this edit, or was this project's music before.
|
|
137
|
+
const music = content.shortsMusic;
|
|
138
|
+
if (music !== undefined &&
|
|
139
|
+
!prepared.some((row) => row.id === music && row.projectId === projectId) &&
|
|
140
|
+
deps.db
|
|
141
|
+
.prepare("SELECT 1 FROM project_revisions WHERE project_id=? AND json_extract(content,'$.shortsMusic')=? LIMIT 1")
|
|
142
|
+
.get(projectId, music) === undefined)
|
|
143
|
+
fields.push({
|
|
144
|
+
field: "content.shortsMusic",
|
|
145
|
+
message: "This music file does not belong to this project. Reload the page and choose the music again.",
|
|
146
|
+
});
|
|
118
147
|
return fields;
|
|
119
148
|
}
|
|
120
149
|
export async function measureAudio(deps, projectId, path) {
|
|
@@ -132,12 +161,25 @@ export function assetPath(deps, projectId, id) {
|
|
|
132
161
|
.prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
|
|
133
162
|
.get(projectId, id)).path;
|
|
134
163
|
}
|
|
164
|
+
// A stage whose own file the project uses: Provide for audio and the thumbnail, Upload for the
|
|
165
|
+
// establishing image.
|
|
166
|
+
export function providesOwnFile(config, kind) {
|
|
167
|
+
return kind === "reference"
|
|
168
|
+
? usesReference(config) && config.reference?.source === "provide"
|
|
169
|
+
: config.sources[kind] === "provide";
|
|
170
|
+
}
|
|
171
|
+
// Where each provided file is published: its work key and output role.
|
|
172
|
+
export const providedOutput = {
|
|
173
|
+
audio: { workKey: "audio:provided", role: "audio_body" },
|
|
174
|
+
thumbnail: { workKey: "thumbnail:image", role: "thumbnail" },
|
|
175
|
+
reference: { workKey: referenceKey, role: "reference" },
|
|
176
|
+
};
|
|
135
177
|
export function providedAssetSelected(base, kind, assetId) {
|
|
136
|
-
return (base.revision.config
|
|
178
|
+
return (providesOwnFile(base.revision.config, kind) &&
|
|
137
179
|
base.outputs.some((row) => row.selected &&
|
|
138
180
|
row.assetId === assetId &&
|
|
139
|
-
row.workKey ===
|
|
140
|
-
row.output.role ===
|
|
181
|
+
row.workKey === providedOutput[kind].workKey &&
|
|
182
|
+
row.output.role === providedOutput[kind].role));
|
|
141
183
|
}
|
|
142
184
|
export function validateReplacementAvailability(deps, base, edit) {
|
|
143
185
|
const previous = new Set([
|
|
@@ -145,10 +187,10 @@ export function validateReplacementAvailability(deps, base, edit) {
|
|
|
145
187
|
...Object.values(base.revision.content.imageDefinitions).map((row) => row.assetId),
|
|
146
188
|
...Object.values(base.revision.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
|
|
147
189
|
]);
|
|
148
|
-
for (const kind of ["audio", "thumbnail"]) {
|
|
190
|
+
for (const kind of ["audio", "thumbnail", "reference"]) {
|
|
149
191
|
const assetId = edit.content.provided[kind];
|
|
150
192
|
if (assetId !== undefined &&
|
|
151
|
-
edit.config
|
|
193
|
+
providesOwnFile(edit.config, kind) &&
|
|
152
194
|
!providedAssetSelected(base, kind, assetId) &&
|
|
153
195
|
!edit.uploads?.some((row) => row.destination.kind === "provided" && row.destination.stage === kind))
|
|
154
196
|
previous.delete(assetId);
|