@gentbajko/slopify 3.0.0 → 3.0.1
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/README.md +6 -4
- package/dist/adapter-registry.js +28 -0
- package/dist/adapters/key-probes.js +57 -8
- package/dist/adapters/tts/gemini.js +206 -0
- package/dist/adapters/tts/pcm-mp3.js +62 -0
- package/dist/adapters/tts/system.js +147 -0
- package/dist/assets/models.yaml +38 -0
- package/dist/assets/style-preview/narration.mp3 +0 -0
- package/dist/assets/style-preview/tall-1.jpg +0 -0
- package/dist/assets/style-preview/tall-2.jpg +0 -0
- package/dist/assets/style-preview/tall-3.jpg +0 -0
- package/dist/assets/style-preview/wide-1.jpg +0 -0
- package/dist/assets/style-preview/wide-2.jpg +0 -0
- package/dist/assets/style-preview/wide-3.jpg +0 -0
- package/dist/catalog/registry.js +4 -1
- package/dist/catalog/validate.js +2 -2
- package/dist/edge/autostart/docker-record.js +2 -2
- package/dist/edge/autostart/docker.js +68 -0
- package/dist/edge/autostart/prompt.js +7 -1
- package/dist/edge/autostart/service.js +18 -9
- package/dist/edge/cli-args.js +71 -0
- package/dist/edge/cli.js +11 -16
- package/dist/edge/docker-install/run.js +7 -1
- package/dist/edge/events/hub.js +3 -1
- package/dist/edge/http/actions.js +6 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/byte-range.js +42 -0
- package/dist/edge/http/diagnostics.js +1 -0
- package/dist/edge/http/draft-files.js +2 -4
- package/dist/edge/http/files.js +10 -6
- package/dist/edge/http/onboarding.js +64 -4
- package/dist/edge/http/project-templates.js +19 -2
- package/dist/edge/http/projects.js +6 -1
- package/dist/edge/http/providers.js +39 -9
- package/dist/edge/http/reviews.js +18 -8
- package/dist/edge/http/revision-files.js +2 -4
- package/dist/edge/http/schedules.js +9 -5
- package/dist/edge/http/storage.js +16 -2
- package/dist/edge/http/studio.js +111 -31
- package/dist/edge/http/style-preview.js +3 -42
- package/dist/edge/http/tutorials.js +59 -0
- package/dist/edge/http/youtube-edits.js +11 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/host-cli/gemini-login.js +83 -0
- package/dist/host-cli/status.js +4 -2
- package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
- package/dist/kernel/ports/model.js +5 -3
- package/dist/kernel/ports/system-speech.js +203 -0
- package/dist/kernel/runner/graph.js +17 -1
- package/dist/kernel/runner/index.js +4 -4
- package/dist/kernel/runner/providers.js +1 -1
- package/dist/main.js +5 -0
- package/dist/model-catalog.js +2 -0
- package/dist/patch-notes/3.0.1.md +105 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/rules.js +3 -3
- package/dist/slices/admission/start.js +1 -1
- package/dist/slices/channels/cast-match.js +1 -1
- package/dist/slices/channels/repo.js +3 -0
- package/dist/slices/channels/schema.js +23 -0
- package/dist/slices/channels/service.js +6 -6
- package/dist/slices/control/index.js +3 -1
- package/dist/slices/control/providers.js +4 -1
- package/dist/slices/episodes/related.js +2 -2
- package/dist/slices/estimate/requests.js +9 -1
- package/dist/slices/eta/history.js +56 -0
- package/dist/slices/eta/model.js +63 -0
- package/dist/slices/eta/view.js +25 -0
- package/dist/slices/fixes/rules.js +1 -1
- package/dist/slices/fonts/coverage.js +1 -1
- package/dist/slices/loudness/model.js +1 -1
- package/dist/slices/model-upkeep/usage.js +4 -2
- package/dist/slices/narration/pauses-model.js +1 -1
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/notifications/notifier.js +28 -2
- package/dist/slices/notifications/rules.js +26 -2
- package/dist/slices/onboarding/first-run.js +40 -14
- package/dist/slices/onboarding/full-video.js +81 -0
- package/dist/slices/onboarding/model.js +21 -0
- package/dist/slices/onboarding/quick-short.js +25 -10
- package/dist/slices/play-drafts/convert.js +3 -3
- package/dist/slices/play-drafts/readiness.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -3
- package/dist/slices/project-templates/next-chapter.js +94 -0
- package/dist/slices/project-templates/one-off.js +1 -1
- package/dist/slices/project-templates/schema.js +7 -1
- package/dist/slices/rebuild/recipe-exports.js +5 -0
- package/dist/slices/rebuild/recipe-voices.js +1 -1
- package/dist/slices/rebuild/review-redo.js +15 -0
- package/dist/slices/rebuild/runtime-export-bed.js +1 -1
- package/dist/slices/rebuild/runtime-review.js +35 -5
- package/dist/slices/rebuild/runtime-store.js +23 -1
- package/dist/slices/rebuild/runtime-voices.js +2 -2
- package/dist/slices/rebuild/service-readiness.js +3 -3
- package/dist/slices/reviews/repo.js +16 -4
- package/dist/slices/run-cost/panel.js +30 -8
- package/dist/slices/run-cost/pricing.js +4 -1
- package/dist/slices/schedules/agenda.js +66 -2
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/scheduler.js +1 -1
- package/dist/slices/schedules/schema.js +43 -2
- package/dist/slices/schedules/similar.js +41 -3
- package/dist/slices/schedules/topic-list.js +2 -2
- package/dist/slices/schedules/topics.js +118 -17
- package/dist/slices/settings/first-run.js +5 -1
- package/dist/slices/settings/health.js +64 -7
- package/dist/slices/settings/key-guides.js +17 -7
- package/dist/slices/settings/key-test.js +12 -4
- package/dist/slices/settings/keys.js +10 -5
- package/dist/slices/settings/model-reach.js +52 -0
- package/dist/slices/settings/model.js +49 -0
- package/dist/slices/settings/models.js +5 -1
- package/dist/slices/settings/readiness.js +14 -2
- package/dist/slices/settings/tutorial-schema.js +5 -0
- package/dist/slices/shorts/render.js +18 -17
- package/dist/slices/storage/documents.js +11 -3
- package/dist/slices/storage/downloads.js +33 -1
- package/dist/slices/storage/portable.js +4 -2
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +6 -4
- package/dist/slices/studio/queue.js +69 -0
- package/dist/slices/studio/settings.js +42 -5
- package/dist/slices/style-preview/narration.js +33 -0
- package/dist/slices/style-preview/render.js +43 -56
- package/dist/slices/style-preview/schema.js +17 -3
- package/dist/slices/style-preview/service.js +3 -4
- package/dist/slices/style-preview/settings.js +75 -25
- package/dist/slices/tutorials/anchors.js +57 -0
- package/dist/slices/tutorials/library.js +185 -0
- package/dist/slices/voices/audio-files.js +7 -2
- package/dist/slices/voices/cast.js +26 -8
- package/dist/slices/voices/grouping.js +10 -0
- package/dist/slices/voices/model.js +49 -10
- package/dist/slices/voices/script.js +1 -1
- package/dist/slices/youtube/edits-repo.js +19 -4
- package/dist/slices/youtube/placeholders.js +2 -2
- package/dist/tutorials/AI-CLIs.md +146 -0
- package/dist/tutorials/Backups.md +121 -0
- package/dist/tutorials/Calendar.md +103 -0
- package/dist/tutorials/Cast-Library.md +112 -0
- package/dist/tutorials/Channels.md +148 -0
- package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
- package/dist/tutorials/Docker.md +171 -0
- package/dist/tutorials/Document-Themes.md +207 -0
- package/dist/tutorials/Editing-a-Project.md +204 -0
- package/dist/tutorials/FAQ.md +134 -0
- package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
- package/dist/tutorials/Home-and-Projects.md +174 -0
- package/dist/tutorials/Home.md +119 -0
- package/dist/tutorials/Install.md +154 -0
- package/dist/tutorials/Intros-and-Outros.md +86 -0
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
- package/dist/tutorials/Library-Overview.md +75 -0
- package/dist/tutorials/Models.md +133 -0
- package/dist/tutorials/Multiple-Voices.md +161 -0
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
- package/dist/tutorials/Notifications.md +86 -0
- package/dist/tutorials/Other-Languages.md +110 -0
- package/dist/tutorials/PDF-Documents.md +100 -0
- package/dist/tutorials/Play-Images.md +135 -0
- package/dist/tutorials/Play-Narration.md +167 -0
- package/dist/tutorials/Play-Outputs.md +109 -0
- package/dist/tutorials/Play-Overview.md +216 -0
- package/dist/tutorials/Play-Title-and-Article.md +135 -0
- package/dist/tutorials/Play-Video-and-Style.md +143 -0
- package/dist/tutorials/Project-Page.md +228 -0
- package/dist/tutorials/Prompts.md +136 -0
- package/dist/tutorials/Providers-and-Keys.md +290 -0
- package/dist/tutorials/Publishing-to-YouTube.md +135 -0
- package/dist/tutorials/Recovery-and-Retries.md +141 -0
- package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
- package/dist/tutorials/Schedules.md +154 -0
- package/dist/tutorials/Settings-Reference.md +258 -0
- package/dist/tutorials/Shorts.md +116 -0
- package/dist/tutorials/Start-at-Login.md +126 -0
- package/dist/tutorials/Studio-Extension.md +146 -0
- package/dist/tutorials/Templates.md +146 -0
- package/dist/tutorials/Trash.md +87 -0
- package/dist/tutorials/Troubleshooting.md +149 -0
- package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
- package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
- package/dist/tutorials/Video-Editing.md +135 -0
- package/dist/tutorials/Where-Your-Files-Live.md +149 -0
- package/dist/tutorials/YouTube-Description.md +132 -0
- package/dist/tutorials/Your-First-Short.md +125 -0
- package/dist/tutorials/_Footer.md +1 -0
- package/dist/tutorials/_Sidebar.md +69 -0
- package/dist/web/assets/index-DBLSfqA-.css +1 -0
- package/dist/web/assets/index-DFdIhmm0.js +307 -0
- package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-B9OY1ioX.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CUA2sUxU.css +0 -1
- package/dist/web/assets/index-CX590kE-.js +0 -303
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { detectSpeechCached } from "../../kernel/ports/system-speech.js";
|
|
1
2
|
import { cliPathStatus } from "./cli-paths.js";
|
|
2
3
|
import { cliProbeTimeoutMs, readinessFromProbe } from "./cli-status.js";
|
|
3
|
-
import { isLocalCliProvider, providers } from "./model.js";
|
|
4
|
+
import { isLocalCliProvider, providers, sharedKeyOf } from "./model.js";
|
|
4
5
|
import { keyedProviders } from "./repo.js";
|
|
5
6
|
// Every supported provider is listed, keyed or not, found or not, so Play can grey one out with
|
|
6
7
|
// a reason instead of hiding it. A keyed provider is ready when a key is stored; a CLI provider
|
|
@@ -10,6 +11,16 @@ export async function providerStatuses(deps) {
|
|
|
10
11
|
const probes = new Map();
|
|
11
12
|
return await Promise.all(providers.map(async (provider) => {
|
|
12
13
|
const base = { id: provider.id, family: provider.family, displayName: provider.displayName };
|
|
14
|
+
if (provider.auth === "local") {
|
|
15
|
+
const found = await detectSpeechCached(deps.probe, deps.host ?? process);
|
|
16
|
+
const engine = found.engines[0];
|
|
17
|
+
return {
|
|
18
|
+
...base,
|
|
19
|
+
readiness: engine === undefined
|
|
20
|
+
? { kind: "local", available: false, ...(found.issue ? { issue: found.issue } : {}) }
|
|
21
|
+
: { kind: "local", available: true, engine: engine.name },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
13
24
|
if (provider.auth !== "cli")
|
|
14
25
|
return { ...base, readiness: keyedReadiness(keyed, provider.id) };
|
|
15
26
|
if (deps.hostCliStatus && isLocalCliProvider(provider.id)) {
|
|
@@ -41,5 +52,6 @@ export async function providerStatuses(deps) {
|
|
|
41
52
|
}));
|
|
42
53
|
}
|
|
43
54
|
function keyedReadiness(keyed, id) {
|
|
44
|
-
|
|
55
|
+
const shared = sharedKeyOf[id];
|
|
56
|
+
return { kind: "keyed", hasKey: keyed.has(id) || (shared !== undefined && keyed.has(shared)) };
|
|
45
57
|
}
|
|
@@ -177,26 +177,27 @@ export async function renderShort(run) {
|
|
|
177
177
|
}), { mode: 0o600 });
|
|
178
178
|
mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
|
|
179
179
|
copyFileSync(run.font.path, join(workspace, "fonts", `selected${run.font.extension}`));
|
|
180
|
+
const edit = shortEditList({
|
|
181
|
+
audioPath: audio,
|
|
182
|
+
seconds,
|
|
183
|
+
images: run.images,
|
|
184
|
+
imageSeconds: run.imageSeconds / speed,
|
|
185
|
+
motionStyle: run.motionStyle,
|
|
186
|
+
zoomPercent: run.zoomPercent,
|
|
187
|
+
...(run.figures === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: {
|
|
190
|
+
figures: run.figures.map((figure) => ({
|
|
191
|
+
...figure,
|
|
192
|
+
start: figure.start / speed,
|
|
193
|
+
end: figure.end / speed,
|
|
194
|
+
})),
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
180
197
|
return await renderSlideshow({
|
|
181
198
|
bin: run.bin,
|
|
182
199
|
master: run.master,
|
|
183
|
-
edit:
|
|
184
|
-
audioPath: audio,
|
|
185
|
-
seconds,
|
|
186
|
-
images: run.images,
|
|
187
|
-
imageSeconds: run.imageSeconds / speed,
|
|
188
|
-
motionStyle: run.motionStyle,
|
|
189
|
-
zoomPercent: run.zoomPercent,
|
|
190
|
-
...(run.figures === undefined
|
|
191
|
-
? {}
|
|
192
|
-
: {
|
|
193
|
-
figures: run.figures.map((figure) => ({
|
|
194
|
-
...figure,
|
|
195
|
-
start: figure.start / speed,
|
|
196
|
-
end: figure.end / speed,
|
|
197
|
-
})),
|
|
198
|
-
}),
|
|
199
|
-
}),
|
|
200
|
+
edit: run.frame === undefined ? edit : { ...edit, ...run.frame },
|
|
200
201
|
output: run.output,
|
|
201
202
|
burnSubtitles: true,
|
|
202
203
|
cwd: workspace,
|
|
@@ -4,15 +4,23 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
const execute = promisify(execFile);
|
|
7
|
-
|
|
7
|
+
const execBytes = async (command, args) => (await execute(command, [...args], { timeout: 10_000, windowsHide: true, encoding: "buffer" }))
|
|
8
|
+
.stdout;
|
|
9
|
+
// Output is always decoded as UTF-8: the PowerShell command below switches its own output to
|
|
10
|
+
// UTF-8 first, so a Documents folder named with non-ASCII letters (a Cyrillic or accented
|
|
11
|
+
// OneDrive path) survives instead of arriving in the console codepage.
|
|
12
|
+
export function nodeDocumentsHost(exec = execBytes) {
|
|
8
13
|
return {
|
|
9
14
|
platform: process.platform,
|
|
10
15
|
env: process.env,
|
|
11
16
|
home: homedir(),
|
|
12
|
-
run: async (command, args) => (await
|
|
17
|
+
run: async (command, args) => new TextDecoder("utf-8").decode(await exec(command, args)),
|
|
13
18
|
read: (path) => readFile(path, "utf8").catch(() => undefined),
|
|
14
19
|
};
|
|
15
20
|
}
|
|
21
|
+
// Forces PowerShell's stdout to UTF-8 before printing: by default it writes in the console's
|
|
22
|
+
// codepage (437, 850, 1252...), which mangles every non-ASCII letter in the path.
|
|
23
|
+
export const WINDOWS_DOCUMENTS_COMMAND = "[Console]::OutputEncoding = [Text.Encoding]::UTF8; [Environment]::GetFolderPath('MyDocuments')";
|
|
16
24
|
// The user's own Documents folder, the way the system itself names it: Windows asks for the
|
|
17
25
|
// known folder (which follows OneDrive and a folder moved in its Properties), Linux asks
|
|
18
26
|
// xdg-user-dir and then reads user-dirs.dirs, macOS has no setting for it. Never throws: the
|
|
@@ -24,7 +32,7 @@ export async function documentsDir(host) {
|
|
|
24
32
|
"-NoProfile",
|
|
25
33
|
"-NonInteractive",
|
|
26
34
|
"-Command",
|
|
27
|
-
|
|
35
|
+
WINDOWS_DOCUMENTS_COMMAND,
|
|
28
36
|
])
|
|
29
37
|
.then((out) => out.trim())
|
|
30
38
|
.catch(() => "");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { extname } from "node:path";
|
|
3
3
|
import { zipSync } from "fflate";
|
|
4
|
+
import { effectiveDescription } from "../youtube/edits-repo.js";
|
|
4
5
|
import { assetOf } from "./asset-name.js";
|
|
5
6
|
import { outputPath } from "./layout.js";
|
|
6
7
|
import { outputsOf, projectTitle } from "./repo.js";
|
|
@@ -57,16 +58,47 @@ export function findDownload(deps, projectId, asset) {
|
|
|
57
58
|
if (stats === undefined) {
|
|
58
59
|
return { ok: false, reason: "missing-file" };
|
|
59
60
|
}
|
|
61
|
+
const shown = output.role === "youtube_description" || output.role === "youtube_tags"
|
|
62
|
+
? shownYoutubeText(deps, projectId, output.role)
|
|
63
|
+
: undefined;
|
|
60
64
|
return {
|
|
61
65
|
ok: true,
|
|
62
66
|
download: {
|
|
63
67
|
path,
|
|
64
68
|
filename: downloadName(slugOf(title), output),
|
|
65
|
-
bytes: stats,
|
|
69
|
+
bytes: shown?.byteLength ?? stats,
|
|
66
70
|
contentType: contentTypeOf(path),
|
|
71
|
+
...(shown === undefined ? {} : { text: shown }),
|
|
67
72
|
},
|
|
68
73
|
};
|
|
69
74
|
}
|
|
75
|
+
// The description and tags download as the project page shows and copies them, and as Prepare
|
|
76
|
+
// upload hands them on: the user's hand edits over the written text, chapters fitted, and the
|
|
77
|
+
// channel's and project's links filled in (`youtube/edits-repo.ts`). The file on disk stays
|
|
78
|
+
// the generated text, so regeneration can tell the user's edits from its own.
|
|
79
|
+
function shownYoutubeText(deps, projectId, role) {
|
|
80
|
+
const outputs = outputsOf(deps.db, projectId);
|
|
81
|
+
const read = (wanted) => {
|
|
82
|
+
const output = outputs.find((candidate) => candidate.role === wanted);
|
|
83
|
+
if (output === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
const path = outputPath(deps.paths, projectId, output.path);
|
|
86
|
+
return sizeOf(path) === undefined ? undefined : readFileSync(path, "utf8").trim();
|
|
87
|
+
};
|
|
88
|
+
const description = read("youtube_description");
|
|
89
|
+
if (description === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
const video = outputs.find((output) => output.role === "video");
|
|
92
|
+
const shown = effectiveDescription(deps.db, projectId, {
|
|
93
|
+
description,
|
|
94
|
+
tags: read("youtube_tags") ?? "",
|
|
95
|
+
durationSeconds: video?.durationMs === null || video?.durationMs === undefined
|
|
96
|
+
? undefined
|
|
97
|
+
: video.durationMs / 1000,
|
|
98
|
+
});
|
|
99
|
+
const text = role === "youtube_description" ? shown.description : shown.tags;
|
|
100
|
+
return new TextEncoder().encode(`${text}\n`);
|
|
101
|
+
}
|
|
70
102
|
// "download all" images as `<title-slug>-images.zip`, thumbnail included.
|
|
71
103
|
export function imagesZip(deps, projectId) {
|
|
72
104
|
const title = projectTitle(deps.db, projectId);
|
|
@@ -24,7 +24,7 @@ import { listVoices } from "../settings/repo.js";
|
|
|
24
24
|
import { realPersonVoicesKey, voiceIdMax, voiceNameMax } from "../settings/voices.js";
|
|
25
25
|
import { whatsNewSeenKey } from "../settings/whats-new.js";
|
|
26
26
|
import { studioPlaylistMax } from "../studio/model.js";
|
|
27
|
-
import { studioPairingKey, studioPlaylistKey, studioRealFootageKey } from "../studio/settings.js";
|
|
27
|
+
import { studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioRealFootageKey, } from "../studio/settings.js";
|
|
28
28
|
import { channelLinksKey } from "../youtube/edits-repo.js";
|
|
29
29
|
import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
|
|
30
30
|
import { stageKinds } from "./model.js";
|
|
@@ -376,6 +376,8 @@ function portableSettings(settings) {
|
|
|
376
376
|
key === "first-run.done" ||
|
|
377
377
|
key === notificationUrlKey ||
|
|
378
378
|
key === studioPairingKey ||
|
|
379
|
+
// What waits to be filled in Studio belongs to this install's pairing.
|
|
380
|
+
key.startsWith(studioFillQueuePrefix) ||
|
|
379
381
|
key === whatsNewSeenKey ||
|
|
380
382
|
key === patchNotesSeenKey)
|
|
381
383
|
continue;
|
|
@@ -386,7 +388,7 @@ function portableSettings(settings) {
|
|
|
386
388
|
storedAppearance.parse(parsed);
|
|
387
389
|
else if (key === "loudness")
|
|
388
390
|
storedLoudness.parse(parsed);
|
|
389
|
-
else if (key === studioPlaylistKey)
|
|
391
|
+
else if (key === studioPlaylistKey || key.startsWith(studioChannelPlaylistPrefix))
|
|
390
392
|
storedPlaylist.parse(parsed);
|
|
391
393
|
else if (key === channelLinksKey)
|
|
392
394
|
storedChannelLinks.parse(parsed);
|
|
@@ -49,10 +49,12 @@ export function uploadPack(deps, projectId) {
|
|
|
49
49
|
const text = (output) => output === undefined
|
|
50
50
|
? undefined
|
|
51
51
|
: readFileSync(outputPath(deps.paths, projectId, output.path), "utf8").trim();
|
|
52
|
-
const
|
|
52
|
+
const channelId = projectChannelId(deps.db, projectId);
|
|
53
|
+
// The channel's own playlist, else the default (Settings → YouTube Studio).
|
|
54
|
+
const playlist = readStudioPlaylist(deps.db, channelId);
|
|
53
55
|
const missing = [];
|
|
54
56
|
// The channel's setting, then what the project narrates and shows (`disclosure.ts`).
|
|
55
|
-
const setting = channelById(deps.db,
|
|
57
|
+
const setting = channelById(deps.db, channelId)?.aiDisclosure ?? "auto";
|
|
56
58
|
const realPersonVoices = realPersonVoicesOf(deps, config);
|
|
57
59
|
const uploadedClips = config.sources.images === "provide"
|
|
58
60
|
? outputs.filter((output) => output.role === "image" && isClipPath(output.path)).length
|
|
@@ -92,7 +94,7 @@ export function uploadPack(deps, projectId) {
|
|
|
92
94
|
if (description === undefined)
|
|
93
95
|
missing.push(config.youtubeDescription === true
|
|
94
96
|
? "The YouTube description isn't written yet. It is written after the video's subtitle timing; wait for the Video stage to finish."
|
|
95
|
-
: "No YouTube description is written for this project. Turn on YouTube description in Edit project →
|
|
97
|
+
: "No YouTube description is written for this project. Turn on YouTube description in Edit project → Prompts to have one written with chapters and tags.");
|
|
96
98
|
const count = thumbnailCountOf(config);
|
|
97
99
|
// The first thumbnail, then the second and third, and never one left from when the project
|
|
98
100
|
// made three and now makes one.
|
|
@@ -106,7 +108,7 @@ export function uploadPack(deps, projectId) {
|
|
|
106
108
|
else if (thumbnails.length < count)
|
|
107
109
|
missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
|
|
108
110
|
if (playlist === null)
|
|
109
|
-
missing.push("No playlist is set, so the playlist step is left to you. Set one in Settings → YouTube Studio.");
|
|
111
|
+
missing.push("No playlist is set for this project's channel, so the playlist step is left to you. Set one in Settings → YouTube Studio → Playlist.");
|
|
110
112
|
const items = [
|
|
111
113
|
{
|
|
112
114
|
kind: "video",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
4
|
+
import { studioFillQueuePrefix, studioPairing } from "./settings.js";
|
|
5
|
+
// What Fill in YouTube Studio chose, waiting for the extension: one entry per video or short,
|
|
6
|
+
// oldest first. Each new upload dialog in Studio is filled from the first entry, which then
|
|
7
|
+
// leaves the queue, so preparing several shorts in a row fills them one upload at a time.
|
|
8
|
+
// Kept in the settings table under the current pairing (`studio.fillQueue.<token hash>`), so a
|
|
9
|
+
// restart keeps it and a new pairing token starts with nothing waiting. An entry older than
|
|
10
|
+
// `fillQueueMs` is dropped as stale.
|
|
11
|
+
export const fillQueueMs = 24 * 60 * 60 * 1000;
|
|
12
|
+
// More would be a list nobody works through; the oldest leave first.
|
|
13
|
+
export const fillQueueMax = 50;
|
|
14
|
+
const entrySchema = z.object({
|
|
15
|
+
projectId: z.string().min(1).max(64),
|
|
16
|
+
short: z.number().int().min(1).max(99).nullable(),
|
|
17
|
+
at: z.string(),
|
|
18
|
+
});
|
|
19
|
+
function queueKey(db) {
|
|
20
|
+
const hash = createHash("sha256").update(studioPairing(db).token).digest("hex").slice(0, 16);
|
|
21
|
+
return `${studioFillQueuePrefix}${hash}`;
|
|
22
|
+
}
|
|
23
|
+
function write(db, key, entries) {
|
|
24
|
+
if (entries.length === 0)
|
|
25
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
|
26
|
+
else
|
|
27
|
+
writeSetting(db, key, JSON.stringify(entries));
|
|
28
|
+
}
|
|
29
|
+
// The waiting entries, oldest first, without the stale ones.
|
|
30
|
+
export function readFillQueue(db, now) {
|
|
31
|
+
const stored = readSetting(db, queueKey(db));
|
|
32
|
+
if (stored === undefined)
|
|
33
|
+
return [];
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = JSON.parse(stored);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(raw))
|
|
42
|
+
return [];
|
|
43
|
+
return raw.flatMap((one) => {
|
|
44
|
+
const parsed = entrySchema.safeParse(one);
|
|
45
|
+
if (!parsed.success)
|
|
46
|
+
return [];
|
|
47
|
+
const age = now.getTime() - Date.parse(parsed.data.at);
|
|
48
|
+
return Number.isFinite(age) && age <= fillQueueMs ? [parsed.data] : [];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const same = (entry, projectId, short) => entry.projectId === projectId && entry.short === short;
|
|
52
|
+
// Adds an item at the end; one already waiting keeps its place.
|
|
53
|
+
export function enqueueFill(db, projectId, short, now) {
|
|
54
|
+
const key = queueKey(db);
|
|
55
|
+
const current = readFillQueue(db, now);
|
|
56
|
+
if (current.some((entry) => same(entry, projectId, short))) {
|
|
57
|
+
write(db, key, current);
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
const next = [...current, { projectId, short, at: now.toISOString() }].slice(-fillQueueMax);
|
|
61
|
+
write(db, key, next);
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
// Takes an item out: filled by the extension, or removed by the person.
|
|
65
|
+
export function removeFill(db, projectId, short, now) {
|
|
66
|
+
const next = readFillQueue(db, now).filter((entry) => !same(entry, projectId, short));
|
|
67
|
+
write(db, queueKey(db), next);
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
@@ -12,8 +12,38 @@ const pairingSchema = z.object({
|
|
|
12
12
|
origin: z.string().nullable(),
|
|
13
13
|
pairedAt: z.string().nullable(),
|
|
14
14
|
});
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// A channel's own playlist is a row of its own, `studio.playlist.<channelId>`; a channel
|
|
16
|
+
// without one uses the default above.
|
|
17
|
+
export const studioChannelPlaylistPrefix = `${studioPlaylistKey}.`;
|
|
18
|
+
function playlistKey(channelId) {
|
|
19
|
+
return channelId === undefined ? studioPlaylistKey : `${studioChannelPlaylistPrefix}${channelId}`;
|
|
20
|
+
}
|
|
21
|
+
// The playlist an upload pack names: the channel's own, else the default. Without a channel,
|
|
22
|
+
// the default alone.
|
|
23
|
+
export function readStudioPlaylist(db, channelId) {
|
|
24
|
+
if (channelId !== undefined) {
|
|
25
|
+
const own = storedPlaylist(db, playlistKey(channelId));
|
|
26
|
+
if (own !== null)
|
|
27
|
+
return own;
|
|
28
|
+
}
|
|
29
|
+
return storedPlaylist(db, studioPlaylistKey);
|
|
30
|
+
}
|
|
31
|
+
// Every channel's own playlist, by channel id.
|
|
32
|
+
export function readChannelPlaylists(db) {
|
|
33
|
+
const prefix = studioChannelPlaylistPrefix;
|
|
34
|
+
const rows = db
|
|
35
|
+
.prepare("SELECT key FROM settings WHERE substr(key, 1, ?) = ? ORDER BY key")
|
|
36
|
+
.all(prefix.length, prefix);
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const { key } of rows) {
|
|
39
|
+
const value = storedPlaylist(db, key);
|
|
40
|
+
if (value !== null)
|
|
41
|
+
out[key.slice(prefix.length)] = value;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function storedPlaylist(db, key) {
|
|
46
|
+
const stored = readSetting(db, key);
|
|
17
47
|
if (stored === undefined)
|
|
18
48
|
return null;
|
|
19
49
|
try {
|
|
@@ -29,13 +59,16 @@ export function studioPlaylistProblem(raw) {
|
|
|
29
59
|
? `The playlist name is longer than YouTube allows (${String(studioPlaylistMax)} characters). Shorten it in Settings → YouTube Studio.`
|
|
30
60
|
: undefined;
|
|
31
61
|
}
|
|
32
|
-
|
|
62
|
+
// Saves the default playlist, or a channel's own; empty clears it (the channel then uses the
|
|
63
|
+
// default again).
|
|
64
|
+
export function saveStudioPlaylist(db, raw, channelId) {
|
|
33
65
|
const value = raw.trim();
|
|
66
|
+
const key = playlistKey(channelId);
|
|
34
67
|
if (value === "") {
|
|
35
|
-
db.prepare("DELETE FROM settings WHERE key = ?").run(
|
|
68
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
|
36
69
|
return null;
|
|
37
70
|
}
|
|
38
|
-
writeSetting(db,
|
|
71
|
+
writeSetting(db, key, JSON.stringify(value));
|
|
39
72
|
return value;
|
|
40
73
|
}
|
|
41
74
|
// The projects whose uploaded clips are real footage (filmed, not generated): ticked in the
|
|
@@ -83,12 +116,16 @@ export function studioPairing(db) {
|
|
|
83
116
|
}
|
|
84
117
|
return resetStudioPairing(db);
|
|
85
118
|
}
|
|
119
|
+
// What is waiting to be filled in Studio is kept per pairing (`queue.ts`): one row,
|
|
120
|
+
// `studio.fillQueue.<token hash>`, so a new token starts with nothing waiting.
|
|
121
|
+
export const studioFillQueuePrefix = "studio.fillQueue.";
|
|
86
122
|
export function resetStudioPairing(db) {
|
|
87
123
|
const pairing = {
|
|
88
124
|
token: randomBytes(24).toString("base64url"),
|
|
89
125
|
origin: null,
|
|
90
126
|
pairedAt: null,
|
|
91
127
|
};
|
|
128
|
+
db.prepare("DELETE FROM settings WHERE substr(key, 1, ?) = ?").run(studioFillQueuePrefix.length, studioFillQueuePrefix);
|
|
92
129
|
writeSetting(db, studioPairingKey, JSON.stringify(pairing));
|
|
93
130
|
return pairing;
|
|
94
131
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
// The sample a preview is made of: six seconds of the bundled sample project's narration
|
|
4
|
+
// ("The Library of Alexandria", `assets/sample/sample-project.tar`) with the word timing its
|
|
5
|
+
// alignment found, and three of its images in each shape. Taken once, offline, from the body
|
|
6
|
+
// narration at 3.4 s (the video's 4.4 s, after its one second of edge silence); the words are
|
|
7
|
+
// on the clip's own timeline. Nothing here is generated at preview time.
|
|
8
|
+
export const sampleNarrationSeconds = 6;
|
|
9
|
+
export const sampleNarrationWords = [
|
|
10
|
+
{ text: "Around", start: 0.5, end: 0.88 },
|
|
11
|
+
{ text: "three", start: 1.14, end: 1.36 },
|
|
12
|
+
{ text: "hundred", start: 1.46, end: 1.78 },
|
|
13
|
+
{ text: "years", start: 1.88, end: 2.26 },
|
|
14
|
+
{ text: "before", start: 2.4, end: 2.78 },
|
|
15
|
+
{ text: "our", start: 2.92, end: 3.02 },
|
|
16
|
+
{ text: "era,", start: 3.22, end: 3.4 },
|
|
17
|
+
{ text: "on", start: 4.18, end: 4.22 },
|
|
18
|
+
{ text: "the", start: 4.3, end: 4.36 },
|
|
19
|
+
{ text: "coast", start: 4.44, end: 4.72 },
|
|
20
|
+
{ text: "of", start: 4.84, end: 4.88 },
|
|
21
|
+
{ text: "Egypt,", start: 5.06, end: 5.4 },
|
|
22
|
+
];
|
|
23
|
+
export const sampleNarrationText = sampleNarrationWords.map((word) => word.text).join(" ");
|
|
24
|
+
// The sample project's title, the headline a Shorts preview shows when none is given.
|
|
25
|
+
export const sampleShortTitle = "The Library of Alexandria";
|
|
26
|
+
export function bundledSampleAssets(dir = fileURLToPath(new URL("../../assets/style-preview/", import.meta.url))) {
|
|
27
|
+
const at = (name) => join(dir, name);
|
|
28
|
+
return {
|
|
29
|
+
narration: at("narration.mp3"),
|
|
30
|
+
wide: [1, 2, 3].map((n) => at(`wide-${String(n)}.jpg`)),
|
|
31
|
+
tall: [1, 2, 3].map((n) => at(`tall-${String(n)}.jpg`)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { copyFileSync,
|
|
1
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { defaultMotionStyle, defaultZoomPercent } from "../admission/rules.js";
|
|
4
|
-
import { isMissingFont, resolveFont } from "../fonts/index.js";
|
|
4
|
+
import { isMissingFont, resolveBoldFont, resolveFont } from "../fonts/index.js";
|
|
5
|
+
import { renderShort } from "../shorts/render.js";
|
|
5
6
|
import { serializeAss } from "../subtitles/captions.js";
|
|
6
7
|
import { subtitleFrame } from "../subtitles/layout.js";
|
|
7
|
-
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
8
8
|
import { planRender } from "../video/plan.js";
|
|
9
9
|
import { renderSlideshow } from "../video/slideshow.js";
|
|
10
|
+
import { bundledSampleAssets, sampleNarrationSeconds } from "./narration.js";
|
|
10
11
|
import { stylePreviewSeconds } from "./schema.js";
|
|
11
|
-
import {
|
|
12
|
+
import { previewCues, previewWords } from "./settings.js";
|
|
12
13
|
export class StylePreviewError extends Error {
|
|
13
14
|
reason;
|
|
14
15
|
constructor(reason, message) {
|
|
@@ -23,69 +24,30 @@ const previewFrame = {
|
|
|
23
24
|
"16:9": { width: 480, height: 270 },
|
|
24
25
|
"9:16": { width: 270, height: 480 },
|
|
25
26
|
};
|
|
26
|
-
const stillSize = 1280;
|
|
27
27
|
const shotSeconds = stylePreviewSeconds / 3;
|
|
28
28
|
const chapterTitle = "Chapter one";
|
|
29
|
-
// Three stills, each a different colour story with a horizon and a light, so motion, grade
|
|
30
|
-
// and transitions all read. Square, so either format crops them to cover.
|
|
31
|
-
const stills = [
|
|
32
|
-
`gradients=s=${stillSize}x${stillSize}:c0=0x1d2b53:c1=0xe07a5f:x0=0:y0=0:x1=0:y1=${stillSize}:d=1,` +
|
|
33
|
-
"drawbox=x=820:y=300:w=140:h=140:color=0xfff1c1@0.9:t=fill," +
|
|
34
|
-
"drawbox=x=0:y=880:w=iw:h=400:color=0x14213d@0.85:t=fill",
|
|
35
|
-
`gradients=s=${stillSize}x${stillSize}:c0=0x0b3d2e:c1=0x88c9a1:x0=0:y0=${stillSize}:x1=${stillSize}:y1=0:d=1,` +
|
|
36
|
-
"drawbox=x=200:y=380:w=260:h=260:color=0xf4f1de@0.8:t=fill," +
|
|
37
|
-
"drawbox=x=0:y=840:w=iw:h=440:color=0x081c15@0.9:t=fill",
|
|
38
|
-
`gradients=s=${stillSize}x${stillSize}:c0=0x3d1e6d:c1=0xf2a65a:x0=${stillSize}:y0=0:x1=0:y1=${stillSize}:d=1,` +
|
|
39
|
-
"drawbox=x=540:y=330:w=200:h=200:color=0xffd166@0.9:t=fill," +
|
|
40
|
-
"drawbox=x=0:y=820:w=iw:h=460:color=0x1b0f2e@0.85:t=fill",
|
|
41
|
-
];
|
|
42
29
|
export function ffmpegStylePreview(deps) {
|
|
43
|
-
|
|
44
|
-
const ensureSamples = (signal) => {
|
|
45
|
-
samples ??= makeSamples(deps, signal).catch((error) => {
|
|
46
|
-
samples = undefined;
|
|
47
|
-
throw error;
|
|
48
|
-
});
|
|
49
|
-
return samples;
|
|
50
|
-
};
|
|
30
|
+
const assets = deps.assets ?? bundledSampleAssets();
|
|
51
31
|
return async (settings, output, signal, picture) => {
|
|
52
32
|
try {
|
|
53
33
|
// A real picture is shown for all three shots: the motion, the Look and the captions read
|
|
54
34
|
// on it, and the transition and chapter card still show at the cuts.
|
|
55
|
-
const images = picture
|
|
56
|
-
|
|
35
|
+
const images = picture !== undefined
|
|
36
|
+
? [picture, picture, picture]
|
|
37
|
+
: settings.format === "9:16"
|
|
38
|
+
? assets.tall
|
|
39
|
+
: assets.wide;
|
|
40
|
+
if (settings.short !== undefined)
|
|
41
|
+
await renderShortPreview(deps, settings, settings.short, assets, images, output, signal);
|
|
42
|
+
else
|
|
43
|
+
await renderPreview(deps, settings, assets, images, output, signal);
|
|
57
44
|
}
|
|
58
45
|
catch (error) {
|
|
59
46
|
throw explained(deps.log, error);
|
|
60
47
|
}
|
|
61
48
|
};
|
|
62
49
|
}
|
|
63
|
-
async function
|
|
64
|
-
const folder = join(deps.dir, "samples");
|
|
65
|
-
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
66
|
-
const paths = [];
|
|
67
|
-
for (const [at, source] of stills.entries()) {
|
|
68
|
-
const path = join(folder, `still-${String(at + 1)}.png`);
|
|
69
|
-
paths.push(path);
|
|
70
|
-
if (existsSync(path))
|
|
71
|
-
continue;
|
|
72
|
-
const part = join(folder, `still-${String(at + 1)}.part.png`);
|
|
73
|
-
await runFfmpeg({
|
|
74
|
-
bin: deps.ffmpeg,
|
|
75
|
-
args: [
|
|
76
|
-
...["-hide_banner", "-nostdin", "-loglevel", "error", "-y"],
|
|
77
|
-
...["-f", "lavfi", "-i", source, "-frames:v", "1", part],
|
|
78
|
-
],
|
|
79
|
-
signal,
|
|
80
|
-
log: deps.log,
|
|
81
|
-
onProgress: () => { },
|
|
82
|
-
});
|
|
83
|
-
renameSync(part, path);
|
|
84
|
-
}
|
|
85
|
-
return paths;
|
|
86
|
-
}
|
|
87
|
-
async function renderPreview(deps, settings, images, output, signal) {
|
|
88
|
-
// A render on a given picture never made the stills, which is what created the folder.
|
|
50
|
+
async function renderPreview(deps, settings, assets, images, output, signal) {
|
|
89
51
|
mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
|
|
90
52
|
const workspace = mkdtempSync(join(deps.dir, "work-"));
|
|
91
53
|
try {
|
|
@@ -118,14 +80,14 @@ async function renderPreview(deps, settings, images, output, signal) {
|
|
|
118
80
|
const edit = {
|
|
119
81
|
...plan.editList,
|
|
120
82
|
...previewFrame[settings.format],
|
|
121
|
-
audio: [{ kind: "
|
|
83
|
+
audio: [{ kind: "body", path: assets.narration, seconds: plan.totalSeconds }],
|
|
122
84
|
};
|
|
123
85
|
const captions = settings.captions;
|
|
124
86
|
if (captions !== null) {
|
|
125
87
|
const font = await resolveFont(deps.paths, captions.fontId);
|
|
126
88
|
mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
|
|
127
89
|
copyFileSync(font.path, join(workspace, "fonts", `selected${font.extension}`));
|
|
128
|
-
writeFileSync(join(workspace, "subtitles.ass"), serializeAss(
|
|
90
|
+
writeFileSync(join(workspace, "subtitles.ass"), serializeAss(previewCues(captions.text), {
|
|
129
91
|
...subtitleFrame(settings.format),
|
|
130
92
|
fontName: font.assName,
|
|
131
93
|
fontSize: captions.fontSize,
|
|
@@ -148,6 +110,31 @@ async function renderPreview(deps, settings, images, output, signal) {
|
|
|
148
110
|
rmSync(workspace, { recursive: true, force: true });
|
|
149
111
|
}
|
|
150
112
|
}
|
|
113
|
+
// The Shorts layout, drawn at the preview's size: the same audio cut, image timing, captions
|
|
114
|
+
// and headline a short gets, from the sample narration.
|
|
115
|
+
async function renderShortPreview(deps, settings, short, assets, images, output, signal) {
|
|
116
|
+
mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
await renderShort({
|
|
118
|
+
bin: deps.ffmpeg,
|
|
119
|
+
timeline: [{ kind: "body", path: assets.narration, seconds: sampleNarrationSeconds }],
|
|
120
|
+
start: 0,
|
|
121
|
+
end: sampleNarrationSeconds,
|
|
122
|
+
images,
|
|
123
|
+
imageSeconds: shotSeconds,
|
|
124
|
+
motionStyle: defaultMotionStyle,
|
|
125
|
+
zoomPercent: defaultZoomPercent,
|
|
126
|
+
words: previewWords(short.text),
|
|
127
|
+
font: await resolveBoldFont(deps.paths, short.fontId),
|
|
128
|
+
...(short.title === null ? {} : { title: short.title }),
|
|
129
|
+
speed: short.speed,
|
|
130
|
+
frame: previewFrame[settings.format],
|
|
131
|
+
output,
|
|
132
|
+
scratch: deps.dir,
|
|
133
|
+
signal,
|
|
134
|
+
log: deps.log,
|
|
135
|
+
onProgress: () => { },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
151
138
|
function explained(log, error) {
|
|
152
139
|
if (error instanceof StylePreviewError)
|
|
153
140
|
return error;
|
|
@@ -3,13 +3,16 @@ import { formats } from "../../kernel/pipeline.js";
|
|
|
3
3
|
import { videoEditSchema } from "../admission/schema.js";
|
|
4
4
|
import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
|
|
5
5
|
// "See it before you make it": a few seconds rendered by the real renderer with a project's
|
|
6
|
-
// caption and Look settings, on
|
|
6
|
+
// caption and Look settings, on the bundled sample's images and narration. Browser-safe, so
|
|
7
7
|
// Play and Edit project read the same request and reply shapes the server checks.
|
|
8
|
-
// How long the preview runs: three
|
|
9
|
-
// card both show.
|
|
8
|
+
// How long the preview runs: three images of two seconds, so a transition and a chapter
|
|
9
|
+
// card both show, under six seconds of the sample narration.
|
|
10
10
|
export const stylePreviewSeconds = 6;
|
|
11
11
|
export const stylePreviewTextMax = 200;
|
|
12
|
+
// The sample text Play starts with. It, or no text, captions the sample narration with its own
|
|
13
|
+
// words; any other text is spread over the stretches the narration speaks.
|
|
12
14
|
export const defaultPreviewText = "Every story begins with a word.";
|
|
15
|
+
export const shortTitlePreviewMax = 100;
|
|
13
16
|
// A picture the preview is drawn on instead of the sample stills: the establishing image a
|
|
14
17
|
// draft uploaded (its staged file), one a project already has (its output), or a channel cast
|
|
15
18
|
// member's picture (by content hash). One the server can no longer find falls back to the
|
|
@@ -33,6 +36,17 @@ export const stylePreviewRequestSchema = z.object({
|
|
|
33
36
|
videoEdit: videoEditSchema.optional(),
|
|
34
37
|
previewText: z.string().max(stylePreviewTextMax).optional(),
|
|
35
38
|
image: stylePreviewImageSchema.optional(),
|
|
39
|
+
// The Shorts layout instead of the video's: 9:16 through the Shorts renderer, with its
|
|
40
|
+
// big word-by-word captions in the caption font, the title as a headline when it is on,
|
|
41
|
+
// and the clips' speed. The caption size, position and the Look don't apply to a short.
|
|
42
|
+
shorts: z
|
|
43
|
+
.object({
|
|
44
|
+
titleOnScreen: z.boolean(),
|
|
45
|
+
speed: z.number().finite().min(1).max(1.25).optional(),
|
|
46
|
+
// The headline; absent or blank shows the sample's title.
|
|
47
|
+
title: z.string().max(shortTitlePreviewMax).optional(),
|
|
48
|
+
})
|
|
49
|
+
.optional(),
|
|
36
50
|
// Render again even when this exact preview is already saved.
|
|
37
51
|
force: z.boolean().optional(),
|
|
38
52
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { normalizeStylePreview, stylePreviewHash } from "./settings.js";
|
|
4
|
+
import { normalizeStylePreview, previewSeconds, stylePreviewHash } from "./settings.js";
|
|
6
5
|
// Rendered previews are kept in the data folder by the hash of their settings, so a setting
|
|
7
6
|
// changed back, or the same style in another project, plays at once. `force` renders again
|
|
8
7
|
// anyway. Requests for a preview already being rendered wait for that render instead of
|
|
@@ -49,7 +48,7 @@ export function createStylePreviews(deps) {
|
|
|
49
48
|
const saved = file(hash);
|
|
50
49
|
if (saved === undefined)
|
|
51
50
|
throw new Error("The style preview render finished without writing its video.");
|
|
52
|
-
return { hash, cached: false, seconds:
|
|
51
|
+
return { hash, cached: false, seconds: previewSeconds(settings), version: saved.version };
|
|
53
52
|
};
|
|
54
53
|
return {
|
|
55
54
|
file,
|
|
@@ -65,7 +64,7 @@ export function createStylePreviews(deps) {
|
|
|
65
64
|
return Promise.resolve({
|
|
66
65
|
hash,
|
|
67
66
|
cached: true,
|
|
68
|
-
seconds:
|
|
67
|
+
seconds: previewSeconds(settings),
|
|
69
68
|
version: saved.version,
|
|
70
69
|
});
|
|
71
70
|
const started = run(hash, settings, picture).finally(() => {
|