@gentbajko/slopify 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter-registry.js +1 -1
- package/dist/adapters/image/fal.js +77 -6
- package/dist/adapters/image/replicate.js +41 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +82 -1
- package/dist/catalog/schema.js +10 -0
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +32 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/runner/attempt.js +4 -1
- package/dist/kernel/runner/providers.js +20 -0
- package/dist/main.js +3 -0
- package/dist/slices/admission/rules.js +44 -2
- package/dist/slices/admission/schema.js +31 -0
- package/dist/slices/estimate/index.js +57 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +6 -1
- package/dist/slices/play-drafts/convert.js +37 -1
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/schema.js +24 -1
- package/dist/slices/project-templates/from-project.js +22 -0
- package/dist/slices/project-templates/setup.js +2 -0
- package/dist/slices/rebuild/preview-details.js +29 -1
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +6 -2
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +1 -0
- package/dist/slices/rebuild/recipe-legacy.js +9 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +179 -0
- package/dist/slices/rebuild/recipe-validation.js +19 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +15 -0
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +330 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/transition-repo.js +39 -0
- package/dist/slices/revisions/mutation-assets.js +18 -0
- package/dist/slices/revisions/mutation-prepare.js +9 -7
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +11 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +8 -0
- package/dist/slices/storage/model.js +6 -0
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-BGgYMm2u.css +1 -0
- package/dist/web/assets/index-BVVgtntL.js +171 -0
- package/dist/web/assets/{pdf-BkdJFKQ-.js → pdf-CAJNT254.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cw1mnRrx.js +0 -168
- package/dist/web/assets/index-kAKdJa-U.css +0 -1
|
@@ -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
|
+
}
|