@gentbajko/slopify 2.3.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter-registry.js +9 -2
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +99 -8
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +48 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +65 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +13 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +44 -3
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the picked templates, the slot names they ask for, and the rendered text stored on the
|
|
3
3
|
// project. The bodies are read at the click, not at selection, so an edit made in between
|
|
4
4
|
// is the one that runs.
|
|
5
|
-
import { normaliseDraft, usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
|
|
5
|
+
import { normaliseDraft, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
|
|
6
6
|
import { collectFields, render } from "../admission/substitute.js";
|
|
7
7
|
import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
|
|
8
8
|
export function pickTemplates(db, input, snapshot) {
|
|
@@ -25,6 +25,15 @@ export function pickTemplates(db, input, snapshot) {
|
|
|
25
25
|
// None picked is the built-in prompt, which asks for no keyword.
|
|
26
26
|
if (usesYoutubeDescription(draft))
|
|
27
27
|
body(db, "description", draft.descriptionPrompt, "descriptionPrompt", missing, text, "description", snapshot);
|
|
28
|
+
// The Shorts step's two prompts; none picked is the built-in one, which asks for no keyword.
|
|
29
|
+
if (usesShorts(draft)) {
|
|
30
|
+
body(db, "shorts", draft.shorts?.prompt, "shorts.prompt", missing, text, "shorts", snapshot);
|
|
31
|
+
body(db, "image", draft.shorts?.imagePrompt, "shorts.imagePrompt", missing, image, "shortsImage", snapshot);
|
|
32
|
+
}
|
|
33
|
+
// The establishing image's prompt: an image prompt like the others, its keywords filled the
|
|
34
|
+
// same way.
|
|
35
|
+
if (usesReference(draft) && draft.reference?.source === "prompt")
|
|
36
|
+
body(db, "image", draft.reference.prompt, "reference.prompt", missing, image, "referencePrompt", snapshot);
|
|
28
37
|
if (sources.images === "generate") {
|
|
29
38
|
for (const [index, picked] of draft.imagePrompts.entries()) {
|
|
30
39
|
const field = `imagePrompts.${String(index)}.name`;
|
|
@@ -36,7 +45,9 @@ export function pickTemplates(db, input, snapshot) {
|
|
|
36
45
|
if (sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") {
|
|
37
46
|
body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt", snapshot);
|
|
38
47
|
}
|
|
39
|
-
const fields = collectFields(
|
|
48
|
+
const fields = collectFields(
|
|
49
|
+
// The title's keywords are asked for like a prompt's: it is filled when the run starts.
|
|
50
|
+
[draft.title, ...text.map((picked) => picked.body)], image.map((picked) => picked.body));
|
|
40
51
|
return {
|
|
41
52
|
draft: withSavedModes(draft, intro, outro),
|
|
42
53
|
requiredSlots: fields.map((field) => field.name),
|
|
@@ -25,9 +25,15 @@ export const librarySnapshotSchema = z
|
|
|
25
25
|
})
|
|
26
26
|
.strict()
|
|
27
27
|
.readonly();
|
|
28
|
+
// A template names its prompts and intros/outros; the Library's current one of that name is
|
|
29
|
+
// what a run from it uses, so editing a prompt reaches every template and schedule that names
|
|
30
|
+
// it. The copy the template saved is only the fallback for one since deleted or renamed, so a
|
|
31
|
+
// template never stops working because its Library changed.
|
|
28
32
|
export function snapshotPrompt(db, snapshot, kind, name) {
|
|
29
|
-
return (
|
|
33
|
+
return (promptByName(db, kind, name) ??
|
|
34
|
+
snapshot?.prompts.find((row) => row.kind === kind && row.name.toLowerCase() === name.toLowerCase()));
|
|
30
35
|
}
|
|
31
36
|
export function snapshotEntry(db, snapshot, category, name) {
|
|
32
|
-
return (
|
|
37
|
+
return (entryByName(db, category, name) ??
|
|
38
|
+
snapshot?.entries.find((row) => row.category === category && row.name.toLowerCase() === name.toLowerCase()));
|
|
33
39
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { redact } from "../../kernel/log.js";
|
|
2
|
+
import { noticeOf, noticeText, webhookBody } from "./rules.js";
|
|
3
|
+
import { sendFailureText } from "./send.js";
|
|
4
|
+
export function createRunNotifier(deps) {
|
|
5
|
+
// ceiling: one entry per project seen since boot, a few dozen bytes each.
|
|
6
|
+
const states = new Map();
|
|
7
|
+
const reasons = new Map();
|
|
8
|
+
const inflight = new Set();
|
|
9
|
+
let closed = false;
|
|
10
|
+
const notify = (projectId, kind) => {
|
|
11
|
+
const url = deps.url();
|
|
12
|
+
if (url === null)
|
|
13
|
+
return;
|
|
14
|
+
const subject = deps.subject(projectId);
|
|
15
|
+
if (subject === undefined)
|
|
16
|
+
return;
|
|
17
|
+
const text = noticeText(kind, { ...subject, reason: reasons.get(projectId) });
|
|
18
|
+
const body = redact(webhookBody(text));
|
|
19
|
+
const sending = deps
|
|
20
|
+
.send(url, body)
|
|
21
|
+
.then((result) => {
|
|
22
|
+
if (!result.ok)
|
|
23
|
+
deps.log.write("warn", "notification.failed", {
|
|
24
|
+
projectId,
|
|
25
|
+
detail: `${kind} notification not delivered: ${sendFailureText(result)}`,
|
|
26
|
+
});
|
|
27
|
+
})
|
|
28
|
+
.catch((error) => {
|
|
29
|
+
deps.log.write("warn", "notification.failed", {
|
|
30
|
+
projectId,
|
|
31
|
+
detail: `${kind} notification not delivered: ${error instanceof Error ? error.message : String(error)}`,
|
|
32
|
+
});
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
inflight.delete(sending);
|
|
36
|
+
});
|
|
37
|
+
inflight.add(sending);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
observe: (event) => {
|
|
41
|
+
if (closed)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
if (event.type === "stage.state") {
|
|
45
|
+
if (event.state === "failed" && event.failureReason !== undefined)
|
|
46
|
+
reasons.set(event.projectId, event.failureReason);
|
|
47
|
+
else if (event.state === "running")
|
|
48
|
+
reasons.delete(event.projectId);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (event.type !== "project.state")
|
|
52
|
+
return;
|
|
53
|
+
const previous = states.get(event.projectId);
|
|
54
|
+
if (previous === event.state)
|
|
55
|
+
return;
|
|
56
|
+
states.set(event.projectId, event.state);
|
|
57
|
+
const kind = noticeOf(previous, event.state);
|
|
58
|
+
if (kind !== undefined)
|
|
59
|
+
notify(event.projectId, kind);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
deps.log.write("warn", "notification.failed", {
|
|
63
|
+
projectId: event.projectId,
|
|
64
|
+
detail: `notification skipped: ${error instanceof Error ? error.message : String(error)}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
close: () => {
|
|
69
|
+
closed = true;
|
|
70
|
+
},
|
|
71
|
+
settled: async () => {
|
|
72
|
+
while (inflight.size > 0)
|
|
73
|
+
await Promise.all([...inflight]);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Only a run seen running can finish, fail or stop to wait: a project first seen already done
|
|
2
|
+
// was done before anyone was watching. Paused and canceled are what the user just pressed, so
|
|
3
|
+
// they say nothing. `pending` after `running` is the runner finding nothing it may start: a
|
|
4
|
+
// review checkpoint holding the next step, or held work waiting for Resume.
|
|
5
|
+
export function noticeOf(previous, next) {
|
|
6
|
+
if (previous !== "running")
|
|
7
|
+
return undefined;
|
|
8
|
+
switch (next) {
|
|
9
|
+
case "done":
|
|
10
|
+
return "ready";
|
|
11
|
+
case "failed":
|
|
12
|
+
return "failed";
|
|
13
|
+
case "pending":
|
|
14
|
+
return "waiting";
|
|
15
|
+
default:
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const reasonMax = 140;
|
|
20
|
+
// A provider's failure can run to paragraphs; a notification has a line.
|
|
21
|
+
export function shortReason(reason) {
|
|
22
|
+
const line = reason
|
|
23
|
+
?.split(/\r?\n/)
|
|
24
|
+
.map((part) => part.trim())
|
|
25
|
+
.find((part) => part !== "");
|
|
26
|
+
if (line === undefined)
|
|
27
|
+
return undefined;
|
|
28
|
+
return line.length <= reasonMax ? line : `${line.slice(0, reasonMax - 1).trimEnd()}…`;
|
|
29
|
+
}
|
|
30
|
+
export function noticeText(kind, subject) {
|
|
31
|
+
const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
|
|
32
|
+
switch (kind) {
|
|
33
|
+
case "ready":
|
|
34
|
+
return subject.makesVideo
|
|
35
|
+
? { headline: `Video ready: ${title}`, detail: "Open the project to watch it." }
|
|
36
|
+
: { headline: `Run finished: ${title}`, detail: "Open the project to see its files." };
|
|
37
|
+
case "failed": {
|
|
38
|
+
const reason = shortReason(subject.reason);
|
|
39
|
+
return {
|
|
40
|
+
headline: reason === undefined ? `Run failed: ${title}` : `Run failed: ${title} — ${reason}`,
|
|
41
|
+
detail: "Open the project to see what stopped it and press Retry.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
case "waiting":
|
|
45
|
+
return {
|
|
46
|
+
headline: `Waiting for you: ${title}`,
|
|
47
|
+
detail: "Open the project to review the held step or press Resume.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export const testNotice = {
|
|
52
|
+
headline: "Slopify test notification",
|
|
53
|
+
detail: "Notifications work. You'll get one when a run finishes, fails or waits for you.",
|
|
54
|
+
};
|
|
55
|
+
// The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
|
|
56
|
+
// but the project's title, what happened and the provider's own short reason goes in it.
|
|
57
|
+
export function webhookBody(text) {
|
|
58
|
+
return `${text.headline}\n${text.detail}\n`;
|
|
59
|
+
}
|
|
60
|
+
export const notificationUrlMax = 2048;
|
|
61
|
+
// Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
|
|
62
|
+
// fetch refuses those, and a password in a URL would sit in the database in plain sight.
|
|
63
|
+
export function notificationUrlProblem(value) {
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
if (trimmed === "")
|
|
66
|
+
return undefined;
|
|
67
|
+
if (trimmed.length > notificationUrlMax)
|
|
68
|
+
return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = new URL(trimmed);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
|
|
75
|
+
}
|
|
76
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
77
|
+
return "The Notification URL must start with https:// or http://.";
|
|
78
|
+
if (parsed.username !== "" || parsed.password !== "")
|
|
79
|
+
return "Remove the user name and password from the Notification URL; Slopify can't send them.";
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// One POST to the user's Notification URL. Short, single-shot and never retried: a
|
|
2
|
+
// notification that did not arrive is logged, and a run never waits on one.
|
|
3
|
+
export const notificationTimeoutMs = 5_000;
|
|
4
|
+
export function createNotificationSender(fetchImpl, timeoutMs = notificationTimeoutMs) {
|
|
5
|
+
return async (url, body) => {
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetchImpl(url, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
10
|
+
body,
|
|
11
|
+
redirect: "manual",
|
|
12
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
13
|
+
});
|
|
14
|
+
// The answer's body is never read; cancelling it frees the socket.
|
|
15
|
+
await response.body?.cancel().catch(() => { });
|
|
16
|
+
return response.ok ? { ok: true } : { ok: false, reason: "refused", status: response.status };
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
reason: error instanceof DOMException &&
|
|
22
|
+
(error.name === "TimeoutError" || error.name === "AbortError")
|
|
23
|
+
? "timeout"
|
|
24
|
+
: "unreachable",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// What went wrong, in words fit for both the log and the Settings screen. The URL itself is
|
|
30
|
+
// never repeated: an ntfy topic in it is as good as a password.
|
|
31
|
+
export function sendFailureText(result) {
|
|
32
|
+
switch (result.reason) {
|
|
33
|
+
case "timeout":
|
|
34
|
+
return `the Notification URL didn't answer within ${String(notificationTimeoutMs / 1000)} seconds`;
|
|
35
|
+
case "unreachable":
|
|
36
|
+
return "the Notification URL couldn't be reached (wrong address, no network, or the server is down)";
|
|
37
|
+
case "refused":
|
|
38
|
+
return `the Notification URL answered HTTP ${String(result.status)}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
2
|
+
import { notificationUrlProblem } from "./rules.js";
|
|
3
|
+
// Its own row in the key/value `settings` table. Backups leave it out
|
|
4
|
+
// (`slices/storage/portable.ts`): an ntfy topic in the URL is as good as a password.
|
|
5
|
+
export const notificationUrlKey = "notificationUrl";
|
|
6
|
+
export function readNotificationUrl(db) {
|
|
7
|
+
const stored = readSetting(db, notificationUrlKey);
|
|
8
|
+
if (stored === undefined)
|
|
9
|
+
return null;
|
|
10
|
+
let value;
|
|
11
|
+
try {
|
|
12
|
+
value = JSON.parse(stored);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
// A hand-edited row that no longer passes the rules is treated as not set rather than
|
|
18
|
+
// POSTed to.
|
|
19
|
+
return typeof value === "string" && value !== "" && notificationUrlProblem(value) === undefined
|
|
20
|
+
? value
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
export function saveNotificationUrl(db, raw) {
|
|
24
|
+
const problem = notificationUrlProblem(raw);
|
|
25
|
+
if (problem !== undefined)
|
|
26
|
+
return { ok: false, message: problem };
|
|
27
|
+
const url = raw.trim();
|
|
28
|
+
if (url === "") {
|
|
29
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(notificationUrlKey);
|
|
30
|
+
return { ok: true, url: null };
|
|
31
|
+
}
|
|
32
|
+
writeSetting(db, notificationUrlKey, JSON.stringify(url));
|
|
33
|
+
return { ok: true, url };
|
|
34
|
+
}
|
|
@@ -2,7 +2,16 @@ import { z } from "zod";
|
|
|
2
2
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
|
|
3
3
|
import { runDraftSchema } from "../admission/schema.js";
|
|
4
4
|
import { draftDocumentThemeOf } from "../document/model.js";
|
|
5
|
+
import { defaultShorts, shortsExtrasOf, shortsSettingsProblems, } from "../shorts/model.js";
|
|
5
6
|
import { defaultSubtitles } from "../subtitles/model.js";
|
|
7
|
+
// Said where the control is, since the music sits in a closed disclosure on the Export rail.
|
|
8
|
+
const musicMessages = {
|
|
9
|
+
uploading: "The shorts' background music is still uploading, so the run can't start yet. Wait for it to finish, or remove it under Outputs → Export → More shorts options → Background music.",
|
|
10
|
+
missing: "The shorts' background music file is no longer available (it was not uploaded again after the draft was copied, or the upload failed). Choose the file again under Outputs → Export → More shorts options → Background music, or remove it.",
|
|
11
|
+
};
|
|
12
|
+
function pickMusic(id) {
|
|
13
|
+
return id === undefined ? {} : { shortsMusic: id };
|
|
14
|
+
}
|
|
6
15
|
export function toAdmissionDraft(input) {
|
|
7
16
|
const { form } = input.document;
|
|
8
17
|
const fields = [];
|
|
@@ -48,15 +57,18 @@ export function toAdmissionDraft(input) {
|
|
|
48
57
|
});
|
|
49
58
|
return undefined;
|
|
50
59
|
};
|
|
51
|
-
const file = (ref, kind, field
|
|
60
|
+
const file = (ref, kind, field, messages = {
|
|
61
|
+
uploading: "This file is still uploading. Wait for it to finish.",
|
|
62
|
+
missing: "That upload is no longer available. Choose the file again.",
|
|
63
|
+
}) => {
|
|
52
64
|
const attachment = input.attachments.find((item) => item.id === ref?.attachmentId && item.kind === kind && item.name === ref.name);
|
|
53
65
|
if (attachment?.state === "ready" && attachment.stagedFileId !== null)
|
|
54
66
|
return attachment.stagedFileId;
|
|
55
67
|
fields.push({
|
|
56
68
|
field,
|
|
57
69
|
message: attachment?.state === "copying" || attachment?.state === "pending"
|
|
58
|
-
?
|
|
59
|
-
:
|
|
70
|
+
? messages.uploading
|
|
71
|
+
: messages.missing,
|
|
60
72
|
});
|
|
61
73
|
return undefined;
|
|
62
74
|
};
|
|
@@ -66,6 +78,36 @@ export function toAdmissionDraft(input) {
|
|
|
66
78
|
? "files"
|
|
67
79
|
: form.subtitles.mode;
|
|
68
80
|
const chunkMode = sources.audio === "generate" ? form.chunking.mode : "whole";
|
|
81
|
+
const shortsOn = sources.audio !== "off" && form.shorts?.enabled === true;
|
|
82
|
+
// Refused in admission's words, so Play and the run say the same sentence.
|
|
83
|
+
const shortsOf = (raw) => {
|
|
84
|
+
const whole = (value) => (value.trim() === "" ? Number.NaN : Number(value));
|
|
85
|
+
const settings = {
|
|
86
|
+
enabled: true,
|
|
87
|
+
count: whole(raw.count),
|
|
88
|
+
minSeconds: whole(raw.minSeconds),
|
|
89
|
+
maxSeconds: whole(raw.maxSeconds),
|
|
90
|
+
...(raw.prompt.trim() ? { prompt: raw.prompt } : {}),
|
|
91
|
+
...(raw.imagePrompt.trim() ? { imagePrompt: raw.imagePrompt } : {}),
|
|
92
|
+
...shortsExtrasOf(raw),
|
|
93
|
+
};
|
|
94
|
+
for (const problem of shortsSettingsProblems(settings))
|
|
95
|
+
fields.push({ field: `shorts.${problem.field}`, message: problem.message });
|
|
96
|
+
const { musicVolume, speed, ...rest } = settings;
|
|
97
|
+
return {
|
|
98
|
+
...rest,
|
|
99
|
+
// A number already refused above is left out rather than saved as NaN.
|
|
100
|
+
...(musicVolume !== undefined && Number.isFinite(musicVolume) ? { musicVolume } : {}),
|
|
101
|
+
...(speed !== undefined && Number.isFinite(speed) ? { speed } : {}),
|
|
102
|
+
count: Number.isFinite(settings.count) ? settings.count : defaultShorts.count,
|
|
103
|
+
minSeconds: Number.isFinite(settings.minSeconds)
|
|
104
|
+
? settings.minSeconds
|
|
105
|
+
: defaultShorts.minSeconds,
|
|
106
|
+
maxSeconds: Number.isFinite(settings.maxSeconds)
|
|
107
|
+
? settings.maxSeconds
|
|
108
|
+
: defaultShorts.maxSeconds,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
69
111
|
const draft = {
|
|
70
112
|
...(form.checkpoints === undefined ? {} : { checkpoints: form.checkpoints }),
|
|
71
113
|
title: form.title,
|
|
@@ -88,6 +130,19 @@ export function toAdmissionDraft(input) {
|
|
|
88
130
|
: form.audio
|
|
89
131
|
: undefined,
|
|
90
132
|
images: form.images,
|
|
133
|
+
// The establishing image belongs to generated images; Off, or images not generated,
|
|
134
|
+
// leaves it out, which is what every draft saved before it was.
|
|
135
|
+
...(sources.images === "generate" &&
|
|
136
|
+
form.reference !== undefined &&
|
|
137
|
+
form.reference.source !== "off"
|
|
138
|
+
? {
|
|
139
|
+
reference: {
|
|
140
|
+
source: form.reference.source,
|
|
141
|
+
...(form.reference.source === "prompt" ? { prompt: form.reference.prompt } : {}),
|
|
142
|
+
thumbnail: form.reference.thumbnail,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
91
146
|
articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
|
|
92
147
|
...(sources.audio === "generate" && form.narrationPrompt?.trim()
|
|
93
148
|
? { narrationPrompt: form.narrationPrompt }
|
|
@@ -99,6 +154,9 @@ export function toAdmissionDraft(input) {
|
|
|
99
154
|
...(form.descriptionPrompt?.trim() ? { descriptionPrompt: form.descriptionPrompt } : {}),
|
|
100
155
|
}
|
|
101
156
|
: {}),
|
|
157
|
+
// Cut from the narration too, so the same rule: a switch left on with narration Off asks
|
|
158
|
+
// for nothing.
|
|
159
|
+
...(shortsOn && form.shorts !== undefined ? { shorts: shortsOf(form.shorts) } : {}),
|
|
102
160
|
imagePrompts: sources.images === "generate"
|
|
103
161
|
? form.imagePrompts.map((prompt, index) => ({
|
|
104
162
|
name: prompt.name,
|
|
@@ -120,12 +178,20 @@ export function toAdmissionDraft(input) {
|
|
|
120
178
|
thumbnail: sources.thumbnail === "provide"
|
|
121
179
|
? file(form.provided.thumbnail, "thumbnail", "provided.thumbnail")
|
|
122
180
|
: undefined,
|
|
181
|
+
reference: sources.images === "generate" && form.reference?.source === "provide"
|
|
182
|
+
? file(form.provided.reference ?? null, "reference", "provided.reference")
|
|
183
|
+
: undefined,
|
|
123
184
|
images: sources.images === "provide"
|
|
124
185
|
? form.provided.images.flatMap((ref, index) => {
|
|
125
186
|
const id = file(ref, "images", `provided.images.${index}`);
|
|
126
187
|
return id === undefined ? [] : [id];
|
|
127
188
|
})
|
|
128
189
|
: [],
|
|
190
|
+
// Optional, so only a file that was attached is checked; with Shorts off it is kept on
|
|
191
|
+
// the draft for when Shorts is turned back on, and never reaches the run.
|
|
192
|
+
...(shortsOn && form.provided.shortsMusic
|
|
193
|
+
? pickMusic(file(form.provided.shortsMusic, "audio", "shorts.music", musicMessages))
|
|
194
|
+
: {}),
|
|
129
195
|
},
|
|
130
196
|
chunking: {
|
|
131
197
|
mode: chunkMode,
|
|
@@ -144,11 +210,13 @@ export function toAdmissionDraft(input) {
|
|
|
144
210
|
fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
|
|
145
211
|
},
|
|
146
212
|
silenceGapSeconds: input.silenceGapSeconds,
|
|
147
|
-
imageSeconds: measure("imageSeconds", sources.video === "generate", defaultImageSeconds, imageSecondsProblem),
|
|
213
|
+
imageSeconds: measure("imageSeconds", sources.video === "generate" || shortsOn, defaultImageSeconds, imageSecondsProblem),
|
|
148
214
|
zoomPercent: measure("zoomPercent", sources.video === "generate", defaultZoomPercent, zoomPercentProblem),
|
|
149
215
|
// A choice from a list, so there is nothing to refuse; a run without a video keeps it
|
|
150
216
|
// for when the video is turned back on.
|
|
151
217
|
motionStyle: form.motionStyle,
|
|
218
|
+
// Kept the same way; admission checks it only while the video renders.
|
|
219
|
+
...(form.videoEdit === undefined ? {} : { videoEdit: form.videoEdit }),
|
|
152
220
|
edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
|
|
153
221
|
};
|
|
154
222
|
const parsed = runDraftSchema.safeParse(draft);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { checkRuntimeModel } from "../../catalog/runtime-models.js";
|
|
2
2
|
import { modelFields } from "../../catalog/validate.js";
|
|
3
3
|
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
4
|
-
import { usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
|
|
4
|
+
import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
|
|
5
5
|
import { cliPathChanged } from "../settings/cli-paths.js";
|
|
6
6
|
import { hasKey, listVoices } from "../settings/repo.js";
|
|
7
|
+
import { usesAnimation } from "../video/edit-settings.js";
|
|
7
8
|
export function choices(runs) {
|
|
8
9
|
const selected = runs
|
|
9
10
|
.flatMap(({ draft: d }) => [
|
|
@@ -17,7 +18,8 @@ export function choices(runs) {
|
|
|
17
18
|
d.intro?.mode === "llm" ||
|
|
18
19
|
d.outro?.mode === "llm" ||
|
|
19
20
|
usesNarrationPreparation(d) ||
|
|
20
|
-
usesYoutubeDescription(d)
|
|
21
|
+
usesYoutubeDescription(d) ||
|
|
22
|
+
usesShorts(d),
|
|
21
23
|
},
|
|
22
24
|
{
|
|
23
25
|
field: "audio",
|
|
@@ -30,7 +32,9 @@ export function choices(runs) {
|
|
|
30
32
|
family: "image",
|
|
31
33
|
choice: d.images,
|
|
32
34
|
needed: d.sources.images === "generate" ||
|
|
33
|
-
["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail)
|
|
35
|
+
["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail) ||
|
|
36
|
+
usesShorts(d) ||
|
|
37
|
+
usesAnimation(d),
|
|
34
38
|
},
|
|
35
39
|
])
|
|
36
40
|
.flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
3
|
+
import { draftAttachmentKinds } from "./schema.js";
|
|
3
4
|
const rowSchema = z.object({
|
|
4
5
|
id: z.string(),
|
|
5
6
|
schema_version: z.number(),
|
|
@@ -21,7 +22,7 @@ const attachmentSchema = z.object({
|
|
|
21
22
|
id: z.string(),
|
|
22
23
|
draft_id: z.string(),
|
|
23
24
|
staged_file_id: z.string().nullable(),
|
|
24
|
-
kind: z.enum(
|
|
25
|
+
kind: z.enum(draftAttachmentKinds),
|
|
25
26
|
original_filename: z.string(),
|
|
26
27
|
status: z.enum(["pending", "ready", "reattach"]),
|
|
27
28
|
error: z.string().nullable(),
|
|
@@ -52,6 +53,11 @@ export function attachmentRefs(document) {
|
|
|
52
53
|
...(p.audio === null ? [] : [{ ...p.audio, kind: "audio" }]),
|
|
53
54
|
...p.images.map((file) => ({ ...file, kind: "images" })),
|
|
54
55
|
...(p.thumbnail === null ? [] : [{ ...p.thumbnail, kind: "thumbnail" }]),
|
|
56
|
+
...(p.reference === undefined || p.reference === null
|
|
57
|
+
? []
|
|
58
|
+
: [{ ...p.reference, kind: "reference" }]),
|
|
59
|
+
// The shorts' music is staged like narration audio, so it is an audio attachment.
|
|
60
|
+
...(p.shortsMusic == null ? [] : [{ ...p.shortsMusic, kind: "audio" }]),
|
|
55
61
|
];
|
|
56
62
|
}
|
|
57
63
|
export function requestHash(value) {
|
|
@@ -110,7 +110,9 @@ export function resolveReviewInputs(deps, view, font) {
|
|
|
110
110
|
const activeIds = new Set(runs.flatMap((run) => [
|
|
111
111
|
run.draft.provided.audio,
|
|
112
112
|
run.draft.provided.thumbnail,
|
|
113
|
+
run.draft.provided.reference,
|
|
113
114
|
...(run.draft.provided.images ?? []),
|
|
115
|
+
run.draft.provided.shortsMusic,
|
|
114
116
|
]));
|
|
115
117
|
const attachmentIdentity = fresh.value.attachments.flatMap((file) => file.stagedFileId !== null && activeIds.has(file.stagedFileId)
|
|
116
118
|
? [{ id: file.id, stagedFileId: file.stagedFileId, bytes: file.bytes }]
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formats, stageKinds } from "../../kernel/pipeline.js";
|
|
3
3
|
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
4
|
-
import { motionStyles, stageSources } from "../admission/model.js";
|
|
4
|
+
import { motionStyles, referenceSources, stageSources } from "../admission/model.js";
|
|
5
5
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
|
|
6
|
-
import { runDraftSchema } from "../admission/schema.js";
|
|
6
|
+
import { runDraftSchema, videoEditSchema } from "../admission/schema.js";
|
|
7
7
|
import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
|
|
8
8
|
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
9
9
|
import { librarySnapshotSchema } from "../library/snapshot.js";
|
|
@@ -15,6 +15,8 @@ const values = z.record(text, text).readonly();
|
|
|
15
15
|
const provider = z
|
|
16
16
|
.object({ provider: text, model: text, thinking: z.enum(thinkingModes).optional() })
|
|
17
17
|
.strict();
|
|
18
|
+
// What a draft's uploads are for: a stage's files, or the Images stage's establishing image.
|
|
19
|
+
export const draftAttachmentKinds = ["audio", "images", "thumbnail", "reference"];
|
|
18
20
|
const file = z.object({ attachmentId: id, name: text }).strict().readonly();
|
|
19
21
|
export const playDraftFormSchema = z
|
|
20
22
|
.object({
|
|
@@ -45,11 +47,42 @@ export const playDraftFormSchema = z
|
|
|
45
47
|
})
|
|
46
48
|
.readonly(),
|
|
47
49
|
images: provider.readonly(),
|
|
50
|
+
// Absent on drafts and templates saved before the establishing image: Off. The prompt is
|
|
51
|
+
// an image prompt's name, "" when none is picked.
|
|
52
|
+
reference: z
|
|
53
|
+
.object({
|
|
54
|
+
source: z.enum(["off", ...referenceSources]),
|
|
55
|
+
prompt: text,
|
|
56
|
+
thumbnail: z.boolean(),
|
|
57
|
+
})
|
|
58
|
+
.strict()
|
|
59
|
+
.readonly()
|
|
60
|
+
.optional(),
|
|
48
61
|
articlePrompt: text,
|
|
49
62
|
narrationPrompt: text.optional(),
|
|
50
63
|
// Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
|
|
51
64
|
youtubeDescription: z.boolean().optional(),
|
|
52
65
|
descriptionPrompt: text.optional(),
|
|
66
|
+
// Absent on drafts and templates saved before Shorts: off. The numbers are raw text, like
|
|
67
|
+
// every other number on Play; the prompts are names, "" being the built-in ones.
|
|
68
|
+
shorts: z
|
|
69
|
+
.object({
|
|
70
|
+
enabled: z.boolean(),
|
|
71
|
+
count: text,
|
|
72
|
+
minSeconds: text,
|
|
73
|
+
maxSeconds: text,
|
|
74
|
+
prompt: text,
|
|
75
|
+
imagePrompt: text,
|
|
76
|
+
// Absent on drafts saved before these: the title off, no link, the music at its
|
|
77
|
+
// default level, normal speed. The two numbers are raw text too.
|
|
78
|
+
titleOnScreen: z.boolean().optional(),
|
|
79
|
+
fullVideoLink: text.optional(),
|
|
80
|
+
musicVolume: text.optional(),
|
|
81
|
+
speed: text.optional(),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
.readonly()
|
|
85
|
+
.optional(),
|
|
53
86
|
imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
|
|
54
87
|
thumbnailPrompt: text,
|
|
55
88
|
intro: text,
|
|
@@ -74,6 +107,9 @@ export const playDraftFormSchema = z
|
|
|
74
107
|
edgeSilenceSeconds: text.default(String(defaultEdgeSilenceSeconds)),
|
|
75
108
|
zoomPercent: text.default(String(defaultZoomPercent)),
|
|
76
109
|
motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
|
|
110
|
+
// Absent on drafts and templates saved before the edit settings: today's slideshow. Every
|
|
111
|
+
// field is a pick from a list, so it is kept as the settings themselves.
|
|
112
|
+
videoEdit: videoEditSchema.strict().readonly().optional(),
|
|
77
113
|
values,
|
|
78
114
|
provided: z
|
|
79
115
|
.object({
|
|
@@ -82,6 +118,11 @@ export const playDraftFormSchema = z
|
|
|
82
118
|
audio: file.nullable(),
|
|
83
119
|
thumbnail: file.nullable(),
|
|
84
120
|
images: z.array(file).readonly(),
|
|
121
|
+
// The uploaded establishing image; absent on drafts saved before it.
|
|
122
|
+
reference: file.nullable().optional(),
|
|
123
|
+
// The Shorts step's background music, uploaded as an audio attachment. Absent on
|
|
124
|
+
// drafts and templates saved before Play offered it: no music.
|
|
125
|
+
shortsMusic: file.nullable().optional(),
|
|
85
126
|
})
|
|
86
127
|
.strict()
|
|
87
128
|
.readonly(),
|
|
@@ -127,7 +168,7 @@ export const discardDraftInputSchema = z
|
|
|
127
168
|
export const draftAttachmentSchema = z
|
|
128
169
|
.object({
|
|
129
170
|
id,
|
|
130
|
-
kind: z.enum(
|
|
171
|
+
kind: z.enum(draftAttachmentKinds),
|
|
131
172
|
name: text,
|
|
132
173
|
state: z.enum(["pending", "copying", "ready", "reattach"]),
|
|
133
174
|
stagedFileId: text.nullable(),
|
|
@@ -208,6 +208,12 @@ export function forkDraft(deps, input) {
|
|
|
208
208
|
audio: p.audio === null ? null : rewrite(p.audio),
|
|
209
209
|
thumbnail: p.thumbnail === null ? null : rewrite(p.thumbnail),
|
|
210
210
|
images: p.images.map(rewrite),
|
|
211
|
+
...(p.reference === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: { reference: p.reference === null ? null : rewrite(p.reference) }),
|
|
214
|
+
...(p.shortsMusic === undefined
|
|
215
|
+
? {}
|
|
216
|
+
: { shortsMusic: p.shortsMusic === null ? null : rewrite(p.shortsMusic) }),
|
|
211
217
|
},
|
|
212
218
|
},
|
|
213
219
|
};
|
|
@@ -29,7 +29,8 @@ export async function uploadDraftAttachment(deps, input) {
|
|
|
29
29
|
return refuse("conflict");
|
|
30
30
|
const storage = { ...deps, emit: () => undefined };
|
|
31
31
|
const result = await stageUpload(storage, {
|
|
32
|
-
|
|
32
|
+
// The establishing image is the Images stage's upload.
|
|
33
|
+
stageKind: attachment.kind === "reference" ? "images" : attachment.kind,
|
|
33
34
|
originalFilename: attachment.original_filename,
|
|
34
35
|
content: input.content,
|
|
35
36
|
onAllocated(file) {
|