@gentbajko/slopify 3.9.1 → 3.10.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/dist/catalog/model-info.js +33 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/channels.js +6 -1
- package/dist/edge/http/document-themes.js +2 -2
- package/dist/edge/http/files.js +6 -2
- package/dist/edge/http/outputs.js +67 -0
- package/dist/edge/http/planning.js +18 -3
- package/dist/edge/http/projects.js +7 -4
- package/dist/edge/http/providers.js +4 -6
- package/dist/edge/http/revision-files.js +25 -7
- package/dist/edge/http/schedules.js +30 -0
- package/dist/edge/http/trash.js +51 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
- package/dist/patch-notes/3.10.0.md +79 -0
- package/dist/patch-notes/3.10.1.md +18 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/heads.js +59 -0
- package/dist/slices/admission/rules.js +6 -1
- package/dist/slices/batch/index.js +22 -0
- package/dist/slices/channels/rebrand.js +7 -1
- package/dist/slices/channels/repo.js +2 -1
- package/dist/slices/channels/schema.js +5 -0
- package/dist/slices/channels/service.js +23 -3
- package/dist/slices/estimate/index.js +2 -0
- package/dist/slices/library/transfer.js +164 -0
- package/dist/slices/narration/aliases-table.js +82 -0
- package/dist/slices/outputs/add.js +192 -0
- package/dist/slices/outputs/model.js +36 -0
- package/dist/slices/outputs/service.js +136 -0
- package/dist/slices/rebuild/model.js +1 -1
- package/dist/slices/rebuild/recipe-text.js +9 -4
- package/dist/slices/rebuild/recipe-visual.js +6 -0
- package/dist/slices/rebuild/runtime-local.js +10 -3
- package/dist/slices/rebuild/runtime-provider.js +7 -0
- package/dist/slices/revisions/downloads.js +38 -17
- package/dist/slices/revisions/model.js +3 -1
- package/dist/slices/revisions/mutation-assets.js +29 -2
- package/dist/slices/revisions/mutation-prepare.js +44 -9
- package/dist/slices/revisions/schema.js +4 -0
- package/dist/slices/schedules/agenda.js +21 -16
- package/dist/slices/schedules/held-bulk.js +66 -0
- package/dist/slices/schedules/topics.js +1 -1
- package/dist/slices/storage/backup-import.js +8 -4
- package/dist/slices/storage/downloads.js +42 -4
- package/dist/slices/studio/backfill.js +8 -1
- package/dist/slices/voices/adaptation.js +19 -0
- package/dist/slices/voices/model.js +4 -2
- package/dist/slices/youtube/model.js +33 -2
- package/dist/tutorials/AI-CLIs.md +1 -1
- package/dist/tutorials/Backups.md +5 -5
- package/dist/tutorials/Calendar.md +2 -2
- package/dist/tutorials/Channels.md +1 -1
- package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
- package/dist/tutorials/Document-Themes.md +2 -2
- package/dist/tutorials/Editing-a-Project.md +2 -2
- package/dist/tutorials/FAQ.md +1 -1
- package/dist/tutorials/Home-and-Projects.md +33 -22
- package/dist/tutorials/Intros-and-Outros.md +4 -4
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
- package/dist/tutorials/Library-Overview.md +16 -15
- package/dist/tutorials/Multiple-Voices.md +3 -3
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
- package/dist/tutorials/PDF-Documents.md +5 -5
- package/dist/tutorials/Play-Narration.md +3 -3
- package/dist/tutorials/Play-Outputs.md +1 -1
- package/dist/tutorials/Play-Overview.md +4 -4
- package/dist/tutorials/Play-Title-and-Article.md +3 -3
- package/dist/tutorials/Project-Page.md +7 -6
- package/dist/tutorials/Prompts.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +2 -2
- package/dist/tutorials/Recovery-and-Retries.md +1 -1
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +35 -31
- package/dist/tutorials/Templates.md +1 -1
- package/dist/tutorials/Trash.md +5 -5
- package/dist/tutorials/Troubleshooting.md +2 -2
- package/dist/tutorials/Where-Your-Files-Live.md +4 -4
- package/dist/web/assets/index-CGwlNEwD.js +325 -0
- package/dist/web/assets/index-DmPZKDb1.css +1 -0
- package/dist/web/assets/{pdf-D04vvdOD.js → pdf-3nEY4HYq.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-IWb_SctN.js +0 -310
- package/dist/web/assets/index-_D68Tk8q.css +0 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { thinkingModes } from "../kernel/ports/llm.js";
|
|
2
|
+
// What a picker shows for one catalogued model: its name, thinking modes, list price and
|
|
3
|
+
// context size, so the choice is made knowing what it costs and how much it reads at once.
|
|
4
|
+
export function catalogueModelInfo(model) {
|
|
5
|
+
const price = priceOf(model.pricing);
|
|
6
|
+
const llm = "llm" in model ? model.llm : undefined;
|
|
7
|
+
return {
|
|
8
|
+
id: model.id,
|
|
9
|
+
name: model.name,
|
|
10
|
+
...(llm?.thinking ? { thinkingModes: Object.keys(llm.thinking).filter(isThinkingMode) } : {}),
|
|
11
|
+
...(price === undefined ? {} : { price }),
|
|
12
|
+
...(llm?.contextTokens === undefined ? {} : { contextTokens: llm.contextTokens }),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function priceOf(pricing) {
|
|
16
|
+
const price = {
|
|
17
|
+
...(pricing.inputPerMillionTokens === undefined
|
|
18
|
+
? {}
|
|
19
|
+
: { inputPerMillionTokens: pricing.inputPerMillionTokens }),
|
|
20
|
+
...(pricing.outputPerMillionTokens === undefined
|
|
21
|
+
? {}
|
|
22
|
+
: { outputPerMillionTokens: pricing.outputPerMillionTokens }),
|
|
23
|
+
...(pricing.perMillionCharacters === undefined
|
|
24
|
+
? {}
|
|
25
|
+
: { perMillionCharacters: pricing.perMillionCharacters }),
|
|
26
|
+
...(pricing.perImage === undefined ? {} : { perImage: pricing.perImage }),
|
|
27
|
+
...(pricing.perMinute === undefined ? {} : { perMinute: pricing.perMinute }),
|
|
28
|
+
};
|
|
29
|
+
return Object.keys(price).length === 0 ? undefined : price;
|
|
30
|
+
}
|
|
31
|
+
function isThinkingMode(key) {
|
|
32
|
+
return thinkingModes.some((mode) => mode === key);
|
|
33
|
+
}
|
package/dist/edge/http/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { homeRoutes, uploadedRoutes } from "./home.js";
|
|
|
21
21
|
import { narrationPeakRoutes } from "./narration-peaks.js";
|
|
22
22
|
import { onboardingRoutes } from "./onboarding.js";
|
|
23
23
|
import { openFolderRoutes } from "./open-folder.js";
|
|
24
|
+
import { outputRoutes } from "./outputs.js";
|
|
24
25
|
import { patchNotesRoutes } from "./patch-notes.js";
|
|
25
26
|
import { planningRoutes } from "./planning.js";
|
|
26
27
|
import { problem, problemFromError, titleOf } from "./problem.js";
|
|
@@ -75,6 +76,7 @@ function apiRoutes(deps, startedAt) {
|
|
|
75
76
|
.route("/projects", checkpointRoutes(deps))
|
|
76
77
|
.route("/projects", reviewRoutes(deps))
|
|
77
78
|
.route("/projects", revisionRoutes(deps))
|
|
79
|
+
.route("/projects", outputRoutes(deps))
|
|
78
80
|
.route("/projects", revisionFolderRoutes(deps))
|
|
79
81
|
.route("/projects", openFolderRoutes(deps))
|
|
80
82
|
.route("/projects", audioPreviewRoutes(deps))
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { deleteCastImage, generateCastImage, uploadCastImage, } from "../../slices/channels/cast-images.js";
|
|
7
7
|
import { castImageMaxBytes } from "../../slices/channels/images.js";
|
|
8
8
|
import { imageBlob } from "../../slices/channels/repo.js";
|
|
9
|
-
import { createCastMember, createChannel, deleteCastMember, deleteChannel, listChannels, moveTemplate, readChannel, setChannelAiDisclosure, updateCastMember, updateChannel, } from "../../slices/channels/service.js";
|
|
9
|
+
import { createCastMember, createChannel, deleteCastMember, deleteChannel, listChannels, moveCastMember, moveTemplate, readChannel, setChannelAiDisclosure, updateCastMember, updateChannel, } from "../../slices/channels/service.js";
|
|
10
10
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
11
11
|
const idParam = z.object({ id: z.uuid() });
|
|
12
12
|
const memberParam = z.object({ memberId: z.uuid() });
|
|
@@ -93,6 +93,11 @@ export function channelRoutes(deps) {
|
|
|
93
93
|
.post("/:id/cast", zValidator("param", idParam, onInvalid), async (c) => {
|
|
94
94
|
const result = createCastMember(service, c.req.valid("param").id, await body(c));
|
|
95
95
|
return result.ok ? c.json(result.value, 201) : refused(c, result);
|
|
96
|
+
})
|
|
97
|
+
// Move up, Move down and their Undo: one member to a place in the cast's order.
|
|
98
|
+
.post("/:id/cast/move", zValidator("param", idParam, onInvalid), async (c) => {
|
|
99
|
+
const result = moveCastMember(service, c.req.valid("param").id, await body(c));
|
|
100
|
+
return result.ok ? c.json({ cast: result.value }) : refused(c, result);
|
|
96
101
|
}));
|
|
97
102
|
}
|
|
98
103
|
async function body(c) {
|
|
@@ -62,7 +62,7 @@ function refused(c, failure) {
|
|
|
62
62
|
extensions: { fields: failure.fields },
|
|
63
63
|
});
|
|
64
64
|
case "duplicate-name": {
|
|
65
|
-
const message = "Another
|
|
65
|
+
const message = "Another PDF theme already has this name. Choose a different name.";
|
|
66
66
|
return problem(c, {
|
|
67
67
|
status: 409,
|
|
68
68
|
title: titleOf(409),
|
|
@@ -74,7 +74,7 @@ function refused(c, failure) {
|
|
|
74
74
|
return problem(c, {
|
|
75
75
|
status: 404,
|
|
76
76
|
title: titleOf(404),
|
|
77
|
-
detail: "This
|
|
77
|
+
detail: "This PDF theme no longer exists; it may have been deleted in another tab. Go back to Library → PDF themes.",
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
}
|
package/dist/edge/http/files.js
CHANGED
|
@@ -12,6 +12,9 @@ const projectParam = z.object({
|
|
|
12
12
|
.max(64)
|
|
13
13
|
.regex(/^[0-9A-Za-z_-]+$/),
|
|
14
14
|
});
|
|
15
|
+
const setParam = projectParam.extend({
|
|
16
|
+
set: z.enum(["images.zip", "thumbnails.zip", "shorts.zip"]),
|
|
17
|
+
});
|
|
15
18
|
const assetParam = projectParam.extend({
|
|
16
19
|
asset: z
|
|
17
20
|
.string()
|
|
@@ -25,8 +28,9 @@ export function fileRoutes(deps) {
|
|
|
25
28
|
const waveform = waveformAnswer(deps.decodePeaks);
|
|
26
29
|
return (new Hono()
|
|
27
30
|
// Declared before :asset so the zip is never read as an asset name.
|
|
28
|
-
.get("/files/:projectId
|
|
29
|
-
const
|
|
31
|
+
.get("/files/:projectId/:set{(?:images|thumbnails|shorts)\\.zip}", zValidator("param", setParam, onInvalid), (c) => {
|
|
32
|
+
const { projectId, set } = c.req.valid("param");
|
|
33
|
+
const result = imagesZip(storage, projectId, set === "images.zip" ? "images" : set === "thumbnails.zip" ? "thumbnails" : "shorts");
|
|
30
34
|
if (!result.ok) {
|
|
31
35
|
return missing(c, result.reason);
|
|
32
36
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { addOutputPreviewRequestSchema, addOutputRequestSchema, } from "../../slices/outputs/model.js";
|
|
5
|
+
import { addOutput, previewAddedOutput, } from "../../slices/outputs/service.js";
|
|
6
|
+
import { currentRevisionId } from "../../slices/revisions/repo.js";
|
|
7
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
|
+
const projectParam = z.object({
|
|
9
|
+
id: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.max(64)
|
|
13
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
14
|
+
});
|
|
15
|
+
const statuses = {
|
|
16
|
+
"no-project": 404,
|
|
17
|
+
"no-revision": 404,
|
|
18
|
+
conflict: 409,
|
|
19
|
+
"idempotency-conflict": 409,
|
|
20
|
+
"invalid-edit": 400,
|
|
21
|
+
already: 409,
|
|
22
|
+
"needs-article": 409,
|
|
23
|
+
"narration-taken": 409,
|
|
24
|
+
"adaptation-not-accepted": 409,
|
|
25
|
+
};
|
|
26
|
+
const details = {
|
|
27
|
+
"no-project": "This project no longer exists. Go back to Projects to pick another.",
|
|
28
|
+
"no-revision": "This saved version of the project no longer exists. Reload the page, then open Add another output again.",
|
|
29
|
+
conflict: "This project changed while you were choosing. Reload the page, then open Add another output again.",
|
|
30
|
+
"idempotency-conflict": "This output was already requested with different details. Reload the page, then try again.",
|
|
31
|
+
"invalid-edit": "This project is missing a choice the new output needs. Set the fields listed in this project's Settings, save, then add the output again.",
|
|
32
|
+
already: "This project already makes that output. Find it in its section on the left.",
|
|
33
|
+
"needs-article": "That output is made from the article, which this project does not have yet. Wait for the Article step to finish, then add it.",
|
|
34
|
+
"narration-taken": "This project already has narration, and a project has one. Keep it, or change its format under Speakers in this project's Settings → Providers.",
|
|
35
|
+
"adaptation-not-accepted": 'This output rewrites your article as a conversation. Tick "Adapt my article into a conversation" in Add another output, then add it.',
|
|
36
|
+
};
|
|
37
|
+
// Add another output: what it reuses, makes and costs, then the revision that switches it on.
|
|
38
|
+
export function outputRoutes(deps) {
|
|
39
|
+
const service = { ...deps, catalogue: deps.catalogue ?? deps.rebuild?.catalogue };
|
|
40
|
+
const refused = (c, projectId, value) => {
|
|
41
|
+
const status = statuses[value.reason];
|
|
42
|
+
return problem(c, {
|
|
43
|
+
status,
|
|
44
|
+
title: titleOf(status),
|
|
45
|
+
detail: value.message ?? details[value.reason],
|
|
46
|
+
extensions: {
|
|
47
|
+
reason: value.reason,
|
|
48
|
+
currentRevisionId: currentRevisionId(deps.db, projectId) ?? null,
|
|
49
|
+
fields: value.fields ?? [],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
return new Hono()
|
|
54
|
+
.post("/:id/outputs/preview", zValidator("param", projectParam, onInvalid), zValidator("json", addOutputPreviewRequestSchema, onInvalid), (c) => {
|
|
55
|
+
const projectId = c.req.valid("param").id;
|
|
56
|
+
const result = previewAddedOutput(service, { projectId, ...c.req.valid("json") });
|
|
57
|
+
return result.ok ? c.json(result.value) : refused(c, projectId, result);
|
|
58
|
+
})
|
|
59
|
+
.post("/:id/outputs", zValidator("param", projectParam, onInvalid), zValidator("json", addOutputRequestSchema, onInvalid), async (c) => {
|
|
60
|
+
const projectId = c.req.valid("param").id;
|
|
61
|
+
const result = await addOutput(service, { projectId, ...c.req.valid("json") });
|
|
62
|
+
if (!result.ok)
|
|
63
|
+
return refused(c, projectId, result);
|
|
64
|
+
deps.hub.emit(projectId, { type: "project.updated", projectId });
|
|
65
|
+
return c.json(result);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { modelFields } from "../../catalog/validate.js";
|
|
5
5
|
import { runDraftSchema } from "../../slices/admission/repo.js";
|
|
6
6
|
import { admit } from "../../slices/admission/rules.js";
|
|
7
|
-
import { batchExists, enqueueBatch, pumpQueue, queueEntries } from "../../slices/batch/index.js";
|
|
7
|
+
import { batchExists, enqueueBatch, moveQueued, pumpQueue, queueEntries, } from "../../slices/batch/index.js";
|
|
8
8
|
import { castVoicedRun } from "../../slices/channels/runs.js";
|
|
9
9
|
import { estimateRun } from "../../slices/estimate/index.js";
|
|
10
10
|
import { resolveFont } from "../../slices/fonts/index.js";
|
|
@@ -26,6 +26,8 @@ const planningBody = z.object({
|
|
|
26
26
|
.optional(),
|
|
27
27
|
});
|
|
28
28
|
const batchBody = planningBody.extend({ requestId: z.uuid() });
|
|
29
|
+
const queueParam = z.object({ projectId: z.string().min(1).max(64) });
|
|
30
|
+
const queueMoveBody = z.object({ by: z.union([z.literal(-1), z.literal(1)]) }).strict();
|
|
29
31
|
export function planningRoutes(deps) {
|
|
30
32
|
const prepare = (input) => {
|
|
31
33
|
const drafts = input.items?.map((item) => ({
|
|
@@ -58,8 +60,21 @@ export function planningRoutes(deps) {
|
|
|
58
60
|
};
|
|
59
61
|
});
|
|
60
62
|
};
|
|
61
|
-
return new Hono()
|
|
63
|
+
return (new Hono()
|
|
62
64
|
.get("/queue", (c) => c.json({ queue: queueEntries(deps.db) }))
|
|
65
|
+
// Batch queue's Up and Down for a video still waiting its turn.
|
|
66
|
+
.post("/queue/:projectId/move", zValidator("param", queueParam, onInvalid), zValidator("json", queueMoveBody, onInvalid), (c) => {
|
|
67
|
+
const result = moveQueued(deps.db, c.req.valid("param").projectId, c.req.valid("json").by);
|
|
68
|
+
if (result.ok)
|
|
69
|
+
return c.json({ queue: result.queue });
|
|
70
|
+
return problem(c, {
|
|
71
|
+
status: 409,
|
|
72
|
+
title: titleOf(409),
|
|
73
|
+
detail: result.reason === "edge"
|
|
74
|
+
? "This video is already at that end of the waiting videos, so it can't move further."
|
|
75
|
+
: "This video has already started or left the queue, so its place can't change. The queue has been read again.",
|
|
76
|
+
});
|
|
77
|
+
})
|
|
63
78
|
.post("/estimate", zValidator("json", planningBody, onInvalid), (c) => {
|
|
64
79
|
const input = c.req.valid("json");
|
|
65
80
|
const runs = prepare(input);
|
|
@@ -110,5 +125,5 @@ export function planningRoutes(deps) {
|
|
|
110
125
|
const queue = enqueueBatch({ ...deps, emit: (event) => deps.hub.emitGlobal(event) }, input.requestId, runs);
|
|
111
126
|
pumpQueue(deps.db, deps.runner);
|
|
112
127
|
return c.json({ queue }, 201);
|
|
113
|
-
});
|
|
128
|
+
}));
|
|
114
129
|
}
|
|
@@ -3,7 +3,8 @@ import { Hono } from "hono";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { transact } from "../../kernel/db/tx.js";
|
|
5
5
|
import { derive, progressOf } from "../../kernel/runner/graph.js";
|
|
6
|
-
import {
|
|
6
|
+
import { listProjectHeads } from "../../slices/admission/heads.js";
|
|
7
|
+
import { projectById, projectTrashed, runDraftSchema, stageStandingsByProject, stagesOf, } from "../../slices/admission/repo.js";
|
|
7
8
|
import { defaultChannelId } from "../../slices/channels/model.js";
|
|
8
9
|
import { channelById, projectChannels, setProjectChannel } from "../../slices/channels/repo.js";
|
|
9
10
|
import { withProjectControl } from "../../slices/control/lock.js";
|
|
@@ -56,7 +57,9 @@ export function projectRoutes(deps) {
|
|
|
56
57
|
return c.json({ project: summarise(created.project), stages: stagesOf(deps.db, created.project.id) }, 201);
|
|
57
58
|
})
|
|
58
59
|
// One statement for every project's stage standings, not one per row: the list needs
|
|
59
|
-
// a status word and a meter, and both come out of the same five columns.
|
|
60
|
+
// a status word and a meter, and both come out of the same five columns. Each row carries
|
|
61
|
+
// only the settings a list reads (`ListingConfig`); a project page reads the whole
|
|
62
|
+
// configuration from GET /:id.
|
|
60
63
|
.get("/", (c) => {
|
|
61
64
|
const standings = stageStandingsByProject(deps.db);
|
|
62
65
|
const channels = projectChannels(deps.db);
|
|
@@ -64,7 +67,7 @@ export function projectRoutes(deps) {
|
|
|
64
67
|
const aside = setAsideProjects(deps.db);
|
|
65
68
|
const numbers = longVideoStats(deps.db);
|
|
66
69
|
const waits = limitWaitsByProject(deps.db);
|
|
67
|
-
const projects =
|
|
70
|
+
const projects = listProjectHeads(deps.db).map((project) => {
|
|
68
71
|
const stages = standings.get(project.id) ?? [];
|
|
69
72
|
const waiting = waits.get(project.id);
|
|
70
73
|
return {
|
|
@@ -131,7 +134,7 @@ export function projectRoutes(deps) {
|
|
|
131
134
|
title: titleOf(404),
|
|
132
135
|
detail: "That channel no longer exists. Reload the page and pick one from the list, or make it again in Channels.",
|
|
133
136
|
});
|
|
134
|
-
const live = new Set(
|
|
137
|
+
const live = new Set(listProjectHeads(deps.db).map((project) => project.id));
|
|
135
138
|
const moving = [...new Set(projectIds)].filter((id) => live.has(id));
|
|
136
139
|
transact(deps.db, () => {
|
|
137
140
|
for (const id of moving)
|
|
@@ -3,6 +3,7 @@ import { zValidator } from "@hono/zod-validator";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { keyProbes } from "../../adapters/key-probes.js";
|
|
6
|
+
import { catalogueModelInfo } from "../../catalog/model-info.js";
|
|
6
7
|
import { videoModelsOf } from "../../catalog/schema.js";
|
|
7
8
|
import { defaultSpeechVoice, detectSpeechCached } from "../../kernel/ports/system-speech.js";
|
|
8
9
|
import { switchAllRetired, switchRetiredModel } from "../../slices/model-upkeep/switch.js";
|
|
@@ -161,12 +162,9 @@ export function providerRoutes(deps) {
|
|
|
161
162
|
});
|
|
162
163
|
if (deps.catalogue && !isUncataloguedProvider(id))
|
|
163
164
|
return c.json({
|
|
164
|
-
models: deps.catalogue
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
? { thinkingModes: Object.keys(m.llm.thinking) }
|
|
168
|
-
: {}),
|
|
169
|
-
})),
|
|
165
|
+
models: deps.catalogue
|
|
166
|
+
.models(id, providerById(id).family)
|
|
167
|
+
.map((m) => ({ ...m, ...catalogueModelInfo(m) })),
|
|
170
168
|
allowsCustom: false,
|
|
171
169
|
notice: "Curated active models from models.yaml. Edit it locally or refresh in Settings.",
|
|
172
170
|
warning: deps.catalogue.status().warning ?? undefined,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { zValidator } from "@hono/zod-validator";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { findRevisionDownload,
|
|
4
|
+
import { findRevisionDownload, revisionSetZip, } from "../../slices/revisions/downloads.js";
|
|
5
5
|
import { fileResponse } from "./byte-range.js";
|
|
6
6
|
import { replyForFolder } from "./folder-location.js";
|
|
7
7
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
@@ -14,12 +14,28 @@ const id = z
|
|
|
14
14
|
const revisionParam = z.object({ projectId: id, revisionId: id });
|
|
15
15
|
const recordParam = revisionParam.extend({ recordId: id });
|
|
16
16
|
const folderParam = z.object({ id, revisionId: id, recordId: id });
|
|
17
|
+
const setParam = revisionParam.extend({
|
|
18
|
+
set: z.enum(["images.zip", "thumbnails.zip", "shorts.zip"]),
|
|
19
|
+
});
|
|
20
|
+
const setQuery = z.object({
|
|
21
|
+
only: z
|
|
22
|
+
.string()
|
|
23
|
+
.regex(/^[0-9A-Za-z_-]{1,64}(?:,[0-9A-Za-z_-]{1,64}){0,499}$/)
|
|
24
|
+
.optional(),
|
|
25
|
+
});
|
|
26
|
+
function zipSetOf(file) {
|
|
27
|
+
return file === "images.zip" ? "images" : file === "thumbnails.zip" ? "thumbnails" : "shorts";
|
|
28
|
+
}
|
|
17
29
|
export function revisionFileRoutes(deps) {
|
|
18
30
|
const waveform = waveformAnswer(deps.decodePeaks);
|
|
19
|
-
return new Hono()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
return (new Hono()
|
|
32
|
+
// Images' Download all, and the thumbnails or shorts as one zip. `?only=` names the records
|
|
33
|
+
// picked, so the zip holds exactly those. Declared before :recordId so a zip is never read
|
|
34
|
+
// as a record.
|
|
35
|
+
.get("/files/:projectId/revisions/:revisionId/:set{(?:images|thumbnails|shorts)\\.zip}", zValidator("param", setParam, onInvalid), zValidator("query", setQuery, onInvalid), (c) => {
|
|
36
|
+
const { projectId, revisionId, set } = c.req.valid("param");
|
|
37
|
+
const only = c.req.valid("query").only;
|
|
38
|
+
const result = revisionSetZip(deps, projectId, revisionId, zipSetOf(set), only === undefined ? undefined : only.split(","));
|
|
23
39
|
if (!result.ok)
|
|
24
40
|
return unavailable(c, result.reason);
|
|
25
41
|
return c.body(result.bytes, 200, {
|
|
@@ -46,7 +62,7 @@ export function revisionFileRoutes(deps) {
|
|
|
46
62
|
"content-disposition": `${inline ? "inline" : "attachment"}; filename="${value.filename}"`,
|
|
47
63
|
"x-content-type-options": "nosniff",
|
|
48
64
|
});
|
|
49
|
-
});
|
|
65
|
+
}));
|
|
50
66
|
}
|
|
51
67
|
export function revisionFolderRoutes(deps) {
|
|
52
68
|
return new Hono().post("/:id/revisions/:revisionId/:recordId/open-folder", zValidator("param", folderParam, onInvalid), async (c) => {
|
|
@@ -70,7 +86,9 @@ function unavailable(c, reason) {
|
|
|
70
86
|
title: titleOf(404),
|
|
71
87
|
detail: reason === "missing-file"
|
|
72
88
|
? "This file was deleted or moved from the project folder. In the Edit tab, use Choose what to remake (in the project's More menu) to make it again."
|
|
73
|
-
:
|
|
89
|
+
: reason === "no-images"
|
|
90
|
+
? "There are no saved files of this kind to put in the zip yet. Wait for the step that makes them to finish, then download again."
|
|
91
|
+
: "This file is no longer part of the project. Reload the page to see its current files.",
|
|
74
92
|
extensions: { reason },
|
|
75
93
|
});
|
|
76
94
|
}
|
|
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|
|
3
3
|
import { HTTPException } from "hono/http-exception";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { calendarRange } from "../../slices/schedules/agenda.js";
|
|
6
|
+
import { rejectHeldTopics, restoreRejectedTopics } from "../../slices/schedules/held-bulk.js";
|
|
6
7
|
import { preparedTopics, prepareTopic } from "../../slices/schedules/prepare.js";
|
|
7
8
|
import { calendarMaxDays, calendarSchema, heldTopicEditSchema, heldTopicSchema, queueMax, scheduleCreateSchema, scheduleRunSchema, scheduleSummarySchema, scheduleUpdateSchema, topicMoveSchema, topicQueueSchema, topicTransferSchema, } from "../../slices/schedules/schema.js";
|
|
8
9
|
import { cancelSchedule, createSchedule, deleteSchedule, listScheduleRuns, listSchedules, pauseSchedule, resumeSchedule, updateSchedule, } from "../../slices/schedules/service.js";
|
|
@@ -10,6 +11,21 @@ import { approveHeldTopics, editHeldTopic, heldTopics, moveTopic, rejectHeldTopi
|
|
|
10
11
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
11
12
|
const id = z.object({ id: z.uuid() });
|
|
12
13
|
const topicParam = z.object({ id: z.uuid(), topicId: z.string().min(1).max(100) });
|
|
14
|
+
const topicIds = z.array(z.string().min(1).max(100)).min(1).max(queueMax);
|
|
15
|
+
const heldIds = z.object({ ids: topicIds }).strict();
|
|
16
|
+
const heldRestore = z
|
|
17
|
+
.object({
|
|
18
|
+
topics: z
|
|
19
|
+
.array(z
|
|
20
|
+
.object({
|
|
21
|
+
id: z.string().min(1).max(100),
|
|
22
|
+
values: z.record(z.string(), z.string()).optional(),
|
|
23
|
+
})
|
|
24
|
+
.strict())
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(queueMax),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
13
29
|
function refused(c, result) {
|
|
14
30
|
const status = ["not-found", "missing-template", "topic-not-found"].includes(result.reason)
|
|
15
31
|
? 404
|
|
@@ -175,6 +191,20 @@ function routes(deps) {
|
|
|
175
191
|
.post("/:id/topics/held/approve-all", zValidator("param", id, onInvalid), (c) => {
|
|
176
192
|
const result = approveHeldTopics(service(), c.req.valid("param").id, "all");
|
|
177
193
|
return result.ok ? c.json(scheduleSummarySchema.parse(result.value)) : refused(c, result);
|
|
194
|
+
})
|
|
195
|
+
// Approve selected, Reject selected (Reject all sends the ids it showed), and the Undo that
|
|
196
|
+
// puts turned-down topics back.
|
|
197
|
+
.post("/:id/topics/held/approve", zValidator("param", id, onInvalid), zValidator("json", heldIds, onInvalid), (c) => {
|
|
198
|
+
const result = approveHeldTopics(service(), c.req.valid("param").id, c.req.valid("json").ids);
|
|
199
|
+
return result.ok ? c.json(scheduleSummarySchema.parse(result.value)) : refused(c, result);
|
|
200
|
+
})
|
|
201
|
+
.post("/:id/topics/held/reject", zValidator("param", id, onInvalid), zValidator("json", heldIds, onInvalid), (c) => {
|
|
202
|
+
const result = rejectHeldTopics(service(), c.req.valid("param").id, c.req.valid("json").ids);
|
|
203
|
+
return result.ok ? c.json(scheduleSummarySchema.parse(result.value)) : refused(c, result);
|
|
204
|
+
})
|
|
205
|
+
.post("/:id/topics/held/restore", zValidator("param", id, onInvalid), zValidator("json", heldRestore, onInvalid), (c) => {
|
|
206
|
+
const result = restoreRejectedTopics(service(), c.req.valid("param").id, c.req.valid("json").topics);
|
|
207
|
+
return result.ok ? c.json(scheduleSummarySchema.parse(result.value)) : refused(c, result);
|
|
178
208
|
})
|
|
179
209
|
.post("/:id/topics/held/:topicId/approve", zValidator("param", topicParam, onInvalid), zValidator("json", z.object({ title: z.string().optional() }).strict(), onInvalid), (c) => {
|
|
180
210
|
const { id: scheduleId, topicId } = c.req.valid("param");
|
package/dist/edge/http/trash.js
CHANGED
|
@@ -2,7 +2,7 @@ import { zValidator } from "@hono/zod-validator";
|
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { withProjectControl } from "../../slices/control/lock.js";
|
|
5
|
-
import { trashKinds } from "../../slices/trash/model.js";
|
|
5
|
+
import { trashKinds, } from "../../slices/trash/model.js";
|
|
6
6
|
import { deleteNow, listTrash, restoreItem } from "../../slices/trash/service.js";
|
|
7
7
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
8
|
const itemParam = z.object({
|
|
@@ -13,6 +13,17 @@ const itemParam = z.object({
|
|
|
13
13
|
.max(64)
|
|
14
14
|
.regex(/^[0-9A-Za-z_-]+$/),
|
|
15
15
|
});
|
|
16
|
+
// Settings → Trash's Restore selected / Restore all and Delete selected / Empty trash: the
|
|
17
|
+
// items named, each done on its own so one refusal leaves the rest done.
|
|
18
|
+
const manySchema = z.object({ items: z.array(itemParam).min(1).max(5000) });
|
|
19
|
+
// A template comes back before the schedules that run it, so restoring both at once works.
|
|
20
|
+
const restoreOrder = {
|
|
21
|
+
template: 0,
|
|
22
|
+
prompt: 1,
|
|
23
|
+
entry: 1,
|
|
24
|
+
project: 1,
|
|
25
|
+
schedule: 2,
|
|
26
|
+
};
|
|
16
27
|
const status = {
|
|
17
28
|
"not-found": 404,
|
|
18
29
|
running: 409,
|
|
@@ -27,6 +38,14 @@ const details = {
|
|
|
27
38
|
"template-in-trash": "This schedule's template is in the trash too. Restore the template first (it is listed here), then restore the schedule.",
|
|
28
39
|
"template-gone": "This schedule's template was removed for good, so the schedule has nothing to run. It cannot be restored; Delete now removes it from the trash.",
|
|
29
40
|
};
|
|
41
|
+
function failedItem(kind, id, reason, detail) {
|
|
42
|
+
return {
|
|
43
|
+
kind,
|
|
44
|
+
id,
|
|
45
|
+
reason,
|
|
46
|
+
detail: detail === undefined ? details[reason] : `${details[reason]} (${detail})`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
30
49
|
function refused(c, reason, detail) {
|
|
31
50
|
return problem(c, {
|
|
32
51
|
status: status[reason],
|
|
@@ -35,7 +54,7 @@ function refused(c, reason, detail) {
|
|
|
35
54
|
extensions: { reason },
|
|
36
55
|
});
|
|
37
56
|
}
|
|
38
|
-
// Settings → Trash: the list, Restore and Delete now. The daily purge runs from main.ts.
|
|
57
|
+
// Settings → Trash: the list, Restore and Delete now, one item or many. The daily purge runs from main.ts.
|
|
39
58
|
export function trashRoutes(deps) {
|
|
40
59
|
const trash = {
|
|
41
60
|
db: deps.db,
|
|
@@ -46,6 +65,36 @@ export function trashRoutes(deps) {
|
|
|
46
65
|
};
|
|
47
66
|
return new Hono()
|
|
48
67
|
.get("/", (c) => c.json({ items: listTrash(trash) }))
|
|
68
|
+
.post("/bulk/restore", zValidator("json", manySchema, onInvalid), (c) => {
|
|
69
|
+
const items = [...c.req.valid("json").items].sort((a, b) => restoreOrder[a.kind] - restoreOrder[b.kind]);
|
|
70
|
+
const restored = [];
|
|
71
|
+
const failed = [];
|
|
72
|
+
for (const { kind, id } of items) {
|
|
73
|
+
const result = restoreItem(trash, kind, id);
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
failed.push(failedItem(kind, id, result.reason));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
restored.push(result.value);
|
|
79
|
+
if (kind === "project")
|
|
80
|
+
deps.runner.tick(id);
|
|
81
|
+
}
|
|
82
|
+
return c.json({ restored, failed });
|
|
83
|
+
})
|
|
84
|
+
.post("/bulk/delete", zValidator("json", manySchema, onInvalid), async (c) => {
|
|
85
|
+
const deleted = [];
|
|
86
|
+
const failed = [];
|
|
87
|
+
for (const { kind, id } of c.req.valid("json").items) {
|
|
88
|
+
const result = kind === "project"
|
|
89
|
+
? await withProjectControl(deps.db, id, () => deleteNow(trash, kind, id))
|
|
90
|
+
: deleteNow(trash, kind, id);
|
|
91
|
+
if (result.ok)
|
|
92
|
+
deleted.push({ kind, id });
|
|
93
|
+
else
|
|
94
|
+
failed.push(failedItem(kind, id, result.reason, result.detail));
|
|
95
|
+
}
|
|
96
|
+
return c.json({ deleted, failed });
|
|
97
|
+
})
|
|
49
98
|
.post("/:kind/:id/restore", zValidator("param", itemParam, onInvalid), (c) => {
|
|
50
99
|
const { kind, id } = c.req.valid("param");
|
|
51
100
|
const result = restoreItem(trash, kind, id);
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- The cast's own order (3.10.0): the Cast tab and every list of a channel's cast follow it, and
|
|
2
|
+
-- Move up / Move down change it. Members made before this keep the alphabetical order they were
|
|
3
|
+
-- shown in; a new member goes last.
|
|
4
|
+
ALTER TABLE cast_members ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
|
|
5
|
+
UPDATE cast_members SET position = (
|
|
6
|
+
SELECT count(*) FROM cast_members other
|
|
7
|
+
WHERE other.channel_id = cast_members.channel_id
|
|
8
|
+
AND (lower(other.name) < lower(cast_members.name)
|
|
9
|
+
OR (lower(other.name) = lower(cast_members.name) AND other.id < cast_members.id))
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX cast_members_order ON cast_members(channel_id, position);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Slopify 3.10.0
|
|
2
|
+
|
|
3
|
+
Released 4 October 2026. The last 3.x release: Slopify is easier to understand from the first click to the downloaded file.
|
|
4
|
+
|
|
5
|
+
## Start with what you're making
|
|
6
|
+
|
|
7
|
+
- **Create asks "What are you making?"** Video, Article, Audiobook, Podcast or Images. You only see the setup that work needs; articles, audiobooks, podcasts and images never ask for a channel or YouTube details (those fold under **Publishing extras**). The sidebar key is now **Create**.
|
|
8
|
+
- **Your text, said plainly.** The Article choice reads **Write it for me**, **Use my text as written**, **Adapt my text** or **No text**. **Show the text that will be spoken** shows the exact words before you start.
|
|
9
|
+
- **Try a sample of your text** under the voice plays a short passage of your own text in the chosen voice, with its price, before a long run.
|
|
10
|
+
- **Where a setting comes from.** Projects made from a template mark each row **From ‹template›** or **Changed here**, and **Save as template** says which future projects and schedules will use it.
|
|
11
|
+
- Model dropdowns show each model's price and context size, and long lists get a filter. Play shows how much of your CLI plan its last reading left used.
|
|
12
|
+
|
|
13
|
+
## One project, several outputs
|
|
14
|
+
|
|
15
|
+
- **New Outputs section on every project.** Each output shows a preview, its state in words (Current, Uses older material, In progress, Failed), which version of its source it was made from, its main download and **Other formats**.
|
|
16
|
+
- **Add another output** turns a project into narration, an audiobook (chaptered MP3/M4B), images, a video, a PDF or a podcast. It shows what is reused, what is made and the extra cost first, and only the new work runs.
|
|
17
|
+
- **Podcast from an article.** **Adapt my article into a conversation** makes a two-host episode; the article stays exactly as written and the conversation script sits beside it.
|
|
18
|
+
- **Download all** for images, thumbnails and shorts lists the files and their version first and zips only what you tick. Remade files download as `…-v2`.
|
|
19
|
+
- **Prepare upload** only appears on videos made for YouTube.
|
|
20
|
+
|
|
21
|
+
## Reviewing the work
|
|
22
|
+
|
|
23
|
+
- The Audio section's text plays along: press a line to play from there, and the line being spoken is highlighted. Narration chunks are marked on the waveform. Chapter and missing-subtitle times play from that moment.
|
|
24
|
+
- Podcasts and interviews show **Conversation, turn by turn** with speaker names. The pencil beside any line opens Edit project → Narration at that passage.
|
|
25
|
+
- Each caption has **Play**; each picked short has **Play the clip** and **Hear the ending**.
|
|
26
|
+
- Images and thumbnails have **Compare** (before and now, with their prompts) and **Replace with my file**. Thumbnails get a **See it as YouTube shows it** preview, and shorts a **Show what YouTube covers on a phone** switch.
|
|
27
|
+
- Pictures in the full-size view zoom and drag. Players remember volume and speed, and starting one pauses the others.
|
|
28
|
+
- **Replace one thumbnail with your own picture** from the button beside it; the old one stays in History. The caption style preview for 9:16 shorts can show what YouTube covers on a phone.
|
|
29
|
+
|
|
30
|
+
## Your work is safe
|
|
31
|
+
|
|
32
|
+
- **Unsaved project edits are kept in your browser** through leaving, reloading or a Slopify update. Opening the project again offers **Restored your unsaved edit** with Keep or Discard, and Discard changes has Undo.
|
|
33
|
+
- If the project changed while you edited, choose **Reload latest and re-apply my edit** or **Copy my edit**.
|
|
34
|
+
- **No paid remake starts unseen.** Remaking outdated outputs opens **Choose what to remake** with what is made again, what is rebuilt on your computer, what stays and the estimated cost; only free local rebuilds start immediately. Regenerate dialogs show an approximate price.
|
|
35
|
+
- Outdated files say what they're based on (**Uses the previous text**) and their downloads are labelled as the previous version.
|
|
36
|
+
- Each failed step has its own **Try again**, and error details have **Copy details** and **Download diagnostics**.
|
|
37
|
+
|
|
38
|
+
## Home, loading and connection
|
|
39
|
+
|
|
40
|
+
- **Home is one work list:** reviews to approve, paused and failed runs, suggested topics, then videos ready to upload. Running now and Coming up only appear when they have something; This week's totals are behind **Show totals**, say which channel they cover, and give the spend across all channels beside a single channel's.
|
|
41
|
+
- When something doesn't load, Slopify says what failed with **Retry** instead of an empty list or "reload the page". After a failed refresh the last data stays, with its time.
|
|
42
|
+
- The projects list loads much faster on large libraries.
|
|
43
|
+
- A line at the top says when Slopify is **Reconnecting** or **Offline** and numbers may be out of date, then **Back online**.
|
|
44
|
+
- **While you were away** sums up runs that finished, failed or wait for you.
|
|
45
|
+
|
|
46
|
+
## Lists you can work with
|
|
47
|
+
|
|
48
|
+
- **Projects:** tick rows to mark uploaded, move to another channel or delete, each with Undo. **Remake outdated** on several projects opens one review with what each makes again, its cost, the total and which projects are left out and why; one press starts them. On Home, tick ready videos and **Mark selected uploaded**. Sort by newest, recently changed, name or status; 50 at a time with Show more.
|
|
49
|
+
- **Schedules:** paste a list to add one topic per line; Move to top/bottom, Insert below, Duplicate and Alt+↑/↓; tick topics to remove them with Undo. Topics waiting can be approved or rejected in bulk, and rejections undone. Duplicate a schedule as a paused copy. Search and sort appear at five schedules.
|
|
50
|
+
- **Calendar:** Undo after a move, Earlier and Later, grips on draggable topics, **Move to…** in the list view, and every topic links to its schedule.
|
|
51
|
+
- **Batch queue:** move a waiting video up or down, or remove it, with Undo.
|
|
52
|
+
- **Trash:** filter by kind, exact deletion times, Restore or Delete selected, Restore all and Empty trash. It now says exactly what goes there: channels, cast members, PDF themes and episode summaries are deleted permanently, and their delete dialogs say so.
|
|
53
|
+
- **Library:** grouped into Templates and Building blocks (Prompts, Intros & outros, PDF themes, Narration aliases). Search and sort, exact times on hover, and the picked item is in the address. Tick rows to **Duplicate selected** or **Delete selected** (with Undo where they go to the trash), and **Import or export** prompts, intros and outros, PDF themes and narration aliases as a file (aliases also as CSV from a spreadsheet). Narration aliases take a pasted list, search and Undo.
|
|
54
|
+
- **Cast keeps the order you set** (Move up, Move down, to top or bottom, Alt+↑/↓), and backups carry it. Tick several speakers to remove them with Undo. Speakers and cast members can be duplicated; cast and episode summaries can be searched; Studio imports open 200 rows at a time; Releases pages through eight weeks.
|
|
55
|
+
|
|
56
|
+
## Settings and forms
|
|
57
|
+
|
|
58
|
+
- **Settings has seven groups instead of fourteen**, starting with what is connected: Connections, Production defaults, Notifications, Publishing, Backup & storage, General and About. Old links still open.
|
|
59
|
+
- Enter saves single fields; API keys stay hidden until **Show key**. Forms that wait for Save say **Unsaved changes** with Discard.
|
|
60
|
+
- Schedule problems show under each field and focus moves to the first. The spend ceiling is in dollars, and Timezone suggests names.
|
|
61
|
+
- Colours accept `#abc` or `#aabbcc` everywhere and warn when text would be hard to read. The PDF theme editor marks changed and broken settings, with jump buttons and Reset; volumes, ambient level and subtitle size have Reset too.
|
|
62
|
+
- Release **Free plan times** and **Swap with** only act when you press **Take this time** or **Swap**.
|
|
63
|
+
- Prompts get line numbers, a counter and **Go to line** for errors. The YouTube section's counts follow your typing, and hashtags only warn past YouTube's real limit of 60; titles over 100 characters or with `<` `>` are flagged.
|
|
64
|
+
- Subtitles name the right language and model size; fonts and system voices can be previewed and filtered.
|
|
65
|
+
|
|
66
|
+
## Getting around
|
|
67
|
+
|
|
68
|
+
- Your place is in the address: a project's section, a Settings section, a channel tab and Projects' filter, search and sort survive a reload and work with Back. A project's back link returns to where you opened it.
|
|
69
|
+
- **Ctrl+K** finds channels, templates, schedules, prompts, intros and outros, PDF themes and every Settings section by name, shows recent commands first, and can start the tutorial or reopen the welcome screen.
|
|
70
|
+
- Error notices stay until closed; Undo lasts longer and waits while you point at it.
|
|
71
|
+
- Skip to content, focus on each new screen's heading, reasons on unavailable buttons when focused, and section status in words, not lamp colour alone.
|
|
72
|
+
- Phones get Channels in the bottom bar and a single Support menu. Unknown pages, deleted projects and missing channels show a clear screen with somewhere to go. Lists squeezed beside a panel put their buttons under the name.
|
|
73
|
+
|
|
74
|
+
## Same words everywhere
|
|
75
|
+
|
|
76
|
+
- A project's state reads the same on Projects, Calendar, Home and the project page: **Queued** before it starts, **Waiting for your review** when held, **Off** for a step you turned off.
|
|
77
|
+
- Provider problems use one set of words in Create and Settings: No key, Not found, Signed out, Needs an update.
|
|
78
|
+
- Delete dialogs offer **Keep it** as the way out. Plainer names: **Clear leftover files**, **Check whether it started**, **More audio settings**. Prices are written one way everywhere, down to a fraction of a cent.
|
|
79
|
+
- Older dates show their year and the exact time on hover. Long text fields grow as you type. **Copy project ID** is in the project's More menu.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Slopify 3.10.1
|
|
2
|
+
|
|
3
|
+
Released 4 October 2026.
|
|
4
|
+
|
|
5
|
+
## YouTube Studio extension (1.2.2)
|
|
6
|
+
|
|
7
|
+
- **The schedule date sticks.** Studio stopped taking the date the extension typed on the Visibility step ("Studio didn't keep the date Slopify typed"). The extension now commits the date the way pressing Enter does, so the date and time are both set and you only press Schedule.
|
|
8
|
+
- **Shorts playlists.** Settings → Publishing → YouTube Studio warns when a playlist named for shorts is set to Videos and shorts: with that setting every short also went into your long-video playlists. Set it to Shorts only, and the long-video ones to Long videos only.
|
|
9
|
+
|
|
10
|
+
## Opening a long project
|
|
11
|
+
|
|
12
|
+
- **No more freeze when opening a finished long project.** Working out which narration chunk each transcript line came from (for the pencil beside each line) re-read every chunk for every line: a two-hour project froze the page for about 7 seconds. It now takes a few milliseconds.
|
|
13
|
+
|
|
14
|
+
## Upload status
|
|
15
|
+
|
|
16
|
+
- **One Studio video belongs to one upload.** When two projects have a short with the same title, the video Studio lists under that title was ticked as uploaded on both. Now a video already linked to a project's upload is never given to another.
|
|
17
|
+
|
|
18
|
+
Download the extension again from Settings → YouTube Studio and reload it.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.10.1",
|
|
4
|
+
"title": "Slopify 3.10.1",
|
|
5
|
+
"version": "3.10.1",
|
|
6
|
+
"date": "2026-10-04"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.10.0",
|
|
10
|
+
"title": "Slopify 3.10.0",
|
|
11
|
+
"version": "3.10.0",
|
|
12
|
+
"date": "2026-10-04"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.9.1",
|
|
4
16
|
"title": "Slopify 3.9.1",
|