@gentbajko/slopify 3.0.7 → 3.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/edge/autostart/native.js +12 -3
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0042-narration-retries.sql +13 -0
- package/dist/main.js +9 -1
- package/dist/patch-notes/3.0.8.md +23 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/rebuild/narration-retry.js +128 -0
- package/dist/slices/rebuild/recovery-selection.js +7 -0
- package/dist/slices/rebuild/runtime-subtitles.js +53 -11
- package/dist/slices/storage/backup-format.js +1 -0
- package/package.json +1 -1
|
@@ -77,6 +77,10 @@ export function createNativeAutostart(o) {
|
|
|
77
77
|
const ours = text.includes(platform === "linux" ? desktopMarker : launcherMarker);
|
|
78
78
|
return { state: ours ? "ours" : "foreign", text };
|
|
79
79
|
}
|
|
80
|
+
const thisLauncher = launcherRef(platform, at.launcher);
|
|
81
|
+
function runsThisFolder(now) {
|
|
82
|
+
return now.state === "ours" && (now.text?.includes(thisLauncher) ?? false);
|
|
83
|
+
}
|
|
80
84
|
function state(enabled) {
|
|
81
85
|
return {
|
|
82
86
|
available: true,
|
|
@@ -113,7 +117,9 @@ export function createNativeAutostart(o) {
|
|
|
113
117
|
await o.fs.write(at.entry, entryText, 0o644);
|
|
114
118
|
}
|
|
115
119
|
return {
|
|
116
|
-
|
|
120
|
+
// On only when the entry starts this data folder: a second Slopify (another --data-dir,
|
|
121
|
+
// a test copy) sees the switch off, so turning it off there can't remove this one's entry.
|
|
122
|
+
status: async () => state(runsThisFolder(await entryNow())),
|
|
117
123
|
enable: async () => {
|
|
118
124
|
if (platform === "win32") {
|
|
119
125
|
const problem = windowsPathProblem(o.dataDir);
|
|
@@ -121,6 +127,8 @@ export function createNativeAutostart(o) {
|
|
|
121
127
|
throw new Error(problem);
|
|
122
128
|
}
|
|
123
129
|
const now = await entryNow();
|
|
130
|
+
if (now.state === "ours" && !runsThisFolder(now))
|
|
131
|
+
throw new Error(`Another Slopify, with a different data folder, already starts when you log in (${at.where}). Slopify left it alone. Turn it off in that Slopify's Settings → General, or delete ${platform === "win32" ? `the ${runValue} entry in Task Manager → Startup apps` : "that file"}, then turn the switch on here again.`);
|
|
124
132
|
if (now.state === "foreign")
|
|
125
133
|
throw new Error(platform === "win32"
|
|
126
134
|
? `Your start-up list already has an entry named ${runValue} that Slopify didn't make (${runKey}). Slopify left it alone. Remove it in Task Manager → Startup apps, or rename it, then turn the switch on again.`
|
|
@@ -131,7 +139,8 @@ export function createNativeAutostart(o) {
|
|
|
131
139
|
},
|
|
132
140
|
disable: async () => {
|
|
133
141
|
const now = await entryNow();
|
|
134
|
-
|
|
142
|
+
// Only this data folder's entry: another Slopify's is not this switch's to remove.
|
|
143
|
+
if (runsThisFolder(now)) {
|
|
135
144
|
if (platform === "win32") {
|
|
136
145
|
const result = await o.exec("reg", ["delete", runKey, "/v", runValue, "/f"]);
|
|
137
146
|
if (result.code !== 0)
|
|
@@ -151,7 +160,7 @@ export function createNativeAutostart(o) {
|
|
|
151
160
|
const now = await entryNow();
|
|
152
161
|
// Only the entry that runs this data folder's launcher: a Slopify started with another
|
|
153
162
|
// --data-dir must not take over the one the switch was turned on from.
|
|
154
|
-
if (
|
|
163
|
+
if (!runsThisFolder(now))
|
|
155
164
|
return;
|
|
156
165
|
await writeLauncher();
|
|
157
166
|
await writeEntry(now.text);
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- A narration chunk whose audio stopped matching its text is recorded again by itself: the
|
|
2
|
+
-- caption step asks for it here, and the retry starts once that step's failure is saved. The
|
|
3
|
+
-- count caps it at two tries per chunk; `state` is what the last try did.
|
|
4
|
+
CREATE TABLE narration_retries (
|
|
5
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
6
|
+
chunk_key TEXT NOT NULL,
|
|
7
|
+
tries INTEGER NOT NULL CHECK (tries > 0),
|
|
8
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'failed')),
|
|
9
|
+
detail TEXT,
|
|
10
|
+
updated_at TEXT NOT NULL,
|
|
11
|
+
PRIMARY KEY(project_id, chunk_key)
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX narration_retries_pending ON narration_retries(state) WHERE state = 'pending';
|
package/dist/main.js
CHANGED
|
@@ -55,6 +55,7 @@ import { createNotificationSender } from "./slices/notifications/send.js";
|
|
|
55
55
|
import { readNotificationUrl } from "./slices/notifications/settings.js";
|
|
56
56
|
import { seedSamples } from "./slices/onboarding/sample.js";
|
|
57
57
|
import { templateById } from "./slices/project-templates/repo.js";
|
|
58
|
+
import { createNarrationRetries } from "./slices/rebuild/narration-retry.js";
|
|
58
59
|
import { recoverProject } from "./slices/rebuild/recovery.js";
|
|
59
60
|
import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
|
|
60
61
|
import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
|
|
@@ -261,8 +262,12 @@ export async function boot(config, options = {}) {
|
|
|
261
262
|
});
|
|
262
263
|
const audioPreviews = createAudioPreviewStore();
|
|
263
264
|
const reviewRedos = createReviewRedos();
|
|
265
|
+
const narrationRetries = createNarrationRetries();
|
|
264
266
|
const runner = wireRunner({
|
|
265
|
-
onFinished: (work) =>
|
|
267
|
+
onFinished: (work) => {
|
|
268
|
+
reviewRedos.kick(work.projectId);
|
|
269
|
+
narrationRetries.kick(work.projectId);
|
|
270
|
+
},
|
|
266
271
|
db,
|
|
267
272
|
paths,
|
|
268
273
|
clock,
|
|
@@ -386,6 +391,9 @@ export async function boot(config, options = {}) {
|
|
|
386
391
|
// Redos a review asked for before the last shutdown start now.
|
|
387
392
|
reviewRedos.bind(rebuild);
|
|
388
393
|
reviewRedos.kick();
|
|
394
|
+
// So do narration chunks the captions asked to record again.
|
|
395
|
+
narrationRetries.bind(rebuild);
|
|
396
|
+
narrationRetries.kick();
|
|
389
397
|
// A stage that was waiting for a CLI's plan limits when the app stopped carries on waiting.
|
|
390
398
|
void resumeAfterRestart(db, async (projectId) => {
|
|
391
399
|
const baseRevisionId = currentRevisionId(runtimeDb, projectId);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Slopify 3.0.8
|
|
2
|
+
|
|
3
|
+
Released 29 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.8 fixes a garbled narration sentence by itself instead of stopping the run.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Garbled narration is recorded again by itself.** A run no longer stops for a sentence the voice mumbled or skipped.
|
|
10
|
+
|
|
11
|
+
## Narration
|
|
12
|
+
|
|
13
|
+
- When captions find the narration no longer matches the text (the voice garbled or skipped a sentence), Slopify records that narration chunk again by itself, rebuilds the narration, captions and video, and carries on. It used to stop the run until you pressed Regenerate on the chunk in Edit project → Narration and continued.
|
|
14
|
+
- Each chunk gets two tries. The step says which try is running; if the audio still doesn't match after both, the voice reads something its own way (a year, an abbreviation or a name), and the step says to reword that sentence in Edit project → Narration.
|
|
15
|
+
- Uploaded narration, captions you edited by hand and multi-voice turns aren't retried: they stop and ask, as before.
|
|
16
|
+
|
|
17
|
+
## Everywhere
|
|
18
|
+
|
|
19
|
+
- **Start Slopify when I log in** belongs to one data folder. A second Slopify on the same account (another `--data-dir`) sees the switch off and can no longer remove or take over the first one's login entry.
|
|
20
|
+
|
|
21
|
+
## Upgrading from 3.0.7
|
|
22
|
+
|
|
23
|
+
- **Nothing becomes outdated.** Retries start with the next run that finds a mismatch.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
4
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
5
|
+
import { recoverProject } from "./recovery.js";
|
|
6
|
+
// How many times one narration chunk is recorded again by itself. A voice that garbles a
|
|
7
|
+
// sentence rarely does it twice; one that reads a name its own way does it every time, so
|
|
8
|
+
// after this the run stops and asks the person.
|
|
9
|
+
export const narrationRetryLimit = 2;
|
|
10
|
+
const row = z.object({
|
|
11
|
+
project_id: z.string(),
|
|
12
|
+
chunk_key: z.string(),
|
|
13
|
+
tries: z.number(),
|
|
14
|
+
state: z.enum(["pending", "started", "failed"]),
|
|
15
|
+
});
|
|
16
|
+
// Asked by the caption step when the audio stopped matching the text in a chunk. Returns the
|
|
17
|
+
// try this is (1 or 2), or undefined once the chunk has had its tries.
|
|
18
|
+
export function requestNarrationRetry(db, input) {
|
|
19
|
+
return transact(db, () => {
|
|
20
|
+
const saved = db
|
|
21
|
+
.prepare("SELECT tries FROM narration_retries WHERE project_id=? AND chunk_key=?")
|
|
22
|
+
.get(input.projectId, input.chunkKey);
|
|
23
|
+
const tries = saved === undefined ? 0 : z.number().parse(saved.tries);
|
|
24
|
+
if (tries >= narrationRetryLimit)
|
|
25
|
+
return undefined;
|
|
26
|
+
db.prepare(`INSERT INTO narration_retries(project_id, chunk_key, tries, state, detail, updated_at)
|
|
27
|
+
VALUES (?, ?, 1, 'pending', NULL, ?)
|
|
28
|
+
ON CONFLICT(project_id, chunk_key)
|
|
29
|
+
DO UPDATE SET tries=tries+1, state='pending', detail=NULL, updated_at=excluded.updated_at`).run(input.projectId, input.chunkKey, input.now);
|
|
30
|
+
return tries + 1;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function pendingNarrationRetries(db, projectId) {
|
|
34
|
+
const rows = projectId === undefined
|
|
35
|
+
? db.prepare("SELECT * FROM narration_retries WHERE state='pending'").all()
|
|
36
|
+
: db
|
|
37
|
+
.prepare("SELECT * FROM narration_retries WHERE state='pending' AND project_id=?")
|
|
38
|
+
.all(projectId);
|
|
39
|
+
return rows.map((value) => {
|
|
40
|
+
const parsed = row.parse(value);
|
|
41
|
+
return { projectId: parsed.project_id, chunkKey: parsed.chunk_key, tries: parsed.tries };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function settle(db, retry, state, detail, now) {
|
|
45
|
+
db.prepare("UPDATE narration_retries SET state=?, detail=?, updated_at=? WHERE project_id=? AND chunk_key=? AND tries=?").run(state, detail, now, retry.projectId, retry.chunkKey, retry.tries);
|
|
46
|
+
}
|
|
47
|
+
// Starts the retries the caption step asked for, once its failure is saved (the runner's
|
|
48
|
+
// `onFinished`). The chunk is made again through Redo's path: a new regeneration token saved as
|
|
49
|
+
// a new project version, then the chunk and everything built on it (the joined narration,
|
|
50
|
+
// the captions, the video) rebuilt as ordinary work.
|
|
51
|
+
export function createNarrationRetries() {
|
|
52
|
+
let bound;
|
|
53
|
+
const running = new Set();
|
|
54
|
+
const kick = (projectId) => {
|
|
55
|
+
const deps = bound;
|
|
56
|
+
if (deps === undefined)
|
|
57
|
+
return;
|
|
58
|
+
for (const retry of pendingNarrationRetries(deps.db, projectId)) {
|
|
59
|
+
const id = `${retry.projectId}\n${retry.chunkKey}`;
|
|
60
|
+
if (running.has(id))
|
|
61
|
+
continue;
|
|
62
|
+
running.add(id);
|
|
63
|
+
void recordAgain(deps, retry)
|
|
64
|
+
.catch((error) => {
|
|
65
|
+
deps.log.write("error", "narration.retry", {
|
|
66
|
+
projectId: retry.projectId,
|
|
67
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
settle(deps.db, retry, "failed", "internal error", deps.clock.now().toISOString());
|
|
70
|
+
})
|
|
71
|
+
.finally(() => {
|
|
72
|
+
running.delete(id);
|
|
73
|
+
try {
|
|
74
|
+
deps.runner.tick(retry.projectId);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// The next tick of this project picks the released work up.
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
bind: (deps) => {
|
|
84
|
+
bound = deps;
|
|
85
|
+
},
|
|
86
|
+
kick,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function recordAgain(deps, retry) {
|
|
90
|
+
const base = currentRevisionId(deps.db, retry.projectId);
|
|
91
|
+
if (base === undefined) {
|
|
92
|
+
settle(deps.db, retry, "failed", "no project", deps.clock.now().toISOString());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const result = await recoverProject(deps, retry.projectId, {
|
|
96
|
+
baseRevisionId: base,
|
|
97
|
+
idempotencyKey: retryKey(retry),
|
|
98
|
+
action: { kind: "redo", item: retry.chunkKey },
|
|
99
|
+
}, { pendingSuperseded: true });
|
|
100
|
+
const now = deps.clock.now().toISOString();
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
settle(deps.db, retry, "started", null, now);
|
|
103
|
+
deps.log.write("info", "narration.retry", {
|
|
104
|
+
projectId: retry.projectId,
|
|
105
|
+
detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
settle(deps.db, retry, "failed", refusal(result), now);
|
|
110
|
+
deps.log.write("warn", "narration.retry", {
|
|
111
|
+
projectId: retry.projectId,
|
|
112
|
+
detail: `Could not record ${retry.chunkKey} again: ${refusal(result)}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
deps.emit(retry.projectId, { type: "project.updated", projectId: retry.projectId });
|
|
116
|
+
}
|
|
117
|
+
function refusal(result) {
|
|
118
|
+
const detail = result.fields?.map((field) => field.message).join(" ");
|
|
119
|
+
return detail ? `${result.reason}: ${detail}` : result.reason;
|
|
120
|
+
}
|
|
121
|
+
// One request identity per try, so a restart that starts the same try again gets the first
|
|
122
|
+
// start's answer instead of a second recording.
|
|
123
|
+
export function retryKey(retry) {
|
|
124
|
+
const hex = createHash("sha256")
|
|
125
|
+
.update(`narration-retry:${retry.projectId}:${retry.chunkKey}:${String(retry.tries)}`)
|
|
126
|
+
.digest("hex");
|
|
127
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
128
|
+
}
|
|
@@ -44,6 +44,13 @@ export function redoTarget(view, plan, item) {
|
|
|
44
44
|
return plan.recipes.some((row) => row.key === `${item}:prompts`)
|
|
45
45
|
? own([`${item}:prompts`])
|
|
46
46
|
: undefined;
|
|
47
|
+
// One narration chunk, by the key the Narration editor regenerates it by: its TTS requests
|
|
48
|
+
// are the roots (`narration-retry.ts`).
|
|
49
|
+
const chunk = plan.recipes
|
|
50
|
+
.filter((row) => row.input.kind === "tts" && row.input.logicalKey === item)
|
|
51
|
+
.map((row) => row.key);
|
|
52
|
+
if (chunk.length > 0)
|
|
53
|
+
return own(chunk);
|
|
47
54
|
return plan.recipes.some((row) => row.key === item && row.kind === "provider")
|
|
48
55
|
? own([item])
|
|
49
56
|
: undefined;
|
|
@@ -16,6 +16,7 @@ import { speakerColour } from "../voices/palette.js";
|
|
|
16
16
|
import { speakerPanelEvents, usesSpeakerPanel } from "../voices/panel.js";
|
|
17
17
|
import { panelPortraits } from "../voices/portraits.js";
|
|
18
18
|
import { attributeWords } from "../voices/timing.js";
|
|
19
|
+
import { narrationRetryLimit, requestNarrationRetry } from "./narration-retry.js";
|
|
19
20
|
import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, revisionTurns, } from "./runtime-export-inputs.js";
|
|
20
21
|
import { narrationChunks, narrationTextParts, } from "./runtime-narration-text.js";
|
|
21
22
|
import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
|
|
@@ -77,7 +78,7 @@ async function timing(deps, context, piece, snapshot) {
|
|
|
77
78
|
context.signal.throwIfAborted();
|
|
78
79
|
const { path, kind } = segment;
|
|
79
80
|
if (path !== null && spoken(kind)) {
|
|
80
|
-
const aligned = await located(snapshot, kind, () => alignSubtitles({
|
|
81
|
+
const aligned = await located(deps, context, snapshot, kind, () => alignSubtitles({
|
|
81
82
|
audioPath: path,
|
|
82
83
|
text: revisionTranscript(deps, snapshot, kind),
|
|
83
84
|
aliases: narrationAliasesOf(snapshot.view.revision.config),
|
|
@@ -250,45 +251,86 @@ async function files(deps, context, piece, snapshot) {
|
|
|
250
251
|
}
|
|
251
252
|
// A mismatch names the place to fix: the time in the narration, the chunk that holds it (as
|
|
252
253
|
// the Narration editor numbers them) and what was expected against what was heard. The
|
|
253
|
-
// usual cause is a TTS request that skipped or reworded a sentence
|
|
254
|
-
|
|
254
|
+
// usual cause is a TTS request that skipped or reworded a sentence, which a second request
|
|
255
|
+
// almost always reads right, so a chunk made by a voice is first recorded again by itself
|
|
256
|
+
// (`narration-retry.ts`); the message says so, and says when its tries are used up.
|
|
257
|
+
async function located(deps, context, snapshot, segment, align) {
|
|
255
258
|
try {
|
|
256
259
|
return await align();
|
|
257
260
|
}
|
|
258
261
|
catch (error) {
|
|
259
262
|
if (!(error instanceof SubtitleMismatch))
|
|
260
263
|
throw error;
|
|
261
|
-
|
|
264
|
+
const chunks = mismatchChunks(snapshot, segment);
|
|
265
|
+
const at = mismatchChunk(chunks, error);
|
|
266
|
+
const chunk = at === -1 ? undefined : chunks[at];
|
|
267
|
+
const retry = chunk !== undefined && retryable(snapshot, chunk.key)
|
|
268
|
+
? {
|
|
269
|
+
chunk: at + 1,
|
|
270
|
+
try: requestNarrationRetry(deps.db, {
|
|
271
|
+
projectId: context.work.projectId,
|
|
272
|
+
chunkKey: chunk.key,
|
|
273
|
+
now: deps.clock.now().toISOString(),
|
|
274
|
+
}),
|
|
275
|
+
}
|
|
276
|
+
: undefined;
|
|
277
|
+
throw new Error(describeMismatch(chunks, segment, error, retry), { cause: error });
|
|
262
278
|
}
|
|
263
279
|
}
|
|
264
|
-
|
|
265
|
-
|
|
280
|
+
// A chunk a voice made from the text: not uploaded audio, not captions edited by hand (their
|
|
281
|
+
// remake waits for the person) and not a multi-voice turn, whose key the regeneration list
|
|
282
|
+
// can't hold yet.
|
|
283
|
+
function retryable(snapshot, key) {
|
|
284
|
+
if (snapshot.view.revision.content.subtitleCues !== undefined)
|
|
285
|
+
return false;
|
|
286
|
+
if (/:turn:\d+$/.test(key))
|
|
287
|
+
return false;
|
|
288
|
+
return snapshot.plan.recipes.some((row) => row.input.kind === "tts" && row.input.logicalKey === key);
|
|
289
|
+
}
|
|
290
|
+
function mismatchChunks(snapshot, segment) {
|
|
266
291
|
try {
|
|
267
|
-
|
|
292
|
+
return narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
|
|
268
293
|
}
|
|
269
294
|
catch {
|
|
270
295
|
// Without a readable plan the message still gives the time and the words.
|
|
296
|
+
return [];
|
|
271
297
|
}
|
|
272
|
-
return describeMismatch(chunks, segment, mismatch);
|
|
273
298
|
}
|
|
274
|
-
export function
|
|
299
|
+
export function mismatchMessage(snapshot, segment, mismatch) {
|
|
300
|
+
return describeMismatch(mismatchChunks(snapshot, segment), segment, mismatch);
|
|
301
|
+
}
|
|
302
|
+
// The chunk that holds the words the audio stopped matching, by index; -1 when none does.
|
|
303
|
+
export function mismatchChunk(chunks, mismatch) {
|
|
275
304
|
const probe = comparable(mismatch.expected).split(" ").slice(0, 6).join(" ");
|
|
276
|
-
|
|
305
|
+
return probe === ""
|
|
277
306
|
? chunks.length - 1
|
|
278
307
|
: chunks.findIndex((chunk) => comparable(chunk.spokenText).includes(probe));
|
|
308
|
+
}
|
|
309
|
+
export function describeMismatch(chunks, segment, mismatch,
|
|
310
|
+
// Set when the chunk is one a voice made: `try` is the automatic try just asked for, or
|
|
311
|
+
// undefined once its tries are used up.
|
|
312
|
+
retry) {
|
|
313
|
+
const at = mismatchChunk(chunks, mismatch);
|
|
279
314
|
const chunk = at === -1 ? undefined : chunks[at];
|
|
280
315
|
const where = chunk === undefined
|
|
281
316
|
? `${clock(mismatch.at)} into the ${segment} narration`
|
|
282
317
|
: `${clock(mismatch.at)} into the ${segment} narration, in narration chunk ${String(at + 1)} of ${String(chunks.length)} (it starts "${opening(chunk.spokenText)}")`;
|
|
283
318
|
const expected = mismatch.expected === "" ? "the end of the text" : `"${mismatch.expected}…"`;
|
|
284
319
|
const heard = mismatch.heard === "" ? "no more speech" : `"${mismatch.heard.toLowerCase()}…"`;
|
|
320
|
+
const stopped = `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard}`;
|
|
321
|
+
if (retry !== undefined) {
|
|
322
|
+
const n = String(retry.chunk);
|
|
323
|
+
return retry.try === undefined
|
|
324
|
+
? `${stopped} Slopify already recorded narration chunk ${n} again ${String(narrationRetryLimit)} times and the audio still doesn't match there, so the voice probably reads something its own way (a year, an abbreviation or a name). Listen at ${clock(mismatch.at)}: if words are missing or wrong, reword that sentence in Edit project → Narration → narration chunk ${n}, save, then Continue the run. If it says them right, use Download diagnostics in Settings and report it.`
|
|
325
|
+
: `${stopped} The voice probably skipped or garbled words there, so Slopify is recording narration chunk ${n} again by itself (try ${String(retry.try)} of ${String(narrationRetryLimit)}) and carries on with the video when it's done. If this message is still here in a few minutes, regenerate narration chunk ${n} in Edit project → Narration, then Continue the run.`;
|
|
326
|
+
}
|
|
285
327
|
const fix = chunk === undefined
|
|
286
328
|
? `Listen at ${clock(mismatch.at)}: if words are missing or wrong, regenerate that part in Edit project → Narration, then Continue the run.`
|
|
287
329
|
: `Listen at ${clock(mismatch.at)}: if words are missing or wrong, regenerate narration chunk ${String(at + 1)} in Edit project → Narration, then Continue the run.`;
|
|
288
330
|
// A voice that reads a year, an abbreviation or a name its own way reads it the same way
|
|
289
331
|
// again, so a remake would cost a chunk and fail here once more.
|
|
290
332
|
const same = "If it says them right, only another way (a year, an abbreviation or a name read its own way), regenerating gives the same reading: use Download diagnostics in Settings and report it.";
|
|
291
|
-
return
|
|
333
|
+
return `${stopped} The recording there probably skips or changes words. ${fix} ${same}`;
|
|
292
334
|
}
|
|
293
335
|
function comparable(text) {
|
|
294
336
|
return text
|
|
@@ -57,6 +57,7 @@ export const projectTables = [
|
|
|
57
57
|
"project_uploads",
|
|
58
58
|
// Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
|
|
59
59
|
// current wait, a lease of this install) and prompt_softening (a pending one-off request).
|
|
60
|
+
// Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
|
|
60
61
|
"review_verdicts",
|
|
61
62
|
"provider_usage",
|
|
62
63
|
"plan_limit_readings",
|