@gentbajko/slopify 3.2.8 → 3.2.9

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.
@@ -9,6 +9,7 @@ import { channelById } from "../../slices/channels/repo.js";
9
9
  import { findDownload } from "../../slices/storage/downloads.js";
10
10
  import { studioPlaylistMax } from "../../slices/studio/model.js";
11
11
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
12
+ import { writeUploadPick } from "../../slices/studio/pick.js";
12
13
  import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
13
14
  import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
14
15
  import { forgetVideo, projectVideos, recordVideo, setAbState, videoIdOf, videoIdPattern, waitingAbTests, } from "../../slices/studio/videos.js";
@@ -46,6 +47,10 @@ const projectPlaylistsBody = z.object({
46
47
  playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
47
48
  });
48
49
  const realFootageBody = z.object({ realFootage: z.boolean() });
50
+ const pickBody = z.object({
51
+ title: z.number().int().min(0).max(9),
52
+ thumbnail: z.number().int().min(0).max(9),
53
+ });
49
54
  const shortField = z.number().int().min(1).max(99).nullable().optional();
50
55
  const extVideoBody = z.object({
51
56
  projectId: id,
@@ -172,6 +177,19 @@ export function studioRoutes(deps) {
172
177
  saveRealFootage(deps.db, projectId, c.req.valid("json").realFootage);
173
178
  const result = uploadPack(deps, projectId);
174
179
  return result.ok ? c.json(result.pack) : unknownProject(c);
180
+ })
181
+ // Prepare upload's pick: which title and thumbnail the upload carries; the A/B test tries
182
+ // the others once the video is public.
183
+ .put("/packs/:projectId/pick", zValidator("param", projectParam, onInvalid), zValidator("json", pickBody, onInvalid), (c) => {
184
+ const denied = samePage(c);
185
+ if (denied !== undefined)
186
+ return denied;
187
+ const { projectId } = c.req.valid("param");
188
+ if (!uploadPack(deps, projectId).ok)
189
+ return unknownProject(c);
190
+ writeUploadPick(deps.db, projectId, c.req.valid("json"));
191
+ const result = uploadPack(deps, projectId);
192
+ return result.ok ? c.json(result.pack) : unknownProject(c);
175
193
  })
176
194
  // Prepare upload's playlist ticks: which of the channel's playlists this project goes into.
177
195
  .put("/packs/:projectId/playlists", zValidator("param", projectParam, onInvalid), zValidator("json", projectPlaylistsBody, onInvalid), (c) => {
@@ -0,0 +1,15 @@
1
+ # Slopify 3.2.9
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Projects
6
+
7
+ - **Renaming a project no longer makes everything again.** The pictures, scenes, looks and shorts stay as they are: they keep what the project was about. What shows the title follows the new name: the YouTube description and other titles, the PDF and the audio files' tags.
8
+
9
+ ## Prepare upload
10
+
11
+ - **Pick the upload's title and thumbnail.** The A/B test now starts after the video is public, so the upload itself carries one title and one thumbnail: choose the title among the project's and its other titles, and press **Use for upload** on a thumbnail. Fill in YouTube Studio uses your pick, and the A/B test later tries the others beside it.
12
+
13
+ ## Upgrading from 3.2.8
14
+
15
+ - **Nothing becomes outdated.** A project renamed before this version keeps whatever its rename already queued.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.2.9",
4
+ "title": "Slopify 3.2.9",
5
+ "version": "3.2.9",
6
+ "date": "2026-10-02"
7
+ },
2
8
  {
3
9
  "id": "3.2.8",
4
10
  "title": "Slopify 3.2.8",
@@ -45,3 +45,7 @@ export const referenceKey = "reference:image";
45
45
  // mode existed was) or one vertical short of about a minute, made by the same stages with
46
46
  // the Shorts renderer's word-by-word captions (`admission/short-mode.ts`).
47
47
  export const runModes = ["video", "short"];
48
+ // What the project is about, for the steps that only use the title as context.
49
+ export function subjectOf(config) {
50
+ return config.subjectTitle ?? config.title;
51
+ }
@@ -190,4 +190,5 @@ export const runDraftSchema = z.object({
190
190
  export const runConfigSchema = runDraftSchema.extend({
191
191
  rendered: z.record(z.string(), z.string()),
192
192
  titlePattern: z.string().optional(),
193
+ subjectTitle: z.string().optional(),
193
194
  });
@@ -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 { 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";
@@ -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]),
@@ -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,10 @@
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`, so the scenes, looks, cast and shorts made from it keep
3
+ // their fingerprints and nothing is drawn again. What shows the title (YouTube's text, the
4
+ // PDF, the files' tags) follows the new name. An edit can't drop a kept subject either.
5
+ export function keptSubject(base, edit) {
6
+ const subject = base.subjectTitle ?? (edit.config.title !== base.title ? base.title : undefined);
7
+ if (subject === undefined || edit.config.subjectTitle === subject)
8
+ return edit;
9
+ return { ...edit, config: { ...edit.config, subjectTitle: subject } };
10
+ }
@@ -17,6 +17,7 @@ import { usesVoices } from "../voices/model.js";
17
17
  import { effectiveDescription } from "../youtube/edits-repo.js";
18
18
  import { aiDisclosureOf } from "./disclosure.js";
19
19
  import { studioAudience, studioTitleMax, } from "./model.js";
20
+ import { picked, readUploadPick } from "./pick.js";
20
21
  import { projectPlaylists, readRealFootage } from "./settings.js";
21
22
  // What `shorts.json` holds for each picked clip; only what the pack uses.
22
23
  const clipsSchema = z.object({
@@ -112,18 +113,34 @@ export function uploadPack(deps, projectId) {
112
113
  missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
113
114
  if (playlistChoices.length === 0)
114
115
  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.");
116
+ // The video's titles and thumbnails in the project's order, and the ones the person picked
117
+ // for the upload first (`pick.ts`); the A/B test later tries the rest beside them.
118
+ const ownTitle = project.title.slice(0, studioTitleMax);
119
+ const allTitles = [
120
+ ownTitle,
121
+ ...(edited?.titles ?? "")
122
+ .split("\n")
123
+ .map((one) => one.trim().slice(0, studioTitleMax))
124
+ .filter((one) => one !== ""),
125
+ ];
126
+ const allThumbnails = thumbnails.map(file);
127
+ const pick = readUploadPick(deps.db, projectId);
128
+ const arranged = picked(allTitles, allThumbnails, pick);
115
129
  const items = [
116
130
  {
117
131
  kind: "video",
118
132
  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 !== ""),
133
+ title: arranged.titles[0] ?? ownTitle,
134
+ titles: arranged.titles.slice(1),
124
135
  description: description ?? "",
125
136
  tags: tagsOf(tagsFile),
126
- thumbnails: thumbnails.map(file),
137
+ thumbnails: arranged.thumbnails,
138
+ pickable: {
139
+ titles: allTitles,
140
+ thumbnails: allThumbnails,
141
+ title: pick.title < allTitles.length ? pick.title : 0,
142
+ thumbnail: pick.thumbnail < allThumbnails.length ? pick.thumbnail : 0,
143
+ },
127
144
  audience: studioAudience,
128
145
  alteredContent: disclosure("video"),
129
146
  playlists,
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import { readSetting, writeSetting } from "../settings/repo.js";
3
+ const pickSchema = z.object({
4
+ title: z.number().int().min(0).max(9),
5
+ thumbnail: z.number().int().min(0).max(9),
6
+ });
7
+ const keyOf = (projectId) => `studio.uploadPick.${projectId}`;
8
+ export function readUploadPick(db, projectId) {
9
+ const stored = readSetting(db, keyOf(projectId));
10
+ if (stored === undefined)
11
+ return { title: 0, thumbnail: 0 };
12
+ try {
13
+ const parsed = pickSchema.safeParse(JSON.parse(stored));
14
+ return parsed.success ? parsed.data : { title: 0, thumbnail: 0 };
15
+ }
16
+ catch {
17
+ return { title: 0, thumbnail: 0 };
18
+ }
19
+ }
20
+ export function writeUploadPick(db, projectId, pick) {
21
+ writeSetting(db, keyOf(projectId), JSON.stringify(pickSchema.parse(pick)));
22
+ }
23
+ // The chosen title first, the others after it in their order; the same for the thumbnails. A
24
+ // pick past the end (a title list rewritten shorter) falls back to the first.
25
+ export function picked(titles, thumbnails, pick) {
26
+ const first = (list, at) => {
27
+ const index = at < list.length ? at : 0;
28
+ const chosen = list[index];
29
+ return chosen === undefined ? list : [chosen, ...list.filter((_, one) => one !== index)];
30
+ };
31
+ return { titles: first(titles, pick.title), thumbnails: first(thumbnails, pick.thumbnail) };
32
+ }