@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.
Files changed (90) hide show
  1. package/dist/adapter-registry.js +1 -1
  2. package/dist/adapters/image/fal.js +77 -6
  3. package/dist/adapters/image/replicate.js +41 -2
  4. package/dist/adapters/image/video.js +28 -0
  5. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  6. package/dist/assets/fonts/SOURCE.txt +6 -0
  7. package/dist/assets/models.yaml +82 -1
  8. package/dist/catalog/schema.js +10 -0
  9. package/dist/catalog/store.js +2 -2
  10. package/dist/catalog/validate.js +32 -4
  11. package/dist/edge/docker-launch.js +4 -0
  12. package/dist/edge/docker-projects/engine.js +57 -1
  13. package/dist/edge/docker-projects/install.js +10 -2
  14. package/dist/edge/docker-projects/prune.js +66 -0
  15. package/dist/edge/http/providers.js +8 -0
  16. package/dist/edge/http/timeouts.js +38 -0
  17. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  18. package/dist/kernel/runner/attempt.js +4 -1
  19. package/dist/kernel/runner/providers.js +20 -0
  20. package/dist/main.js +3 -0
  21. package/dist/slices/admission/rules.js +44 -2
  22. package/dist/slices/admission/schema.js +31 -0
  23. package/dist/slices/estimate/index.js +57 -2
  24. package/dist/slices/fonts/catalog.js +23 -0
  25. package/dist/slices/fonts/index.js +1 -1
  26. package/dist/slices/library/model.js +8 -1
  27. package/dist/slices/library/slots.js +6 -1
  28. package/dist/slices/play-drafts/convert.js +37 -1
  29. package/dist/slices/play-drafts/readiness.js +7 -3
  30. package/dist/slices/play-drafts/schema.js +24 -1
  31. package/dist/slices/project-templates/from-project.js +22 -0
  32. package/dist/slices/project-templates/setup.js +2 -0
  33. package/dist/slices/rebuild/preview-details.js +29 -1
  34. package/dist/slices/rebuild/preview-plan.js +2 -2
  35. package/dist/slices/rebuild/recipe-build.js +6 -2
  36. package/dist/slices/rebuild/recipe-edit.js +178 -0
  37. package/dist/slices/rebuild/recipe-exports.js +9 -4
  38. package/dist/slices/rebuild/recipe-input-schema.js +1 -0
  39. package/dist/slices/rebuild/recipe-legacy.js +9 -0
  40. package/dist/slices/rebuild/recipe-model.js +7 -1
  41. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  42. package/dist/slices/rebuild/recipe-save.js +25 -1
  43. package/dist/slices/rebuild/recipe-shorts.js +179 -0
  44. package/dist/slices/rebuild/recipe-validation.js +19 -2
  45. package/dist/slices/rebuild/recipe-visual.js +10 -1
  46. package/dist/slices/rebuild/recipe-work.js +83 -1
  47. package/dist/slices/rebuild/runtime-animate.js +110 -0
  48. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  49. package/dist/slices/rebuild/runtime-export.js +13 -1
  50. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  51. package/dist/slices/rebuild/runtime-piece-label.js +15 -0
  52. package/dist/slices/rebuild/runtime-publication.js +25 -4
  53. package/dist/slices/rebuild/runtime-run.js +11 -5
  54. package/dist/slices/rebuild/runtime-shorts.js +330 -0
  55. package/dist/slices/rebuild/runtime-store.js +7 -3
  56. package/dist/slices/rebuild/transition-repo.js +39 -0
  57. package/dist/slices/revisions/mutation-assets.js +18 -0
  58. package/dist/slices/revisions/mutation-prepare.js +9 -7
  59. package/dist/slices/revisions/mutations.js +2 -1
  60. package/dist/slices/revisions/publish.js +20 -2
  61. package/dist/slices/revisions/schema.js +11 -0
  62. package/dist/slices/shorts/captions.js +272 -0
  63. package/dist/slices/shorts/clips.js +89 -0
  64. package/dist/slices/shorts/model.js +186 -0
  65. package/dist/slices/shorts/pick.js +232 -0
  66. package/dist/slices/shorts/prompts.js +68 -0
  67. package/dist/slices/shorts/render.js +163 -0
  68. package/dist/slices/storage/asset-name.js +9 -2
  69. package/dist/slices/storage/layout.js +8 -0
  70. package/dist/slices/storage/model.js +6 -0
  71. package/dist/slices/storage/schema.js +3 -0
  72. package/dist/slices/telemetry/model.js +3 -0
  73. package/dist/slices/video/cards.js +72 -0
  74. package/dist/slices/video/chapters.js +89 -0
  75. package/dist/slices/video/cuts.js +141 -0
  76. package/dist/slices/video/edit-list.js +36 -6
  77. package/dist/slices/video/edit-settings.js +191 -0
  78. package/dist/slices/video/ffmpeg.js +155 -29
  79. package/dist/slices/video/look.js +164 -0
  80. package/dist/slices/video/plan.js +68 -11
  81. package/dist/slices/video/slideshow.js +22 -6
  82. package/dist/slices/video/transitions.js +51 -0
  83. package/dist/slices/youtube/transcript.js +54 -0
  84. package/dist/web/assets/index-BGgYMm2u.css +1 -0
  85. package/dist/web/assets/index-BVVgtntL.js +171 -0
  86. package/dist/web/assets/{pdf-BkdJFKQ-.js → pdf-CAJNT254.js} +1 -1
  87. package/dist/web/index.html +2 -2
  88. package/package.json +1 -1
  89. package/dist/web/assets/index-Cw1mnRrx.js +0 -168
  90. package/dist/web/assets/index-kAKdJa-U.css +0 -1
@@ -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
+ }
@@ -2,7 +2,8 @@
2
2
  // the project page builds the same URL for every image, player and download link it
3
3
  // draws, and `downloads.ts` reads the disk: importing it into the SPA would drag
4
4
  // `node:fs` and the zip encoder into the browser bundle. Nothing here touches IO.
5
- // An output's role is its asset name; images add their place in the slideshow, and the
5
+ // An output's role is its asset name; images (and animated images) add their place in the
6
+ // slideshow, and the
6
7
  // instructions add their stage, those being the two roles a project holds more than one of -
7
8
  // research and the article each store what they sent.
8
9
  export function assetOf(output) {
@@ -10,10 +11,16 @@ export function assetOf(output) {
10
11
  if ((output.role === "narration_txt" || output.role === "tts_script") &&
11
12
  output.meta.segment !== undefined)
12
13
  return `${asset}-${output.meta.segment}`;
14
+ // A project holds one video per short and several images per short.
15
+ if (output.role === "short_video" && output.meta.short !== undefined)
16
+ return `${asset}-${String(output.meta.short)}`;
17
+ if (output.role === "short_image" && output.meta.short !== undefined)
18
+ return `${asset}-${String(output.meta.short)}-${String(output.meta.index ?? 1)}`;
13
19
  if (output.role === "instructions") {
14
20
  return `${output.stageKind}-${asset}`;
15
21
  }
16
- return output.role === "image" && output.meta.index !== undefined
22
+ return (output.role === "image" || output.role === "animated_image") &&
23
+ output.meta.index !== undefined
17
24
  ? `${asset}-${String(output.meta.index)}`
18
25
  : asset;
19
26
  }
@@ -60,6 +60,14 @@ export function outputFileName(role, index, extension, stageKind) {
60
60
  return "description.txt";
61
61
  case "youtube_tags":
62
62
  return "tags.txt";
63
+ case "shorts":
64
+ return "shorts.json";
65
+ case "short_image":
66
+ return `shorts/image-${String(index).padStart(3, "0")}${extension}`;
67
+ case "short_video":
68
+ return `shorts/short-${String(index).padStart(2, "0")}${extension}`;
69
+ case "animated_image":
70
+ return `animated/image-${String(index).padStart(3, "0")}${extension}`;
63
71
  case "audio_body":
64
72
  return `audio-body${extension}`;
65
73
  case "audio_export":
@@ -27,5 +27,11 @@ export const outputRoles = [
27
27
  "document_pdf",
28
28
  "youtube_description",
29
29
  "youtube_tags",
30
+ // The Shorts step: the picked clips (JSON), each clip's vertical images, and each clip.
31
+ "shorts",
32
+ "short_image",
33
+ "short_video",
34
+ // An image of the slideshow brought to life by an image-to-video model (Animate images).
35
+ "animated_image",
30
36
  ];
31
37
  export const stagedFileStates = ["copying", "staged"];
@@ -9,6 +9,9 @@ export const metaSchema = z.object({
9
9
  promptName: z.string().optional(),
10
10
  prompt: z.string().optional(),
11
11
  index: z.number().optional(),
12
+ short: z.number().optional(),
13
+ sentences: z.tuple([z.number(), z.number()]).optional(),
14
+ warnings: z.array(z.string()).optional(),
12
15
  provider: z.string().optional(),
13
16
  model: z.string().optional(),
14
17
  voice: z.string().optional(),
@@ -37,4 +37,7 @@ export const payloadSchema = z.strictObject({
37
37
  // A written YouTube description. Its event names no stage: it is made inside the Video
38
38
  // stage, and a collector counting stage "video" as a finished video must not count it.
39
39
  descriptions: z.number().int().nonnegative().optional(),
40
+ // A rendered short. Like a description it names no stage: the Shorts step runs inside
41
+ // the Video stage, and a short is not a finished video.
42
+ shorts: z.number().int().nonnegative().optional(),
40
43
  });
@@ -0,0 +1,72 @@
1
+ // Chapter cards as an ASS script the clips draw with libass, the renderer the captions use,
2
+ // so a card is set in the subtitle font without a font-rendering filter of its own (this
3
+ // ffmpeg has none). Each card is centred, well above where captions sit by default, and fades
4
+ // in and out. Every clip a card shows in gets its own script with the times counted from the
5
+ // clip's first frame (`offset`), since a clip's frames count from zero.
6
+ // ceiling: the owner's eye. The fades take 0.4 s each of the card's 2.5 s.
7
+ const fadeMs = 400;
8
+ export function cardsAss(edit, cards, fontName, offset = 0) {
9
+ const { width, height, fps } = edit;
10
+ // The title is sized from the frame's short side, so a vertical video's card fits too. At
11
+ // 8% it read as a caption rather than a chapter title; 11% and bold gives it presence.
12
+ const size = Math.round(Math.min(width, height) * 0.11);
13
+ // A wide, blurred, half-clear dark outline: a soft shadow all round that keeps the title
14
+ // readable on a bright picture without a box behind it.
15
+ const outline = Math.max(2, Math.round(size / 10));
16
+ const header = [
17
+ "[Script Info]",
18
+ "ScriptType: v4.00+",
19
+ `PlayResX: ${String(width)}`,
20
+ `PlayResY: ${String(height)}`,
21
+ "WrapStyle: 0",
22
+ "ScaledBorderAndShadow: yes",
23
+ "",
24
+ "[V4+ Styles]",
25
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
26
+ // White bold text, the soft outline, no offset shadow, centred (alignment 5), letter-spaced.
27
+ `Style: Card,${fontName.replace(/,/g, " ")},${String(size)},&H00FFFFFF,&H00FFFFFF,&H60000000,&H00000000,-1,0,0,0,100,100,${String(Math.round(size / 12))},0,1,${String(outline)},0,5,${String(Math.round(width * 0.1))},${String(Math.round(width * 0.1))},0,1`,
28
+ "",
29
+ "[Events]",
30
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
31
+ ];
32
+ const events = cards.flatMap((card) => {
33
+ const from = card.startFrame - offset;
34
+ const to = from + card.frames;
35
+ if (to <= 0)
36
+ return [];
37
+ // A card that began before this clip starts with the clip, part way through its fade in
38
+ // (or past it), so it runs on from the clip before without a second fade.
39
+ const before = Math.max(0, (-from / fps) * 1000);
40
+ const length = Math.round((to / fps) * 1000);
41
+ const fade = before === 0
42
+ ? `\\fad(${String(fadeMs)},${String(fadeMs)})`
43
+ : `\\fade(${String(Math.round(255 * Math.max(0, 1 - before / fadeMs)))},0,255,0,${String(Math.max(0, Math.round(fadeMs - before)))},${String(length - fadeMs)},${String(length)})`;
44
+ return [
45
+ `Dialogue: 0,${assTime(Math.max(0, from) / fps)},${assTime(to / fps)},Card,,0,0,0,,{\\blur${String(outline)}${fade}${settle(before)}}${assText(card.title)}`,
46
+ ];
47
+ });
48
+ return `${[...header, ...events].join("\n")}\n`;
49
+ }
50
+ // The title settles from 96% to full size while it fades in, a small movement that reads as
51
+ // a title arriving rather than text appearing. A card carried on from the clip before is
52
+ // already settled.
53
+ function settle(before) {
54
+ if (before >= fadeMs)
55
+ return "";
56
+ const from = Math.round(96 + (4 * before) / fadeMs);
57
+ return `\\fscx${String(from)}\\fscy${String(from)}\\t(0,${String(Math.round(fadeMs - before))},\\fscx100\\fscy100)`;
58
+ }
59
+ // h:mm:ss.cc, the centiseconds ASS counts in.
60
+ function assTime(seconds) {
61
+ const centis = Math.max(0, Math.round(seconds * 100));
62
+ const h = Math.floor(centis / 360000);
63
+ const m = Math.floor((centis % 360000) / 6000);
64
+ const s = Math.floor((centis % 6000) / 100);
65
+ const c = centis % 100;
66
+ return `${String(h)}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(c).padStart(2, "0")}`;
67
+ }
68
+ // A title is plain text: braces would start an override block and a backslash an escape, so
69
+ // both are replaced, and a line break is a space.
70
+ function assText(text) {
71
+ return text.replace(/[{}]/g, "").replace(/\\/g, "/").replace(/\s+/g, " ").trim();
72
+ }