@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,89 @@
|
|
|
1
|
+
import { splitEndMatter } from "../article/split.js";
|
|
2
|
+
// ceiling: a heading is looked for within 200 words either side of where its share of the
|
|
3
|
+
// article puts it; past that, a rewritten narration has drifted too far to trust a match.
|
|
4
|
+
const searchWords = 200;
|
|
5
|
+
// The heading's first words that have to match, in order.
|
|
6
|
+
const matchWords = 4;
|
|
7
|
+
export function headingChapters(markdown, words) {
|
|
8
|
+
if (words.length === 0)
|
|
9
|
+
return [];
|
|
10
|
+
const headings = topHeadings(splitEndMatter(markdown).body);
|
|
11
|
+
const articleWords = tokens(splitEndMatter(markdown).body).length;
|
|
12
|
+
if (headings.length === 0 || articleWords === 0)
|
|
13
|
+
return [];
|
|
14
|
+
const spoken = words.map((word) => normalise(word.text));
|
|
15
|
+
const chapters = [];
|
|
16
|
+
let after = -1;
|
|
17
|
+
for (const heading of headings) {
|
|
18
|
+
const estimate = Math.round((heading.at / articleWords) * words.length);
|
|
19
|
+
const wanted = tokens(heading.title).slice(0, matchWords);
|
|
20
|
+
const found = findNear(spoken, wanted, estimate, after + 1);
|
|
21
|
+
const index = found ?? Math.max(after + 1, Math.min(estimate, words.length - 1));
|
|
22
|
+
const word = words[index];
|
|
23
|
+
if (word === undefined || index <= after)
|
|
24
|
+
continue;
|
|
25
|
+
chapters.push({ title: heading.title, start: word.start });
|
|
26
|
+
after = index;
|
|
27
|
+
}
|
|
28
|
+
return chapters;
|
|
29
|
+
}
|
|
30
|
+
// The article's chapter headings: the highest level used, except a lone title heading at the
|
|
31
|
+
// very top, which names the article rather than a part of it.
|
|
32
|
+
export function topHeadings(markdown) {
|
|
33
|
+
const found = [];
|
|
34
|
+
let fenced = false;
|
|
35
|
+
let before = 0;
|
|
36
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
37
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
38
|
+
fenced = !fenced;
|
|
39
|
+
const matched = fenced ? null : /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
40
|
+
if (matched?.[1] !== undefined && matched[2] !== undefined) {
|
|
41
|
+
const title = plainHeading(matched[2]);
|
|
42
|
+
if (title !== "")
|
|
43
|
+
found.push({ level: matched[1].length, title, at: before });
|
|
44
|
+
}
|
|
45
|
+
before += tokens(line).length;
|
|
46
|
+
}
|
|
47
|
+
if (found.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const top = Math.min(...found.map((heading) => heading.level));
|
|
50
|
+
const atTop = found.filter((heading) => heading.level === top);
|
|
51
|
+
const first = found[0];
|
|
52
|
+
if (atTop.length === 1 && first !== undefined && first.level === top && found.length > 1) {
|
|
53
|
+
const rest = found.slice(1);
|
|
54
|
+
const next = Math.min(...rest.map((heading) => heading.level));
|
|
55
|
+
return rest.filter((heading) => heading.level === next);
|
|
56
|
+
}
|
|
57
|
+
return atTop;
|
|
58
|
+
}
|
|
59
|
+
function plainHeading(text) {
|
|
60
|
+
return text
|
|
61
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
62
|
+
.replace(/[*_`~]/g, "")
|
|
63
|
+
.replace(/\s+/g, " ")
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
function tokens(text) {
|
|
67
|
+
return text
|
|
68
|
+
.split(/\s+/)
|
|
69
|
+
.map(normalise)
|
|
70
|
+
.filter((token) => token !== "");
|
|
71
|
+
}
|
|
72
|
+
function normalise(word) {
|
|
73
|
+
return word.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
74
|
+
}
|
|
75
|
+
// The start of the run of `wanted` in `spoken` nearest to `estimate`, at or after `from`.
|
|
76
|
+
function findNear(spoken, wanted, estimate, from) {
|
|
77
|
+
if (wanted.length === 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
const low = Math.max(from, estimate - searchWords);
|
|
80
|
+
const high = Math.min(spoken.length - wanted.length, estimate + searchWords);
|
|
81
|
+
let best;
|
|
82
|
+
for (let at = low; at <= high; at += 1) {
|
|
83
|
+
if (!wanted.every((token, offset) => spoken[at + offset] === token))
|
|
84
|
+
continue;
|
|
85
|
+
if (best === undefined || Math.abs(at - estimate) < Math.abs(best - estimate))
|
|
86
|
+
best = at;
|
|
87
|
+
}
|
|
88
|
+
return best;
|
|
89
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { transcriptSentences } from "../youtube/transcript.js";
|
|
2
|
+
import { cutFloorShare } from "./edit-settings.js";
|
|
3
|
+
// "Follow the narration": every cut lands in the pause after a sentence, as near to Seconds
|
|
4
|
+
// per image as the sentences allow, and a chapter always starts on a new shot. Pure, so the
|
|
5
|
+
// same timing plans the same cuts every time and a re-render is identical.
|
|
6
|
+
//
|
|
7
|
+
// The images carry no link to a passage of the article: each one is rendered from an image
|
|
8
|
+
// prompt template filled with the run's keyword values, never from a paragraph. So they are
|
|
9
|
+
// spread over the narration in slideshow order, taking turns as they do with Every N
|
|
10
|
+
// seconds; only where the cuts fall changes.
|
|
11
|
+
// A cut sits in the pause between two sentences: halfway through it, but never more than this
|
|
12
|
+
// long after the last word, so a long breath does not hold the old picture over the new
|
|
13
|
+
// sentence's first word.
|
|
14
|
+
const pauseCutSeconds = 0.25;
|
|
15
|
+
// A chapter start is moved onto the nearest sentence pause within this many seconds; a
|
|
16
|
+
// chapter the model or a heading places mid-sentence would otherwise cut mid-word.
|
|
17
|
+
const chapterSnapSeconds = 3;
|
|
18
|
+
// Where the narration pauses between sentences, in seconds into the video. The word times
|
|
19
|
+
// already count the silence at the start, the intro and the gaps (`runtime-subtitles.ts`).
|
|
20
|
+
export function sentenceCutPoints(words) {
|
|
21
|
+
const sentences = transcriptSentences(words);
|
|
22
|
+
const points = [];
|
|
23
|
+
for (const [index, sentence] of sentences.entries()) {
|
|
24
|
+
const next = sentences[index + 1];
|
|
25
|
+
if (next === undefined)
|
|
26
|
+
break;
|
|
27
|
+
const pause = Math.max(0, next.start - sentence.end);
|
|
28
|
+
points.push(sentence.end + Math.min(pause / 2, pauseCutSeconds));
|
|
29
|
+
}
|
|
30
|
+
return points;
|
|
31
|
+
}
|
|
32
|
+
// The frames of each shot, in timeline order, adding up to `totalFrames`. Between two
|
|
33
|
+
// chapter starts the shots are laid greedily: each next cut is the sentence pause nearest to
|
|
34
|
+
// Seconds per image after the last one, no nearer than the floor (40% of it) and no further
|
|
35
|
+
// than twice it; with no pause in that window the cut falls at Seconds per image, as it does
|
|
36
|
+
// with Every N seconds. What is left before a chapter start that could not hold a full shot
|
|
37
|
+
// and a floor-length one after it becomes the last shot, so no shot runs past 1.4 times
|
|
38
|
+
// Seconds per image unless the sentences leave no pause.
|
|
39
|
+
export function narrationShotFrames(input) {
|
|
40
|
+
const total = input.totalFrames / input.fps;
|
|
41
|
+
if (!(total > 0) || !(input.imageSeconds > 0))
|
|
42
|
+
return [Math.max(1, input.totalFrames)];
|
|
43
|
+
const floor = input.imageSeconds * (input.floorShare ?? cutFloorShare);
|
|
44
|
+
const points = [...input.cutPoints]
|
|
45
|
+
.filter((point) => Number.isFinite(point) && point > 0 && point < total)
|
|
46
|
+
.toSorted((left, right) => left - right);
|
|
47
|
+
// A chapter that opens with the narration's first words would cut the silence before them
|
|
48
|
+
// into a shot of its own; the first shot opens that chapter anyway.
|
|
49
|
+
const bounds = chapterCuts(input.chapterStarts, points, total).filter((cut) => cut >= floor);
|
|
50
|
+
const cuts = [];
|
|
51
|
+
let start = 0;
|
|
52
|
+
for (const end of [...bounds, total]) {
|
|
53
|
+
let position = start;
|
|
54
|
+
while (end - position > input.imageSeconds + floor) {
|
|
55
|
+
const target = position + input.imageSeconds;
|
|
56
|
+
const low = position + floor;
|
|
57
|
+
const high = Math.min(position + 2 * input.imageSeconds, end - floor);
|
|
58
|
+
const cut = nearest(points.filter((point) => point >= low && point <= high), target);
|
|
59
|
+
position = cut ?? target;
|
|
60
|
+
cuts.push(position);
|
|
61
|
+
}
|
|
62
|
+
if (end < total)
|
|
63
|
+
cuts.push(end);
|
|
64
|
+
start = end;
|
|
65
|
+
}
|
|
66
|
+
return framesBetween(cuts, input.totalFrames, input.fps);
|
|
67
|
+
}
|
|
68
|
+
// The chapter starts as cuts: inside the video, each moved to the nearest pause within 3 s,
|
|
69
|
+
// and at least one frame from the start, the end and each other.
|
|
70
|
+
export function chapterCuts(starts, points, total) {
|
|
71
|
+
const snapped = starts
|
|
72
|
+
.filter((start) => Number.isFinite(start))
|
|
73
|
+
.map((start) => {
|
|
74
|
+
const near = nearest(points.filter((point) => Math.abs(point - start) <= chapterSnapSeconds), start);
|
|
75
|
+
return near ?? start;
|
|
76
|
+
})
|
|
77
|
+
.filter((start) => start > 0 && start < total)
|
|
78
|
+
.toSorted((left, right) => left - right);
|
|
79
|
+
return snapped.filter((start, index) => index === 0 || start > (snapped[index - 1] ?? 0));
|
|
80
|
+
}
|
|
81
|
+
function nearest(values, target) {
|
|
82
|
+
let best;
|
|
83
|
+
for (const value of values)
|
|
84
|
+
if (best === undefined || Math.abs(value - target) < Math.abs(best - target))
|
|
85
|
+
best = value;
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
// Cut times to whole frames. Two cuts that round to the same frame are one cut, so every shot
|
|
89
|
+
// is at least one frame, and the last shot runs to the end.
|
|
90
|
+
function framesBetween(cuts, totalFrames, fps) {
|
|
91
|
+
const marks = [];
|
|
92
|
+
for (const cut of cuts) {
|
|
93
|
+
const frame = Math.round(cut * fps);
|
|
94
|
+
if (frame > (marks.at(-1) ?? 0) && frame < totalFrames)
|
|
95
|
+
marks.push(frame);
|
|
96
|
+
}
|
|
97
|
+
const shots = [];
|
|
98
|
+
let from = 0;
|
|
99
|
+
for (const mark of [...marks, totalFrames]) {
|
|
100
|
+
shots.push(mark - from);
|
|
101
|
+
from = mark;
|
|
102
|
+
}
|
|
103
|
+
return shots.filter((frames) => frames > 0);
|
|
104
|
+
}
|
|
105
|
+
// The shots (0-based) the video and each chapter open on, as the render will cut them: what
|
|
106
|
+
// "Chapter openers" animates. The cuts before a chapter do not depend on how long the video
|
|
107
|
+
// runs after it, so the timeline is taken to end just past the last chapter.
|
|
108
|
+
export function chapterOpeningShots(input) {
|
|
109
|
+
const { fps, imageSeconds } = input;
|
|
110
|
+
const last = Math.max(0, ...input.chapterStarts.filter((start) => Number.isFinite(start)));
|
|
111
|
+
const totalFrames = Math.round((last + 1) * fps);
|
|
112
|
+
if (!input.narration) {
|
|
113
|
+
// Every N seconds does not move a chapter: the image on screen when it starts opens it.
|
|
114
|
+
const each = Math.max(1, Math.round(imageSeconds * fps));
|
|
115
|
+
return [
|
|
116
|
+
...new Set([
|
|
117
|
+
0,
|
|
118
|
+
...input.chapterStarts
|
|
119
|
+
.filter((start) => Number.isFinite(start) && start > 0)
|
|
120
|
+
.map((start) => Math.floor(Math.round(start * fps) / each)),
|
|
121
|
+
]),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
const starts = chapterStartFrames(input.chapterStarts, input.cutPoints, totalFrames, fps);
|
|
125
|
+
const shots = narrationShotFrames({ ...input, totalFrames });
|
|
126
|
+
const opening = [0];
|
|
127
|
+
let frame = 0;
|
|
128
|
+
for (const [index, frames] of shots.entries()) {
|
|
129
|
+
if (starts.includes(frame) && index > 0)
|
|
130
|
+
opening.push(index);
|
|
131
|
+
frame += frames;
|
|
132
|
+
}
|
|
133
|
+
return opening;
|
|
134
|
+
}
|
|
135
|
+
// The frame each chapter's shot starts on, for the chapter cards and the chapter openers:
|
|
136
|
+
// the same snapping the cuts use, so a card appears with its shot.
|
|
137
|
+
export function chapterStartFrames(starts, points, totalFrames, fps) {
|
|
138
|
+
return chapterCuts(starts, points, totalFrames / fps)
|
|
139
|
+
.map((start) => Math.round(start * fps))
|
|
140
|
+
.filter((frame) => frame > 0 && frame < totalFrames);
|
|
141
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { atmospheres, colorGrades, lookLevels, } from "./edit-settings.js";
|
|
2
3
|
// The edit list is the whole video as data: what plays, for how many frames, how it moves,
|
|
3
4
|
// and the audio under it. Planning (`plan.ts`) writes one and the renderer (`ffmpeg.ts`,
|
|
4
5
|
// `slideshow.ts`) reads nothing else, so a new kind of shot or movement is a new case
|
|
@@ -6,11 +7,10 @@ import { z } from "zod";
|
|
|
6
7
|
// It is plain JSON, recorded in render.json, and carries a version so a later Slopify can
|
|
7
8
|
// tell an older list from its own.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
// `Shot.transition` says how a shot enters from
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// means bumps the version instead.
|
|
10
|
+
// Each extension is a new optional field or union case, so a version-1 list stays readable:
|
|
11
|
+
// `Shot.source` has a `video` kind for clips, `Shot.transition` says how a shot enters from
|
|
12
|
+
// the one before, and the list's `look` and `cards` are the effects every shot is rendered
|
|
13
|
+
// with. A change that alters what an existing field means bumps the version instead.
|
|
14
14
|
export const editListVersion = 1;
|
|
15
15
|
// The same list with every file path passed through `map`: render.json records paths
|
|
16
16
|
// relative to the project folder.
|
|
@@ -25,6 +25,9 @@ export function withPaths(edit, map) {
|
|
|
25
25
|
...shot,
|
|
26
26
|
source: { ...shot.source, path: map(shot.source.path) },
|
|
27
27
|
})),
|
|
28
|
+
...(edit.cardFont === undefined
|
|
29
|
+
? {}
|
|
30
|
+
: { cardFont: { ...edit.cardFont, path: map(edit.cardFont.path) } }),
|
|
28
31
|
};
|
|
29
32
|
}
|
|
30
33
|
const share = z.number().min(0).max(1);
|
|
@@ -36,6 +39,26 @@ const motionSchema = z.discriminatedUnion("kind", [
|
|
|
36
39
|
z.object({ kind: z.literal("still") }).strict(),
|
|
37
40
|
]);
|
|
38
41
|
const count = z.number().int().positive();
|
|
42
|
+
const sourceSchema = z.discriminatedUnion("kind", [
|
|
43
|
+
z.object({ kind: z.literal("image"), path: z.string().min(1) }).strict(),
|
|
44
|
+
z
|
|
45
|
+
.object({ kind: z.literal("video"), path: z.string().min(1), seconds: z.number().positive() })
|
|
46
|
+
.strict(),
|
|
47
|
+
]);
|
|
48
|
+
const transitionSchema = z
|
|
49
|
+
.object({ kind: z.enum(["crossfade", "fadeblack", "slide", "wipe"]), frames: count })
|
|
50
|
+
.strict();
|
|
51
|
+
const lookSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
vignette: z.enum(lookLevels),
|
|
54
|
+
grain: z.enum(lookLevels),
|
|
55
|
+
grade: z.enum(colorGrades),
|
|
56
|
+
atmosphere: z.enum(atmospheres),
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
const cardSchema = z
|
|
60
|
+
.object({ title: z.string().min(1), startFrame: z.number().int().nonnegative(), frames: count })
|
|
61
|
+
.strict();
|
|
39
62
|
const editListSchema = z
|
|
40
63
|
.object({
|
|
41
64
|
version: z.literal(editListVersion),
|
|
@@ -52,12 +75,19 @@ const editListSchema = z
|
|
|
52
75
|
shots: z
|
|
53
76
|
.array(z
|
|
54
77
|
.object({
|
|
55
|
-
source:
|
|
78
|
+
source: sourceSchema,
|
|
56
79
|
frames: count,
|
|
57
80
|
motion: motionSchema,
|
|
81
|
+
transition: transitionSchema.optional(),
|
|
58
82
|
})
|
|
59
83
|
.strict())
|
|
60
84
|
.min(1),
|
|
85
|
+
look: lookSchema.optional(),
|
|
86
|
+
cards: z.array(cardSchema).optional(),
|
|
87
|
+
cardFont: z
|
|
88
|
+
.object({ path: z.string().min(1), name: z.string().min(1) })
|
|
89
|
+
.strict()
|
|
90
|
+
.optional(),
|
|
61
91
|
})
|
|
62
92
|
.strict();
|
|
63
93
|
// Reads a recorded edit list back. Strict, so a list written by a newer Slopify, with a
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// How the Video stage edits the slideshow beyond seconds per image, zoom and motion: where the
|
|
2
|
+
// cuts fall, how one shot hands over to the next, the Look (vignette, grain, colour, an
|
|
3
|
+
// atmosphere overlay and chapter cards), and which images come to life as short clips.
|
|
4
|
+
// Browser-safe: Play, Edit project, the review summary and the change list read these names,
|
|
5
|
+
// labels and limits too.
|
|
6
|
+
//
|
|
7
|
+
// A project saved before this existed has no settings at all, and absent reads as
|
|
8
|
+
// `legacyVideoEdit`: cuts every N seconds, hard cuts, no Look, nothing animated. That is what
|
|
9
|
+
// every video was, and the render recipe leaves every one of those values out of its
|
|
10
|
+
// fingerprint, so no finished video turns outdated. A new project starts from
|
|
11
|
+
// `defaultVideoEdit`, which follows the narration.
|
|
12
|
+
export const cutModes = ["interval", "narration"];
|
|
13
|
+
export const transitionKinds = ["cut", "crossfade", "fadeblack", "slide", "wipe"];
|
|
14
|
+
export const lookLevels = ["off", "subtle", "strong"];
|
|
15
|
+
export const colorGrades = ["none", "warm", "cold", "desaturated", "sepia"];
|
|
16
|
+
// Drawn procedurally by ffmpeg's own filters (`look.ts`); no third-party footage.
|
|
17
|
+
export const atmospheres = ["none", "embers", "dust", "fog"];
|
|
18
|
+
export const animateModes = ["off", "chapters", "every"];
|
|
19
|
+
export const legacyVideoEdit = {
|
|
20
|
+
cuts: "interval",
|
|
21
|
+
transition: "cut",
|
|
22
|
+
transitionSeconds: 0.6,
|
|
23
|
+
vignette: "off",
|
|
24
|
+
grain: "off",
|
|
25
|
+
grade: "none",
|
|
26
|
+
atmosphere: "none",
|
|
27
|
+
chapterCards: false,
|
|
28
|
+
animate: "off",
|
|
29
|
+
animateEvery: 3,
|
|
30
|
+
animateModel: "",
|
|
31
|
+
};
|
|
32
|
+
// What a new project starts with: the owner's pick is to follow the narration.
|
|
33
|
+
export const defaultVideoEdit = { ...legacyVideoEdit, cuts: "narration" };
|
|
34
|
+
// ceiling: under 0.2 s a transition is a flash rather than a move; over 2 s it eats a short
|
|
35
|
+
// shot. The steps are the ones Play and Edit project offer.
|
|
36
|
+
export const transitionSecondsMin = 0.2;
|
|
37
|
+
export const transitionSecondsMax = 2;
|
|
38
|
+
export const transitionSecondsSteps = [0.2, 0.4, 0.6, 0.8, 1, 1.2, 1.5, 2];
|
|
39
|
+
export const animateEveryMin = 2;
|
|
40
|
+
export const animateEveryMax = 10;
|
|
41
|
+
// Image-to-video models make 5-second clips at their cheapest; a longer shot slows or loops
|
|
42
|
+
// the clip (`video/clip-timing.ts`).
|
|
43
|
+
export const animatedClipSeconds = 5;
|
|
44
|
+
// What an image is animated with: keep the picture, move a little. Image-to-video models
|
|
45
|
+
// invent the most when told the least.
|
|
46
|
+
export const animatePrompt = "Subtle cinematic motion: a slow, steady camera push-in with gentle ambient movement in the scene. Keep the composition, colours, lighting and style of the image exactly. No new objects, no text, no cuts.";
|
|
47
|
+
// A cut never lands closer than this share of Seconds per image to the one before, unless a
|
|
48
|
+
// chapter starts there.
|
|
49
|
+
export const cutFloorShare = 0.4;
|
|
50
|
+
// How long a chapter card stays on screen, fades included.
|
|
51
|
+
export const chapterCardSeconds = 2.5;
|
|
52
|
+
export const cutModeLabels = {
|
|
53
|
+
interval: "Every N seconds",
|
|
54
|
+
narration: "Follow the narration",
|
|
55
|
+
};
|
|
56
|
+
export const transitionLabels = {
|
|
57
|
+
cut: "Cut",
|
|
58
|
+
crossfade: "Crossfade",
|
|
59
|
+
fadeblack: "Fade through black",
|
|
60
|
+
slide: "Slide",
|
|
61
|
+
wipe: "Wipe",
|
|
62
|
+
};
|
|
63
|
+
export const lookLevelLabels = {
|
|
64
|
+
off: "Off",
|
|
65
|
+
subtle: "Subtle",
|
|
66
|
+
strong: "Strong",
|
|
67
|
+
};
|
|
68
|
+
export const colorGradeLabels = {
|
|
69
|
+
none: "None",
|
|
70
|
+
warm: "Warm fantasy",
|
|
71
|
+
cold: "Cold",
|
|
72
|
+
desaturated: "Desaturated",
|
|
73
|
+
sepia: "Sepia",
|
|
74
|
+
};
|
|
75
|
+
export const atmosphereLabels = {
|
|
76
|
+
none: "None",
|
|
77
|
+
embers: "Embers",
|
|
78
|
+
dust: "Dust",
|
|
79
|
+
fog: "Fog",
|
|
80
|
+
};
|
|
81
|
+
export const animateModeLabels = {
|
|
82
|
+
off: "Off",
|
|
83
|
+
chapters: "Chapter openers",
|
|
84
|
+
every: "Every Nth image",
|
|
85
|
+
};
|
|
86
|
+
// The settings a project renders with: its own, or today's behaviour when it has none.
|
|
87
|
+
export function videoEditOf(config) {
|
|
88
|
+
return config.videoEdit ?? legacyVideoEdit;
|
|
89
|
+
}
|
|
90
|
+
// Following the narration needs narration to follow and a video to cut; otherwise the video
|
|
91
|
+
// falls back to Every N seconds, which is also what its fingerprint says.
|
|
92
|
+
export function usesNarrationCuts(config) {
|
|
93
|
+
return (videoEditOf(config).cuts === "narration" &&
|
|
94
|
+
config.sources.audio !== "off" &&
|
|
95
|
+
config.sources.video === "generate");
|
|
96
|
+
}
|
|
97
|
+
// Chapter cards are placed from the narration's word timing, so they need narration too.
|
|
98
|
+
export function usesChapterCards(config) {
|
|
99
|
+
return (videoEditOf(config).chapterCards &&
|
|
100
|
+
config.sources.audio !== "off" &&
|
|
101
|
+
config.sources.video === "generate");
|
|
102
|
+
}
|
|
103
|
+
// "Chapter openers" needs chapters, which come from the narration's word timing.
|
|
104
|
+
export function usesAnimation(config) {
|
|
105
|
+
const edit = videoEditOf(config);
|
|
106
|
+
return (edit.animate !== "off" &&
|
|
107
|
+
config.sources.video === "generate" &&
|
|
108
|
+
(edit.animate === "every" || config.sources.audio !== "off"));
|
|
109
|
+
}
|
|
110
|
+
// Whether the render needs the word timing: to cut on sentences, to place chapter cards, or to
|
|
111
|
+
// know which images open a chapter.
|
|
112
|
+
export function editNeedsTiming(config) {
|
|
113
|
+
return (usesNarrationCuts(config) ||
|
|
114
|
+
usesChapterCards(config) ||
|
|
115
|
+
(usesAnimation(config) && videoEditOf(config).animate === "chapters"));
|
|
116
|
+
}
|
|
117
|
+
// The words a problem with one of the settings is said in; shared by admission, Play's draft
|
|
118
|
+
// conversion and Edit project.
|
|
119
|
+
export function videoEditProblems(config) {
|
|
120
|
+
const edit = config.videoEdit;
|
|
121
|
+
if (edit === undefined || config.sources.video !== "generate")
|
|
122
|
+
return [];
|
|
123
|
+
const problems = [];
|
|
124
|
+
if (edit.transition !== "cut" &&
|
|
125
|
+
!(Number.isFinite(edit.transitionSeconds) &&
|
|
126
|
+
edit.transitionSeconds >= transitionSecondsMin &&
|
|
127
|
+
edit.transitionSeconds <= transitionSecondsMax))
|
|
128
|
+
problems.push({
|
|
129
|
+
field: "transitionSeconds",
|
|
130
|
+
message: `Choose a transition length between ${String(transitionSecondsMin)} and ${String(transitionSecondsMax)} seconds.`,
|
|
131
|
+
});
|
|
132
|
+
if (edit.chapterCards && config.sources.audio === "off")
|
|
133
|
+
problems.push({
|
|
134
|
+
field: "chapterCards",
|
|
135
|
+
message: "Chapter cards are placed from the narration. Turn narration on, or turn chapter cards off.",
|
|
136
|
+
});
|
|
137
|
+
if (edit.animate === "chapters" && config.sources.audio === "off")
|
|
138
|
+
problems.push({
|
|
139
|
+
field: "animate",
|
|
140
|
+
message: "Chapter openers are found from the narration. Turn narration on, or choose Every Nth image.",
|
|
141
|
+
});
|
|
142
|
+
if (edit.animate === "every" &&
|
|
143
|
+
!(Number.isInteger(edit.animateEvery) &&
|
|
144
|
+
edit.animateEvery >= animateEveryMin &&
|
|
145
|
+
edit.animateEvery <= animateEveryMax))
|
|
146
|
+
problems.push({
|
|
147
|
+
field: "animateEvery",
|
|
148
|
+
message: `Choose every 2nd to every ${String(animateEveryMax)}th image.`,
|
|
149
|
+
});
|
|
150
|
+
if (edit.animate !== "off" && edit.animateModel.trim() === "")
|
|
151
|
+
problems.push({
|
|
152
|
+
field: "animateModel",
|
|
153
|
+
message: "Choose an image-to-video model to animate images with, or turn Animate images off.",
|
|
154
|
+
});
|
|
155
|
+
return problems;
|
|
156
|
+
}
|
|
157
|
+
// The settings in words, one row each, as the change list and the review summary show them.
|
|
158
|
+
export function videoEditRows(edit) {
|
|
159
|
+
const nth = (n) => (n === 2 ? "2nd" : n === 3 ? "3rd" : `${String(n)}th`);
|
|
160
|
+
return [
|
|
161
|
+
["Cuts", cutModeLabels[edit.cuts]],
|
|
162
|
+
[
|
|
163
|
+
"Transition",
|
|
164
|
+
edit.transition === "cut"
|
|
165
|
+
? transitionLabels.cut
|
|
166
|
+
: `${transitionLabels[edit.transition]} · ${String(edit.transitionSeconds)} s`,
|
|
167
|
+
],
|
|
168
|
+
["Vignette", lookLevelLabels[edit.vignette]],
|
|
169
|
+
["Film grain", lookLevelLabels[edit.grain]],
|
|
170
|
+
["Colour grade", colorGradeLabels[edit.grade]],
|
|
171
|
+
["Atmosphere", atmosphereLabels[edit.atmosphere]],
|
|
172
|
+
["Chapter cards", edit.chapterCards ? "On" : "Off"],
|
|
173
|
+
[
|
|
174
|
+
"Animate images",
|
|
175
|
+
edit.animate === "off"
|
|
176
|
+
? animateModeLabels.off
|
|
177
|
+
: `${edit.animate === "every" ? `Every ${nth(edit.animateEvery)} image` : animateModeLabels.chapters}${edit.animateModel.trim() === "" ? "" : ` · ${edit.animateModel}`}`,
|
|
178
|
+
],
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
// Which images of the slideshow order (0-based) are animated: every Nth from the first, or
|
|
182
|
+
// the ones that open a chapter. `chapterImages` is the image index each chapter opens on.
|
|
183
|
+
export function animatedImageIndexes(edit, imageCount, chapterImages = []) {
|
|
184
|
+
if (edit.animate === "off" || imageCount <= 0)
|
|
185
|
+
return [];
|
|
186
|
+
if (edit.animate === "every") {
|
|
187
|
+
const every = Math.max(1, Math.round(edit.animateEvery));
|
|
188
|
+
return Array.from({ length: imageCount }, (_value, at) => at).filter((at) => at % every === 0);
|
|
189
|
+
}
|
|
190
|
+
return [...new Set(chapterImages.filter((at) => at >= 0 && at < imageCount))].toSorted((left, right) => left - right);
|
|
191
|
+
}
|