@gentbajko/slopify 3.0.0 → 3.0.2
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/README.md +6 -4
- package/dist/adapter-registry.js +28 -0
- package/dist/adapters/alignment/text.js +3 -1
- package/dist/adapters/key-probes.js +57 -8
- package/dist/adapters/tts/gemini.js +206 -0
- package/dist/adapters/tts/pcm-mp3.js +62 -0
- package/dist/adapters/tts/system.js +147 -0
- package/dist/assets/models.yaml +38 -0
- package/dist/assets/style-preview/narration.mp3 +0 -0
- package/dist/assets/style-preview/tall-1.jpg +0 -0
- package/dist/assets/style-preview/tall-2.jpg +0 -0
- package/dist/assets/style-preview/tall-3.jpg +0 -0
- package/dist/assets/style-preview/wide-1.jpg +0 -0
- package/dist/assets/style-preview/wide-2.jpg +0 -0
- package/dist/assets/style-preview/wide-3.jpg +0 -0
- package/dist/catalog/registry.js +4 -1
- package/dist/catalog/validate.js +2 -2
- package/dist/edge/autostart/docker-record.js +2 -2
- package/dist/edge/autostart/docker.js +68 -0
- package/dist/edge/autostart/prompt.js +7 -1
- package/dist/edge/autostart/service.js +18 -9
- package/dist/edge/cli-args.js +71 -0
- package/dist/edge/cli.js +11 -16
- package/dist/edge/docker-install/run.js +7 -1
- package/dist/edge/events/hub.js +3 -1
- package/dist/edge/http/actions.js +6 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/byte-range.js +42 -0
- package/dist/edge/http/diagnostics.js +1 -0
- package/dist/edge/http/draft-files.js +2 -4
- package/dist/edge/http/files.js +10 -6
- package/dist/edge/http/onboarding.js +64 -4
- package/dist/edge/http/project-templates.js +19 -2
- package/dist/edge/http/projects.js +6 -1
- package/dist/edge/http/providers.js +39 -9
- package/dist/edge/http/reviews.js +18 -8
- package/dist/edge/http/revision-files.js +2 -4
- package/dist/edge/http/schedules.js +9 -5
- package/dist/edge/http/storage.js +16 -2
- package/dist/edge/http/studio.js +111 -31
- package/dist/edge/http/style-preview.js +3 -42
- package/dist/edge/http/tutorials.js +59 -0
- package/dist/edge/http/youtube-edits.js +11 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/host-cli/gemini-login.js +83 -0
- package/dist/host-cli/status.js +4 -2
- package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
- package/dist/kernel/ports/model.js +5 -3
- package/dist/kernel/ports/system-speech.js +203 -0
- package/dist/kernel/runner/graph.js +17 -1
- package/dist/kernel/runner/index.js +4 -4
- package/dist/kernel/runner/providers.js +1 -1
- package/dist/main.js +5 -0
- package/dist/model-catalog.js +2 -0
- package/dist/patch-notes/3.0.1.md +105 -0
- package/dist/patch-notes/3.0.2.md +20 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/rules.js +3 -3
- package/dist/slices/admission/start.js +1 -1
- package/dist/slices/channels/cast-match.js +1 -1
- package/dist/slices/channels/repo.js +3 -0
- package/dist/slices/channels/schema.js +23 -0
- package/dist/slices/channels/service.js +6 -6
- package/dist/slices/control/index.js +3 -1
- package/dist/slices/control/providers.js +4 -1
- package/dist/slices/episodes/related.js +2 -2
- package/dist/slices/estimate/requests.js +9 -1
- package/dist/slices/eta/history.js +56 -0
- package/dist/slices/eta/model.js +63 -0
- package/dist/slices/eta/view.js +25 -0
- package/dist/slices/fixes/rules.js +1 -1
- package/dist/slices/fonts/coverage.js +1 -1
- package/dist/slices/loudness/model.js +1 -1
- package/dist/slices/model-upkeep/usage.js +4 -2
- package/dist/slices/narration/pauses-model.js +1 -1
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/notifications/notifier.js +28 -2
- package/dist/slices/notifications/rules.js +26 -2
- package/dist/slices/onboarding/first-run.js +40 -14
- package/dist/slices/onboarding/full-video.js +81 -0
- package/dist/slices/onboarding/model.js +21 -0
- package/dist/slices/onboarding/quick-short.js +25 -10
- package/dist/slices/play-drafts/convert.js +3 -3
- package/dist/slices/play-drafts/readiness.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -3
- package/dist/slices/project-templates/next-chapter.js +94 -0
- package/dist/slices/project-templates/one-off.js +1 -1
- package/dist/slices/project-templates/schema.js +7 -1
- package/dist/slices/rebuild/recipe-exports.js +5 -0
- package/dist/slices/rebuild/recipe-save.js +1 -1
- package/dist/slices/rebuild/recipe-voices.js +1 -1
- package/dist/slices/rebuild/review-redo.js +15 -0
- package/dist/slices/rebuild/runtime-export-bed.js +1 -1
- package/dist/slices/rebuild/runtime-review.js +35 -5
- package/dist/slices/rebuild/runtime-store.js +23 -1
- package/dist/slices/rebuild/runtime-voices.js +2 -2
- package/dist/slices/rebuild/service-readiness.js +3 -3
- package/dist/slices/reviews/repo.js +16 -4
- package/dist/slices/run-cost/panel.js +30 -8
- package/dist/slices/run-cost/pricing.js +4 -1
- package/dist/slices/schedules/agenda.js +66 -2
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/scheduler.js +1 -1
- package/dist/slices/schedules/schema.js +43 -2
- package/dist/slices/schedules/similar.js +41 -3
- package/dist/slices/schedules/topic-list.js +2 -2
- package/dist/slices/schedules/topics.js +118 -17
- package/dist/slices/settings/first-run.js +5 -1
- package/dist/slices/settings/health.js +64 -7
- package/dist/slices/settings/key-guides.js +17 -7
- package/dist/slices/settings/key-test.js +12 -4
- package/dist/slices/settings/keys.js +10 -5
- package/dist/slices/settings/model-reach.js +52 -0
- package/dist/slices/settings/model.js +49 -0
- package/dist/slices/settings/models.js +5 -1
- package/dist/slices/settings/readiness.js +14 -2
- package/dist/slices/settings/tutorial-schema.js +5 -0
- package/dist/slices/shorts/render.js +18 -17
- package/dist/slices/storage/documents.js +11 -3
- package/dist/slices/storage/downloads.js +33 -1
- package/dist/slices/storage/portable.js +4 -2
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +6 -4
- package/dist/slices/studio/queue.js +69 -0
- package/dist/slices/studio/settings.js +42 -5
- package/dist/slices/style-preview/narration.js +33 -0
- package/dist/slices/style-preview/render.js +43 -56
- package/dist/slices/style-preview/schema.js +17 -3
- package/dist/slices/style-preview/service.js +3 -4
- package/dist/slices/style-preview/settings.js +75 -25
- package/dist/slices/tutorials/anchors.js +57 -0
- package/dist/slices/tutorials/library.js +185 -0
- package/dist/slices/voices/audio-files.js +7 -2
- package/dist/slices/voices/cast.js +26 -8
- package/dist/slices/voices/grouping.js +10 -0
- package/dist/slices/voices/model.js +49 -10
- package/dist/slices/voices/script.js +1 -1
- package/dist/slices/youtube/edits-repo.js +19 -4
- package/dist/slices/youtube/placeholders.js +2 -2
- package/dist/tutorials/AI-CLIs.md +146 -0
- package/dist/tutorials/Backups.md +121 -0
- package/dist/tutorials/Calendar.md +103 -0
- package/dist/tutorials/Cast-Library.md +112 -0
- package/dist/tutorials/Channels.md +148 -0
- package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
- package/dist/tutorials/Docker.md +171 -0
- package/dist/tutorials/Document-Themes.md +207 -0
- package/dist/tutorials/Editing-a-Project.md +204 -0
- package/dist/tutorials/FAQ.md +134 -0
- package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
- package/dist/tutorials/Home-and-Projects.md +174 -0
- package/dist/tutorials/Home.md +119 -0
- package/dist/tutorials/Install.md +154 -0
- package/dist/tutorials/Intros-and-Outros.md +86 -0
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
- package/dist/tutorials/Library-Overview.md +75 -0
- package/dist/tutorials/Models.md +133 -0
- package/dist/tutorials/Multiple-Voices.md +161 -0
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
- package/dist/tutorials/Notifications.md +86 -0
- package/dist/tutorials/Other-Languages.md +110 -0
- package/dist/tutorials/PDF-Documents.md +100 -0
- package/dist/tutorials/Play-Images.md +135 -0
- package/dist/tutorials/Play-Narration.md +167 -0
- package/dist/tutorials/Play-Outputs.md +109 -0
- package/dist/tutorials/Play-Overview.md +216 -0
- package/dist/tutorials/Play-Title-and-Article.md +135 -0
- package/dist/tutorials/Play-Video-and-Style.md +143 -0
- package/dist/tutorials/Project-Page.md +228 -0
- package/dist/tutorials/Prompts.md +136 -0
- package/dist/tutorials/Providers-and-Keys.md +290 -0
- package/dist/tutorials/Publishing-to-YouTube.md +135 -0
- package/dist/tutorials/Recovery-and-Retries.md +141 -0
- package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
- package/dist/tutorials/Schedules.md +154 -0
- package/dist/tutorials/Settings-Reference.md +258 -0
- package/dist/tutorials/Shorts.md +116 -0
- package/dist/tutorials/Start-at-Login.md +126 -0
- package/dist/tutorials/Studio-Extension.md +146 -0
- package/dist/tutorials/Templates.md +146 -0
- package/dist/tutorials/Trash.md +87 -0
- package/dist/tutorials/Troubleshooting.md +149 -0
- package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
- package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
- package/dist/tutorials/Video-Editing.md +135 -0
- package/dist/tutorials/Where-Your-Files-Live.md +149 -0
- package/dist/tutorials/YouTube-Description.md +132 -0
- package/dist/tutorials/Your-First-Short.md +125 -0
- package/dist/tutorials/_Footer.md +1 -0
- package/dist/tutorials/_Sidebar.md +69 -0
- package/dist/web/assets/index-C4McGNke.js +307 -0
- package/dist/web/assets/index-DBLSfqA-.css +1 -0
- package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CUA2sUxU.css +0 -1
- package/dist/web/assets/index-CX590kE-.js +0 -303
|
@@ -8,7 +8,7 @@ import { latestVerdict, saveVerdict } from "../reviews/repo.js";
|
|
|
8
8
|
import { parseVerdict, reviewMessages } from "../reviews/verdict.js";
|
|
9
9
|
import { pickedShortsOf } from "../shorts/clips.js";
|
|
10
10
|
import { outputPath } from "../storage/layout.js";
|
|
11
|
-
import { exportSnapshot } from "./runtime-export-inputs.js";
|
|
11
|
+
import { exportSnapshot, revisionTranscript, } from "./runtime-export-inputs.js";
|
|
12
12
|
import { savedCatalogue } from "./runtime-plan.js";
|
|
13
13
|
import { publishResult } from "./runtime-publication.js";
|
|
14
14
|
import { wordsSchema } from "./runtime-subtitles.js";
|
|
@@ -46,8 +46,8 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
|
|
|
46
46
|
if (values === undefined || !values.success)
|
|
47
47
|
throw new Error("Slopify hit an internal error (a review step was set up wrongly). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
48
48
|
const [stage, itemKey, , provider, model, thinking, prompt, mode, retries] = values.data;
|
|
49
|
-
const
|
|
50
|
-
const material = reviewMaterial(deps, context,
|
|
49
|
+
const snapshot = exportSnapshot(deps, context, piece);
|
|
50
|
+
const material = reviewMaterial(deps, context, snapshot, stage, itemKey, prompt);
|
|
51
51
|
const previous = latestVerdict(deps.db, context.work.projectId, itemKey);
|
|
52
52
|
const attempt = nextAttempt(previous, material.itemFingerprint);
|
|
53
53
|
const row = deps.db
|
|
@@ -102,6 +102,16 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
|
|
|
102
102
|
outcome: saved.outcome,
|
|
103
103
|
attempt: saved.attempt,
|
|
104
104
|
});
|
|
105
|
+
// Kept and flagged: nothing more happens to the item until someone decides.
|
|
106
|
+
if (saved.outcome === "flagged")
|
|
107
|
+
context.emit({
|
|
108
|
+
type: "review.flagged",
|
|
109
|
+
projectId: context.work.projectId,
|
|
110
|
+
verdictId: saved.id,
|
|
111
|
+
stage: saved.stage,
|
|
112
|
+
itemKey: saved.itemKey,
|
|
113
|
+
...(saved.reasons[0] === undefined ? {} : { reason: saved.reasons[0] }),
|
|
114
|
+
});
|
|
105
115
|
deps.count?.("stage.completed", {
|
|
106
116
|
provider,
|
|
107
117
|
model,
|
|
@@ -110,7 +120,27 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
|
|
|
110
120
|
});
|
|
111
121
|
return "done";
|
|
112
122
|
}
|
|
113
|
-
|
|
123
|
+
// What the voice was asked to say, segment by segment, as the word timing matched it: the intro
|
|
124
|
+
// and outro, and the prepared or hand-edited spoken text rather than the article as written.
|
|
125
|
+
// Falls back to the article when those texts can't be read.
|
|
126
|
+
function spokenText(deps, snapshot) {
|
|
127
|
+
const present = ["intro", "body", "outro"].filter((kind) => snapshot.view.outputs.some((one) => one.selected &&
|
|
128
|
+
one.available &&
|
|
129
|
+
one.state === "ready" &&
|
|
130
|
+
one.output.role === `audio_${kind}`));
|
|
131
|
+
try {
|
|
132
|
+
const text = present.map((kind) => revisionTranscript(deps, snapshot, kind)).join("\n\n");
|
|
133
|
+
if (text.trim() !== "")
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// The article below is still a fair reference.
|
|
138
|
+
}
|
|
139
|
+
return plainText(snapshot.view.articleMarkdown ?? "");
|
|
140
|
+
}
|
|
141
|
+
function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
142
|
+
const { view } = snapshot;
|
|
143
|
+
const recipes = snapshot.plan.recipes;
|
|
114
144
|
const config = view.revision.config;
|
|
115
145
|
const output = (workKey, role) => view.outputs.find((row) => row.workKey === workKey &&
|
|
116
146
|
row.selected &&
|
|
@@ -186,7 +216,7 @@ function reviewMaterial(deps, context, view, recipes, stage, itemKey, prompt) {
|
|
|
186
216
|
request: request([
|
|
187
217
|
{
|
|
188
218
|
label: "Text the narration was read from",
|
|
189
|
-
text: clip(
|
|
219
|
+
text: clip(spokenText(deps, snapshot)),
|
|
190
220
|
},
|
|
191
221
|
{ label: "What the timing heard", text: clip(words.map((word) => word.text).join(" ")) },
|
|
192
222
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
|
+
import { optionalDeps } from "../../kernel/runner/graph.js";
|
|
3
4
|
import { sourceOf } from "../admission/model.js";
|
|
4
5
|
import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
|
|
5
6
|
import { reviewsNarration } from "../reviews/rules.js";
|
|
@@ -104,10 +105,31 @@ export function invocationReady(deps, work) {
|
|
|
104
105
|
return false;
|
|
105
106
|
return recipe.dependsOn.every((key) => {
|
|
106
107
|
const dependency = plan.work.find((value) => value.key === key);
|
|
107
|
-
|
|
108
|
+
if (dependency?.disposition === "reuse" && !dependency.inflight)
|
|
109
|
+
return true;
|
|
110
|
+
// A dependency the step can do without (the document's cover) releases it once it has
|
|
111
|
+
// failed for good; the step then runs without it. The fingerprint is unchanged: it
|
|
112
|
+
// already names the missing cover, so a thumbnail made later marks the document
|
|
113
|
+
// outdated rather than current.
|
|
114
|
+
return (dependency !== undefined &&
|
|
115
|
+
(optionalDeps[recipe.stage]?.includes(dependency.stage) ?? false) &&
|
|
116
|
+
gaveUp(deps, work.projectId, key));
|
|
108
117
|
});
|
|
109
118
|
});
|
|
110
119
|
}
|
|
120
|
+
// Whether the current run's step for `key` failed or was canceled and isn't waiting to try
|
|
121
|
+
// again by itself.
|
|
122
|
+
function gaveUp(deps, projectId, key) {
|
|
123
|
+
const row = deps.db
|
|
124
|
+
.prepare(`SELECT w.state,w.retry_at FROM revision_work_reservations r
|
|
125
|
+
JOIN revision_work w ON w.id=r.work_id
|
|
126
|
+
JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id
|
|
127
|
+
WHERE r.project_id=? AND r.work_key=?`)
|
|
128
|
+
.get(projectId, key);
|
|
129
|
+
return (row !== undefined &&
|
|
130
|
+
row.retry_at === null &&
|
|
131
|
+
(row.state === "failed" || row.state === "canceled"));
|
|
132
|
+
}
|
|
111
133
|
export function executionStandings(deps, projectId, derived) {
|
|
112
134
|
const revisionId = currentRevisionId(deps.db, projectId);
|
|
113
135
|
const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
|
|
@@ -41,7 +41,7 @@ export async function executeVoicesRecipe(deps, context, piece) {
|
|
|
41
41
|
const { words } = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, timing.output.path), "utf8")));
|
|
42
42
|
const audio = await revisionAudio(deps, context, view, { levelled: true });
|
|
43
43
|
if (audio.length === 0)
|
|
44
|
-
throw new Error("The MP3 and M4B files need narration audio, but this project has none. Turn narration on in Edit project, or turn Audio files off under Speakers (Play →
|
|
44
|
+
throw new Error("The MP3 and M4B files need narration audio, but this project has none. Turn narration on in Edit project, or turn Audio files off under Speakers (Play → Narration, or Edit project → Providers), then Try again.");
|
|
45
45
|
const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
46
46
|
const chapters = audioChapters({
|
|
47
47
|
title: view.revision.config.title,
|
|
@@ -57,7 +57,7 @@ export async function executeVoicesRecipe(deps, context, piece) {
|
|
|
57
57
|
const prepared = [];
|
|
58
58
|
try {
|
|
59
59
|
const metadata = join(directory, "chapters.txt");
|
|
60
|
-
writeFileSync(metadata, ffmetadata(view.revision.config.title, chapters), { mode: 0o600 });
|
|
60
|
+
writeFileSync(metadata, ffmetadata(view.revision.config.title, chapters, view.revision.config.voices?.book), { mode: 0o600 });
|
|
61
61
|
// Level the volume: the sound is mixed once and mastered to the audio files' target, and both
|
|
62
62
|
// files are encoded from that (`loudness/model.ts`).
|
|
63
63
|
const goal = masterGoal(view.revision.config, "audioFiles");
|
|
@@ -2,7 +2,7 @@ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
|
|
|
2
2
|
import { referenceRefusal, takesReferenceImage } from "../../catalog/schema.js";
|
|
3
3
|
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
4
4
|
import { cliPathChanged } from "../settings/cli-paths.js";
|
|
5
|
-
import { isLocalCliProvider } from "../settings/model.js";
|
|
5
|
+
import { isLocalCliProvider, isUncataloguedProvider } from "../settings/model.js";
|
|
6
6
|
import { hasKey, listVoices } from "../settings/repo.js";
|
|
7
7
|
import { requiresNewSubmission } from "./preview-retained.js";
|
|
8
8
|
import { recipeProviderChoice } from "./recipe-provider-choice.js";
|
|
@@ -33,7 +33,7 @@ export async function checkReadiness(deps, snapshot, view) {
|
|
|
33
33
|
if (provider === undefined || !readinessIsUsable(provider.readiness)) {
|
|
34
34
|
fields.push({
|
|
35
35
|
field: choice.family,
|
|
36
|
-
message: provider
|
|
36
|
+
message: provider !== undefined && provider.readiness.kind !== "keyed" && provider.readiness.issue
|
|
37
37
|
? provider.readiness.issue
|
|
38
38
|
: "Configure this provider before rebuilding.",
|
|
39
39
|
});
|
|
@@ -60,7 +60,7 @@ export function localReadiness(deps, snapshot, view, providers, catalogue) {
|
|
|
60
60
|
row.id === choice.model &&
|
|
61
61
|
row.enabled &&
|
|
62
62
|
!row.deprecated);
|
|
63
|
-
if (model === undefined && !
|
|
63
|
+
if (model === undefined && !isUncataloguedProvider(choice.provider))
|
|
64
64
|
fields.push({
|
|
65
65
|
field: `${choice.family}.model`,
|
|
66
66
|
message: "The selected model is no longer available.",
|
|
@@ -90,17 +90,29 @@ export function setRedoState(db, id, state, error = null) {
|
|
|
90
90
|
db.prepare("UPDATE review_verdicts SET redo_state=?, redo_error=? WHERE id=?").run(state, error, id);
|
|
91
91
|
}
|
|
92
92
|
// Overrule accepts a failed item as it is; Redo records that the person had it made again.
|
|
93
|
-
// A passed verdict has nothing to overrule
|
|
94
|
-
// item
|
|
93
|
+
// A passed verdict has nothing to overrule. Overrule also calls off an automatic redo that is
|
|
94
|
+
// still waiting to start (what depends on the item then goes ahead with it as it is), but a
|
|
95
|
+
// redo already under way owns the item until it lands.
|
|
95
96
|
export function actOnVerdict(db, projectId, id, action, at) {
|
|
96
97
|
const record = verdictById(db, projectId, id);
|
|
97
98
|
if (record === undefined)
|
|
98
99
|
return { ok: false, reason: "not-found" };
|
|
99
100
|
if (action === "overruled" && record.passed)
|
|
100
101
|
return { ok: false, reason: "conflict" };
|
|
101
|
-
if (record.redoState === "
|
|
102
|
+
if (record.redoState === "started")
|
|
102
103
|
return { ok: false, reason: "conflict" };
|
|
103
|
-
|
|
104
|
+
if (record.redoState === "pending") {
|
|
105
|
+
if (action !== "overruled")
|
|
106
|
+
return { ok: false, reason: "conflict" };
|
|
107
|
+
const called = db
|
|
108
|
+
.prepare("UPDATE review_verdicts SET action=?, action_at=?, redo_state=NULL WHERE id=? AND redo_state='pending'")
|
|
109
|
+
.run(action, at, id);
|
|
110
|
+
if (Number(called.changes) !== 1)
|
|
111
|
+
return { ok: false, reason: "conflict" };
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
db.prepare("UPDATE review_verdicts SET action=?, action_at=? WHERE id=?").run(action, at, id);
|
|
115
|
+
}
|
|
104
116
|
const updated = verdictById(db, projectId, id);
|
|
105
117
|
return updated === undefined ? { ok: false, reason: "not-found" } : { ok: true, value: updated };
|
|
106
118
|
}
|
|
@@ -207,16 +207,35 @@ function planUses(db, projectId, rows) {
|
|
|
207
207
|
});
|
|
208
208
|
}
|
|
209
209
|
export function limitWaitsOf(db, projectId) {
|
|
210
|
+
return limitWaitRows(db, projectId).map((row) => row.wait);
|
|
211
|
+
}
|
|
212
|
+
// Every project's waits at once, for the lists that show "Waiting for Codex limits" on a row
|
|
213
|
+
// (Projects, Home's Running now, the calendar) without asking once per project.
|
|
214
|
+
export function limitWaitsByProject(db) {
|
|
215
|
+
const found = new Map();
|
|
216
|
+
for (const row of limitWaitRows(db)) {
|
|
217
|
+
const list = found.get(row.projectId) ?? [];
|
|
218
|
+
list.push(row.wait);
|
|
219
|
+
found.set(row.projectId, list);
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
// A wait as a list row carries it: the plan's name and times, not its account key.
|
|
224
|
+
export function listingWait(wait) {
|
|
225
|
+
return { name: wait.name, stage: wait.stage, resetsAt: wait.resetsAt, retryAt: wait.retryAt };
|
|
226
|
+
}
|
|
227
|
+
function limitWaitRows(db, projectId) {
|
|
210
228
|
// Only a stage still running is waiting: a row left by a cancel or pause is not shown.
|
|
211
229
|
return db
|
|
212
|
-
.prepare(`SELECT w.account, w.stage, l.resets_at, l.retry_at FROM plan_limit_waiters w
|
|
230
|
+
.prepare(`SELECT w.project_id, w.account, w.stage, l.resets_at, l.retry_at FROM plan_limit_waiters w
|
|
213
231
|
JOIN plan_limit_waits l ON l.account = w.account
|
|
214
232
|
JOIN stages s ON s.project_id = w.project_id AND s.kind = w.stage AND s.state = 'running'
|
|
215
|
-
WHERE w.project_id = ? ORDER BY w.since`)
|
|
216
|
-
.all(projectId)
|
|
233
|
+
${projectId === undefined ? "" : "WHERE w.project_id = ?"} ORDER BY w.since`)
|
|
234
|
+
.all(...(projectId === undefined ? [] : [projectId]))
|
|
217
235
|
.map((raw) => {
|
|
218
236
|
const row = z
|
|
219
237
|
.object({
|
|
238
|
+
project_id: z.string(),
|
|
220
239
|
account: z.enum(planAccounts),
|
|
221
240
|
stage: z.enum(stageKinds),
|
|
222
241
|
resets_at: z.string().nullable(),
|
|
@@ -224,11 +243,14 @@ export function limitWaitsOf(db, projectId) {
|
|
|
224
243
|
})
|
|
225
244
|
.parse(raw);
|
|
226
245
|
return {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
projectId: row.project_id,
|
|
247
|
+
wait: {
|
|
248
|
+
account: row.account,
|
|
249
|
+
name: planAccountNames[row.account],
|
|
250
|
+
stage: row.stage,
|
|
251
|
+
resetsAt: row.resets_at,
|
|
252
|
+
retryAt: row.retry_at,
|
|
253
|
+
},
|
|
232
254
|
};
|
|
233
255
|
});
|
|
234
256
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isLocalCliProvider } from "../settings/model.js";
|
|
1
|
+
import { isLocalCliProvider, systemVoiceProvider } from "../settings/model.js";
|
|
2
2
|
// Per million tokens; cached input at its own rate when the catalogue gives one.
|
|
3
3
|
export function tokenCost(rates, tokensIn, tokensOut, cachedTokens = 0) {
|
|
4
4
|
if (rates.inputPerMillionTokens === undefined || rates.outputPerMillionTokens === undefined)
|
|
@@ -56,6 +56,9 @@ export function priceCall(call, catalogue) {
|
|
|
56
56
|
price: api === undefined ? null : { catalogue: date, model: api.id, ...api.pricing },
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
// The computer's own voice costs nothing.
|
|
60
|
+
if (call.provider === systemVoiceProvider)
|
|
61
|
+
return { onPlan: false, cost: 0, apiModel: null, apiCost: null, price: null };
|
|
59
62
|
const family = call.kind === "video" ? "image" : call.kind;
|
|
60
63
|
const entry = catalogue[family].find((model) => model.provider === call.provider && model.id === call.model);
|
|
61
64
|
const rates = entry?.pricing;
|
|
@@ -2,6 +2,10 @@ import { z } from "zod";
|
|
|
2
2
|
import { derive } from "../../kernel/runner/graph.js";
|
|
3
3
|
import { liveProject, projectPaused, stageStandingsByProject } from "../admission/repo.js";
|
|
4
4
|
import { queueEntries } from "../batch/index.js";
|
|
5
|
+
import { sampleIds } from "../onboarding/model.js";
|
|
6
|
+
import { readSampleRecord } from "../onboarding/state.js";
|
|
7
|
+
import { limitWaitsByProject, listingWait } from "../run-cost/panel.js";
|
|
8
|
+
import { uploadedProjects } from "../uploads/repo.js";
|
|
5
9
|
import { nextOccurrence } from "./calendar.js";
|
|
6
10
|
import { scheduleRows } from "./repo.js";
|
|
7
11
|
import { calendarMaxDays, } from "./schema.js";
|
|
@@ -92,13 +96,64 @@ function upcomingRuns(deps, schedule, now, from, to) {
|
|
|
92
96
|
const projectRow = z.object({
|
|
93
97
|
id: z.string(),
|
|
94
98
|
title: z.string(),
|
|
99
|
+
config: z.string(),
|
|
95
100
|
created_at: z.string(),
|
|
96
101
|
finished_at: z.string().nullable(),
|
|
97
102
|
});
|
|
103
|
+
// Projects the person has something to decide on in the current revision: a review
|
|
104
|
+
// checkpoint holding the run, or an automatic review that failed an item and waits for
|
|
105
|
+
// Overrule or Redo (its latest verdict, with no redo under way).
|
|
106
|
+
function awaitingReview(db) {
|
|
107
|
+
const ids = db
|
|
108
|
+
.prepare(`SELECT c.project_id AS id FROM review_checkpoints c
|
|
109
|
+
JOIN project_heads h ON h.project_id=c.project_id AND h.revision_id=c.revision_id
|
|
110
|
+
WHERE c.state IN ('held','pending-review') AND c.approved_at IS NULL
|
|
111
|
+
UNION
|
|
112
|
+
SELECT v.project_id AS id FROM review_verdicts v
|
|
113
|
+
JOIN project_heads h ON h.project_id=v.project_id AND h.revision_id=v.revision_id
|
|
114
|
+
WHERE v.passed=0 AND v.action IS NULL AND (v.redo_state IS NULL OR v.redo_state='failed')
|
|
115
|
+
AND NOT EXISTS (SELECT 1 FROM review_verdicts n WHERE n.project_id=v.project_id
|
|
116
|
+
AND n.item_key=v.item_key AND (n.created_at>v.created_at OR (n.created_at=v.created_at AND n.rowid>v.rowid)))`)
|
|
117
|
+
.all();
|
|
118
|
+
return new Set(ids.map((row) => z.string().parse(row.id)));
|
|
119
|
+
}
|
|
120
|
+
// Whether a finished project's settings make a video: projects stored before sources were
|
|
121
|
+
// recorded made one.
|
|
122
|
+
const videoSource = z.object({ sources: z.object({ video: z.string() }).partial() }).partial();
|
|
123
|
+
function makesVideo(config) {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = videoSource.safeParse(JSON.parse(config));
|
|
126
|
+
return !parsed.success || parsed.data.sources?.video !== "off";
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function sampleProjectIds(db) {
|
|
133
|
+
return new Set(sampleIds.flatMap((id) => {
|
|
134
|
+
const record = readSampleRecord(db, id);
|
|
135
|
+
return record?.projectId === undefined ? [] : [record.projectId];
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
// What a project on the calendar asks of the person, if anything. The same readings Home's
|
|
139
|
+
// Needs you uses: a failed or paused run, or one waiting for a review.
|
|
140
|
+
function needsOf(state, reviewing) {
|
|
141
|
+
if (state === "failed")
|
|
142
|
+
return "failed";
|
|
143
|
+
if (state === "paused")
|
|
144
|
+
return "paused";
|
|
145
|
+
if (reviewing && state !== "canceled")
|
|
146
|
+
return "review";
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
98
149
|
// Running (or waiting) projects made before `to`, and finished ones whose last stage ended in
|
|
99
|
-
// the range.
|
|
150
|
+
// the range, with what each needs from the person and whether its video is ready to upload.
|
|
100
151
|
function projectsIn(deps, from, to) {
|
|
101
152
|
const standings = stageStandingsByProject(deps.db);
|
|
153
|
+
const reviewing = awaitingReview(deps.db);
|
|
154
|
+
const uploads = uploadedProjects(deps.db);
|
|
155
|
+
const samples = sampleProjectIds(deps.db);
|
|
156
|
+
const waits = limitWaitsByProject(deps.db);
|
|
102
157
|
const fromSchedule = new Map();
|
|
103
158
|
for (const row of deps.db
|
|
104
159
|
.prepare(`SELECT DISTINCT admitted.value AS project_id, schedule_runs.schedule_id
|
|
@@ -108,7 +163,7 @@ function projectsIn(deps, from, to) {
|
|
|
108
163
|
fromSchedule.set(parsed.project_id, parsed.schedule_id);
|
|
109
164
|
}
|
|
110
165
|
return deps.db
|
|
111
|
-
.prepare(`SELECT projects.id,projects.title,projects.created_at,max(stages.finished_at) AS finished_at
|
|
166
|
+
.prepare(`SELECT projects.id,projects.title,projects.config,projects.created_at,max(stages.finished_at) AS finished_at
|
|
112
167
|
FROM projects LEFT JOIN stages ON stages.project_id=projects.id
|
|
113
168
|
WHERE projects.created_at<? AND ${liveProject()} GROUP BY projects.id ORDER BY projects.created_at,projects.id`)
|
|
114
169
|
.all(to.toISOString())
|
|
@@ -121,6 +176,12 @@ function projectsIn(deps, from, to) {
|
|
|
121
176
|
if (ended < from.toISOString() || ended >= to.toISOString())
|
|
122
177
|
return [];
|
|
123
178
|
}
|
|
179
|
+
const needs = needsOf(state, reviewing.has(project.id));
|
|
180
|
+
const ready = (state === "done" || state === "partial") &&
|
|
181
|
+
makesVideo(project.config) &&
|
|
182
|
+
!uploads.has(project.id) &&
|
|
183
|
+
!samples.has(project.id);
|
|
184
|
+
const waiting = waits.get(project.id);
|
|
124
185
|
return [
|
|
125
186
|
{
|
|
126
187
|
id: project.id,
|
|
@@ -129,6 +190,9 @@ function projectsIn(deps, from, to) {
|
|
|
129
190
|
createdAt: project.created_at,
|
|
130
191
|
finishedAt: terminal ? (project.finished_at ?? project.created_at) : null,
|
|
131
192
|
scheduleId: fromSchedule.get(project.id) ?? null,
|
|
193
|
+
...(needs === undefined ? {} : { needs }),
|
|
194
|
+
...(ready ? { readyToUpload: true } : {}),
|
|
195
|
+
...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
|
|
132
196
|
},
|
|
133
197
|
];
|
|
134
198
|
});
|
|
@@ -348,7 +348,7 @@ function parseRun(row) {
|
|
|
348
348
|
const internalRunError = "Slopify hit an internal error while starting this run. The next run will try again; if it keeps happening, use Download diagnostics in Settings and report it.";
|
|
349
349
|
const runErrors = {
|
|
350
350
|
"missing-template": "The template this schedule uses was deleted or changed. Edit the schedule and choose a template again.",
|
|
351
|
-
"unsupported-media": "This template uses audio, images, a thumbnail, shorts background music or an ambient sound file you supplied, which scheduled runs cannot use. Pick a template that generates these instead, or on Play remove the music under Outputs →
|
|
351
|
+
"unsupported-media": "This template uses audio, images, a thumbnail, shorts background music or an ambient sound file you supplied, which scheduled runs cannot use. Pick a template that generates these instead, or on Play remove the music under Outputs → More shorts options (or pick Rain, Fireplace or Wind under Video and style → Ambient sound) and save the template again.",
|
|
352
352
|
"spend-limit": "Not started: the estimated cost was above this schedule's spend limit, or some prices were unknown. Raise the spend limit or choose models with known prices.",
|
|
353
353
|
readiness: "Not started: a provider was not ready, for example a missing API key, model or voice. Check Settings → Providers; the next run will try again.",
|
|
354
354
|
"not-found": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
|
|
@@ -194,7 +194,7 @@ async function execute(deps, claimed) {
|
|
|
194
194
|
}
|
|
195
195
|
// One project per run. The first queued topic fills the chosen keyword, the schedule's fixed
|
|
196
196
|
// values fill the rest over the template's own, and the template's title is filled from the
|
|
197
|
-
// same keywords, so "
|
|
197
|
+
// same keywords, so "History: {{Topic}}" names the project after the topic. A topic saved
|
|
198
198
|
// before topics had a keyword brings its own title and values instead.
|
|
199
199
|
function runForm(form, schedule, topic) {
|
|
200
200
|
return {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { projectStates } from "../../kernel/pipeline.js";
|
|
2
|
+
import { projectStates, stageKinds } from "../../kernel/pipeline.js";
|
|
3
3
|
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
4
4
|
import { cadenceSchema, validTimeZone } from "./calendar.js";
|
|
5
5
|
const id = z.uuid();
|
|
6
|
+
// What a project on the calendar waits on the person for: a failed or paused run, or a review
|
|
7
|
+
// (a checkpoint holding the run, or an automatic review's failed item).
|
|
8
|
+
export const calendarNeeds = ["failed", "paused", "review"];
|
|
6
9
|
const keywordName = z
|
|
7
10
|
.string()
|
|
8
11
|
.min(1)
|
|
@@ -148,10 +151,22 @@ export const scheduleRunSchema = z
|
|
|
148
151
|
})
|
|
149
152
|
.strict()
|
|
150
153
|
.readonly();
|
|
154
|
+
// `values` are the keywords the person set on a held topic before approving it; they go with it
|
|
155
|
+
// into the queue. Absent from a reply of a Slopify before they existed: none.
|
|
151
156
|
export const heldTopicSchema = z
|
|
152
|
-
.object({
|
|
157
|
+
.object({
|
|
158
|
+
id,
|
|
159
|
+
title: z.string(),
|
|
160
|
+
values: z.record(z.string(), z.string()).readonly().default({}),
|
|
161
|
+
rank: z.number().int(),
|
|
162
|
+
createdAt: z.string(),
|
|
163
|
+
})
|
|
153
164
|
.strict()
|
|
154
165
|
.readonly();
|
|
166
|
+
// Edit on a held topic: its title, and its keywords when given (replacing the ones it had).
|
|
167
|
+
export const heldTopicEditSchema = z
|
|
168
|
+
.object({ title: z.string(), values: z.record(keywordName, z.string()).optional() })
|
|
169
|
+
.strict();
|
|
155
170
|
// Reordering is by position against the version the person saw, so a run taking the first
|
|
156
171
|
// topic meanwhile makes the move refuse instead of moving the wrong one.
|
|
157
172
|
export const topicMoveSchema = z
|
|
@@ -162,6 +177,15 @@ export const topicMoveSchema = z
|
|
|
162
177
|
})
|
|
163
178
|
.strict()
|
|
164
179
|
.readonly();
|
|
180
|
+
// The whole queue at once: the schedule page adds, renames, removes and reorders topics in
|
|
181
|
+
// place, and Undo puts the queue back as it was.
|
|
182
|
+
export const topicQueueSchema = z
|
|
183
|
+
.object({
|
|
184
|
+
baseVersion: z.number().int().positive(),
|
|
185
|
+
items: z.array(item).max(queueMax).readonly(),
|
|
186
|
+
})
|
|
187
|
+
.strict()
|
|
188
|
+
.readonly();
|
|
165
189
|
// The calendar: what will run, what is running or finished, and what waits in the batch queue.
|
|
166
190
|
// `topicSource` says where a run's topic comes from: the queue (`topic` names it), topics held
|
|
167
191
|
// for approval, a generation still to come, or none (the template as saved).
|
|
@@ -194,6 +218,23 @@ export const calendarProjectSchema = z
|
|
|
194
218
|
createdAt: z.string(),
|
|
195
219
|
finishedAt: z.string().nullable(),
|
|
196
220
|
scheduleId: z.string().nullable(),
|
|
221
|
+
// What the project waits on the person for; left out when nothing.
|
|
222
|
+
needs: z.enum(calendarNeeds).optional(),
|
|
223
|
+
// Finished, makes a video, not marked uploaded and not a bundled sample.
|
|
224
|
+
readyToUpload: z.literal(true).optional(),
|
|
225
|
+
// Stages waiting for a CLI plan's limits to reset.
|
|
226
|
+
limitWaits: z
|
|
227
|
+
.array(z
|
|
228
|
+
.object({
|
|
229
|
+
name: z.string(),
|
|
230
|
+
stage: z.enum(stageKinds),
|
|
231
|
+
resetsAt: z.string().nullable(),
|
|
232
|
+
retryAt: z.string(),
|
|
233
|
+
})
|
|
234
|
+
.strict()
|
|
235
|
+
.readonly())
|
|
236
|
+
.readonly()
|
|
237
|
+
.optional(),
|
|
197
238
|
})
|
|
198
239
|
.strict()
|
|
199
240
|
.readonly();
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
// - the texts are equal, or
|
|
6
6
|
// - their word sets overlap by at least `jaccardMin` (shared words / all words), or
|
|
7
7
|
// - one's words all appear in the other's and cover at least `containmentMin` of it
|
|
8
|
-
// ("
|
|
8
|
+
// ("Cleopatra" and "Cleopatra's Palace" are one video; "Red Pyramids" and "Bent Pyramids" are two).
|
|
9
9
|
// Words are compared without filler words ("the", "of", …) and without a plural "s".
|
|
10
10
|
// A project title is compared more loosely: every word of the topic appearing in it is enough,
|
|
11
|
-
// because the title wraps the topic in the template's own words ("
|
|
11
|
+
// because the title wraps the topic in the template's own words ("History: Cleopatra"). A topic
|
|
12
|
+
// of one or two words is too easily found inside an unrelated title ("Pyramids" in "The Great
|
|
13
|
+
// Pyramids of Giza"), so one of the title's clauses must hold exactly its words, leaving aside
|
|
14
|
+
// question and framing words ("Hypatia" in "Who was Hypatia? The Last Scholar Explained").
|
|
12
15
|
export const jaccardMin = 0.6;
|
|
13
16
|
export const containmentMin = 0.5;
|
|
14
17
|
const filler = new Set([
|
|
@@ -65,15 +68,50 @@ export function similarTopics(a, b) {
|
|
|
65
68
|
const large = Math.max(wa.size, wb.size);
|
|
66
69
|
return common === small && small / large >= containmentMin;
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
export const shortTopicWords = 2;
|
|
72
|
+
// A topic already made as a project: all of its words appear in the project's title, or for a
|
|
73
|
+
// short topic, the title or one of its parts is near the same.
|
|
69
74
|
export function topicInTitle(topic, title) {
|
|
70
75
|
if (similarTopics(topic, title))
|
|
71
76
|
return true;
|
|
72
77
|
const words = topicWords(topic);
|
|
73
78
|
if (words.size === 0)
|
|
74
79
|
return false;
|
|
80
|
+
if (words.size <= shortTopicWords) {
|
|
81
|
+
const wanted = subjectWords(topic);
|
|
82
|
+
return titleClauses(title).some((clause) => sameWords(subjectWords(clause), wanted));
|
|
83
|
+
}
|
|
75
84
|
return shared(words, topicWords(title)) === words.size;
|
|
76
85
|
}
|
|
86
|
+
// Words that frame a subject in a title without being part of it.
|
|
87
|
+
const framing = new Set([
|
|
88
|
+
"who",
|
|
89
|
+
"what",
|
|
90
|
+
"why",
|
|
91
|
+
"how",
|
|
92
|
+
"when",
|
|
93
|
+
"where",
|
|
94
|
+
"which",
|
|
95
|
+
"is",
|
|
96
|
+
"was",
|
|
97
|
+
"are",
|
|
98
|
+
"were",
|
|
99
|
+
"really",
|
|
100
|
+
"explained",
|
|
101
|
+
]);
|
|
102
|
+
function subjectWords(text) {
|
|
103
|
+
return new Set([...topicWords(text)].filter((word) => !framing.has(word)));
|
|
104
|
+
}
|
|
105
|
+
function sameWords(a, b) {
|
|
106
|
+
return a.size > 0 && a.size === b.size && shared(a, b) === a.size;
|
|
107
|
+
}
|
|
108
|
+
// "History: Cleopatra (Part 2) | Who is Hypatia?" → ["History", "Cleopatra", "Part 2", "Who is Hypatia"].
|
|
109
|
+
function titleClauses(title) {
|
|
110
|
+
return title
|
|
111
|
+
.split(/\s*[:|()[\]?!.,;\u2013\u2014]\s*|\s+-\s+/u)
|
|
112
|
+
.map((part) => part.trim())
|
|
113
|
+
.filter((part) => part !== "");
|
|
114
|
+
}
|
|
77
115
|
// Keeps the candidates that are new, in their order, dropping repeats among themselves too.
|
|
78
116
|
export function newTopics(candidates, known) {
|
|
79
117
|
const kept = [];
|
|
@@ -105,7 +105,7 @@ export function parseTopicList(text, options) {
|
|
|
105
105
|
return {
|
|
106
106
|
ok: false,
|
|
107
107
|
problems: [
|
|
108
|
-
`The list couldn't be read as YAML or JSON: ${message ?? ""}. Write one item per topic, such as "- Topic:
|
|
108
|
+
`The list couldn't be read as YAML or JSON: ${message ?? ""}. Write one item per topic, such as "- Topic: Cleopatra".`,
|
|
109
109
|
],
|
|
110
110
|
};
|
|
111
111
|
}
|
|
@@ -125,7 +125,7 @@ export function parseTopicList(text, options) {
|
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
127
|
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
128
|
-
problems.push(`${name} is not a topic or a set of keywords. Write it as "-
|
|
128
|
+
problems.push(`${name} is not a topic or a set of keywords. Write it as "- Cleopatra" or "- Topic: Cleopatra".`);
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
131
|
let title = "";
|