@gentbajko/slopify 3.2.8 → 3.3.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/edge/http/projects.js +8 -0
- package/dist/edge/http/studio.js +300 -58
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0046-youtube-upload-state.sql +6 -0
- package/dist/kernel/db/migrations/0047-studio-autopilot.sql +39 -0
- package/dist/patch-notes/3.2.9.md +15 -0
- package/dist/patch-notes/3.3.0.md +40 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/model.js +4 -0
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/article/segments.js +2 -1
- package/dist/slices/rebuild/recipe-appearance.js +2 -1
- package/dist/slices/rebuild/recipe-document.js +2 -2
- package/dist/slices/rebuild/recipe-exports.js +2 -1
- package/dist/slices/rebuild/recipe-reference.js +2 -2
- package/dist/slices/rebuild/recipe-scenes.js +3 -3
- package/dist/slices/rebuild/recipe-shorts.js +3 -2
- package/dist/slices/rebuild/recipe-text.js +2 -1
- package/dist/slices/rebuild/recipe-visual.js +4 -4
- package/dist/slices/rebuild/recipe-youtube.js +2 -1
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/subject.js +9 -0
- package/dist/slices/storage/backup-format.js +4 -0
- package/dist/slices/storage/portable.js +12 -1
- package/dist/slices/studio/backfill.js +34 -0
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +64 -8
- package/dist/slices/studio/pick.js +33 -0
- package/dist/slices/studio/plan.js +190 -0
- package/dist/slices/studio/queue.js +7 -0
- package/dist/slices/studio/settings.js +2 -0
- package/dist/slices/studio/stats.js +71 -0
- package/dist/slices/studio/videos.js +45 -11
- package/dist/slices/youtube/titles.js +2 -2
- package/dist/web/assets/index-6wMtRXOo.css +1 -0
- package/dist/web/assets/index-BKn7EPwB.js +310 -0
- package/dist/web/assets/{pdf-CqdWRPHL.js → pdf-BPADxYaD.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BQrub4xw.js +0 -309
- package/dist/web/assets/index-DaTBqaK8.css +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
2
|
+
import { subjectOf } from "../admission/model.js";
|
|
2
3
|
import { noTokens, plusUsage } from "../telemetry/model.js";
|
|
3
4
|
export const categories = ["intro", "outro"];
|
|
4
5
|
export async function writeSegment(providers, choice, config, category, article, sent) {
|
|
@@ -43,7 +44,7 @@ export function segmentMessages(body, config, article) {
|
|
|
43
44
|
content: [
|
|
44
45
|
body,
|
|
45
46
|
"",
|
|
46
|
-
`Video title: ${config
|
|
47
|
+
`Video title: ${subjectOf(config)}`,
|
|
47
48
|
"",
|
|
48
49
|
"Keyword values for this run:",
|
|
49
50
|
"",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
2
|
+
import { subjectOf } from "../admission/model.js";
|
|
2
3
|
import { appearanceFor, appearanceMessages, appearanceSchema, usesAppearance, withAppearance, } from "../images/appearance.js";
|
|
3
4
|
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
4
5
|
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
@@ -36,7 +37,7 @@ export function imageAppearance(context, text) {
|
|
|
36
37
|
const { config } = context;
|
|
37
38
|
if (!pictureBodies(context).some(usesAppearance))
|
|
38
39
|
return undefined;
|
|
39
|
-
const messages = withLanguage(appearanceMessages({ title: config
|
|
40
|
+
const messages = withLanguage(appearanceMessages({ title: subjectOf(config), article: text.articleText ?? "" }), config.language);
|
|
40
41
|
const value = recipe(context, imageAppearanceKey, config.sources.images === "generate" ? "images" : "thumbnail", text.articleText === null
|
|
41
42
|
? {
|
|
42
43
|
kind: "deferred",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sourceOf } from "../admission/model.js";
|
|
1
|
+
import { sourceOf, subjectOf } from "../admission/model.js";
|
|
2
2
|
import { resolvedDocumentTheme } from "../document/theme.js";
|
|
3
3
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
4
4
|
// Bumped when the renderer's output changes for the same inputs, so documents made by an
|
|
@@ -21,7 +21,7 @@ export function documentRecipes(context, text, thumbnail) {
|
|
|
21
21
|
operation: "render-document",
|
|
22
22
|
values: [
|
|
23
23
|
renderer,
|
|
24
|
-
config
|
|
24
|
+
subjectOf(config),
|
|
25
25
|
JSON.parse(JSON.stringify(theme)),
|
|
26
26
|
resourceIdentity(context, text.article),
|
|
27
27
|
notes === undefined ? null : resourceIdentity(context, notes),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { projectLanguage } from "../../kernel/ports/languages.js";
|
|
2
|
+
import { subjectOf } from "../admission/model.js";
|
|
2
3
|
import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
|
|
3
4
|
import { usesShortMode } from "../admission/short-mode.js";
|
|
4
5
|
import { reviewsNarration } from "../reviews/rules.js";
|
|
@@ -78,7 +79,7 @@ export function exportRecipes(context, audio) {
|
|
|
78
79
|
audio.mediaFingerprint,
|
|
79
80
|
resourceIdentity(context, timing),
|
|
80
81
|
(audio.sections ?? []).map((section) => [section.title, section.firstTurn]),
|
|
81
|
-
config
|
|
82
|
+
subjectOf(config),
|
|
82
83
|
...master.values,
|
|
83
84
|
...lines.values,
|
|
84
85
|
// The book's title and chapter become the files' album and track tags; a project
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { referenceKey } from "../admission/model.js";
|
|
1
|
+
import { referenceKey, subjectOf } from "../admission/model.js";
|
|
2
2
|
import { usesReference } from "../admission/rules.js";
|
|
3
3
|
import { lookWait, withLooks } from "./recipe-appearance.js";
|
|
4
4
|
import { castFor } from "./recipe-cast.js";
|
|
@@ -56,7 +56,7 @@ appearance) {
|
|
|
56
56
|
prompt,
|
|
57
57
|
// The establishing image sets the look for the whole video, so it is drawn with every
|
|
58
58
|
// member the title mentions as well as the ones its own brief does.
|
|
59
|
-
...castField(castFor(config, config
|
|
59
|
+
...castField(castFor(config, subjectOf(config), prompt)),
|
|
60
60
|
}, [], { unresolved: prompt.trim() === "" });
|
|
61
61
|
}
|
|
62
62
|
// Which image the others are drawn from: the step's fingerprint, so making it again or
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
3
|
-
import { thumbnailCountOf } from "../admission/model.js";
|
|
3
|
+
import { subjectOf, thumbnailCountOf } from "../admission/model.js";
|
|
4
4
|
import { pictureKind, sceneMessages, usesScene } from "../images/scenes.js";
|
|
5
5
|
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
6
6
|
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
@@ -19,7 +19,7 @@ export function thumbnailScenes(context, text) {
|
|
|
19
19
|
return undefined;
|
|
20
20
|
const count = thumbnailCountOf(config);
|
|
21
21
|
const messages = withLanguage(sceneMessages({
|
|
22
|
-
title: config
|
|
22
|
+
title: subjectOf(config),
|
|
23
23
|
article: text.articleText ?? "",
|
|
24
24
|
pictures: [],
|
|
25
25
|
thumbnails: count,
|
|
@@ -60,7 +60,7 @@ export function imageScenes(context, text) {
|
|
|
60
60
|
}
|
|
61
61
|
if (keys.length === 0)
|
|
62
62
|
return undefined;
|
|
63
|
-
const messages = withLanguage(sceneMessages({ title: config
|
|
63
|
+
const messages = withLanguage(sceneMessages({ title: subjectOf(config), article: text.articleText ?? "", pictures }), config.language);
|
|
64
64
|
const value = recipe(context, imageScenesKey, "images", text.articleText === null
|
|
65
65
|
? {
|
|
66
66
|
kind: "deferred",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
3
|
+
import { subjectOf } from "../admission/model.js";
|
|
3
4
|
import { usesShorts } from "../admission/rules.js";
|
|
4
5
|
import { withoutScene } from "../images/scenes.js";
|
|
5
6
|
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
@@ -53,7 +54,7 @@ appearance) {
|
|
|
53
54
|
shorts.count,
|
|
54
55
|
shorts.minSeconds,
|
|
55
56
|
shorts.maxSeconds,
|
|
56
|
-
config
|
|
57
|
+
subjectOf(config),
|
|
57
58
|
// The project language, only when it is not English, so English keeps its fingerprint.
|
|
58
59
|
...(config.language === undefined || config.language === "en" ? [] : [config.language]),
|
|
59
60
|
],
|
|
@@ -113,7 +114,7 @@ appearance) {
|
|
|
113
114
|
const prompts = {
|
|
114
115
|
...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
|
|
115
116
|
style: looked,
|
|
116
|
-
videoTitle: config
|
|
117
|
+
videoTitle: subjectOf(config),
|
|
117
118
|
shortTitle: clip.title,
|
|
118
119
|
text: clip.text,
|
|
119
120
|
count,
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
3
3
|
import { documentIndex } from "../../kernel/ports/llm-documents.js";
|
|
4
4
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
5
|
+
import { subjectOf } from "../admission/model.js";
|
|
5
6
|
import { usesPronunciationGlossary } from "../admission/rules.js";
|
|
6
7
|
import { render } from "../admission/substitute.js";
|
|
7
8
|
import { articleMessages, continuationMessages } from "../article/continuation.js";
|
|
@@ -225,7 +226,7 @@ export function textRecipes(context) {
|
|
|
225
226
|
const prompt = renderedPrompt(context, "thumbnailPrompt");
|
|
226
227
|
const messages = thumbnailMessages({
|
|
227
228
|
instruction: prompt,
|
|
228
|
-
title: config
|
|
229
|
+
title: subjectOf(config),
|
|
229
230
|
values: config.values,
|
|
230
231
|
format: config.format,
|
|
231
232
|
article: articleText ?? "",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { thumbnailCountOf, thumbnailKey } from "../admission/model.js";
|
|
1
|
+
import { subjectOf, thumbnailCountOf, thumbnailKey } from "../admission/model.js";
|
|
2
2
|
import { usesShortMode } from "../admission/short-mode.js";
|
|
3
3
|
import { render } from "../admission/substitute.js";
|
|
4
4
|
import { withoutScene, withScene } from "../images/scenes.js";
|
|
@@ -123,7 +123,7 @@ appearance) {
|
|
|
123
123
|
// Only for a short, so every long video keeps its fingerprint: the word-by-word
|
|
124
124
|
// captions are drawn from the word timing in the caption font, under the title.
|
|
125
125
|
...(short
|
|
126
|
-
? [["short-v1", config.subtitles?.fontId ?? "default", config
|
|
126
|
+
? [["short-v1", config.subtitles?.fontId ?? "default", subjectOf(config), timing]]
|
|
127
127
|
: []),
|
|
128
128
|
// Only while the video has an ambient bed, so every video without one keeps the
|
|
129
129
|
// fingerprint it always had.
|
|
@@ -213,7 +213,7 @@ appearance) {
|
|
|
213
213
|
kind: "provided",
|
|
214
214
|
version: 1,
|
|
215
215
|
assetId: content.provided.thumbnail ?? null,
|
|
216
|
-
semantic: [config
|
|
216
|
+
semantic: [subjectOf(config), config.format],
|
|
217
217
|
}, [], { unresolved: content.provided.thumbnail === undefined }),
|
|
218
218
|
];
|
|
219
219
|
const promptRecipe = textRecipes.find((value) => value.key === "thumbnail:prompt");
|
|
@@ -266,7 +266,7 @@ appearance) {
|
|
|
266
266
|
prompt: thumbnailVariantPrompt(prompt, variant),
|
|
267
267
|
...(reference === undefined ? {} : { reference: reference.input }),
|
|
268
268
|
// The thumbnail stands for the whole video, like the establishing image.
|
|
269
|
-
...castField(castFor(config, config
|
|
269
|
+
...castField(castFor(config, subjectOf(config), prompt)),
|
|
270
270
|
}, [
|
|
271
271
|
...(promptRecipe === undefined ? [] : [promptRecipe.key]),
|
|
272
272
|
...(reference === undefined ? [] : [reference.key]),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subjectOf } from "../admission/model.js";
|
|
1
2
|
import { usesYoutubeDescription } from "../admission/rules.js";
|
|
2
3
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
3
4
|
import { renderedPrompt } from "./recipe-text.js";
|
|
@@ -24,7 +25,7 @@ export function youtubeRecipes(context, exports) {
|
|
|
24
25
|
// Blank means the built-in prompt; the step fills it in, so a new built-in wording
|
|
25
26
|
// ships as a new operation version rather than a silent change here.
|
|
26
27
|
config.descriptionPrompt?.trim() ? renderedPrompt(context, "description") : "",
|
|
27
|
-
config
|
|
28
|
+
subjectOf(config),
|
|
28
29
|
// The project language, only when it is not English, so English keeps its fingerprint.
|
|
29
30
|
...(config.language === undefined || config.language === "en" ? [] : [config.language]),
|
|
30
31
|
],
|
|
@@ -24,6 +24,7 @@ import { cloneManifest, ensureRevisionStages, projectSelected } from "./projecti
|
|
|
24
24
|
import { insertAsset, insertRevision } from "./repo.js";
|
|
25
25
|
import { bindNarrationSources, validateNarrationIntent, validateTemplateIntent } from "./rules.js";
|
|
26
26
|
import { revisionEditSchema } from "./schema.js";
|
|
27
|
+
import { keptSubject } from "./subject.js";
|
|
27
28
|
export { bindUpload } from "./mutation-assets.js";
|
|
28
29
|
export { restoreRevision } from "./restore.js";
|
|
29
30
|
export async function saveRevision(deps, input) {
|
|
@@ -57,7 +58,7 @@ export async function saveRevision(deps, input) {
|
|
|
57
58
|
currentRevisionId: base.revision.id,
|
|
58
59
|
fields: replanned.fields,
|
|
59
60
|
};
|
|
60
|
-
const edit = bindNarrationSources(base, rebrandedEdit(deps.db, base.revision.config, replanned.edit));
|
|
61
|
+
const edit = bindNarrationSources(base, keptSubject(base.revision.config, rebrandedEdit(deps.db, base.revision.config, replanned.edit)));
|
|
61
62
|
const fields = [
|
|
62
63
|
...settingProblems(edit),
|
|
63
64
|
...validateUploads(deps, edit),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// A renamed project keeps what it is about (`subjectOf`): on the first rename the title it was
|
|
2
|
+
// made with is kept as `subjectTitle`, and every step's fingerprint reads it, so a rename runs
|
|
3
|
+
// nothing. An edit can't drop a kept subject either.
|
|
4
|
+
export function keptSubject(base, edit) {
|
|
5
|
+
const subject = base.subjectTitle ?? (edit.config.title !== base.title ? base.title : undefined);
|
|
6
|
+
if (subject === undefined || edit.config.subjectTitle === subject)
|
|
7
|
+
return edit;
|
|
8
|
+
return { ...edit, config: { ...edit.config, subjectTitle: subject } };
|
|
9
|
+
}
|
|
@@ -57,6 +57,10 @@ export const projectTables = [
|
|
|
57
57
|
"project_uploads",
|
|
58
58
|
// Since 3.2.8: the YouTube video each upload became, and its A/B test.
|
|
59
59
|
"youtube_videos",
|
|
60
|
+
// Since 3.3.0: each project's posting-plan slot, Studio's numbers and its A/B results.
|
|
61
|
+
"upload_slots",
|
|
62
|
+
"video_stats",
|
|
63
|
+
"ab_results",
|
|
60
64
|
// Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
|
|
61
65
|
// current wait, a lease of this install) and prompt_softening (a pending one-off request).
|
|
62
66
|
// Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
|
|
@@ -23,7 +23,9 @@ import { appearances, providerById, providerIds } from "../settings/model.js";
|
|
|
23
23
|
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
|
-
import {
|
|
26
|
+
import { uploadPickPrefix, uploadPickSchema } from "../studio/pick.js";
|
|
27
|
+
import { postingPlanKey, postingPlanSchema } from "../studio/plan.js";
|
|
28
|
+
import { autoCommentKey, storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
|
|
27
29
|
import { channelLinksKey } from "../youtube/edits-repo.js";
|
|
28
30
|
import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
|
|
29
31
|
import { stageKinds } from "./model.js";
|
|
@@ -379,6 +381,11 @@ function portableSettings(settings) {
|
|
|
379
381
|
key === whatsNewSeenKey ||
|
|
380
382
|
key === patchNotesSeenKey)
|
|
381
383
|
continue;
|
|
384
|
+
if (key === autoCommentKey) {
|
|
385
|
+
z.enum(["on", "off"]).parse(value);
|
|
386
|
+
portable[key] = value;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
382
389
|
const parsed = storedJson(value);
|
|
383
390
|
if (key === "silenceGapSeconds")
|
|
384
391
|
storedSilenceGap.parse(parsed);
|
|
@@ -388,6 +395,10 @@ function portableSettings(settings) {
|
|
|
388
395
|
storedLoudness.parse(parsed);
|
|
389
396
|
else if (key === studioPlaylistKey || key.startsWith(studioChannelPlaylistPrefix))
|
|
390
397
|
storedPlaylistsSchema.parse(parsed);
|
|
398
|
+
else if (key === postingPlanKey)
|
|
399
|
+
postingPlanSchema.parse(parsed);
|
|
400
|
+
else if (key.startsWith(uploadPickPrefix))
|
|
401
|
+
uploadPickSchema.parse(parsed);
|
|
391
402
|
else if (key === studioProjectPlaylistsKey)
|
|
392
403
|
storedProjectPlaylistsSchema.parse(parsed);
|
|
393
404
|
else if (key === channelLinksKey)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { listProjects } from "../admission/repo.js";
|
|
2
|
+
import { uploadPack } from "./pack.js";
|
|
3
|
+
import { clearSlot } from "./plan.js";
|
|
4
|
+
import { projectVideos, recordVideo } from "./videos.js";
|
|
5
|
+
const norm = (title) => title.trim().replace(/\s+/g, " ").toLowerCase();
|
|
6
|
+
export function backfillVideos(deps, rows, at) {
|
|
7
|
+
const byTitle = new Map();
|
|
8
|
+
for (const row of rows)
|
|
9
|
+
byTitle.set(norm(row.title), row.videoId);
|
|
10
|
+
if (byTitle.size === 0)
|
|
11
|
+
return 0;
|
|
12
|
+
let found = 0;
|
|
13
|
+
for (const project of listProjects(deps.db)) {
|
|
14
|
+
const known = projectVideos(deps.db, project.id).filter((one) => one.uploadState === "done");
|
|
15
|
+
const result = uploadPack(deps, project.id);
|
|
16
|
+
if (!result.ok)
|
|
17
|
+
continue;
|
|
18
|
+
for (const item of result.pack.items) {
|
|
19
|
+
const short = item.short ?? null;
|
|
20
|
+
if (known.some((one) => one.short === short))
|
|
21
|
+
continue;
|
|
22
|
+
const titles = item.pickable?.titles ?? [item.title, ...item.titles];
|
|
23
|
+
const videoId = titles.map((one) => byTitle.get(norm(one))).find((one) => one !== undefined);
|
|
24
|
+
if (videoId === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
recordVideo(deps.db, project.id, short, videoId, at);
|
|
27
|
+
// Uploaded by hand before: its long video holds no slot of the posting plan.
|
|
28
|
+
if (short === null)
|
|
29
|
+
clearSlot(deps.db, project.id);
|
|
30
|
+
found += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
@@ -14,10 +14,14 @@ import { outputPath } from "../storage/layout.js";
|
|
|
14
14
|
import { outputsOf } from "../storage/repo.js";
|
|
15
15
|
import { videoEditOf } from "../video/edit-settings.js";
|
|
16
16
|
import { usesVoices } from "../voices/model.js";
|
|
17
|
-
import { effectiveDescription } from "../youtube/edits-repo.js";
|
|
17
|
+
import { effectiveDescription, readDescriptionEdits } from "../youtube/edits-repo.js";
|
|
18
|
+
import { linkKey, previousVideoLink } from "../youtube/placeholders.js";
|
|
18
19
|
import { aiDisclosureOf } from "./disclosure.js";
|
|
19
20
|
import { studioAudience, studioTitleMax, } from "./model.js";
|
|
21
|
+
import { picked, readUploadPick } from "./pick.js";
|
|
22
|
+
import { readPlan, scheduleOf } from "./plan.js";
|
|
20
23
|
import { projectPlaylists, readRealFootage } from "./settings.js";
|
|
24
|
+
import { previousLongVideo, videoIdOf, videoOf } from "./videos.js";
|
|
21
25
|
// What `shorts.json` holds for each picked clip; only what the pack uses.
|
|
22
26
|
const clipsSchema = z.object({
|
|
23
27
|
shorts: z.array(z.object({
|
|
@@ -87,6 +91,7 @@ export function uploadPack(deps, projectId) {
|
|
|
87
91
|
description: written,
|
|
88
92
|
tags: writtenTags ?? "",
|
|
89
93
|
titles: text(outputs.find((output) => output.role === "youtube_titles")),
|
|
94
|
+
pinnedComment: text(outputs.find((output) => output.role === "youtube_pinned_comment")),
|
|
90
95
|
// The last chapter is checked against the video's own length when it is known.
|
|
91
96
|
durationSeconds: video?.durationMs === null || video?.durationMs === undefined
|
|
92
97
|
? undefined
|
|
@@ -112,25 +117,66 @@ export function uploadPack(deps, projectId) {
|
|
|
112
117
|
missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
|
|
113
118
|
if (playlistChoices.length === 0)
|
|
114
119
|
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 → Playlists.");
|
|
120
|
+
// The video's titles and thumbnails in the project's order, and the ones the person picked
|
|
121
|
+
// for the upload first (`pick.ts`); the A/B test later tries the rest beside them.
|
|
122
|
+
const ownTitle = project.title.slice(0, studioTitleMax);
|
|
123
|
+
const allTitles = [
|
|
124
|
+
ownTitle,
|
|
125
|
+
...(edited?.titles ?? "")
|
|
126
|
+
.split("\n")
|
|
127
|
+
.map((one) => one.trim().slice(0, studioTitleMax))
|
|
128
|
+
.filter((one) => one !== ""),
|
|
129
|
+
];
|
|
130
|
+
const allThumbnails = thumbnails.map(file);
|
|
131
|
+
const pick = readUploadPick(deps.db, projectId);
|
|
132
|
+
// When each upload goes out, from the posting plan's slot (`plan.ts`), once the project has
|
|
133
|
+
// one: the long video at the slot, each short at its own time after it.
|
|
134
|
+
const plan = readPlan(deps.db);
|
|
135
|
+
const slotRow = deps.db
|
|
136
|
+
.prepare("SELECT row_name, long_at FROM upload_slots WHERE project_id=?")
|
|
137
|
+
.get(projectId);
|
|
138
|
+
const schedule = slotRow === undefined || String(slotRow.long_at) === ""
|
|
139
|
+
? undefined
|
|
140
|
+
: scheduleOf(plan, { row: String(slotRow.row_name), longAt: String(slotRow.long_at) });
|
|
141
|
+
// The long video's captions, its end screen's video (the project's Previous video, else the
|
|
142
|
+
// long video uploaded before it) and the comment to pin; a short's related video is the
|
|
143
|
+
// long video, once it is on YouTube.
|
|
144
|
+
const captions = outputs.find((output) => output.role === "subtitles_srt");
|
|
145
|
+
const previous = edited === undefined ? undefined : readPreviousVideo(deps.db, projectId);
|
|
146
|
+
const endScreenVideoId = previous ?? previousLongVideo(deps.db, projectId);
|
|
147
|
+
const pinnedComment = edited?.pinnedComment.trim() ?? "";
|
|
148
|
+
const arranged = picked(allTitles, allThumbnails, pick);
|
|
115
149
|
const items = [
|
|
116
150
|
{
|
|
117
151
|
kind: "video",
|
|
118
152
|
video: video === undefined ? null : file(video),
|
|
119
|
-
title:
|
|
120
|
-
titles: (
|
|
121
|
-
.split("\n")
|
|
122
|
-
.map((one) => one.trim().slice(0, studioTitleMax))
|
|
123
|
-
.filter((one) => one !== ""),
|
|
153
|
+
title: arranged.titles[0] ?? ownTitle,
|
|
154
|
+
titles: arranged.titles.slice(1),
|
|
124
155
|
description: description ?? "",
|
|
125
156
|
tags: tagsOf(tagsFile),
|
|
126
|
-
thumbnails: thumbnails
|
|
157
|
+
thumbnails: arranged.thumbnails,
|
|
158
|
+
pickable: {
|
|
159
|
+
titles: allTitles,
|
|
160
|
+
thumbnails: allThumbnails,
|
|
161
|
+
title: pick.title < allTitles.length ? pick.title : 0,
|
|
162
|
+
thumbnail: pick.thumbnail < allThumbnails.length ? pick.thumbnail : 0,
|
|
163
|
+
},
|
|
127
164
|
audience: studioAudience,
|
|
128
165
|
alteredContent: disclosure("video"),
|
|
129
166
|
playlists,
|
|
130
167
|
playlist,
|
|
131
168
|
...(edited?.chapterNotice === undefined ? {} : { chapterNotice: edited.chapterNotice }),
|
|
169
|
+
...(schedule === undefined ? {} : { scheduleAt: schedule.longAt }),
|
|
170
|
+
...(captions === undefined ? {} : { captions: file(captions) }),
|
|
171
|
+
...(endScreenVideoId === undefined ? {} : { endScreenVideoId }),
|
|
172
|
+
...(pinnedComment === "" ? {} : { pinnedComment }),
|
|
132
173
|
},
|
|
133
174
|
];
|
|
175
|
+
// The shorts link the long video: the link set in the project, else the one Slopify knows
|
|
176
|
+
// the video by on YouTube (`videos.ts`).
|
|
177
|
+
const longVideo = videoOf(deps.db, projectId, null);
|
|
178
|
+
const fullVideoLink = config.shorts?.fullVideoLink ??
|
|
179
|
+
(longVideo?.uploadState === "done" ? `https://youtu.be/${longVideo.videoId}` : undefined);
|
|
134
180
|
const clips = clipsOf(text(outputs.find((output) => output.role === "shorts")));
|
|
135
181
|
const shortVideos = outputs.filter((output) => output.role === "short_video");
|
|
136
182
|
for (const clip of clips) {
|
|
@@ -151,7 +197,7 @@ export function uploadPack(deps, projectId) {
|
|
|
151
197
|
titles: [],
|
|
152
198
|
description: [
|
|
153
199
|
clip.description,
|
|
154
|
-
fullVideoLine(
|
|
200
|
+
fullVideoLine(fullVideoLink),
|
|
155
201
|
"",
|
|
156
202
|
clip.hashtags.join(" "),
|
|
157
203
|
].join("\n"),
|
|
@@ -163,6 +209,10 @@ export function uploadPack(deps, projectId) {
|
|
|
163
209
|
alteredContent: disclosure("short"),
|
|
164
210
|
playlists,
|
|
165
211
|
playlist,
|
|
212
|
+
...(schedule?.shortsAt[clip.number - 1] === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: { scheduleAt: schedule.shortsAt[clip.number - 1] }),
|
|
215
|
+
...(longVideo?.uploadState === "done" ? { relatedVideoId: longVideo.videoId } : {}),
|
|
166
216
|
});
|
|
167
217
|
}
|
|
168
218
|
return {
|
|
@@ -171,6 +221,7 @@ export function uploadPack(deps, projectId) {
|
|
|
171
221
|
projectId,
|
|
172
222
|
projectTitle: project.title,
|
|
173
223
|
items,
|
|
224
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
174
225
|
missing,
|
|
175
226
|
playlistChoices,
|
|
176
227
|
...(uploadedClips > 0 ? { footage: { clips: uploadedClips, real: realFootage } } : {}),
|
|
@@ -220,3 +271,8 @@ function available(deps, projectId, output) {
|
|
|
220
271
|
throwIfNoEntry: false,
|
|
221
272
|
})?.isFile() === true);
|
|
222
273
|
}
|
|
274
|
+
// The project's Previous video link (YouTube section), as a video id.
|
|
275
|
+
function readPreviousVideo(db, projectId) {
|
|
276
|
+
const link = readDescriptionEdits(db, projectId).links.find((one) => linkKey(one.name) === linkKey(previousVideoLink))?.url;
|
|
277
|
+
return link === undefined ? undefined : videoIdOf(link);
|
|
278
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
3
|
+
export const uploadPickSchema = z.object({
|
|
4
|
+
title: z.number().int().min(0).max(9),
|
|
5
|
+
thumbnail: z.number().int().min(0).max(9),
|
|
6
|
+
});
|
|
7
|
+
export const uploadPickPrefix = "studio.uploadPick.";
|
|
8
|
+
const keyOf = (projectId) => `${uploadPickPrefix}${projectId}`;
|
|
9
|
+
export function readUploadPick(db, projectId) {
|
|
10
|
+
const stored = readSetting(db, keyOf(projectId));
|
|
11
|
+
if (stored === undefined)
|
|
12
|
+
return { title: 0, thumbnail: 0 };
|
|
13
|
+
try {
|
|
14
|
+
const parsed = uploadPickSchema.safeParse(JSON.parse(stored));
|
|
15
|
+
return parsed.success ? parsed.data : { title: 0, thumbnail: 0 };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { title: 0, thumbnail: 0 };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function writeUploadPick(db, projectId, pick) {
|
|
22
|
+
writeSetting(db, keyOf(projectId), JSON.stringify(uploadPickSchema.parse(pick)));
|
|
23
|
+
}
|
|
24
|
+
// The chosen title first, the others after it in their order; the same for the thumbnails. A
|
|
25
|
+
// pick past the end (a title list rewritten shorter) falls back to the first.
|
|
26
|
+
export function picked(titles, thumbnails, pick) {
|
|
27
|
+
const first = (list, at) => {
|
|
28
|
+
const index = at < list.length ? at : 0;
|
|
29
|
+
const chosen = list[index];
|
|
30
|
+
return chosen === undefined ? list : [chosen, ...list.filter((_, one) => one !== index)];
|
|
31
|
+
};
|
|
32
|
+
return { titles: first(titles, pick.title), thumbnails: first(thumbnails, pick.thumbnail) };
|
|
33
|
+
}
|