@gentbajko/slopify 2.3.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter-registry.js +9 -2
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +99 -8
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +48 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +65 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +13 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +44 -3
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// the project page builds the same URL for every image, player and download link it
|
|
3
3
|
// draws, and `downloads.ts` reads the disk: importing it into the SPA would drag
|
|
4
4
|
// `node:fs` and the zip encoder into the browser bundle. Nothing here touches IO.
|
|
5
|
-
// An output's role is its asset name; images add their place in the
|
|
5
|
+
// An output's role is its asset name; images (and animated images) add their place in the
|
|
6
|
+
// slideshow, and the
|
|
6
7
|
// instructions add their stage, those being the two roles a project holds more than one of -
|
|
7
8
|
// research and the article each store what they sent.
|
|
8
9
|
export function assetOf(output) {
|
|
@@ -10,10 +11,16 @@ export function assetOf(output) {
|
|
|
10
11
|
if ((output.role === "narration_txt" || output.role === "tts_script") &&
|
|
11
12
|
output.meta.segment !== undefined)
|
|
12
13
|
return `${asset}-${output.meta.segment}`;
|
|
14
|
+
// A project holds one video per short and several images per short.
|
|
15
|
+
if (output.role === "short_video" && output.meta.short !== undefined)
|
|
16
|
+
return `${asset}-${String(output.meta.short)}`;
|
|
17
|
+
if (output.role === "short_image" && output.meta.short !== undefined)
|
|
18
|
+
return `${asset}-${String(output.meta.short)}-${String(output.meta.index ?? 1)}`;
|
|
13
19
|
if (output.role === "instructions") {
|
|
14
20
|
return `${output.stageKind}-${asset}`;
|
|
15
21
|
}
|
|
16
|
-
return output.role === "image"
|
|
22
|
+
return (output.role === "image" || output.role === "animated_image") &&
|
|
23
|
+
output.meta.index !== undefined
|
|
17
24
|
? `${asset}-${String(output.meta.index)}`
|
|
18
25
|
: asset;
|
|
19
26
|
}
|
|
@@ -5,6 +5,14 @@ export function projectDir(paths, projectId) {
|
|
|
5
5
|
export function outputPath(paths, projectId, relativePath) {
|
|
6
6
|
return contained(projectDir(paths, projectId), relativePath);
|
|
7
7
|
}
|
|
8
|
+
// Scheduled backups (slices/backups) land here unless the user picks another folder. It sits
|
|
9
|
+
// inside the projects root because that is the one folder a Docker install shares with the
|
|
10
|
+
// host, so the archives end up on the user's disk and not only in the container's volume.
|
|
11
|
+
// Project folders are ULIDs, so the name never collides with one; reconcile leaves it alone.
|
|
12
|
+
export const backupsFolderName = "Backups";
|
|
13
|
+
export function defaultBackupsDir(paths) {
|
|
14
|
+
return contained(paths.projects, backupsFolderName);
|
|
15
|
+
}
|
|
8
16
|
export function stagingPath(paths, stagedFileId) {
|
|
9
17
|
return contained(paths.staging, stagedFileId);
|
|
10
18
|
}
|
|
@@ -60,6 +68,14 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
60
68
|
return "description.txt";
|
|
61
69
|
case "youtube_tags":
|
|
62
70
|
return "tags.txt";
|
|
71
|
+
case "shorts":
|
|
72
|
+
return "shorts.json";
|
|
73
|
+
case "short_image":
|
|
74
|
+
return `shorts/image-${String(index).padStart(3, "0")}${extension}`;
|
|
75
|
+
case "short_video":
|
|
76
|
+
return `shorts/short-${String(index).padStart(2, "0")}${extension}`;
|
|
77
|
+
case "animated_image":
|
|
78
|
+
return `animated/image-${String(index).padStart(3, "0")}${extension}`;
|
|
63
79
|
case "audio_body":
|
|
64
80
|
return `audio-body${extension}`;
|
|
65
81
|
case "audio_export":
|
|
@@ -70,6 +86,8 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
70
86
|
return `audio-outro${extension}`;
|
|
71
87
|
case "thumbnail":
|
|
72
88
|
return `thumbnail${extension}`;
|
|
89
|
+
case "reference":
|
|
90
|
+
return `reference${extension}`;
|
|
73
91
|
case "image":
|
|
74
92
|
return `images/${String(index).padStart(3, "0")}${extension}`;
|
|
75
93
|
}
|
|
@@ -27,5 +27,14 @@ export const outputRoles = [
|
|
|
27
27
|
"document_pdf",
|
|
28
28
|
"youtube_description",
|
|
29
29
|
"youtube_tags",
|
|
30
|
+
// The Shorts step: the picked clips (JSON), each clip's vertical images, and each clip.
|
|
31
|
+
"shorts",
|
|
32
|
+
"short_image",
|
|
33
|
+
"short_video",
|
|
34
|
+
// An image of the slideshow brought to life by an image-to-video model (Animate images).
|
|
35
|
+
"animated_image",
|
|
36
|
+
// The Images stage's establishing image: made or uploaded first, drawn from by the other
|
|
37
|
+
// images, never shown in the video.
|
|
38
|
+
"reference",
|
|
30
39
|
];
|
|
31
40
|
export const stagedFileStates = ["copying", "staged"];
|
|
@@ -12,12 +12,13 @@ import { readFontMetadata } from "../fonts/sfnt.js";
|
|
|
12
12
|
import { lintEntry, lintPrompt } from "../library/lint.js";
|
|
13
13
|
import { entryCategories, entryModes, promptKinds } from "../library/model.js";
|
|
14
14
|
import { listEntries, listPrompts } from "../library/repo.js";
|
|
15
|
+
import { notificationUrlKey } from "../notifications/settings.js";
|
|
15
16
|
import { projectTemplateSchema } from "../project-templates/schema.js";
|
|
16
17
|
import { cliPathMaxLength } from "../settings/cli-paths.js";
|
|
17
18
|
import { appearances, providerById, providerIds } from "../settings/model.js";
|
|
18
19
|
import { listVoices } from "../settings/repo.js";
|
|
19
20
|
import { voiceIdMax, voiceNameMax } from "../settings/voices.js";
|
|
20
|
-
import { projectDir, stagingPath } from "./layout.js";
|
|
21
|
+
import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
|
|
21
22
|
import { stageKinds } from "./model.js";
|
|
22
23
|
import { insertStagedFile, stagedFiles } from "./repo.js";
|
|
23
24
|
const record = z.record(z.string(), z.string());
|
|
@@ -326,7 +327,8 @@ const storedAppearance = z.enum(appearances);
|
|
|
326
327
|
function portableSettings(settings) {
|
|
327
328
|
const portable = {};
|
|
328
329
|
for (const [key, value] of Object.entries(settings)) {
|
|
329
|
-
|
|
330
|
+
// The Notification URL stays on this machine: an ntfy topic in it is as good as a password.
|
|
331
|
+
if (key === "tutorial.session" || key === notificationUrlKey)
|
|
330
332
|
continue;
|
|
331
333
|
const parsed = storedJson(value);
|
|
332
334
|
if (key === "silenceGapSeconds")
|
|
@@ -862,7 +864,8 @@ function planStagedImport(deps, archive, files) {
|
|
|
862
864
|
});
|
|
863
865
|
}
|
|
864
866
|
export function storageBytes(paths) {
|
|
865
|
-
|
|
867
|
+
// Scheduled backups default to a folder inside the projects root; they are not project files.
|
|
868
|
+
const projects = directoryBytes(paths.projects) - directoryBytes(defaultBackupsDir(paths));
|
|
866
869
|
const staging = directoryBytes(paths.staging);
|
|
867
870
|
return { data: directoryBytes(paths.dataDir), projects, staging };
|
|
868
871
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
|
-
import { outputPath, stagingPath } from "./layout.js";
|
|
3
|
+
import { backupsFolderName, outputPath, stagingPath } from "./layout.js";
|
|
4
4
|
import { stagedFiles as stagedRows } from "./repo.js";
|
|
5
5
|
import { stagedFileReferenced } from "./staging-refs.js";
|
|
6
6
|
export function reconcileStorage(db, paths) {
|
|
@@ -33,6 +33,10 @@ export function reconcileStorage(db, paths) {
|
|
|
33
33
|
let orphanFiles = 0;
|
|
34
34
|
for (const entry of readdirSync(paths.projects, { withFileTypes: true })) {
|
|
35
35
|
const path = join(paths.projects, entry.name);
|
|
36
|
+
// The scheduled backups' default folder: its archives belong to slices/backups, which
|
|
37
|
+
// prunes them by its own rules.
|
|
38
|
+
if (entry.name === backupsFolderName && entry.isDirectory())
|
|
39
|
+
continue;
|
|
36
40
|
if (!entry.isDirectory()) {
|
|
37
41
|
unlinkSync(path);
|
|
38
42
|
orphanFiles += 1;
|
|
@@ -9,6 +9,9 @@ export const metaSchema = z.object({
|
|
|
9
9
|
promptName: z.string().optional(),
|
|
10
10
|
prompt: z.string().optional(),
|
|
11
11
|
index: z.number().optional(),
|
|
12
|
+
short: z.number().optional(),
|
|
13
|
+
sentences: z.tuple([z.number(), z.number()]).optional(),
|
|
14
|
+
warnings: z.array(z.string()).optional(),
|
|
12
15
|
provider: z.string().optional(),
|
|
13
16
|
model: z.string().optional(),
|
|
14
17
|
voice: z.string().optional(),
|
|
@@ -37,4 +37,7 @@ export const payloadSchema = z.strictObject({
|
|
|
37
37
|
// A written YouTube description. Its event names no stage: it is made inside the Video
|
|
38
38
|
// stage, and a collector counting stage "video" as a finished video must not count it.
|
|
39
39
|
descriptions: z.number().int().nonnegative().optional(),
|
|
40
|
+
// A rendered short. Like a description it names no stage: the Shorts step runs inside
|
|
41
|
+
// the Video stage, and a short is not a finished video.
|
|
42
|
+
shorts: z.number().int().nonnegative().optional(),
|
|
40
43
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Chapter cards as an ASS script the clips draw with libass, the renderer the captions use,
|
|
2
|
+
// so a card is set in the subtitle font without a font-rendering filter of its own (this
|
|
3
|
+
// ffmpeg has none). Each card is centred, well above where captions sit by default, and fades
|
|
4
|
+
// in and out. Every clip a card shows in gets its own script with the times counted from the
|
|
5
|
+
// clip's first frame (`offset`), since a clip's frames count from zero.
|
|
6
|
+
// ceiling: the owner's eye. The fades take 0.4 s each of the card's 2.5 s.
|
|
7
|
+
const fadeMs = 400;
|
|
8
|
+
export function cardsAss(edit, cards, fontName, offset = 0) {
|
|
9
|
+
const { width, height, fps } = edit;
|
|
10
|
+
// The title is sized from the frame's short side, so a vertical video's card fits too. At
|
|
11
|
+
// 8% it read as a caption rather than a chapter title; 11% and bold gives it presence.
|
|
12
|
+
const size = Math.round(Math.min(width, height) * 0.11);
|
|
13
|
+
// A wide, blurred, half-clear dark outline: a soft shadow all round that keeps the title
|
|
14
|
+
// readable on a bright picture without a box behind it.
|
|
15
|
+
const outline = Math.max(2, Math.round(size / 10));
|
|
16
|
+
const header = [
|
|
17
|
+
"[Script Info]",
|
|
18
|
+
"ScriptType: v4.00+",
|
|
19
|
+
`PlayResX: ${String(width)}`,
|
|
20
|
+
`PlayResY: ${String(height)}`,
|
|
21
|
+
"WrapStyle: 0",
|
|
22
|
+
"ScaledBorderAndShadow: yes",
|
|
23
|
+
"",
|
|
24
|
+
"[V4+ Styles]",
|
|
25
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
26
|
+
// White bold text, the soft outline, no offset shadow, centred (alignment 5), letter-spaced.
|
|
27
|
+
`Style: Card,${fontName.replace(/,/g, " ")},${String(size)},&H00FFFFFF,&H00FFFFFF,&H60000000,&H00000000,-1,0,0,0,100,100,${String(Math.round(size / 12))},0,1,${String(outline)},0,5,${String(Math.round(width * 0.1))},${String(Math.round(width * 0.1))},0,1`,
|
|
28
|
+
"",
|
|
29
|
+
"[Events]",
|
|
30
|
+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
31
|
+
];
|
|
32
|
+
const events = cards.flatMap((card) => {
|
|
33
|
+
const from = card.startFrame - offset;
|
|
34
|
+
const to = from + card.frames;
|
|
35
|
+
if (to <= 0)
|
|
36
|
+
return [];
|
|
37
|
+
// A card that began before this clip starts with the clip, part way through its fade in
|
|
38
|
+
// (or past it), so it runs on from the clip before without a second fade.
|
|
39
|
+
const before = Math.max(0, (-from / fps) * 1000);
|
|
40
|
+
const length = Math.round((to / fps) * 1000);
|
|
41
|
+
const fade = before === 0
|
|
42
|
+
? `\\fad(${String(fadeMs)},${String(fadeMs)})`
|
|
43
|
+
: `\\fade(${String(Math.round(255 * Math.max(0, 1 - before / fadeMs)))},0,255,0,${String(Math.max(0, Math.round(fadeMs - before)))},${String(length - fadeMs)},${String(length)})`;
|
|
44
|
+
return [
|
|
45
|
+
`Dialogue: 0,${assTime(Math.max(0, from) / fps)},${assTime(to / fps)},Card,,0,0,0,,{\\blur${String(outline)}${fade}${settle(before)}}${assText(card.title)}`,
|
|
46
|
+
];
|
|
47
|
+
});
|
|
48
|
+
return `${[...header, ...events].join("\n")}\n`;
|
|
49
|
+
}
|
|
50
|
+
// The title settles from 96% to full size while it fades in, a small movement that reads as
|
|
51
|
+
// a title arriving rather than text appearing. A card carried on from the clip before is
|
|
52
|
+
// already settled.
|
|
53
|
+
function settle(before) {
|
|
54
|
+
if (before >= fadeMs)
|
|
55
|
+
return "";
|
|
56
|
+
const from = Math.round(96 + (4 * before) / fadeMs);
|
|
57
|
+
return `\\fscx${String(from)}\\fscy${String(from)}\\t(0,${String(Math.round(fadeMs - before))},\\fscx100\\fscy100)`;
|
|
58
|
+
}
|
|
59
|
+
// h:mm:ss.cc, the centiseconds ASS counts in.
|
|
60
|
+
function assTime(seconds) {
|
|
61
|
+
const centis = Math.max(0, Math.round(seconds * 100));
|
|
62
|
+
const h = Math.floor(centis / 360000);
|
|
63
|
+
const m = Math.floor((centis % 360000) / 6000);
|
|
64
|
+
const s = Math.floor((centis % 6000) / 100);
|
|
65
|
+
const c = centis % 100;
|
|
66
|
+
return `${String(h)}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(c).padStart(2, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
// A title is plain text: braces would start an override block and a backslash an escape, so
|
|
69
|
+
// both are replaced, and a line break is a space.
|
|
70
|
+
function assText(text) {
|
|
71
|
+
return text.replace(/[{}]/g, "").replace(/\\/g, "/").replace(/\s+/g, " ").trim();
|
|
72
|
+
}
|
|
@@ -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
|