@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,272 @@
1
+ // The shorts' captions: big, centred a little below the middle of the 9:16 frame, two to
2
+ // four words on screen at a time, and the word being spoken drawn in a strong colour. Built
3
+ // as ASS so the render burns them in with the same `ass` filter and font folder the video's
4
+ // captions use. Pure.
5
+ // Groups break at a sentence end, after a comma once they hold two words, at a pause, and
6
+ // at four words.
7
+ const groupMost = 4;
8
+ const groupPause = 0.6;
9
+ // A word left alone joins a neighbour across a pause up to this long; across a longer one
10
+ // the neighbour would hang on screen through the silence.
11
+ const joinPause = 1.5;
12
+ // A group stays on screen this long after its last word, unless the next one starts first,
13
+ // so a short pause doesn't flash an empty frame.
14
+ const holdSeconds = 0.5;
15
+ // ceiling: 7.5% of the height is about 144 px on 1080×1920, which fits twelve characters of
16
+ // a typical sans-serif per line inside the side margins.
17
+ const fontShare = 0.075;
18
+ const lineCharacters = 12;
19
+ const sideMargin = 80;
20
+ // A little below the middle: clear of the face-height centre of most images, and clear of
21
+ // the title and buttons the apps draw along the bottom.
22
+ const heightShare = 0.62;
23
+ // ASS colours are &HAABBGGRR. White for the words around it, gold for the one being spoken.
24
+ const white = "&H00FFFFFF";
25
+ const spoken = "&H0000D4FF";
26
+ // ceiling: the title headline starts at 6% of the height (115 px) and shrinks to 3.5% until
27
+ // it fits two lines. Barlow Bold averages about a third of its size per letter; 0.45 leaves
28
+ // room for wider fonts a project may caption in.
29
+ const titleShares = [0.06, 0.055, 0.05, 0.045, 0.04, 0.035];
30
+ const letterWidth = 0.45;
31
+ // Below the status bar, search and camera buttons the apps draw along the top: 11% of the
32
+ // height is 211 px on 1920.
33
+ const titleTop = 0.11;
34
+ // The word times of a clip played `speed` times faster: every time divided, so the captions
35
+ // stay on the words as they are heard.
36
+ export function fasterWords(words, speed) {
37
+ if (speed === 1)
38
+ return words;
39
+ return words.map((word) => ({
40
+ ...word,
41
+ start: millis(word.start / speed),
42
+ end: millis(word.end / speed),
43
+ }));
44
+ }
45
+ // The words said inside the clip, with their times moved to the clip's own timeline and
46
+ // clamped to it. A word is kept when most of it falls inside the clip.
47
+ export function clipWords(words, start, end) {
48
+ const length = end - start;
49
+ return words
50
+ .filter((word) => (word.start + word.end) / 2 >= start && (word.start + word.end) / 2 <= end)
51
+ .map((word) => ({
52
+ text: word.text.replace(/\s+/g, " ").trim(),
53
+ start: millis(clamp(word.start - start, 0, length)),
54
+ end: millis(clamp(word.end - start, 0, length)),
55
+ }))
56
+ .filter((word) => word.text !== "" && word.end > word.start);
57
+ }
58
+ export function captionGroups(words) {
59
+ const groups = [];
60
+ let current = [];
61
+ const close = () => {
62
+ if (current.length > 0)
63
+ groups.push(current);
64
+ current = [];
65
+ };
66
+ for (const word of words) {
67
+ const previous = current.at(-1);
68
+ if (previous !== undefined && word.start - previous.end > groupPause)
69
+ close();
70
+ current.push(word);
71
+ if (current.length >= groupMost ||
72
+ sentenceEnd(word.text) ||
73
+ (current.length >= 2 && /[,;:]["'’”)]*$/.test(word.text)))
74
+ close();
75
+ }
76
+ close();
77
+ // Every change leaves one word fewer on its own, so this ends.
78
+ let changed = true;
79
+ while (changed) {
80
+ changed = groups.some((group, at) => group.length === 1 && keepCompany(groups, at));
81
+ for (let at = groups.length - 1; at >= 0; at--)
82
+ if (groups[at]?.length === 0)
83
+ groups.splice(at, 1);
84
+ }
85
+ return groups;
86
+ }
87
+ // A word left on its own would flash by alone, so it joins a neighbouring group: the one
88
+ // before it, or, when it opens a sentence, the one after it first. When the neighbour is
89
+ // too wide to take it, the neighbour's nearest word comes over to make a pair instead,
90
+ // as long as that leaves the neighbour two words. It stays alone only after a long
91
+ // silence, or between neighbours of one or two words that cannot fit it. Returns whether
92
+ // the groups changed.
93
+ function keepCompany(groups, at) {
94
+ const lone = groups[at];
95
+ const word = lone?.[0];
96
+ if (lone === undefined || word === undefined)
97
+ return false;
98
+ const before = groups[at - 1];
99
+ const after = groups[at + 1];
100
+ const opensSentence = before?.at(-1) !== undefined && sentenceEnd(before.at(-1)?.text ?? "");
101
+ // Words either side of a long silence stay apart: joined, one would sit on screen through it.
102
+ const near = (left, right) => left !== undefined && right !== undefined && right.start - left.end <= joinPause;
103
+ const tries = [
104
+ () => {
105
+ if (before === undefined || !near(before.at(-1), word))
106
+ return false;
107
+ if (!fits([...before, word], before))
108
+ return false;
109
+ before.push(word);
110
+ lone.length = 0;
111
+ return true;
112
+ },
113
+ () => {
114
+ if (after === undefined || !near(word, after[0]))
115
+ return false;
116
+ if (!fits([word, ...after], after))
117
+ return false;
118
+ after.unshift(word);
119
+ lone.length = 0;
120
+ return true;
121
+ },
122
+ () => {
123
+ const moved = before?.at(-1);
124
+ if (before === undefined || before.length < 3 || !near(moved, word))
125
+ return false;
126
+ before.pop();
127
+ if (moved !== undefined)
128
+ lone.unshift(moved);
129
+ return true;
130
+ },
131
+ () => {
132
+ const moved = after?.[0];
133
+ if (after === undefined || after.length < 3 || !near(word, moved))
134
+ return false;
135
+ after.shift();
136
+ if (moved !== undefined)
137
+ lone.push(moved);
138
+ return true;
139
+ },
140
+ ];
141
+ const order = opensSentence ? [1, 3, 0, 2] : [0, 2, 1, 3];
142
+ return order.some((index) => tries[index]?.() === true);
143
+ }
144
+ // A group with one more word fits when it holds at most one word over the usual most and
145
+ // its longest line is no wider than a line holds, or than the group's own longest already
146
+ // was.
147
+ function fits(merged, group) {
148
+ const widest = (words) => longestLine(words.map((one) => one.text));
149
+ return (merged.length <= groupMost + 1 && widest(merged) <= Math.max(lineCharacters, widest(group)));
150
+ }
151
+ function sentenceEnd(text) {
152
+ return /[.!?]["'’”)]*$/.test(text);
153
+ }
154
+ export function shortCaptionsAss(words, style) {
155
+ const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
156
+ const size = Math.round(style.height * fontShare);
157
+ const position = `{\\an5\\pos(${String(style.width / 2)},${String(Math.round(style.height * heightShare))})}`;
158
+ const title = style.title === undefined || style.title.text.trim() === ""
159
+ ? undefined
160
+ : { ...titleLayout(style.title.text, style), seconds: style.title.seconds };
161
+ const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${String(style.width)}\nPlayResY: ${String(style.height)}\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Short,${name},${String(size)},${white},${white},&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,7,3,5,${String(sideMargin)},${String(sideMargin)},0,1\n${title === undefined ? "" : `Style: Title,${name},${String(title.size)},${white},${white},&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,6,3,8,${String(sideMargin)},${String(sideMargin)},0,1\n`}\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
162
+ const groups = captionGroups(words);
163
+ // The headline sits on a layer of its own, so a caption never hides it.
164
+ const events = title === undefined
165
+ ? []
166
+ : [
167
+ `Dialogue: 1,${assStamp(0)},${assStamp(title.seconds)},Title,,0,0,0,,{\\an8\\pos(${String(style.width / 2)},${String(Math.round(style.height * titleTop))})}${title.lines.map(assText).join("\\N")}\n`,
168
+ ];
169
+ for (const [at, group] of groups.entries()) {
170
+ const next = groups[at + 1]?.[0];
171
+ const last = group.at(-1);
172
+ if (last === undefined)
173
+ continue;
174
+ const shown = next === undefined ? last.end + holdSeconds : Math.min(last.end + holdSeconds, next.start);
175
+ const lines = lineBreaks(group.map((word) => word.text));
176
+ for (const [index, word] of group.entries()) {
177
+ const from = word.start;
178
+ const to = group[index + 1]?.start ?? Math.max(shown, word.end);
179
+ if (to <= from)
180
+ continue;
181
+ const text = group
182
+ .map((one, place) => {
183
+ const escaped = assText(one.text);
184
+ const shownWord = place === index
185
+ ? `{\\c${spoken}&\\fscx108\\fscy108}${escaped}{\\c${white}&\\fscx100\\fscy100}`
186
+ : escaped;
187
+ return `${shownWord}${lines.has(place) ? "\\N" : place < group.length - 1 ? " " : ""}`;
188
+ })
189
+ .join("");
190
+ events.push(`Dialogue: 0,${assStamp(from)},${assStamp(to)},Short,,0,0,0,,${position}${text}\n`);
191
+ }
192
+ }
193
+ return header + events.join("");
194
+ }
195
+ // The places after which a group breaks onto a second line: none when it fits on one,
196
+ // otherwise the one break that makes the longer line shortest.
197
+ function lineBreaks(texts, fit = lineCharacters) {
198
+ const total = texts.join(" ").length;
199
+ if (total <= fit || texts.length < 2)
200
+ return new Set();
201
+ let best = 0;
202
+ let bestLongest = Number.POSITIVE_INFINITY;
203
+ for (let after = 0; after < texts.length - 1; after++) {
204
+ const top = texts.slice(0, after + 1).join(" ").length;
205
+ const bottom = texts.slice(after + 1).join(" ").length;
206
+ const longest = Math.max(top, bottom);
207
+ if (longest < bestLongest) {
208
+ bestLongest = longest;
209
+ best = after;
210
+ }
211
+ }
212
+ return new Set([best]);
213
+ }
214
+ // The characters of the longest line the words are drawn on.
215
+ function longestLine(texts) {
216
+ return Math.max(...splitAt(texts, lineBreaks(texts)).map((line) => line.length));
217
+ }
218
+ // The title headline: the largest size at which it fits two lines inside the side margins,
219
+ // broken where the longer line is shortest; at the smallest size a title still too long is
220
+ // cut at a word and ends in an ellipsis.
221
+ export function titleLayout(title, style) {
222
+ const words = title
223
+ .replace(/\s+/g, " ")
224
+ .trim()
225
+ .split(" ")
226
+ .filter((word) => word !== "");
227
+ const room = (share) => Math.floor((style.width - 2 * sideMargin) / (style.height * share * letterWidth));
228
+ const laid = (share, texts) => {
229
+ const fit = room(share);
230
+ const breaks = lineBreaks(texts, fit);
231
+ const lines = breaks.size === 0 ? [texts.join(" ")] : splitAt(texts, breaks);
232
+ return { size: Math.round(style.height * share), lines, fit };
233
+ };
234
+ for (const share of titleShares) {
235
+ const layout = laid(share, words);
236
+ if (layout.lines.every((line) => line.length <= layout.fit))
237
+ return { size: layout.size, lines: layout.lines };
238
+ }
239
+ const smallest = titleShares.at(-1) ?? 0.03;
240
+ const fit = room(smallest);
241
+ const kept = [];
242
+ for (const word of words) {
243
+ if ([...kept, word].join(" ").length + 1 > fit * 2 - 1)
244
+ break;
245
+ kept.push(word);
246
+ }
247
+ const layout = laid(smallest, [...kept.slice(0, -1), `${kept.at(-1) ?? ""}…`]);
248
+ return { size: layout.size, lines: layout.lines };
249
+ }
250
+ function splitAt(texts, breaks) {
251
+ const lines = [[]];
252
+ for (const [at, text] of texts.entries()) {
253
+ lines.at(-1)?.push(text);
254
+ if (breaks.has(at))
255
+ lines.push([]);
256
+ }
257
+ return lines.map((line) => line.join(" "));
258
+ }
259
+ function assText(text) {
260
+ // Full-width alternatives stay visible and cannot open an ASS override or escape.
261
+ return text.replaceAll("\\", "\").replaceAll("{", "{").replaceAll("}", "}");
262
+ }
263
+ function assStamp(seconds) {
264
+ const cs = Math.max(0, Math.round(seconds * 100));
265
+ return `${String(Math.floor(cs / 360_000))}:${String(Math.floor(cs / 6000) % 60).padStart(2, "0")}:${String(Math.floor(cs / 100) % 60).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
266
+ }
267
+ function millis(seconds) {
268
+ return Math.round(seconds * 1000) / 1000;
269
+ }
270
+ function clamp(value, low, high) {
271
+ return Math.min(high, Math.max(low, value));
272
+ }
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import { clipBounds, shortPickSchema } from "./pick.js";
3
+ // What the pick saves in its payload. `sentences` (the numbered transcript's times and
4
+ // words) came with hand-set ranges; a pick saved before them has none, and its clips can
5
+ // only be adjusted after the moments are picked again.
6
+ export const pickedShortsSchema = z.object({
7
+ shorts: z.array(shortPickSchema),
8
+ durationSeconds: z.number().finite().nonnegative().optional(),
9
+ sentences: z
10
+ .array(z.object({
11
+ start: z.number().finite().nonnegative(),
12
+ end: z.number().finite().nonnegative(),
13
+ text: z.string(),
14
+ }))
15
+ .optional(),
16
+ });
17
+ export function pickedShortsOf(payload) {
18
+ if (payload === null || payload === undefined)
19
+ return undefined;
20
+ try {
21
+ const parsed = pickedShortsSchema.safeParse(JSON.parse(payload));
22
+ return parsed.success ? parsed.data : undefined;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ // Why a hand-set range can't be used, in the words Edit project shows under the clip; or
29
+ // undefined when it can.
30
+ export function rangeProblem(picked, number, first, last, limits, others = []) {
31
+ const sentences = picked.sentences;
32
+ if (sentences === undefined || sentences.length === 0)
33
+ return "These shorts were picked before clips could be adjusted by hand. Use Pick different moments, then adjust the new clips.";
34
+ if (!picked.shorts.some((clip) => clip.number === number))
35
+ return `There is no short ${String(number)} any more. Reload the page and choose again.`;
36
+ if (!Number.isInteger(first) ||
37
+ !Number.isInteger(last) ||
38
+ first < 1 ||
39
+ last > sentences.length ||
40
+ first > last)
41
+ return `Choose a start sentence that comes before the end sentence, between 1 and ${String(sentences.length)}.`;
42
+ const bounds = clipBounds(sentences, first, last, durationOf(picked));
43
+ if (bounds === undefined)
44
+ return "Choose a start and an end sentence from the transcript.";
45
+ const seconds = bounds.end - bounds.start;
46
+ // The same hundredth-of-a-second allowance the pick is checked with.
47
+ if (seconds < limits.minSeconds - 0.01)
48
+ return `Short ${String(number)} would last ${String(Math.round(seconds))} seconds, shorter than the ${String(limits.minSeconds)}-second minimum. Start it earlier or end it later.`;
49
+ if (seconds > limits.maxSeconds + 0.01)
50
+ return `Short ${String(number)} would last ${String(Math.round(seconds))} seconds, longer than the ${String(limits.maxSeconds)}-second maximum. Start it later or end it earlier.`;
51
+ const clash = others.find((clip) => clip.number !== number && first <= clip.last && last >= clip.first);
52
+ if (clash !== undefined)
53
+ return `Short ${String(number)} would share sentences with short ${String(clash.number)} (sentences ${String(clash.first)}-${String(clash.last)}). Choose a range that doesn't overlap it.`;
54
+ return undefined;
55
+ }
56
+ // The picked clips with every usable hand-set range in place: the model's title,
57
+ // description and hashtags stay, the sentences, times and words are the range's. A range
58
+ // set against another pick, or one that no longer fits the limits, is left out.
59
+ export function effectiveClips(picked, ranges, pick, limits) {
60
+ const sentences = picked.sentences;
61
+ if (ranges === undefined || sentences === undefined)
62
+ return picked.shorts;
63
+ const clips = [...picked.shorts];
64
+ for (const [at, clip] of clips.entries()) {
65
+ const range = ranges[String(clip.number)];
66
+ if (range === undefined || range.pick !== pick)
67
+ continue;
68
+ if (rangeProblem(picked, clip.number, range.first, range.last, limits, clips) !== undefined)
69
+ continue;
70
+ const bounds = clipBounds(sentences, range.first, range.last, durationOf(picked));
71
+ if (bounds === undefined)
72
+ continue;
73
+ clips[at] = {
74
+ ...clip,
75
+ first: range.first,
76
+ last: range.last,
77
+ start: bounds.start,
78
+ end: bounds.end,
79
+ text: sentences
80
+ .slice(range.first - 1, range.last)
81
+ .map((sentence) => sentence.text)
82
+ .join(" "),
83
+ };
84
+ }
85
+ return clips;
86
+ }
87
+ function durationOf(picked) {
88
+ return picked.durationSeconds ?? picked.sentences?.at(-1)?.end ?? 0;
89
+ }
@@ -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
+ }