@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
|
@@ -2,9 +2,28 @@ import { stageKinds } from "../../kernel/pipeline.js";
|
|
|
2
2
|
import { sourceOf } from "../admission/model.js";
|
|
3
3
|
import { allowedSources, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, zoomPercentProblem, } from "../admission/rules.js";
|
|
4
4
|
import { render } from "../admission/substitute.js";
|
|
5
|
+
import { effectiveClips, pickedShortsOf, rangeProblem } from "../shorts/clips.js";
|
|
5
6
|
import { buildRecipes } from "./recipe-build.js";
|
|
6
7
|
import { selectedReference } from "./recipe-model.js";
|
|
7
8
|
import { validateRecipeInputs } from "./recipe-validation.js";
|
|
9
|
+
// A range set by hand for a short is checked against the pick it was set on, while that is
|
|
10
|
+
// still the project's pick; one left from an earlier pick no longer applies and is ignored.
|
|
11
|
+
function shortRangeFields(base, config, content) {
|
|
12
|
+
const ranges = content.shortsRanges;
|
|
13
|
+
const shorts = config.shorts;
|
|
14
|
+
if (ranges === undefined || shorts?.enabled !== true)
|
|
15
|
+
return [];
|
|
16
|
+
const row = base.pieces.find((one) => one.key === "shorts:pick" && one.selected && one.piece.state === "done");
|
|
17
|
+
const picked = pickedShortsOf(row?.piece.payload);
|
|
18
|
+
if (row === undefined || picked === undefined)
|
|
19
|
+
return [];
|
|
20
|
+
const current = Object.fromEntries(Object.entries(ranges).filter(([, range]) => range.pick === row.fingerprint));
|
|
21
|
+
const clips = effectiveClips(picked, current, row.fingerprint, shorts);
|
|
22
|
+
return Object.entries(current).flatMap(([number, range]) => {
|
|
23
|
+
const message = rangeProblem(picked, Number(number), range.first, range.last, shorts, clips);
|
|
24
|
+
return message === undefined ? [] : [{ field: `content.shortsRanges.${number}`, message }];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
8
27
|
export function normalizeArticleIntent(base, edit) {
|
|
9
28
|
const restart = edit.regenerate?.includes("article:body") === true ||
|
|
10
29
|
(base.revision.config.sources.article === "provide" &&
|
|
@@ -27,6 +46,7 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
|
|
|
27
46
|
...validateRevisionEdit(edit.config, content),
|
|
28
47
|
...validateCues(content),
|
|
29
48
|
...validateRecipeInputs(edit.config, content),
|
|
49
|
+
...shortRangeFields(base, edit.config, content),
|
|
30
50
|
];
|
|
31
51
|
if (fields.length > 0)
|
|
32
52
|
return { ok: false, fields };
|
|
@@ -122,7 +142,11 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
|
|
|
122
142
|
: prepared.outputs.includes(row) || (unchanged && row.state === "ready")
|
|
123
143
|
? "ready"
|
|
124
144
|
: row.fingerprint === next.fingerprint
|
|
125
|
-
?
|
|
145
|
+
? // An outdated result made for exactly this step is current again: an undo
|
|
146
|
+
// or a second edit that lands back on it must not ask for it to be redone.
|
|
147
|
+
row.state === "outdated"
|
|
148
|
+
? "ready"
|
|
149
|
+
: row.state
|
|
126
150
|
: next.kind === "provided"
|
|
127
151
|
? "review"
|
|
128
152
|
: "outdated";
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fingerprint } from "../../kernel/runner/work.js";
|
|
3
|
+
import { usesShorts } from "../admission/rules.js";
|
|
4
|
+
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
5
|
+
import { defaultShortsImagePrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
|
|
6
|
+
import { imagePromptMessages } from "../shorts/prompts.js";
|
|
7
|
+
import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
|
|
8
|
+
import { imageChoice } from "./recipe-reference.js";
|
|
9
|
+
import { llmInput, renderedPrompt } from "./recipe-text.js";
|
|
10
|
+
// The Shorts step unfolds as its answers land, the way research's chapters unfold from the
|
|
11
|
+
// plan. `shorts:pick` reads the word timing, so it waits for `subtitles:timing` and runs
|
|
12
|
+
// beside the render; until its saved answer matches, one deferred `shorts:future` stands in
|
|
13
|
+
// for everything after it (and carries the images' cost). Each picked clip then gets
|
|
14
|
+
// `shorts:N:prompts`, one LLM call writing its image prompts; once those match, its
|
|
15
|
+
// `shorts:N:image:M` images and its `shorts:N:render`.
|
|
16
|
+
//
|
|
17
|
+
// A clip's work carries the clip's own token (`clipToken`): the pick's token from when the
|
|
18
|
+
// clip was first picked, plus the one "Make this short again" renews under `shorts:N`. So
|
|
19
|
+
// picking the moments again redoes only the clips whose sentences changed, and one short
|
|
20
|
+
// can be made again alone.
|
|
21
|
+
export const shortsPickKey = "shorts:pick";
|
|
22
|
+
export const shortsFutureKey = "shorts:future";
|
|
23
|
+
export function shortsRecipes(context, exports,
|
|
24
|
+
// The establishing image the shorts' images are drawn from, when it is on.
|
|
25
|
+
reference) {
|
|
26
|
+
const { config } = context;
|
|
27
|
+
const shorts = config.shorts;
|
|
28
|
+
const timing = exports.find((value) => value.key === "subtitles:timing");
|
|
29
|
+
if (!usesShorts(config) || shorts === undefined || timing === undefined)
|
|
30
|
+
return [];
|
|
31
|
+
const pick = recipe(context, shortsPickKey, "video", {
|
|
32
|
+
kind: "local",
|
|
33
|
+
version: 1,
|
|
34
|
+
operation: "shorts-pick-v1",
|
|
35
|
+
values: [
|
|
36
|
+
resourceIdentity(context, timing),
|
|
37
|
+
config.llm?.provider ?? "",
|
|
38
|
+
config.llm?.model ?? "",
|
|
39
|
+
config.llm?.thinking ?? null,
|
|
40
|
+
// Blank means the built-in prompt; the step fills it in, so a new built-in wording
|
|
41
|
+
// ships as a new operation version rather than a silent change here.
|
|
42
|
+
shorts.prompt?.trim() ? renderedPrompt(context, "shorts") : "",
|
|
43
|
+
shorts.count,
|
|
44
|
+
shorts.minSeconds,
|
|
45
|
+
shorts.maxSeconds,
|
|
46
|
+
config.title,
|
|
47
|
+
],
|
|
48
|
+
}, [timing.key],
|
|
49
|
+
// A paid LLM call: readiness, the charge warning and the estimate treat it like one.
|
|
50
|
+
{ kind: "provider" });
|
|
51
|
+
const picked = savedPicked(context, pick);
|
|
52
|
+
const images = config.images ?? { provider: "", model: "" };
|
|
53
|
+
const style = shorts.imagePrompt?.trim()
|
|
54
|
+
? renderedPrompt(context, "shortsImage")
|
|
55
|
+
: defaultShortsImagePrompt;
|
|
56
|
+
if (picked === undefined)
|
|
57
|
+
return [
|
|
58
|
+
pick,
|
|
59
|
+
recipe(context, shortsFutureKey, "video", {
|
|
60
|
+
kind: "deferred",
|
|
61
|
+
version: 1,
|
|
62
|
+
operation: "shorts",
|
|
63
|
+
template: [
|
|
64
|
+
pick.fingerprint,
|
|
65
|
+
style,
|
|
66
|
+
images.provider,
|
|
67
|
+
images.model,
|
|
68
|
+
config.imageSeconds,
|
|
69
|
+
shorts.count,
|
|
70
|
+
shorts.maxSeconds,
|
|
71
|
+
config.llm?.provider ?? "",
|
|
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]),
|
|
77
|
+
],
|
|
78
|
+
}, [pick.key], { tokenKey: shortsPickKey }),
|
|
79
|
+
];
|
|
80
|
+
const recipes = [pick];
|
|
81
|
+
const clips = effectiveClips(picked, context.content.shortsRanges, pick.fingerprint, shorts);
|
|
82
|
+
for (const clip of clips) {
|
|
83
|
+
const prefix = `shorts:${String(clip.number)}`;
|
|
84
|
+
const token = clipToken(context, clip);
|
|
85
|
+
const count = shortImageCount(clip.end - clip.start, config.imageSeconds);
|
|
86
|
+
const prompts = {
|
|
87
|
+
...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
|
|
88
|
+
style,
|
|
89
|
+
videoTitle: config.title,
|
|
90
|
+
shortTitle: clip.title,
|
|
91
|
+
text: clip.text,
|
|
92
|
+
count,
|
|
93
|
+
imageSeconds: config.imageSeconds,
|
|
94
|
+
})), [pick.key], { token }),
|
|
95
|
+
unfoldsImages: { count, provider: images.provider, model: images.model },
|
|
96
|
+
};
|
|
97
|
+
recipes.push(prompts);
|
|
98
|
+
const written = savedPrompts(context, prompts, token);
|
|
99
|
+
if (written === undefined || written.length !== count)
|
|
100
|
+
continue;
|
|
101
|
+
const stills = written.map((prompt, index) => recipe(context, `${prefix}:image:${String(index + 1)}`, "video", {
|
|
102
|
+
kind: "image",
|
|
103
|
+
version: 1,
|
|
104
|
+
...imageChoice(config),
|
|
105
|
+
prompt,
|
|
106
|
+
aspect: "9:16",
|
|
107
|
+
...(reference === undefined ? {} : { reference: reference.input }),
|
|
108
|
+
}, [prompts.key, ...(reference === undefined ? [] : [reference.key])], { token }));
|
|
109
|
+
recipes.push(...stills);
|
|
110
|
+
const extras = renderExtras(context, clip);
|
|
111
|
+
recipes.push(recipe(context, `${prefix}:render`, "video", {
|
|
112
|
+
kind: "local",
|
|
113
|
+
version: 1,
|
|
114
|
+
operation: "short-render-v1",
|
|
115
|
+
values: [
|
|
116
|
+
resourceIdentity(context, timing),
|
|
117
|
+
clip.number,
|
|
118
|
+
clip.start,
|
|
119
|
+
clip.end,
|
|
120
|
+
stills.map((still) => resourceIdentity(context, still)),
|
|
121
|
+
config.imageSeconds,
|
|
122
|
+
config.zoomPercent,
|
|
123
|
+
config.motionStyle,
|
|
124
|
+
config.subtitles?.fontId ?? "default",
|
|
125
|
+
// Only when one of the later settings is in use, so a short rendered before
|
|
126
|
+
// they existed keeps its fingerprint.
|
|
127
|
+
...(extras === undefined ? [] : [extras]),
|
|
128
|
+
],
|
|
129
|
+
}, [pick.key, ...stills.map((still) => still.key)], { token }));
|
|
130
|
+
}
|
|
131
|
+
return recipes;
|
|
132
|
+
}
|
|
133
|
+
// What the render draws and plays beyond the clip itself: the title headline, a faster
|
|
134
|
+
// speed and the background music at its level. Undefined when none is in use.
|
|
135
|
+
export function renderExtras(context, clip) {
|
|
136
|
+
const shorts = context.config.shorts;
|
|
137
|
+
if (shorts === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
const speed = shortsSpeedOf(shorts);
|
|
140
|
+
const music = context.content.shortsMusic;
|
|
141
|
+
const extras = {
|
|
142
|
+
...(shorts.titleOnScreen === true ? { title: clip.title } : {}),
|
|
143
|
+
...(speed === 1 ? {} : { speed }),
|
|
144
|
+
...(music === undefined ? {} : { music: [music, musicVolumeOf(shorts)] }),
|
|
145
|
+
};
|
|
146
|
+
return Object.keys(extras).length === 0 ? undefined : extras;
|
|
147
|
+
}
|
|
148
|
+
// The token a clip's prompts, images and render carry: the pick's token from when the clip
|
|
149
|
+
// was first picked (the current one for a pick saved before clips kept theirs), with the
|
|
150
|
+
// clip's own "Make this short again" token when it has one.
|
|
151
|
+
export function clipToken(context, clip) {
|
|
152
|
+
const tokens = context.content.regenerationTokens;
|
|
153
|
+
const base = clip.seed !== undefined ? clip.seed : (tokens[shortsPickKey] ?? null);
|
|
154
|
+
const own = tokens[shortRemakeKey(clip.number)];
|
|
155
|
+
return own === undefined ? base : fingerprint([base, own]);
|
|
156
|
+
}
|
|
157
|
+
// The regeneration key "Make this short again" renews for short N.
|
|
158
|
+
export function shortRemakeKey(number) {
|
|
159
|
+
return `shorts:${String(number)}`;
|
|
160
|
+
}
|
|
161
|
+
// What the pick saved, when its saved answer is the one this plan asks for.
|
|
162
|
+
export function savedPicked(context, pick) {
|
|
163
|
+
const piece = context.manifest.pieces.find((row) => row.key === pick.key &&
|
|
164
|
+
selectedReference(row) &&
|
|
165
|
+
row.piece.state === "done" &&
|
|
166
|
+
row.fingerprint === pick.fingerprint);
|
|
167
|
+
return pickedShortsOf(piece?.piece.payload);
|
|
168
|
+
}
|
|
169
|
+
// A clip's saved image prompts, when they answer this plan's request. The request carries
|
|
170
|
+
// the model's thinking settings from the catalogue, which the plan without a catalogue has
|
|
171
|
+
// not got, so either fingerprint matches (like `matchingText`).
|
|
172
|
+
function savedPrompts(context, prompts, token) {
|
|
173
|
+
const logical = context.catalogue === undefined || prompts.input.kind !== "llm"
|
|
174
|
+
? prompts.fingerprint
|
|
175
|
+
: recipe({ content: context.content }, prompts.key, prompts.stage, { ...prompts.input, thinkingConfig: null }, prompts.dependsOn, { token }).fingerprint;
|
|
176
|
+
const piece = context.manifest.pieces.find((row) => row.key === prompts.key &&
|
|
177
|
+
selectedReference(row) &&
|
|
178
|
+
row.piece.state === "done" &&
|
|
179
|
+
(row.fingerprint === prompts.fingerprint || row.fingerprint === logical));
|
|
180
|
+
if (piece?.piece.payload === undefined || piece.piece.payload === null)
|
|
181
|
+
return undefined;
|
|
182
|
+
const parsed = z
|
|
183
|
+
.object({ prompts: z.array(z.string()) })
|
|
184
|
+
.safeParse(JSON.parse(piece.piece.payload));
|
|
185
|
+
return parsed.success ? parsed.data.prompts : undefined;
|
|
186
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { narrationPreparationFields, usesNarrationPreparation, usesYoutubeDescription, 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
|
+
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({
|
|
@@ -76,6 +93,20 @@ export function validateRecipeInputs(config, content) {
|
|
|
76
93
|
raw: content.promptTemplates.thumbnailPrompt ?? null,
|
|
77
94
|
literal: config.rendered.thumbnailPrompt,
|
|
78
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
|
+
});
|
|
79
110
|
if (config.sources.images !== "off")
|
|
80
111
|
for (const key of content.imageOrder) {
|
|
81
112
|
const image = content.imageDefinitions[key];
|
|
@@ -1,7 +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
|
-
export function visualRecipes(config, content, audioFingerprint, captionFingerprint
|
|
5
|
+
export function visualRecipes(config, content, audioFingerprint, captionFingerprint,
|
|
6
|
+
// What the Video stage's edit settings add (`recipe-edit.ts`), from the images planned here.
|
|
7
|
+
edit,
|
|
8
|
+
// The establishing image the generated images are drawn from, when it is on.
|
|
9
|
+
reference) {
|
|
5
10
|
const recipes = [];
|
|
6
11
|
const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
|
|
7
12
|
for (const key of imageKeys) {
|
|
@@ -17,13 +22,14 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
|
|
|
17
22
|
: {
|
|
18
23
|
kind: "image",
|
|
19
24
|
version: 1,
|
|
20
|
-
|
|
21
|
-
model: config.images?.model ?? "",
|
|
25
|
+
...imageChoice(config),
|
|
22
26
|
aspect: config.format,
|
|
23
27
|
prompt: prompt ?? "",
|
|
24
|
-
|
|
28
|
+
...(reference === undefined ? {} : { reference: reference.input }),
|
|
29
|
+
}, image.source === "provide" || reference === undefined ? [] : [reference.key], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
|
|
25
30
|
}
|
|
26
31
|
if (config.sources.video !== "off") {
|
|
32
|
+
const edited = edit?.([...recipes]) ?? { recipes: [], values: [], dependsOn: [] };
|
|
27
33
|
const audioKeys = config.sources.audio === "off"
|
|
28
34
|
? []
|
|
29
35
|
: [
|
|
@@ -55,16 +61,24 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
|
|
|
55
61
|
...(config.motionStyle === "zoom" ? [] : [config.motionStyle]),
|
|
56
62
|
// v1 split the timeline evenly across the images; v2 cycles them.
|
|
57
63
|
"slideshow-zoom-v2",
|
|
64
|
+
// Only what the edit settings change; nothing at all for today's slideshow, so
|
|
65
|
+
// its fingerprint is the one it always had.
|
|
66
|
+
...(edited.values.length === 0 ? [] : [["video-edit", ...edited.values]]),
|
|
58
67
|
],
|
|
59
68
|
}, [
|
|
60
69
|
...recipes.map((value) => value.key),
|
|
61
70
|
...audioKeys,
|
|
62
71
|
...(config.subtitles?.mode === "burn-in" ? ["subtitles:files"] : []),
|
|
72
|
+
...edited.dependsOn,
|
|
63
73
|
], { unresolved: imageKeys.length === 0 }));
|
|
74
|
+
// After the export, so the images' recipes above stay the only ones its first values list.
|
|
75
|
+
recipes.splice(recipes.length - 1, 0, ...edited.recipes);
|
|
64
76
|
}
|
|
65
77
|
return recipes;
|
|
66
78
|
}
|
|
67
|
-
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) {
|
|
68
82
|
const { config, content } = context;
|
|
69
83
|
if (config.sources.thumbnail === "off")
|
|
70
84
|
return [];
|
|
@@ -92,16 +106,23 @@ export function thumbnailRecipes(context, textRecipes) {
|
|
|
92
106
|
config.images?.provider ?? null,
|
|
93
107
|
config.images?.model ?? null,
|
|
94
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]),
|
|
95
113
|
],
|
|
96
114
|
}
|
|
97
115
|
: {
|
|
98
116
|
kind: "image",
|
|
99
117
|
version: 1,
|
|
100
|
-
|
|
101
|
-
model: config.images?.model ?? "",
|
|
118
|
+
...imageChoice(config),
|
|
102
119
|
aspect: config.format,
|
|
103
120
|
prompt,
|
|
104
|
-
|
|
121
|
+
...(reference === undefined ? {} : { reference: reference.input }),
|
|
122
|
+
}, [
|
|
123
|
+
...(promptRecipe === undefined ? [] : [promptRecipe.key]),
|
|
124
|
+
...(reference === undefined ? [] : [reference.key]),
|
|
125
|
+
]),
|
|
105
126
|
];
|
|
106
127
|
}
|
|
107
128
|
export function visualAssets(context, recipes) {
|
|
@@ -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 {
|
|
@@ -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 &&
|
|
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";
|
|
@@ -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
|
+
}
|