@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.
Files changed (42) hide show
  1. package/dist/edge/http/projects.js +8 -0
  2. package/dist/edge/http/studio.js +300 -58
  3. package/dist/extension/slopify-studio-chrome.zip +0 -0
  4. package/dist/extension/slopify-studio-firefox.zip +0 -0
  5. package/dist/kernel/db/migrations/0046-youtube-upload-state.sql +6 -0
  6. package/dist/kernel/db/migrations/0047-studio-autopilot.sql +39 -0
  7. package/dist/patch-notes/3.2.9.md +15 -0
  8. package/dist/patch-notes/3.3.0.md +40 -0
  9. package/dist/patch-notes/index.json +12 -0
  10. package/dist/slices/admission/model.js +4 -0
  11. package/dist/slices/admission/schema.js +1 -0
  12. package/dist/slices/article/segments.js +2 -1
  13. package/dist/slices/rebuild/recipe-appearance.js +2 -1
  14. package/dist/slices/rebuild/recipe-document.js +2 -2
  15. package/dist/slices/rebuild/recipe-exports.js +2 -1
  16. package/dist/slices/rebuild/recipe-reference.js +2 -2
  17. package/dist/slices/rebuild/recipe-scenes.js +3 -3
  18. package/dist/slices/rebuild/recipe-shorts.js +3 -2
  19. package/dist/slices/rebuild/recipe-text.js +2 -1
  20. package/dist/slices/rebuild/recipe-visual.js +4 -4
  21. package/dist/slices/rebuild/recipe-youtube.js +2 -1
  22. package/dist/slices/revisions/mutations.js +2 -1
  23. package/dist/slices/revisions/subject.js +9 -0
  24. package/dist/slices/storage/backup-format.js +4 -0
  25. package/dist/slices/storage/portable.js +12 -1
  26. package/dist/slices/studio/backfill.js +34 -0
  27. package/dist/slices/studio/model.js +2 -0
  28. package/dist/slices/studio/pack.js +64 -8
  29. package/dist/slices/studio/pick.js +33 -0
  30. package/dist/slices/studio/plan.js +190 -0
  31. package/dist/slices/studio/queue.js +7 -0
  32. package/dist/slices/studio/settings.js +2 -0
  33. package/dist/slices/studio/stats.js +71 -0
  34. package/dist/slices/studio/videos.js +45 -11
  35. package/dist/slices/youtube/titles.js +2 -2
  36. package/dist/web/assets/index-6wMtRXOo.css +1 -0
  37. package/dist/web/assets/index-BKn7EPwB.js +310 -0
  38. package/dist/web/assets/{pdf-CqdWRPHL.js → pdf-BPADxYaD.js} +1 -1
  39. package/dist/web/index.html +2 -2
  40. package/package.json +1 -1
  41. package/dist/web/assets/index-BQrub4xw.js +0 -309
  42. 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.title}`,
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.title, article: text.articleText ?? "" }), config.language);
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.title,
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.title,
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.title, prompt)),
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.title,
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.title, article: text.articleText ?? "", pictures }), config.language);
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.title,
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.title,
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.title,
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.title, timing]]
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.title, config.format],
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.title, prompt)),
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.title,
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 { storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
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
+ }
@@ -21,6 +21,8 @@ export const studioSteps = [
21
21
  "audience",
22
22
  "altered",
23
23
  "tags",
24
+ // Studio's Visibility step: when it goes out, from the posting plan.
25
+ "schedule",
24
26
  ];
25
27
  // Tags as Studio's field takes them: comma-separated.
26
28
  export function tagsLine(tags) {
@@ -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: project.title.slice(0, studioTitleMax),
120
- titles: (edited?.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.map(file),
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(config.shorts?.fullVideoLink),
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
+ }