@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,186 @@
|
|
|
1
|
+
// The Video stage's optional Shorts step: the text model picks the best self-contained
|
|
2
|
+
// moments of the narration, each becomes a vertical 1080×1920 clip with its own images and
|
|
3
|
+
// big word-by-word captions, and each gets a title, a one-line description and hashtags.
|
|
4
|
+
// Browser-safe: Play, Edit project and the project page read these names and limits too.
|
|
5
|
+
// ceiling: the owner's numbers. Ten clips of three minutes is already half an hour of
|
|
6
|
+
// vertical video from one narration.
|
|
7
|
+
export const shortsCountMin = 1;
|
|
8
|
+
export const shortsCountMax = 10;
|
|
9
|
+
export const shortsSecondsMin = 15;
|
|
10
|
+
export const shortsSecondsMax = 180;
|
|
11
|
+
export const defaultShorts = {
|
|
12
|
+
enabled: false,
|
|
13
|
+
count: 3,
|
|
14
|
+
minSeconds: 60,
|
|
15
|
+
maxSeconds: 120,
|
|
16
|
+
titleOnScreen: true,
|
|
17
|
+
};
|
|
18
|
+
// ceiling: faster than 1.25× the narration stops sounding natural even with the pitch kept;
|
|
19
|
+
// the step is the smallest change a listener notices.
|
|
20
|
+
export const shortsSpeedMin = 1;
|
|
21
|
+
export const shortsSpeedMax = 1.25;
|
|
22
|
+
export const shortsSpeedStep = 0.05;
|
|
23
|
+
// Quiet enough to sit under a voice at full volume; the ducking lowers it further while
|
|
24
|
+
// the narrator speaks.
|
|
25
|
+
export const defaultMusicVolume = 15;
|
|
26
|
+
export const fullVideoLinkMax = 2000;
|
|
27
|
+
// What a short's description ends with when no link to the full video is set: a line to
|
|
28
|
+
// paste it into before uploading.
|
|
29
|
+
export const fullVideoPlaceholder = "[PASTE THE FULL VIDEO LINK HERE]";
|
|
30
|
+
// The line every short's description ends with, pointing viewers to the whole video.
|
|
31
|
+
export function fullVideoLine(link) {
|
|
32
|
+
const trimmed = link?.trim() ?? "";
|
|
33
|
+
return `Watch the full video: ${trimmed === "" ? fullVideoPlaceholder : trimmed}`;
|
|
34
|
+
}
|
|
35
|
+
// What Copy puts on the clipboard for one short: the title, the description ending with the
|
|
36
|
+
// link line, and the hashtags.
|
|
37
|
+
export function shortUploadText(clip, link) {
|
|
38
|
+
return [
|
|
39
|
+
clip.title,
|
|
40
|
+
"",
|
|
41
|
+
`${clip.description}\n${fullVideoLine(link)}`,
|
|
42
|
+
"",
|
|
43
|
+
clip.hashtags.join(" "),
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
// The playback speed the render uses: the setting when it is in range, else 1.
|
|
47
|
+
export function shortsSpeedOf(settings) {
|
|
48
|
+
const speed = settings.speed;
|
|
49
|
+
return speed !== undefined &&
|
|
50
|
+
Number.isFinite(speed) &&
|
|
51
|
+
speed >= shortsSpeedMin &&
|
|
52
|
+
speed <= shortsSpeedMax
|
|
53
|
+
? speed
|
|
54
|
+
: 1;
|
|
55
|
+
}
|
|
56
|
+
export function musicVolumeOf(settings) {
|
|
57
|
+
const volume = settings.musicVolume;
|
|
58
|
+
return volume !== undefined && Number.isFinite(volume) && volume >= 0 && volume <= 100
|
|
59
|
+
? volume
|
|
60
|
+
: defaultMusicVolume;
|
|
61
|
+
}
|
|
62
|
+
// A short's title is shown over the clip on YouTube Shorts, TikTok and Reels; 60 characters
|
|
63
|
+
// fits all three without being cut.
|
|
64
|
+
export const shortTitleMax = 60;
|
|
65
|
+
export const shortHashtagsMin = 3;
|
|
66
|
+
export const shortHashtagsMax = 5;
|
|
67
|
+
// The name Play, Edit project and the review summary show for the prompts used when none is
|
|
68
|
+
// picked.
|
|
69
|
+
export const defaultShortsPromptName = "Built-in";
|
|
70
|
+
// What a project uses when no Shorts prompt from the library is picked. No keywords, so it
|
|
71
|
+
// never asks Play for a field.
|
|
72
|
+
export const defaultShortsPrompt = [
|
|
73
|
+
"Pick the moments of this video that work best as vertical shorts on YouTube Shorts, TikTok and Reels.",
|
|
74
|
+
"",
|
|
75
|
+
"- Each short must make sense on its own, to a viewer who has not seen the rest of the video: no references to earlier parts, no setup that pays off outside the clip.",
|
|
76
|
+
"- Open on a hook: the first sentence should make someone stop scrolling (a surprising fact, a question, a bold claim). Skip clips that start with filler or a transition.",
|
|
77
|
+
"- End on a complete thought, not mid-argument.",
|
|
78
|
+
"- Prefer the most surprising, useful or emotional moments over summaries, and don't pick two shorts that say the same thing.",
|
|
79
|
+
"- Title: at most 60 characters, plain words, no clickbait and no hashtags.",
|
|
80
|
+
"- Description: one line that says what the viewer learns or feels.",
|
|
81
|
+
"- Hashtags: 3-5, about the subject of that clip, most specific last.",
|
|
82
|
+
].join("\n");
|
|
83
|
+
// The style the shorts' images are written in when no Image prompt from the library is
|
|
84
|
+
// picked.
|
|
85
|
+
export const defaultShortsImagePrompt = [
|
|
86
|
+
"Vertical 9:16 images for a short video. Each image illustrates what is being said at that moment of the clip.",
|
|
87
|
+
"Cinematic, high-contrast and uncluttered, with the subject large and centred so it reads on a phone screen.",
|
|
88
|
+
"Leave the lower half calm: captions are drawn over the middle of the frame.",
|
|
89
|
+
"No text, letters, logos or watermarks in the image.",
|
|
90
|
+
].join("\n");
|
|
91
|
+
// How many images a clip gets: one per `imageSeconds` of the clip, the last running to the
|
|
92
|
+
// end, and at least one.
|
|
93
|
+
export function shortImageCount(clipSeconds, imageSeconds) {
|
|
94
|
+
if (!Number.isFinite(clipSeconds) || !Number.isFinite(imageSeconds) || imageSeconds <= 0)
|
|
95
|
+
return 1;
|
|
96
|
+
// A few milliseconds over a whole number of images is timing noise, not another image.
|
|
97
|
+
return Math.max(1, Math.ceil(clipSeconds / imageSeconds - 0.001));
|
|
98
|
+
}
|
|
99
|
+
// The most images the step can ask for: every clip at the longest length allowed. The cost
|
|
100
|
+
// estimate charges this before the clips are picked.
|
|
101
|
+
export function shortsImageUpperBound(settings, imageSeconds) {
|
|
102
|
+
return settings.count * shortImageCount(settings.maxSeconds, imageSeconds);
|
|
103
|
+
}
|
|
104
|
+
// The settings the typed extras stand for. A number that doesn't read as one comes back as
|
|
105
|
+
// NaN, so `shortsSettingsProblems` refuses it where it was typed.
|
|
106
|
+
export function shortsExtrasOf(form) {
|
|
107
|
+
const typed = (value) => value === undefined || value.trim() === "" ? undefined : Number(value);
|
|
108
|
+
const musicVolume = typed(form.musicVolume);
|
|
109
|
+
const speed = typed(form.speed);
|
|
110
|
+
const link = form.fullVideoLink?.trim() ?? "";
|
|
111
|
+
return {
|
|
112
|
+
...(form.titleOnScreen === undefined ? {} : { titleOnScreen: form.titleOnScreen }),
|
|
113
|
+
...(link === "" ? {} : { fullVideoLink: link }),
|
|
114
|
+
...(musicVolume === undefined ? {} : { musicVolume }),
|
|
115
|
+
...(speed === undefined ? {} : { speed }),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// The same settings as the forms show them.
|
|
119
|
+
export function shortsExtrasForm(settings) {
|
|
120
|
+
return {
|
|
121
|
+
...(settings?.titleOnScreen === undefined ? {} : { titleOnScreen: settings.titleOnScreen }),
|
|
122
|
+
...(settings?.fullVideoLink === undefined ? {} : { fullVideoLink: settings.fullVideoLink }),
|
|
123
|
+
...(settings?.musicVolume === undefined ? {} : { musicVolume: String(settings.musicVolume) }),
|
|
124
|
+
...(settings?.speed === undefined ? {} : { speed: settings.speed.toFixed(2) }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// The words a problem with one of the settings is said in; shared by admission, Play's
|
|
128
|
+
// draft conversion and Edit project.
|
|
129
|
+
export function shortsSettingsProblems(settings) {
|
|
130
|
+
const problems = [];
|
|
131
|
+
const whole = (value, min, max) => Number.isInteger(value) && value >= min && value <= max;
|
|
132
|
+
if (!whole(settings.count, shortsCountMin, shortsCountMax))
|
|
133
|
+
problems.push({
|
|
134
|
+
field: "count",
|
|
135
|
+
message: `Enter a whole number of shorts between ${shortsCountMin} and ${shortsCountMax}.`,
|
|
136
|
+
});
|
|
137
|
+
for (const field of ["minSeconds", "maxSeconds"])
|
|
138
|
+
if (!whole(settings[field], shortsSecondsMin, shortsSecondsMax))
|
|
139
|
+
problems.push({
|
|
140
|
+
field,
|
|
141
|
+
message: `Enter a whole number of seconds between ${shortsSecondsMin} and ${shortsSecondsMax}.`,
|
|
142
|
+
});
|
|
143
|
+
// Said against both numbers, so it shows under whichever one was just typed.
|
|
144
|
+
if (problems.every((problem) => problem.field === "count") &&
|
|
145
|
+
settings.minSeconds > settings.maxSeconds)
|
|
146
|
+
for (const field of ["minSeconds", "maxSeconds"])
|
|
147
|
+
problems.push({
|
|
148
|
+
field,
|
|
149
|
+
message: "The longest a short may be must be at least the shortest. Raise the maximum or lower the minimum.",
|
|
150
|
+
});
|
|
151
|
+
const speed = settings.speed;
|
|
152
|
+
// Steps of 0.05 from 1.00, allowing for how a typed or stored number was rounded.
|
|
153
|
+
if (speed !== undefined &&
|
|
154
|
+
(!Number.isFinite(speed) ||
|
|
155
|
+
speed < shortsSpeedMin - 1e-9 ||
|
|
156
|
+
speed > shortsSpeedMax + 1e-9 ||
|
|
157
|
+
Math.abs((speed - shortsSpeedMin) / shortsSpeedStep -
|
|
158
|
+
Math.round((speed - shortsSpeedMin) / shortsSpeedStep)) > 1e-6))
|
|
159
|
+
problems.push({
|
|
160
|
+
field: "speed",
|
|
161
|
+
message: "Choose a speed between 1.00× and 1.25×, in steps of 0.05.",
|
|
162
|
+
});
|
|
163
|
+
if (settings.musicVolume !== undefined && !whole(settings.musicVolume, 0, 100))
|
|
164
|
+
problems.push({
|
|
165
|
+
field: "musicVolume",
|
|
166
|
+
message: "Enter the music volume as a whole number of percent between 0 and 100.",
|
|
167
|
+
});
|
|
168
|
+
const link = settings.fullVideoLink?.trim() ?? "";
|
|
169
|
+
if (link !== "" && !webLink(link))
|
|
170
|
+
problems.push({
|
|
171
|
+
field: "fullVideoLink",
|
|
172
|
+
message: "The full video link must be a whole web address starting with https:// or http://, like https://youtu.be/abc123. Paste it again, or leave the box empty.",
|
|
173
|
+
});
|
|
174
|
+
return problems;
|
|
175
|
+
}
|
|
176
|
+
function webLink(text) {
|
|
177
|
+
if (text.length > fullVideoLinkMax || /\s/.test(text))
|
|
178
|
+
return false;
|
|
179
|
+
try {
|
|
180
|
+
const url = new URL(text);
|
|
181
|
+
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { youtubeTimestamp } from "../youtube/timestamps.js";
|
|
3
|
+
import { shortHashtagsMax, shortHashtagsMin, shortTitleMax } from "./model.js";
|
|
4
|
+
export const shortPickSchema = z.object({
|
|
5
|
+
number: z.number().int().positive(),
|
|
6
|
+
first: z.number().int().positive(),
|
|
7
|
+
last: z.number().int().positive(),
|
|
8
|
+
start: z.number().finite().nonnegative(),
|
|
9
|
+
end: z.number().finite().positive(),
|
|
10
|
+
title: z.string(),
|
|
11
|
+
description: z.string(),
|
|
12
|
+
hashtags: z.array(z.string()),
|
|
13
|
+
why: z.string(),
|
|
14
|
+
text: z.string(),
|
|
15
|
+
seed: z.string().nullable().optional(),
|
|
16
|
+
});
|
|
17
|
+
// A clip keeps the number, title, description, hashtags and seed it had when a pick made
|
|
18
|
+
// again chooses exactly the same sentences, as long as that number is still in range; the
|
|
19
|
+
// other clips take the free numbers in the order they play. Everything after the pick is
|
|
20
|
+
// keyed by number, so an unchanged clip plans the same work under the same keys.
|
|
21
|
+
export function keepNumbers(picks, previous, seed) {
|
|
22
|
+
const taken = new Set();
|
|
23
|
+
const kept = new Map();
|
|
24
|
+
for (const pick of picks) {
|
|
25
|
+
const before = previous.find((one) => one.first === pick.first &&
|
|
26
|
+
one.last === pick.last &&
|
|
27
|
+
one.seed !== undefined &&
|
|
28
|
+
one.number <= picks.length &&
|
|
29
|
+
!taken.has(one.number));
|
|
30
|
+
if (before === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
taken.add(before.number);
|
|
33
|
+
kept.set(pick, { ...before, start: pick.start, end: pick.end, text: pick.text });
|
|
34
|
+
}
|
|
35
|
+
const free = Array.from({ length: picks.length }, (_value, at) => at + 1).filter((number) => !taken.has(number));
|
|
36
|
+
return picks
|
|
37
|
+
.map((pick) => kept.get(pick) ?? { ...pick, number: free.shift() ?? pick.number, seed })
|
|
38
|
+
.toSorted((left, right) => left.number - right.number);
|
|
39
|
+
}
|
|
40
|
+
// ceiling: a clip opens a quarter second before its first word and closes 0.4 s after its
|
|
41
|
+
// last, so the first consonant and the last breath are kept; never past halfway to the
|
|
42
|
+
// neighbouring sentence, so a clip holds nothing of the words around it.
|
|
43
|
+
const leadSeconds = 0.25;
|
|
44
|
+
const tailSeconds = 0.4;
|
|
45
|
+
// Where a clip of sentences `first` to `last` (1-based, inclusive) starts and ends.
|
|
46
|
+
export function clipBounds(sentences, first, last, durationSeconds) {
|
|
47
|
+
const opening = sentences[first - 1];
|
|
48
|
+
const closing = sentences[last - 1];
|
|
49
|
+
if (opening === undefined || closing === undefined || first > last)
|
|
50
|
+
return undefined;
|
|
51
|
+
const before = sentences[first - 2]?.end ?? 0;
|
|
52
|
+
const after = sentences[last]?.start ?? durationSeconds;
|
|
53
|
+
const start = Math.max(0, opening.start - Math.max(0, Math.min(leadSeconds, (opening.start - before) / 2)));
|
|
54
|
+
const end = Math.min(durationSeconds, closing.end + Math.max(0, Math.min(tailSeconds, (after - closing.end) / 2)));
|
|
55
|
+
return { start: round(start), end: round(end) };
|
|
56
|
+
}
|
|
57
|
+
export function pickMessages(brief) {
|
|
58
|
+
const plural = brief.count === 1 ? "short" : "shorts";
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
role: "system",
|
|
62
|
+
content: [
|
|
63
|
+
"You pick clips from a narrated video to publish as vertical shorts. Answer with one JSON array and nothing else, best clip first, in this shape:",
|
|
64
|
+
'[{"first": 12, "last": 19, "title": "...", "description": "...", "hashtags": ["#Example"], "why": "..."}]',
|
|
65
|
+
"",
|
|
66
|
+
"Rules the answer must follow:",
|
|
67
|
+
`- Exactly ${String(brief.count)} ${plural}, unless the video is too short to hold that many.`,
|
|
68
|
+
'- "first" and "last" are sentence numbers from the transcript. A clip runs from the start of sentence "first" to the end of sentence "last", both included.',
|
|
69
|
+
`- Each clip lasts between ${String(brief.minSeconds)} and ${String(brief.maxSeconds)} seconds, measured from the start time of its first sentence to the end time of its last one.`,
|
|
70
|
+
"- No two clips share a sentence.",
|
|
71
|
+
`- "title" is at most ${String(shortTitleMax)} characters, one line, without hashtags.`,
|
|
72
|
+
'- "description" is one line.',
|
|
73
|
+
`- "hashtags" holds ${String(shortHashtagsMin)} to ${String(shortHashtagsMax)} single words, each starting with #.`,
|
|
74
|
+
'- "why" is one short sentence saying why the clip works on its own.',
|
|
75
|
+
].join("\n"),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
role: "user",
|
|
79
|
+
content: [
|
|
80
|
+
brief.instruction,
|
|
81
|
+
"",
|
|
82
|
+
`Video title: ${brief.title}`,
|
|
83
|
+
`Video length: ${youtubeTimestamp(brief.durationSeconds)}`,
|
|
84
|
+
`Shorts wanted: ${String(brief.count)}, each ${String(brief.minSeconds)}-${String(brief.maxSeconds)} seconds long`,
|
|
85
|
+
"",
|
|
86
|
+
"Transcript, one sentence per line, each led by its number and the time it runs in the video:",
|
|
87
|
+
"",
|
|
88
|
+
brief.sentences,
|
|
89
|
+
].join("\n"),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
// The second and last ask: the first answer, and what was wrong with it, so the model can
|
|
94
|
+
// keep the clips that worked and replace the rest.
|
|
95
|
+
export function pickRetryMessages(brief, answer, problems) {
|
|
96
|
+
return [
|
|
97
|
+
...pickMessages(brief),
|
|
98
|
+
{ role: "assistant", content: answer },
|
|
99
|
+
{
|
|
100
|
+
role: "user",
|
|
101
|
+
content: [
|
|
102
|
+
"Some of those clips can't be used:",
|
|
103
|
+
...problems.map((problem) => `- ${problem}`),
|
|
104
|
+
"",
|
|
105
|
+
`Answer again with the whole list of ${String(brief.count)} clips as one JSON array, following every rule.`,
|
|
106
|
+
].join("\n"),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
const answerSchema = z.array(z.object({
|
|
111
|
+
first: z.union([z.number(), z.string()]),
|
|
112
|
+
last: z.union([z.number(), z.string()]),
|
|
113
|
+
title: z.string(),
|
|
114
|
+
description: z.string(),
|
|
115
|
+
hashtags: z.array(z.string()),
|
|
116
|
+
why: z.string().optional(),
|
|
117
|
+
}));
|
|
118
|
+
const fix = "Retry stage, or choose another model in Edit project → Providers.";
|
|
119
|
+
// Timing noise: the length rules are checked to the hundredth of a second.
|
|
120
|
+
const tolerance = 0.01;
|
|
121
|
+
// Reads the model's answer and keeps the clips that follow the rules: in range, the right
|
|
122
|
+
// length, not overlapping one already kept, best first until `count`, then in video order.
|
|
123
|
+
// Not being a JSON array of clips is the one failure; everything else is a problem to tell
|
|
124
|
+
// the model about.
|
|
125
|
+
export function checkPicks(text, sentences, limits) {
|
|
126
|
+
const parsed = answerSchema.safeParse(jsonArrayOf(text));
|
|
127
|
+
if (!parsed.success)
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: `The AI model's choice of shorts didn't come back in the expected format (a JSON list of clips, each with first and last sentence numbers, a title, a description and hashtags). ${fix}`,
|
|
131
|
+
};
|
|
132
|
+
const problems = [];
|
|
133
|
+
const kept = [];
|
|
134
|
+
for (const [index, row] of parsed.data.entries()) {
|
|
135
|
+
const label = `Clip ${String(index + 1)}`;
|
|
136
|
+
if (kept.length >= limits.count)
|
|
137
|
+
break;
|
|
138
|
+
const first = Number(row.first);
|
|
139
|
+
const last = Number(row.last);
|
|
140
|
+
if (!Number.isInteger(first) ||
|
|
141
|
+
!Number.isInteger(last) ||
|
|
142
|
+
first < 1 ||
|
|
143
|
+
last > sentences.length ||
|
|
144
|
+
first > last) {
|
|
145
|
+
problems.push(`${label} runs from sentence ${String(row.first)} to ${String(row.last)}, but the transcript has sentences 1 to ${String(sentences.length)} and the first must not come after the last.`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const bounds = clipBounds(sentences, first, last, limits.durationSeconds);
|
|
149
|
+
if (bounds === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
const { start, end } = bounds;
|
|
152
|
+
const seconds = end - start;
|
|
153
|
+
if (seconds < limits.minSeconds - tolerance || seconds > limits.maxSeconds + tolerance) {
|
|
154
|
+
problems.push(`${label} (sentences ${String(first)}-${String(last)}) lasts ${String(Math.round(seconds))} seconds, and each clip must last ${String(limits.minSeconds)}-${String(limits.maxSeconds)} seconds.`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const clash = kept.find((one) => first <= one.last && last >= one.first);
|
|
158
|
+
if (clash !== undefined) {
|
|
159
|
+
problems.push(`${label} (sentences ${String(first)}-${String(last)}) shares sentences with the clip of sentences ${String(clash.first)}-${String(clash.last)}.`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const title = row.title.replace(/\s+/g, " ").trim();
|
|
163
|
+
if (title === "" || title.length > shortTitleMax) {
|
|
164
|
+
problems.push(title === ""
|
|
165
|
+
? `${label} has no title.`
|
|
166
|
+
: `${label}'s title is ${String(title.length)} characters; keep it to ${String(shortTitleMax)}.`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const description = row.description.replace(/\s+/g, " ").trim();
|
|
170
|
+
if (description === "") {
|
|
171
|
+
problems.push(`${label} has no description.`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const hashtags = [
|
|
175
|
+
...new Set(row.hashtags
|
|
176
|
+
.map((tag) => tag.trim().replace(/^#*/, ""))
|
|
177
|
+
.filter((tag) => /^[\p{L}\p{N}_]+$/u.test(tag))
|
|
178
|
+
.map((tag) => `#${tag}`)),
|
|
179
|
+
].slice(0, shortHashtagsMax);
|
|
180
|
+
if (hashtags.length === 0) {
|
|
181
|
+
problems.push(`${label} has no usable hashtags (single words starting with #).`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
kept.push({
|
|
185
|
+
first,
|
|
186
|
+
last,
|
|
187
|
+
start: round(start),
|
|
188
|
+
end: round(end),
|
|
189
|
+
title,
|
|
190
|
+
description,
|
|
191
|
+
hashtags,
|
|
192
|
+
why: (row.why ?? "").replace(/\s+/g, " ").trim(),
|
|
193
|
+
text: sentences
|
|
194
|
+
.slice(first - 1, last)
|
|
195
|
+
.map((sentence) => sentence.text)
|
|
196
|
+
.join(" "),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (kept.length < limits.count && parsed.data.length < limits.count)
|
|
200
|
+
problems.push(`The answer lists ${String(parsed.data.length)} ${parsed.data.length === 1 ? "clip" : "clips"}, and ${String(limits.count)} were asked for.`);
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
picks: kept
|
|
204
|
+
.toSorted((left, right) => left.start - right.start)
|
|
205
|
+
.map((pick, index) => ({ number: index + 1, ...pick })),
|
|
206
|
+
problems,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// The sentence a failed pick ends on, when not even the second answer held a usable clip.
|
|
210
|
+
export function noPicksMessage(problems, limits) {
|
|
211
|
+
const shortVideo = limits.durationSeconds < limits.minSeconds;
|
|
212
|
+
if (shortVideo)
|
|
213
|
+
return `The narration is ${String(Math.round(limits.durationSeconds))} seconds long, shorter than the ${String(limits.minSeconds)}-second minimum for a short, so there is nothing to cut. Lower the shortest length in Edit project → Shorts, then Retry stage.`;
|
|
214
|
+
return `The AI model didn't pick any clip Slopify could use as a short, twice (${problems[0] ?? "no clips were listed"}). Retry stage; if it keeps happening, widen the length range in Edit project → Shorts, or choose another model in Edit project → Providers.`;
|
|
215
|
+
}
|
|
216
|
+
function round(seconds) {
|
|
217
|
+
return Math.round(seconds * 1000) / 1000;
|
|
218
|
+
}
|
|
219
|
+
// The array in the answer, wherever the model put it: bare, in a ```json fence, or after a
|
|
220
|
+
// sentence of its own.
|
|
221
|
+
function jsonArrayOf(text) {
|
|
222
|
+
const start = text.indexOf("[");
|
|
223
|
+
const end = text.lastIndexOf("]");
|
|
224
|
+
if (start === -1 || end <= start)
|
|
225
|
+
return undefined;
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function imagePromptMessages(brief) {
|
|
3
|
+
const plural = brief.count === 1 ? "prompt" : "prompts";
|
|
4
|
+
return [
|
|
5
|
+
{
|
|
6
|
+
role: "system",
|
|
7
|
+
content: [
|
|
8
|
+
"You write prompts for an image model. Answer with one JSON array of strings and nothing else, like:",
|
|
9
|
+
'["...", "..."]',
|
|
10
|
+
"",
|
|
11
|
+
"Rules the answer must follow:",
|
|
12
|
+
`- Exactly ${String(brief.count)} ${plural}, one per image, in the order the images are shown.`,
|
|
13
|
+
`- Each image is on screen for about ${String(brief.imageSeconds)} seconds of the clip, so image 1 shows the start of what is said, the next one what follows, and so on.`,
|
|
14
|
+
"- Every image is vertical (9:16).",
|
|
15
|
+
"- Each prompt stands on its own: the image model sees one prompt at a time, so repeat the style in every prompt.",
|
|
16
|
+
"- No text, captions or letters in the images.",
|
|
17
|
+
].join("\n"),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
role: "user",
|
|
21
|
+
content: [
|
|
22
|
+
"Style and instructions for every image:",
|
|
23
|
+
brief.style,
|
|
24
|
+
"",
|
|
25
|
+
`Video: ${brief.videoTitle}`,
|
|
26
|
+
`Short: ${brief.shortTitle}`,
|
|
27
|
+
"",
|
|
28
|
+
"What is said in the short:",
|
|
29
|
+
brief.text,
|
|
30
|
+
].join("\n"),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
const fix = "Retry stage, or choose another model in Edit project → Providers.";
|
|
35
|
+
// The reason is the sentence the stage shows; the provider wrapper's `check` asks the model
|
|
36
|
+
// again while attempts remain.
|
|
37
|
+
export function checkImagePrompts(text, count) {
|
|
38
|
+
const parsed = z.array(z.string()).safeParse(jsonArrayOf(text));
|
|
39
|
+
if (!parsed.success)
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
reason: `The AI model's image prompts for a short didn't come back in the expected format (a JSON list of prompts). ${fix}`,
|
|
43
|
+
};
|
|
44
|
+
const prompts = parsed.data.map((prompt) => prompt.replace(/\s+/g, " ").trim());
|
|
45
|
+
if (prompts.length !== count)
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: `The AI model wrote ${String(prompts.length)} image prompts for a short that needs exactly ${String(count)}. ${fix}`,
|
|
49
|
+
};
|
|
50
|
+
if (prompts.some((prompt) => prompt === ""))
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: `One of the AI model's image prompts for a short is empty. ${fix}`,
|
|
54
|
+
};
|
|
55
|
+
return { ok: true, prompts };
|
|
56
|
+
}
|
|
57
|
+
function jsonArrayOf(text) {
|
|
58
|
+
const start = text.indexOf("[");
|
|
59
|
+
const end = text.lastIndexOf("]");
|
|
60
|
+
if (start === -1 || end <= start)
|
|
61
|
+
return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { editListVersion } from "../video/edit-list.js";
|
|
4
|
+
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
5
|
+
import { motionFor } from "../video/motion.js";
|
|
6
|
+
import { fps } from "../video/plan.js";
|
|
7
|
+
import { renderSlideshow } from "../video/slideshow.js";
|
|
8
|
+
import { fasterWords, shortCaptionsAss } from "./captions.js";
|
|
9
|
+
// A short is rendered in three steps. Its sound is cut out of the video's own timeline (the
|
|
10
|
+
// silence at the start, intro, gaps, body, outro), which is the timeline the word timing
|
|
11
|
+
// walked, so a clip's start and end in seconds are the same seconds in the audio. Then the
|
|
12
|
+
// images play as the video's slideshow does, with the project's motion, and the join burns
|
|
13
|
+
// the captions in through the same `ass` filter and font folder the video's captions use.
|
|
14
|
+
export const shortFrame = { width: 1080, height: 1920 };
|
|
15
|
+
const sampleRate = 44100;
|
|
16
|
+
const channelLayout = "stereo";
|
|
17
|
+
// ceiling: the ducking. The music dips once the narration passes about -34 dBFS, by up to
|
|
18
|
+
// 8:1, within 20 ms, and comes back over 400 ms after the voice stops, so it swells in the
|
|
19
|
+
// pauses without pumping between words. It fades in over the first second and out over the
|
|
20
|
+
// last two.
|
|
21
|
+
const duck = "threshold=0.02:ratio=8:attack=20:release=400:makeup=1";
|
|
22
|
+
const fadeIn = 1;
|
|
23
|
+
const fadeOut = 2;
|
|
24
|
+
// The segments of the timeline the clip overlaps, joined and trimmed to the clip at the
|
|
25
|
+
// sample, as a WAV the slideshow's join plays under the images. At a speed above 1 the
|
|
26
|
+
// narration is played faster with its pitch kept (`atempo`); with music, the file is looped
|
|
27
|
+
// to the clip's length, faded in and out, and mixed under the narration, dipping while it
|
|
28
|
+
// speaks.
|
|
29
|
+
export function shortAudioArgs(timeline, start, end, output, options = {}) {
|
|
30
|
+
const inputs = [];
|
|
31
|
+
const chains = [];
|
|
32
|
+
let offset = 0;
|
|
33
|
+
let first;
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (const segment of timeline) {
|
|
36
|
+
const from = offset;
|
|
37
|
+
offset += segment.seconds;
|
|
38
|
+
if (offset <= start || from >= end)
|
|
39
|
+
continue;
|
|
40
|
+
first ??= from;
|
|
41
|
+
if (segment.path === null)
|
|
42
|
+
inputs.push("-f", "lavfi", "-t", segment.seconds.toFixed(6), "-i", `anullsrc=r=${String(sampleRate)}:cl=${channelLayout}`);
|
|
43
|
+
else
|
|
44
|
+
inputs.push("-i", segment.path);
|
|
45
|
+
chains.push(`[${String(count)}:a]aformat=sample_fmts=fltp:sample_rates=${String(sampleRate)}:channel_layouts=${channelLayout}[a${String(count)}]`);
|
|
46
|
+
count += 1;
|
|
47
|
+
}
|
|
48
|
+
if (count === 0 || first === undefined)
|
|
49
|
+
throw new Error("A short's clip falls outside the narration, so it has no sound to cut. Use Pick different moments in Edit project → Shorts, then Retry stage.");
|
|
50
|
+
const joined = Array.from({ length: count }, (_value, at) => `[a${String(at)}]`).join("");
|
|
51
|
+
chains.push(`${joined}concat=n=${String(count)}:v=0:a=1[joined]`);
|
|
52
|
+
const speed = options.speed ?? 1;
|
|
53
|
+
const music = options.music;
|
|
54
|
+
const trimmed = `[joined]atrim=start=${(start - first).toFixed(6)}:end=${(end - first).toFixed(6)},asetpts=PTS-STARTPTS`;
|
|
55
|
+
const tempo = speed === 1 ? "" : `,atempo=${speed.toFixed(4)}`;
|
|
56
|
+
if (music === undefined)
|
|
57
|
+
chains.push(`${trimmed}${tempo}[a]`);
|
|
58
|
+
else {
|
|
59
|
+
const seconds = (end - start) / speed;
|
|
60
|
+
const out = Math.max(0, seconds - fadeOut);
|
|
61
|
+
// Looped endlessly and cut to the clip, so a file shorter than the short plays again.
|
|
62
|
+
inputs.push("-stream_loop", "-1", "-i", music.path);
|
|
63
|
+
chains.push(`${trimmed}${tempo},asplit=2[voice][key]`);
|
|
64
|
+
chains.push(`[${String(count)}:a]aformat=sample_fmts=fltp:sample_rates=${String(sampleRate)}:channel_layouts=${channelLayout},atrim=end=${seconds.toFixed(6)},asetpts=PTS-STARTPTS,volume=${(music.volume / 100).toFixed(4)},afade=t=in:st=0:d=${String(fadeIn)},afade=t=out:st=${out.toFixed(6)}:d=${String(fadeOut)}[bed]`);
|
|
65
|
+
chains.push(`[bed][key]sidechaincompress=${duck}[ducked]`);
|
|
66
|
+
// The narration keeps its level; `normalize=0` stops amix halving both.
|
|
67
|
+
chains.push("[voice][ducked]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]");
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
"-hide_banner",
|
|
71
|
+
"-nostdin",
|
|
72
|
+
"-loglevel",
|
|
73
|
+
"error",
|
|
74
|
+
"-progress",
|
|
75
|
+
"pipe:1",
|
|
76
|
+
"-nostats",
|
|
77
|
+
"-y",
|
|
78
|
+
...inputs,
|
|
79
|
+
"-filter_complex",
|
|
80
|
+
chains.join(";"),
|
|
81
|
+
"-map",
|
|
82
|
+
"[a]",
|
|
83
|
+
"-c:a",
|
|
84
|
+
"pcm_s16le",
|
|
85
|
+
output,
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
// The images each hold `imageSeconds` in order, the last one running to the end of the clip,
|
|
89
|
+
// moving as the video's shots move by their place.
|
|
90
|
+
export function shortEditList(input) {
|
|
91
|
+
if (input.images.length === 0)
|
|
92
|
+
throw new Error("A short has no images to show. Use Make this short again in Edit project → Shorts, then Retry stage.");
|
|
93
|
+
const total = Math.max(1, Math.round(input.seconds * fps));
|
|
94
|
+
const each = Math.max(1, Math.round(input.imageSeconds * fps));
|
|
95
|
+
const images = input.images.slice(0, Math.max(1, Math.ceil(total / each)));
|
|
96
|
+
const shots = images.flatMap((path, at) => {
|
|
97
|
+
const frames = at < images.length - 1 ? each : total - each * (images.length - 1);
|
|
98
|
+
return frames > 0
|
|
99
|
+
? [
|
|
100
|
+
{
|
|
101
|
+
source: { kind: "image", path },
|
|
102
|
+
frames,
|
|
103
|
+
motion: motionFor(input.motionStyle, at, input.zoomPercent),
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
: [];
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
version: editListVersion,
|
|
110
|
+
...shortFrame,
|
|
111
|
+
fps,
|
|
112
|
+
audio: [{ kind: "body", path: input.audioPath, seconds: input.seconds }],
|
|
113
|
+
shots,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function renderShort(run) {
|
|
117
|
+
const speed = run.speed ?? 1;
|
|
118
|
+
// The images keep their share of what is said, so at a higher speed each is on screen
|
|
119
|
+
// for less time and the count stays the one the prompts were written for.
|
|
120
|
+
const seconds = (run.end - run.start) / speed;
|
|
121
|
+
const workspace = mkdtempSync(join(run.scratch, "short-"));
|
|
122
|
+
try {
|
|
123
|
+
const audio = join(workspace, "clip.wav");
|
|
124
|
+
await runFfmpeg({
|
|
125
|
+
bin: run.bin,
|
|
126
|
+
args: shortAudioArgs(run.timeline, run.start, run.end, audio, {
|
|
127
|
+
speed,
|
|
128
|
+
music: run.music,
|
|
129
|
+
}),
|
|
130
|
+
signal: run.signal,
|
|
131
|
+
log: run.log,
|
|
132
|
+
onProgress: () => undefined,
|
|
133
|
+
});
|
|
134
|
+
writeFileSync(join(workspace, "subtitles.ass"), shortCaptionsAss(fasterWords(run.words, speed), {
|
|
135
|
+
...shortFrame,
|
|
136
|
+
fontName: run.font.assName,
|
|
137
|
+
...(run.title === undefined ? {} : { title: { text: run.title, seconds } }),
|
|
138
|
+
}), { mode: 0o600 });
|
|
139
|
+
mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
|
|
140
|
+
copyFileSync(run.font.path, join(workspace, "fonts", `selected${run.font.extension}`));
|
|
141
|
+
await renderSlideshow({
|
|
142
|
+
bin: run.bin,
|
|
143
|
+
edit: shortEditList({
|
|
144
|
+
audioPath: audio,
|
|
145
|
+
seconds,
|
|
146
|
+
images: run.images,
|
|
147
|
+
imageSeconds: run.imageSeconds / speed,
|
|
148
|
+
motionStyle: run.motionStyle,
|
|
149
|
+
zoomPercent: run.zoomPercent,
|
|
150
|
+
}),
|
|
151
|
+
output: run.output,
|
|
152
|
+
burnSubtitles: true,
|
|
153
|
+
cwd: workspace,
|
|
154
|
+
scratch: workspace,
|
|
155
|
+
signal: run.signal,
|
|
156
|
+
log: run.log,
|
|
157
|
+
onProgress: run.onProgress,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|