@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.
- package/dist/edge/http/studio.js +18 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.2.9.md +15 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/model.js +4 -0
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/rebuild/recipe-appearance.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 +2 -2
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/subject.js +10 -0
- package/dist/slices/studio/pack.js +23 -6
- package/dist/slices/studio/pick.js +32 -0
- package/dist/web/assets/index-hUEQRe6s.js +309 -0
- package/dist/web/assets/{pdf-CqdWRPHL.js → pdf-C1Iq6van.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-BQrub4xw.js +0 -309
package/dist/edge/http/studio.js
CHANGED
|
@@ -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) => {
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
@@ -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
|
+
}
|
|
@@ -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 { 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";
|
|
@@ -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]),
|
|
@@ -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:
|
|
120
|
-
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
|
|
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
|
+
}
|