@gentbajko/slopify 2.3.0 → 2.4.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 +1 -1
- package/dist/adapters/image/fal.js +77 -6
- package/dist/adapters/image/replicate.js +41 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +82 -1
- package/dist/catalog/schema.js +10 -0
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +32 -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/http/providers.js +8 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/runner/attempt.js +4 -1
- package/dist/kernel/runner/providers.js +20 -0
- package/dist/main.js +3 -0
- package/dist/slices/admission/rules.js +44 -2
- package/dist/slices/admission/schema.js +31 -0
- package/dist/slices/estimate/index.js +57 -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 +6 -1
- package/dist/slices/play-drafts/convert.js +37 -1
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/schema.js +24 -1
- package/dist/slices/project-templates/from-project.js +22 -0
- package/dist/slices/project-templates/setup.js +2 -0
- package/dist/slices/rebuild/preview-details.js +29 -1
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +6 -2
- 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 +1 -0
- package/dist/slices/rebuild/recipe-legacy.js +9 -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-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +179 -0
- package/dist/slices/rebuild/recipe-validation.js +19 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +83 -1
- 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-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +15 -0
- 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 +330 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/transition-repo.js +39 -0
- package/dist/slices/revisions/mutation-assets.js +18 -0
- package/dist/slices/revisions/mutation-prepare.js +9 -7
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +11 -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 +8 -0
- package/dist/slices/storage/model.js +6 -0
- 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-BGgYMm2u.css +1 -0
- package/dist/web/assets/index-BVVgtntL.js +171 -0
- package/dist/web/assets/{pdf-BkdJFKQ-.js → pdf-CAJNT254.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cw1mnRrx.js +0 -168
- package/dist/web/assets/index-kAKdJa-U.css +0 -1
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { narrationPreparationFields, usesNarrationPreparation, usesYoutubeDescription, youtubeDescriptionFields, } from "../admission/rules.js";
|
|
1
|
+
import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
|
|
2
2
|
import { detectSlots, render } from "../admission/substitute.js";
|
|
3
|
+
import { usesAnimation } from "../video/edit-settings.js";
|
|
3
4
|
export function validateRecipeInputs(config, content) {
|
|
4
5
|
const fields = [
|
|
5
6
|
...narrationPreparationFields(config),
|
|
6
7
|
...youtubeDescriptionFields(config),
|
|
8
|
+
...shortsFields(config),
|
|
9
|
+
...videoEditFields(config),
|
|
7
10
|
];
|
|
8
11
|
const llm = (config.sources.research === "generate" && config.sources.article !== "provide") ||
|
|
9
12
|
(config.sources.article === "generate" && !content.articleEdited) ||
|
|
10
13
|
config.sources.thumbnail === "prompt_by_llm" ||
|
|
11
14
|
usesNarrationPreparation(config) ||
|
|
12
15
|
usesYoutubeDescription(config) ||
|
|
16
|
+
usesShorts(config) ||
|
|
13
17
|
(config.sources.audio === "generate" &&
|
|
14
18
|
(config.intro?.mode === "llm" || config.outro?.mode === "llm"));
|
|
15
19
|
if (llm && (!config.llm?.provider.trim() || !config.llm.model.trim()))
|
|
@@ -24,7 +28,9 @@ export function validateRecipeInputs(config, content) {
|
|
|
24
28
|
content.imageOrder.some((key) => content.imageDefinitions[key]?.source === "generate");
|
|
25
29
|
if ((generatedImages ||
|
|
26
30
|
config.sources.thumbnail === "from_prompt" ||
|
|
27
|
-
config.sources.thumbnail === "prompt_by_llm"
|
|
31
|
+
config.sources.thumbnail === "prompt_by_llm" ||
|
|
32
|
+
usesShorts(config) ||
|
|
33
|
+
usesAnimation(config)) &&
|
|
28
34
|
(!config.images?.provider.trim() || !config.images.model.trim()))
|
|
29
35
|
fields.push({ field: "images", message: "Pick an image provider and model." });
|
|
30
36
|
if (content.imageOrder.length > 60)
|
|
@@ -56,6 +62,17 @@ export function validateRecipeInputs(config, content) {
|
|
|
56
62
|
raw: content.promptTemplates.description,
|
|
57
63
|
literal: config.rendered.description,
|
|
58
64
|
});
|
|
65
|
+
// Likewise the Shorts step's two prompts, each only when one is picked.
|
|
66
|
+
for (const [key, name] of [
|
|
67
|
+
["shorts", config.shorts?.prompt],
|
|
68
|
+
["shortsImage", config.shorts?.imagePrompt],
|
|
69
|
+
])
|
|
70
|
+
if (usesShorts(config) && name?.trim() && content.promptTemplates[key] != null)
|
|
71
|
+
prompts.push({
|
|
72
|
+
field: `rendered.${key}`,
|
|
73
|
+
raw: content.promptTemplates[key] ?? null,
|
|
74
|
+
literal: config.rendered[key],
|
|
75
|
+
});
|
|
59
76
|
if ((config.sources.article === "generate" && !content.articleEdited) ||
|
|
60
77
|
(config.sources.research === "generate" && config.sources.article !== "provide"))
|
|
61
78
|
prompts.push({
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { render } from "../admission/substitute.js";
|
|
2
2
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
3
3
|
import { matchingText, renderedPrompt } from "./recipe-text.js";
|
|
4
|
-
export function visualRecipes(config, content, audioFingerprint, captionFingerprint
|
|
4
|
+
export function visualRecipes(config, content, audioFingerprint, captionFingerprint,
|
|
5
|
+
// What the Video stage's edit settings add (`recipe-edit.ts`), from the images planned here.
|
|
6
|
+
edit) {
|
|
5
7
|
const recipes = [];
|
|
6
8
|
const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
|
|
7
9
|
for (const key of imageKeys) {
|
|
@@ -24,6 +26,7 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
|
|
|
24
26
|
}, [], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
|
|
25
27
|
}
|
|
26
28
|
if (config.sources.video !== "off") {
|
|
29
|
+
const edited = edit?.([...recipes]) ?? { recipes: [], values: [], dependsOn: [] };
|
|
27
30
|
const audioKeys = config.sources.audio === "off"
|
|
28
31
|
? []
|
|
29
32
|
: [
|
|
@@ -55,12 +58,18 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
|
|
|
55
58
|
...(config.motionStyle === "zoom" ? [] : [config.motionStyle]),
|
|
56
59
|
// v1 split the timeline evenly across the images; v2 cycles them.
|
|
57
60
|
"slideshow-zoom-v2",
|
|
61
|
+
// Only what the edit settings change; nothing at all for today's slideshow, so
|
|
62
|
+
// its fingerprint is the one it always had.
|
|
63
|
+
...(edited.values.length === 0 ? [] : [["video-edit", ...edited.values]]),
|
|
58
64
|
],
|
|
59
65
|
}, [
|
|
60
66
|
...recipes.map((value) => value.key),
|
|
61
67
|
...audioKeys,
|
|
62
68
|
...(config.subtitles?.mode === "burn-in" ? ["subtitles:files"] : []),
|
|
69
|
+
...edited.dependsOn,
|
|
63
70
|
], { unresolved: imageKeys.length === 0 }));
|
|
71
|
+
// After the export, so the images' recipes above stay the only ones its first values list.
|
|
72
|
+
recipes.splice(recipes.length - 1, 0, ...edited.recipes);
|
|
64
73
|
}
|
|
65
74
|
return recipes;
|
|
66
75
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { estimateRequests } from "../estimate/index.js";
|
|
3
|
+
import { defaultShortsPrompt, shortsImageUpperBound } from "../shorts/model.js";
|
|
3
4
|
import { planDependencies } from "./dependencies.js";
|
|
4
5
|
import { matchingNarrationPiece } from "./narration-reuse.js";
|
|
5
6
|
import { buildRecipes } from "./recipe-build.js";
|
|
@@ -63,7 +64,7 @@ export function planRevisionWork(revision, manifest, catalogue, availableAssetId
|
|
|
63
64
|
null,
|
|
64
65
|
after: row.fingerprint,
|
|
65
66
|
}));
|
|
66
|
-
const priced = work.
|
|
67
|
+
const priced = work.flatMap((row) => priceRecipes(row, recipes.find((one) => one.key === row.key)));
|
|
67
68
|
return {
|
|
68
69
|
recipes,
|
|
69
70
|
work,
|
|
@@ -180,6 +181,74 @@ function retainedFor(row, logical, manifest, available) {
|
|
|
180
181
|
// ceiling: the timed transcript of a 20-minute narration, about 3,000 words. The real one
|
|
181
182
|
// is only built once the word timing lands.
|
|
182
183
|
const youtubeTranscriptEstimate = 20000;
|
|
184
|
+
// The numbered transcript the shorts are picked from is the same text with a number and a
|
|
185
|
+
// span before every sentence rather than a time before every passage.
|
|
186
|
+
const shortsTranscriptEstimate = 24000;
|
|
187
|
+
// What a step is charged as. Most steps are one request; what stands in for the shorts
|
|
188
|
+
// until they are picked is every clip's image prompts and, at the longest length allowed,
|
|
189
|
+
// every image, which is the most the step can cost.
|
|
190
|
+
export function priceRecipes(work, value) {
|
|
191
|
+
const input = value?.input;
|
|
192
|
+
const unfolds = value?.unfoldsImages;
|
|
193
|
+
// A short's image prompts once the clips are picked: the call, and the images it leads to,
|
|
194
|
+
// as many as the clip's length asks for.
|
|
195
|
+
if (work.disposition === "generate" && unfolds !== undefined)
|
|
196
|
+
return [
|
|
197
|
+
priceRecipe(work, value),
|
|
198
|
+
...Array.from({ length: unfolds.count }, () => ({
|
|
199
|
+
kind: "image",
|
|
200
|
+
stage: work.key,
|
|
201
|
+
provider: unfolds.provider,
|
|
202
|
+
model: unfolds.model,
|
|
203
|
+
detail: `${String(unfolds.count)} vertical ${unfolds.count === 1 ? "image" : "images"} for this short, one per stretch of the seconds each image is held.`,
|
|
204
|
+
})),
|
|
205
|
+
];
|
|
206
|
+
// What stands in for the chapter openers' clips until the chapters are known: one clip for
|
|
207
|
+
// the opening and for each chapter the article's headings promise, the most it can cost.
|
|
208
|
+
if (work.disposition === "generate" &&
|
|
209
|
+
input?.kind === "deferred" &&
|
|
210
|
+
input.operation === "animate" &&
|
|
211
|
+
Array.isArray(input.template)) {
|
|
212
|
+
const [, , provider, model, upper] = input.template;
|
|
213
|
+
const clips = typeof upper === "number" ? upper : 0;
|
|
214
|
+
return Array.from({ length: clips }, () => ({
|
|
215
|
+
kind: "image",
|
|
216
|
+
stage: work.key,
|
|
217
|
+
provider: typeof provider === "string" ? provider : "",
|
|
218
|
+
model: typeof model === "string" ? model : "",
|
|
219
|
+
detail: `Up to ${String(clips)} animated clips, one per chapter opening; the chapters are found when the word timing lands, so fewer may be made.`,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
if (work.disposition !== "generate" ||
|
|
223
|
+
input?.kind !== "deferred" ||
|
|
224
|
+
input.operation !== "shorts" ||
|
|
225
|
+
!Array.isArray(input.template))
|
|
226
|
+
return [priceRecipe(work, value)];
|
|
227
|
+
const [, style, provider, model, imageSeconds, count, maxSeconds, llmProvider, llmModel] = input.template;
|
|
228
|
+
const clips = typeof count === "number" ? count : 0;
|
|
229
|
+
const images = typeof imageSeconds === "number" && typeof maxSeconds === "number"
|
|
230
|
+
? shortsImageUpperBound({ count: clips, maxSeconds }, imageSeconds)
|
|
231
|
+
: 0;
|
|
232
|
+
const detail = `Up to ${String(images)} vertical images: ${String(clips)} shorts at the longest length allowed. The clips are picked when the step runs, so shorter ones use fewer.`;
|
|
233
|
+
return [
|
|
234
|
+
...Array.from({ length: clips }, () => ({
|
|
235
|
+
kind: "llm",
|
|
236
|
+
stage: work.key,
|
|
237
|
+
provider: typeof llmProvider === "string" ? llmProvider : "",
|
|
238
|
+
model: typeof llmModel === "string" ? llmModel : "",
|
|
239
|
+
inputCharacters: (typeof style === "string" ? style.length : 0) + 3000,
|
|
240
|
+
outputCharacters: 2400,
|
|
241
|
+
detail: "One LLM call per short writes its image prompts; its length is estimated.",
|
|
242
|
+
})),
|
|
243
|
+
...Array.from({ length: images }, () => ({
|
|
244
|
+
kind: "image",
|
|
245
|
+
stage: work.key,
|
|
246
|
+
provider: typeof provider === "string" ? provider : "",
|
|
247
|
+
model: typeof model === "string" ? model : "",
|
|
248
|
+
detail,
|
|
249
|
+
})),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
183
252
|
export function priceRecipe(work, value) {
|
|
184
253
|
if (value === undefined || work.disposition !== "generate")
|
|
185
254
|
return {
|
|
@@ -217,6 +286,19 @@ export function priceRecipe(work, value) {
|
|
|
217
286
|
};
|
|
218
287
|
if (input.kind === "image")
|
|
219
288
|
return { kind: "image", stage: work.key, provider: input.provider, model: input.model };
|
|
289
|
+
if (input.kind === "local" && input.operation === "shorts-pick-v1") {
|
|
290
|
+
const [, provider, model, , prompt, count] = Array.isArray(input.values) ? input.values : [];
|
|
291
|
+
return {
|
|
292
|
+
kind: "llm",
|
|
293
|
+
stage: work.key,
|
|
294
|
+
provider: typeof provider === "string" ? provider : "",
|
|
295
|
+
model: typeof model === "string" ? model : "",
|
|
296
|
+
inputCharacters: (typeof prompt === "string" && prompt !== "" ? prompt.length : defaultShortsPrompt.length) +
|
|
297
|
+
shortsTranscriptEstimate,
|
|
298
|
+
outputCharacters: 400 * (typeof count === "number" ? count : 1),
|
|
299
|
+
detail: "The numbered transcript is built when the step runs; its length is estimated. One more call is made when the first answer breaks the rules. Retries are excluded.",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
220
302
|
if (input.kind === "local" && input.operation === "youtube-description-v1") {
|
|
221
303
|
const [, provider, model, , prompt] = Array.isArray(input.values) ? input.values : [];
|
|
222
304
|
return {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
|
|
4
|
+
import { outputPath, projectDir } from "../storage/layout.js";
|
|
5
|
+
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
6
|
+
import { executionView } from "./runtime-plan.js";
|
|
7
|
+
import { preparedResult, publishResult } from "./runtime-publication.js";
|
|
8
|
+
// Animate images: one still of the slideshow sent to the image provider's image-to-video
|
|
9
|
+
// model, and the clip it makes saved beside the image. The still goes up as a small JPEG, so
|
|
10
|
+
// the request stays well under what either provider takes inline.
|
|
11
|
+
//
|
|
12
|
+
// A clip that cannot be made is not worth failing the video for: after the provider's own
|
|
13
|
+
// retries the step finishes without a clip, saying why, and the render shows the still in
|
|
14
|
+
// its place and puts that sentence on the video (`runtime-export.ts`).
|
|
15
|
+
// ceiling: 1280 px on the long side is more than the 720p the catalogue's models are asked
|
|
16
|
+
// for, and keeps a JPEG to a few hundred KB.
|
|
17
|
+
const sentWidth = 1280;
|
|
18
|
+
export async function executeAnimateRecipe(deps, context, providers, piece) {
|
|
19
|
+
if (deps.db
|
|
20
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
21
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
22
|
+
return "done";
|
|
23
|
+
const input = piece.input;
|
|
24
|
+
if (input.kind !== "image" || input.animate === undefined)
|
|
25
|
+
throw new Error("Slopify hit an internal error (an animated image was set up wrongly). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
26
|
+
const imageKey = piece.key.slice("animate:".length);
|
|
27
|
+
const view = executionView(deps, context.work.projectId, context.work.revisionId);
|
|
28
|
+
const index = (view?.revision.content.imageOrder.indexOf(imageKey) ?? -1) + 1;
|
|
29
|
+
const still = view?.outputs.find((row) => row.workKey === `image:${imageKey}` && row.selected && row.available && row.state === "ready");
|
|
30
|
+
const fallback = async (reason) => {
|
|
31
|
+
deps.log.write("warn", "video.animate.fallback", { detail: reason });
|
|
32
|
+
await publishResult(deps, context, piece, [], {
|
|
33
|
+
fallback: `Image ${String(index)} is shown as a still: ${reason}`,
|
|
34
|
+
});
|
|
35
|
+
return "done";
|
|
36
|
+
};
|
|
37
|
+
if (still === undefined)
|
|
38
|
+
return fallback("its image wasn't ready to animate. Use Re-run section on Video after the image is made.");
|
|
39
|
+
const animate = providers.forPiece(piece.id).animate;
|
|
40
|
+
if (animate === undefined)
|
|
41
|
+
throw new Error("Slopify hit an internal error (animating images isn't wired up in this build). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
42
|
+
if (!context.maySubmit(piece.id))
|
|
43
|
+
return "held";
|
|
44
|
+
let made;
|
|
45
|
+
try {
|
|
46
|
+
made = await animate({
|
|
47
|
+
provider: input.provider,
|
|
48
|
+
model: input.model,
|
|
49
|
+
prompt: input.prompt,
|
|
50
|
+
image: await sendable(deps, context, outputPath(deps.paths, context.work.projectId, still.output.path)),
|
|
51
|
+
aspect: input.aspect,
|
|
52
|
+
seconds: input.animate.seconds,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (context.signal.aborted)
|
|
57
|
+
throw error;
|
|
58
|
+
return fallback(`${error instanceof Error ? error.message : String(error)} To try again, use Re-run section on Video.`);
|
|
59
|
+
}
|
|
60
|
+
if (!made.ok)
|
|
61
|
+
return "held";
|
|
62
|
+
const asset = writeAsset(deps, context.work.projectId, "animated.mp4", made.value.bytes);
|
|
63
|
+
try {
|
|
64
|
+
await publishResult(deps, context, piece, [
|
|
65
|
+
preparedResult(deps, context, piece, "animated_image", asset, null, {
|
|
66
|
+
index,
|
|
67
|
+
prompt: input.prompt,
|
|
68
|
+
provider: input.provider,
|
|
69
|
+
model: input.model,
|
|
70
|
+
}),
|
|
71
|
+
], { prompt: input.prompt }, asset);
|
|
72
|
+
deps.count?.("stage.completed", {
|
|
73
|
+
stage: context.work.kind,
|
|
74
|
+
provider: input.provider,
|
|
75
|
+
model: input.model,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
discardPreparedAssets(deps, [asset]);
|
|
80
|
+
}
|
|
81
|
+
return "done";
|
|
82
|
+
}
|
|
83
|
+
// The still as a JPEG no wider than 1280 px, or as it is when there is no ffmpeg to shrink it.
|
|
84
|
+
async function sendable(deps, context, path) {
|
|
85
|
+
const original = () => {
|
|
86
|
+
const bytes = new Uint8Array(readFileSync(path));
|
|
87
|
+
return { bytes, mime: bytes[0] === 0xff ? "image/jpeg" : "image/png" };
|
|
88
|
+
};
|
|
89
|
+
if (deps.ffmpeg === undefined)
|
|
90
|
+
return original();
|
|
91
|
+
const scratch = mkdtempSync(join(projectDir(deps.paths, context.work.projectId), "animate-"));
|
|
92
|
+
try {
|
|
93
|
+
const output = join(scratch, "still.jpg");
|
|
94
|
+
await runFfmpeg({
|
|
95
|
+
bin: deps.ffmpeg,
|
|
96
|
+
args: [
|
|
97
|
+
...["-hide_banner", "-nostdin", "-loglevel", "error", "-y", "-i", path],
|
|
98
|
+
...["-vf", `scale='min(${String(sentWidth)},iw)':-2`, "-frames:v", "1", "-q:v", "3"],
|
|
99
|
+
output,
|
|
100
|
+
],
|
|
101
|
+
signal: context.signal,
|
|
102
|
+
log: deps.log,
|
|
103
|
+
onProgress: () => undefined,
|
|
104
|
+
});
|
|
105
|
+
return { bytes: new Uint8Array(readFileSync(output)), mime: "image/jpeg" };
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { usesYoutubeDescription } from "../admission/rules.js";
|
|
5
|
+
import { resolveFont } from "../fonts/index.js";
|
|
6
|
+
import { outputPath } from "../storage/layout.js";
|
|
7
|
+
import { headingChapters } from "../video/chapters.js";
|
|
8
|
+
import { sentenceCutPoints } from "../video/cuts.js";
|
|
9
|
+
import { usesChapterCards, usesNarrationCuts, videoEditOf, } from "../video/edit-settings.js";
|
|
10
|
+
import { probeDurationMs } from "../video/ffmpeg.js";
|
|
11
|
+
import { wordsSchema } from "./runtime-subtitles.js";
|
|
12
|
+
// The containers an uploaded clip may come in; anything else in the images is a still.
|
|
13
|
+
const clipExtensions = new Set([".mp4", ".mov", ".m4v", ".webm", ".mkv"]);
|
|
14
|
+
export function isClipPath(path) {
|
|
15
|
+
return clipExtensions.has(extname(path).toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
export async function exportEdit(deps, context, view, images) {
|
|
18
|
+
const config = view.revision.config;
|
|
19
|
+
const order = view.revision.content.imageOrder;
|
|
20
|
+
const ready = (key) => view.outputs.find((row) => row.workKey === key && row.selected && row.available && row.state === "ready");
|
|
21
|
+
// Clips: an animated image's clip first, then an uploaded clip in the image's own place.
|
|
22
|
+
const clips = [];
|
|
23
|
+
for (const [index, key] of order.entries()) {
|
|
24
|
+
const animated = ready(`animate:${key}`);
|
|
25
|
+
const path = animated?.output.role === "animated_image"
|
|
26
|
+
? outputPath(deps.paths, context.work.projectId, animated.output.path)
|
|
27
|
+
: images[index] !== undefined && isClipPath(images[index] ?? "")
|
|
28
|
+
? images[index]
|
|
29
|
+
: undefined;
|
|
30
|
+
clips.push(path === undefined
|
|
31
|
+
? undefined
|
|
32
|
+
: { kind: "video", path, seconds: await clipSeconds(deps, context, path, index + 1) });
|
|
33
|
+
}
|
|
34
|
+
const warnings = view.pieces.flatMap((row) => {
|
|
35
|
+
if (!row.key.startsWith("animate:") || !row.selected || row.piece.state !== "done")
|
|
36
|
+
return [];
|
|
37
|
+
const parsed = z
|
|
38
|
+
.object({ fallback: z.string() })
|
|
39
|
+
.safeParse(JSON.parse(row.piece.payload ?? "null"));
|
|
40
|
+
return parsed.success ? [parsed.data.fallback] : [];
|
|
41
|
+
});
|
|
42
|
+
if (config.videoEdit === undefined)
|
|
43
|
+
return {
|
|
44
|
+
edit: clips.some((clip) => clip !== undefined) ? { clips } : undefined,
|
|
45
|
+
warnings,
|
|
46
|
+
settings: undefined,
|
|
47
|
+
};
|
|
48
|
+
const settings = videoEditOf(config);
|
|
49
|
+
const cuts = usesNarrationCuts(config);
|
|
50
|
+
const cards = usesChapterCards(config);
|
|
51
|
+
const words = cuts || cards ? timingWords(deps, context, view) : [];
|
|
52
|
+
const chapters = cuts || cards ? chaptersOf(view, config, words) : [];
|
|
53
|
+
const font = cards
|
|
54
|
+
? await resolveFont(deps.paths, config.subtitles?.fontId ?? "default")
|
|
55
|
+
: undefined;
|
|
56
|
+
return {
|
|
57
|
+
edit: {
|
|
58
|
+
clips,
|
|
59
|
+
...(cuts
|
|
60
|
+
? {
|
|
61
|
+
narration: {
|
|
62
|
+
cutPoints: sentenceCutPoints(words),
|
|
63
|
+
chapterStarts: chapters.map((chapter) => chapter.start),
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
: {}),
|
|
67
|
+
...(settings.transition === "cut"
|
|
68
|
+
? {}
|
|
69
|
+
: { transition: { kind: settings.transition, seconds: settings.transitionSeconds } }),
|
|
70
|
+
look: {
|
|
71
|
+
vignette: settings.vignette,
|
|
72
|
+
grain: settings.grain,
|
|
73
|
+
grade: settings.grade,
|
|
74
|
+
atmosphere: settings.atmosphere,
|
|
75
|
+
},
|
|
76
|
+
...(font === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { cards: { chapters, font: { path: font.path, name: font.assName } } }),
|
|
79
|
+
},
|
|
80
|
+
warnings,
|
|
81
|
+
settings,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function timingWords(deps, context, view) {
|
|
85
|
+
const timing = view.outputs.find((row) => row.selected &&
|
|
86
|
+
row.available &&
|
|
87
|
+
row.state === "ready" &&
|
|
88
|
+
row.workKey === "subtitles:timing" &&
|
|
89
|
+
row.output.role === "subtitle_words");
|
|
90
|
+
if (timing === undefined)
|
|
91
|
+
throw new Error("The narration's word timing, which the video's cuts and chapter cards follow, is missing. Use Re-run section on Video, then Retry stage.");
|
|
92
|
+
return wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8"))).words;
|
|
93
|
+
}
|
|
94
|
+
// The YouTube description's chapters when that step runs, the article's headings otherwise.
|
|
95
|
+
function chaptersOf(view, config, words) {
|
|
96
|
+
if (usesYoutubeDescription(config)) {
|
|
97
|
+
const row = view.pieces.find((one) => one.key === "youtube:description" && one.selected && one.piece.state === "done");
|
|
98
|
+
const parsed = z
|
|
99
|
+
.object({ chapters: z.array(z.object({ start: z.number(), title: z.string() })) })
|
|
100
|
+
.safeParse(JSON.parse(row?.piece.payload ?? "null"));
|
|
101
|
+
if (parsed.success)
|
|
102
|
+
return parsed.data.chapters;
|
|
103
|
+
}
|
|
104
|
+
return headingChapters(view.articleMarkdown ?? "", words);
|
|
105
|
+
}
|
|
106
|
+
async function clipSeconds(deps, context, path, place) {
|
|
107
|
+
let ms;
|
|
108
|
+
try {
|
|
109
|
+
ms = await probeDurationMs(deps.ffmpeg, path, context.signal, deps.log);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (context.signal.aborted)
|
|
113
|
+
throw error;
|
|
114
|
+
ms = 0;
|
|
115
|
+
}
|
|
116
|
+
if (!(ms > 0))
|
|
117
|
+
throw new Error(`Image ${String(place)} is a video clip Slopify can't read, so the video can't show it. Replace it with another clip or an image in Edit project → Images, then Retry stage.`);
|
|
118
|
+
return ms / 1000;
|
|
119
|
+
}
|
|
@@ -7,6 +7,7 @@ import { withPaths } from "../video/edit-list.js";
|
|
|
7
7
|
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
8
8
|
import { planRender } from "../video/plan.js";
|
|
9
9
|
import { renderSlideshow } from "../video/slideshow.js";
|
|
10
|
+
import { exportEdit } from "./runtime-export-edit.js";
|
|
10
11
|
import { exportSnapshot, retainedOutput, revisionAudio, } from "./runtime-export-inputs.js";
|
|
11
12
|
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
12
13
|
export async function executeExportRecipe(deps, context, piece) {
|
|
@@ -53,6 +54,7 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
53
54
|
throw new Error("The video export can't find one of the slideshow images. Use Re-run section on Images (or regenerate that image in Edit project → Images), then Retry stage.");
|
|
54
55
|
return outputPath(deps.paths, context.work.projectId, row.output.path);
|
|
55
56
|
});
|
|
57
|
+
const edited = wav ? undefined : await exportEdit(deps, context, view, images);
|
|
56
58
|
const plan = wav
|
|
57
59
|
? undefined
|
|
58
60
|
: planRender({
|
|
@@ -67,6 +69,7 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
67
69
|
outro: segment("outro"),
|
|
68
70
|
images,
|
|
69
71
|
output: pending.absolutePath,
|
|
72
|
+
edit: edited?.edit,
|
|
70
73
|
});
|
|
71
74
|
const totalSeconds = plan?.totalSeconds ?? audio.reduce((sum, row) => sum + row.seconds, 0);
|
|
72
75
|
const projectDirectory = projectDir(deps.paths, context.work.projectId);
|
|
@@ -90,6 +93,10 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
90
93
|
output: pending.path,
|
|
91
94
|
editList: withPaths(plan.editList, relativePath),
|
|
92
95
|
subtitles: config.subtitles,
|
|
96
|
+
...(edited?.settings === undefined ? {} : { videoEdit: edited.settings }),
|
|
97
|
+
...(edited === undefined || edited.warnings.length === 0
|
|
98
|
+
? {}
|
|
99
|
+
: { warnings: edited.warnings }),
|
|
93
100
|
};
|
|
94
101
|
if (!context.maySubmit(piece.id))
|
|
95
102
|
return "held";
|
|
@@ -122,7 +129,12 @@ export async function executeExportRecipe(deps, context, piece) {
|
|
|
122
129
|
});
|
|
123
130
|
context.signal.throwIfAborted();
|
|
124
131
|
const asset = sealAsset(deps, pending);
|
|
125
|
-
prepared.push(preparedResult(deps, context, piece, wav ? "audio_export" : "video", asset, Math.round(totalSeconds * 1000), {
|
|
132
|
+
prepared.push(preparedResult(deps, context, piece, wav ? "audio_export" : "video", asset, Math.round(totalSeconds * 1000), {
|
|
133
|
+
subtitlesMode: mode,
|
|
134
|
+
...(edited === undefined || edited.warnings.length === 0
|
|
135
|
+
? {}
|
|
136
|
+
: { warnings: edited.warnings }),
|
|
137
|
+
}));
|
|
126
138
|
prepared.push(preparedText(deps, context, piece, "render_params", "render.json", JSON.stringify(record)));
|
|
127
139
|
prepared.push(...captions.map((row) => retainedOutput(deps, view, row)));
|
|
128
140
|
await publishResult(deps, context, piece, prepared, { totalSeconds }, asset);
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { transact } from "../../kernel/db/tx.js";
|
|
3
3
|
import { currentRevisionId } from "../revisions/repo.js";
|
|
4
4
|
import { getRevisionView } from "../revisions/view.js";
|
|
5
|
+
import { recipeInputSchema } from "./recipe-input-schema.js";
|
|
5
6
|
import { insertInvocation } from "./runtime-admission.js";
|
|
6
7
|
import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
|
|
7
8
|
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
@@ -56,6 +57,14 @@ export function materializeAdmittedWork(deps, projectId) {
|
|
|
56
57
|
continue;
|
|
57
58
|
if (plan.work.find((row) => row.key === recipe.key)?.disposition === "review")
|
|
58
59
|
continue;
|
|
60
|
+
// A step waiting on its inputs changes fingerprint each time one lands: every
|
|
61
|
+
// narration chunk moves the joined audio, the timing, the export and the YouTube
|
|
62
|
+
// description after it. The waiting row is the same request with newer inputs, so it
|
|
63
|
+
// takes them in place; minting a row per landing left one never-run row per chunk.
|
|
64
|
+
if (own !== undefined &&
|
|
65
|
+
plan.work.find((row) => row.key === recipe.key)?.disposition !== "reuse" &&
|
|
66
|
+
refreshWaiting(deps, head, own, recipe))
|
|
67
|
+
continue;
|
|
59
68
|
if (own !== undefined) {
|
|
60
69
|
deps.db
|
|
61
70
|
.prepare("UPDATE revision_work SET state='done' WHERE id=? AND state='pending'")
|
|
@@ -89,6 +98,34 @@ export function materializeAdmittedWork(deps, projectId) {
|
|
|
89
98
|
}
|
|
90
99
|
});
|
|
91
100
|
}
|
|
101
|
+
// Only a row nothing has touched: still pending, one unsent piece, no attempt. Anything
|
|
102
|
+
// further along keeps the old path, which retires it and admits a fresh row.
|
|
103
|
+
function refreshWaiting(deps, head, own, recipe) {
|
|
104
|
+
const pieces = workPieces(deps.db, own.work_id);
|
|
105
|
+
const piece = pieces[0];
|
|
106
|
+
if (pieces.length !== 1 ||
|
|
107
|
+
piece === undefined ||
|
|
108
|
+
piece.key !== recipe.key ||
|
|
109
|
+
piece.state !== "pending" ||
|
|
110
|
+
piece.submittedAt !== null ||
|
|
111
|
+
piece.continuation !== null)
|
|
112
|
+
return false;
|
|
113
|
+
const waiting = deps.db
|
|
114
|
+
.prepare("SELECT 1 FROM revision_work w WHERE w.id=? AND w.state='pending' AND NOT EXISTS(SELECT 1 FROM attempts a WHERE a.work_id=w.id)")
|
|
115
|
+
.get(own.work_id);
|
|
116
|
+
if (waiting === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
deps.db
|
|
119
|
+
.prepare("UPDATE revision_work SET fingerprint=? WHERE id=?")
|
|
120
|
+
.run(recipe.fingerprint, own.work_id);
|
|
121
|
+
deps.db
|
|
122
|
+
.prepare("UPDATE revision_work_pieces SET fingerprint=?,request_fingerprint=?,logical_fingerprint=?,input_json=? WHERE id=?")
|
|
123
|
+
.run(recipe.fingerprint, recipe.requestFingerprint, recipe.logicalFingerprint, JSON.stringify(recipeInputSchema.parse(recipe.input)), piece.id);
|
|
124
|
+
deps.db
|
|
125
|
+
.prepare("UPDATE revision_work_reservations SET fingerprint=? WHERE revision_id=? AND work_id=? AND work_key=?")
|
|
126
|
+
.run(recipe.fingerprint, head, own.work_id, recipe.key);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
92
129
|
function desiredAnchor(deps, projectId, head, row) {
|
|
93
130
|
const view = getRevisionView(deps, projectId, head);
|
|
94
131
|
return (view?.revision.fingerprints[row.logical_key ?? row.work_key] ===
|
|
@@ -103,5 +140,16 @@ function futureKey(recipe) {
|
|
|
103
140
|
return "research:planner";
|
|
104
141
|
if (recipe.key === "article:continuation")
|
|
105
142
|
return "article:body";
|
|
143
|
+
// A short's images and render unfold from its image prompts, admitted with them: under the
|
|
144
|
+
// pick on a first run, or on their own when one short is made again and the pick is kept.
|
|
145
|
+
// The prompts unfold from the pick's answer.
|
|
146
|
+
const clip = /^(shorts:\d+):(?:image:\d+|render)$/.exec(recipe.key);
|
|
147
|
+
if (clip?.[1] !== undefined)
|
|
148
|
+
return `${clip[1]}:prompts`;
|
|
149
|
+
if (recipe.key.startsWith("shorts:"))
|
|
150
|
+
return "shorts:pick";
|
|
151
|
+
// The chapter openers' clips unfold from the deferred request that stood in for them.
|
|
152
|
+
if (recipe.key.startsWith("animate:"))
|
|
153
|
+
return "animate:future";
|
|
106
154
|
return recipe.key;
|
|
107
155
|
}
|
|
@@ -12,6 +12,17 @@ export function pieceLabel(deps, context, piece) {
|
|
|
12
12
|
return "Document";
|
|
13
13
|
if (key === "youtube:description")
|
|
14
14
|
return "YouTube description";
|
|
15
|
+
if (key === "shorts:pick" || key === "shorts:future")
|
|
16
|
+
return "Shorts";
|
|
17
|
+
if (key === "animate:future")
|
|
18
|
+
return "Animated images";
|
|
19
|
+
const short = /^shorts:(\d+):(prompts|image:(\d+)|render)$/.exec(key);
|
|
20
|
+
if (short !== null)
|
|
21
|
+
return short[2] === "prompts"
|
|
22
|
+
? `Short ${short[1]} image prompts`
|
|
23
|
+
: short[2] === "render"
|
|
24
|
+
? `Short ${short[1]}`
|
|
25
|
+
: `Short ${short[1]} image ${short[3]}`;
|
|
15
26
|
if (key === "entry:intro:text")
|
|
16
27
|
return "Intro text";
|
|
17
28
|
if (key === "entry:outro:text")
|
|
@@ -28,6 +39,10 @@ export function pieceLabel(deps, context, piece) {
|
|
|
28
39
|
const at = view()?.revision.content.imageOrder.indexOf(key.slice("image:".length)) ?? -1;
|
|
29
40
|
return at === -1 ? "An image" : `Image ${String(at + 1)}`;
|
|
30
41
|
}
|
|
42
|
+
if (key.startsWith("animate:")) {
|
|
43
|
+
const at = view()?.revision.content.imageOrder.indexOf(key.slice("animate:".length)) ?? -1;
|
|
44
|
+
return at === -1 ? "An animated image" : `Animated image ${String(at + 1)}`;
|
|
45
|
+
}
|
|
31
46
|
const narration = /^(?:narration:prepare:(?:intro|body|outro):)?audio:(intro|body|outro)(?::(.+))?$/.exec(key);
|
|
32
47
|
if (narration === null)
|
|
33
48
|
return undefined;
|
|
@@ -8,7 +8,13 @@ import { rebindPublishedNarration } from "./runtime-narration-publication.js";
|
|
|
8
8
|
import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
|
|
9
9
|
export function preparedResult(deps, context, piece, role, asset, durationMs = null, meta = {}) {
|
|
10
10
|
return {
|
|
11
|
-
|
|
11
|
+
// A project holds many images, and one video and several images per short.
|
|
12
|
+
slot: role === "image" ||
|
|
13
|
+
role === "short_image" ||
|
|
14
|
+
role === "short_video" ||
|
|
15
|
+
role === "animated_image"
|
|
16
|
+
? piece.key
|
|
17
|
+
: `${context.work.kind}:${role}`,
|
|
12
18
|
workKey: piece.key,
|
|
13
19
|
fingerprint: piece.logicalFingerprint ?? piece.fingerprint,
|
|
14
20
|
asset,
|
|
@@ -46,7 +52,7 @@ export function preparedTexts(deps, context, piece, texts) {
|
|
|
46
52
|
throw error;
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
|
-
export async function publishResult(deps, context, piece, outputs, payload, asset = null) {
|
|
55
|
+
export async function publishResult(deps, context, piece, outputs, payload, asset = null, retired) {
|
|
50
56
|
try {
|
|
51
57
|
const record = {
|
|
52
58
|
key: piece.key,
|
|
@@ -67,7 +73,7 @@ export async function publishResult(deps, context, piece, outputs, payload, asse
|
|
|
67
73
|
},
|
|
68
74
|
};
|
|
69
75
|
const result = transact(deps.db, () => {
|
|
70
|
-
const committed = commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record]);
|
|
76
|
+
const committed = commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record], retired);
|
|
71
77
|
rebindPublishedNarration(deps, context.work, piece);
|
|
72
78
|
deps.db
|
|
73
79
|
.prepare("UPDATE revision_work_pieces SET state='done' WHERE id=? AND work_id=?")
|
|
@@ -93,7 +99,7 @@ function pieceKind(piece) {
|
|
|
93
99
|
return "chunk";
|
|
94
100
|
if (piece.input.kind === "provided" && /^audio:(intro|outro):\d+$/.test(piece.key))
|
|
95
101
|
return "segment";
|
|
96
|
-
if (piece.key.startsWith("image:"))
|
|
102
|
+
if (piece.key.startsWith("image:") || piece.key.startsWith("animate:"))
|
|
97
103
|
return "image";
|
|
98
104
|
if (piece.key.startsWith("research:chapter:"))
|
|
99
105
|
return "chapter";
|
|
@@ -117,6 +123,21 @@ function pieceIndex(deps, context, piece) {
|
|
|
117
123
|
return 1;
|
|
118
124
|
if (piece.key === "entry:outro:text")
|
|
119
125
|
return 2;
|
|
126
|
+
// The shorts' steps take places of their own, past every other step of the Video stage:
|
|
127
|
+
// the pick, then per short its prompts, its images and its render.
|
|
128
|
+
const short = /^shorts:(?:(\d+):(prompts|image:(\d+)|render)|pick)$/.exec(piece.key);
|
|
129
|
+
if (short !== null)
|
|
130
|
+
return short[1] === undefined
|
|
131
|
+
? 10000
|
|
132
|
+
: 10000 +
|
|
133
|
+
Number(short[1]) * 1000 +
|
|
134
|
+
(short[2] === "prompts" ? 0 : short[2] === "render" ? 999 : Number(short[3]));
|
|
135
|
+
// Animated images take places past the shorts', one per image of the slideshow.
|
|
136
|
+
if (piece.key.startsWith("animate:")) {
|
|
137
|
+
const order = executionView(deps, context.work.projectId, context.work.revisionId)?.revision
|
|
138
|
+
.content.imageOrder;
|
|
139
|
+
return 900000 + (order?.indexOf(piece.key.slice("animate:".length)) ?? -1) + 1;
|
|
140
|
+
}
|
|
120
141
|
const localOrder = [
|
|
121
142
|
"audio:body:concat",
|
|
122
143
|
"audio:intro",
|