Files
DemonGatanjieuandClaude Opus 5.5 0a315bf1f8 Voice-over: a drafts box
Every script is a draft, saved on its own in ComfyUI's user folder
(anomalous_model_browser/voice_over_drafts.json through /userdata), read
again and joined before each write so two tabs keep each other's drafts.
A bar over the script names it and leads to New, Drafts and Close; the box
shows a card per draft (open, rename, copy, delete) and Recently deleted
(30 days). A reload opens the draft that was open; a file voiced again
from the gallery becomes a draft of its own; the one localStorage draft
of the earlier build moves in once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:01:26 +08:00

211 lines
8.5 KiB
JavaScript

/**
* The voice-over drafts box, without DOM or storage: the shape of a kept script, the box that
* holds them (one file in ComfyUI's user folder, ui_script_drafts.js), and how two copies of
* it come together when another tab saved meanwhile.
*
* A box: `{ format: 1, current: id | null, drafts: [{ id, name, created, updated, deleted, script }] }`.
* `script` is what the director keeps of a script: `{ format: 1, lines, groupKey, script, cast }`
* (lines: `{ text, emotion, speaker, tune? }`; script and cast: the whole script's and each
* character's settings, script_tune.js). `deleted` is when it went to Recently deleted (or null);
* it comes back from there until DELETED_DAYS have passed.
*/
export const BOX_FORMAT = 1;
export const DELETED_DAYS = 30;
const DAY = 24 * 60 * 60 * 1000;
const NAME_MAX = 60;
const TITLE_FROM_LINE = 18;
const TUNE_FIELDS = { language: 'string', speed: 'number', preset: 'string' };
/** Only the line settings a draft may hold, with the right types (null when none). */
export function cleanTune(tune) {
const out = {};
for (const [field, type] of Object.entries(TUNE_FIELDS)) if (typeof tune?.[field] === type) out[field] = tune[field];
return Object.keys(out).length ? out : null;
}
/** A kept script as the director can use it: lines without text or with a bad shape are left out. */
export function cleanScript(raw) {
if (raw?.format !== 1 || !Array.isArray(raw.lines)) return null;
const lines = raw.lines.filter(line => typeof line?.text === 'string' && line.text.trim()).map((line) => {
const tune = cleanTune(line.tune);
return {
text: line.text,
emotion: typeof line.emotion === 'string' ? line.emotion : 'main',
speaker: typeof line.speaker === 'string' ? line.speaker : null,
...(tune ? { tune } : {}),
};
});
const cast = {};
for (const [character, own] of Object.entries(raw.cast && typeof raw.cast === 'object' ? raw.cast : {})) {
if (cleanTune(own)) cast[character] = cleanTune(own);
}
return {
format: 1,
lines,
groupKey: typeof raw.groupKey === 'string' ? raw.groupKey : null,
script: cleanTune(raw.script) || {},
cast,
};
}
/** A script worth keeping: at least one line with text. */
export function hasLines(script) {
return Boolean(script?.lines?.some(line => line.text.trim()));
}
export function emptyBox() {
return { format: BOX_FORMAT, current: null, drafts: [] };
}
/** The box read from its file (anything unreadable gives an empty box; bad drafts are left out). */
export function cleanBox(raw) {
if (raw?.format !== BOX_FORMAT || !Array.isArray(raw.drafts)) return emptyBox();
const seen = new Set();
const drafts = [];
for (const item of raw.drafts) {
const script = cleanScript(item?.script);
if (typeof item?.id !== 'string' || !item.id || seen.has(item.id) || !script) continue;
seen.add(item.id);
const updated = Number.isFinite(item.updated) ? item.updated : 0;
drafts.push({
id: item.id,
name: typeof item.name === 'string' ? item.name.slice(0, NAME_MAX) : '',
created: Number.isFinite(item.created) ? item.created : updated,
updated,
deleted: Number.isFinite(item.deleted) ? item.deleted : null,
script,
});
}
const current = drafts.some(draft => draft.id === raw.current && !draft.deleted) ? raw.current : null;
return { format: BOX_FORMAT, current, drafts };
}
export function newId(now = Date.now(), random = Math.random) {
return `${now.toString(36)}-${Math.floor(random() * 36 ** 6).toString(36).padStart(6, '0')}`;
}
/** A new draft holding `script`. */
export function makeDraft(script, now = Date.now(), { name = '', id = newId(now) } = {}) {
return { id, name: String(name).trim().slice(0, NAME_MAX), created: now, updated: now, deleted: null, script };
}
export const findDraft = (box, id) => box.drafts.find(draft => draft.id === id) || null;
/** Puts `draft` in the box (in place of the one with its id). */
export function putDraft(box, draft) {
const at = box.drafts.findIndex(item => item.id === draft.id);
if (at >= 0) box.drafts[at] = draft;
else box.drafts.push(draft);
return draft;
}
/** The script of draft `id` changed. */
export function keepScript(box, id, script, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.script = script;
draft.updated = now;
return draft;
}
export function renameDraft(box, id, name, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.name = String(name || '').trim().slice(0, NAME_MAX);
draft.updated = now;
return draft;
}
/** A copy of draft `id`, named after it. */
export function copyDraft(box, id, copyName, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
return putDraft(box, makeDraft(structuredClone(draft.script), now, { name: copyName }));
}
/** To Recently deleted (it stops being the open one). */
export function deleteDraft(box, id, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.deleted = now;
draft.updated = now;
if (box.current === id) box.current = null;
return draft;
}
export function restoreDraft(box, id, now = Date.now()) {
const draft = findDraft(box, id);
if (!draft) return null;
draft.deleted = null;
draft.updated = now;
return draft;
}
/** Drops the drafts deleted more than DELETED_DAYS ago; true when any went. */
export function dropOldDeleted(box, now = Date.now()) {
const before = box.drafts.length;
box.drafts = box.drafts.filter(draft => !draft.deleted || now - draft.deleted < DELETED_DAYS * DAY);
return box.drafts.length !== before;
}
/** The drafts to show, the last changed first. */
export function liveDrafts(box) {
return box.drafts.filter(draft => !draft.deleted).sort((a, b) => b.updated - a.updated);
}
export function deletedDrafts(box) {
return box.drafts.filter(draft => draft.deleted).sort((a, b) => b.deleted - a.deleted);
}
/**
* This tab's box and the one in the file, together: each draft as it was changed last
* (a deletion is a change). The draft open here stays as it is here.
*/
export function mergeBoxes(mine, theirs) {
const byId = new Map(theirs.drafts.map(draft => [draft.id, draft]));
for (const draft of mine.drafts) {
const other = byId.get(draft.id);
if (!other || draft.updated >= other.updated || draft.id === mine.current) byId.set(draft.id, draft);
}
const drafts = [...byId.values()];
const current = drafts.some(draft => draft.id === mine.current && !draft.deleted) ? mine.current : null;
return { format: BOX_FORMAT, current, drafts };
}
/** What a draft is called: its name, else its first line (shortened). '' when it has neither. */
export function draftTitle(draft) {
if (draft?.name) return draft.name;
const first = draft?.script?.lines?.find(line => line.text.trim())?.text.trim() || '';
return first.length > TITLE_FROM_LINE ? `${first.slice(0, TITLE_FROM_LINE)}…` : first;
}
/** Who reads the draft, in the order they first speak (group keys; null lines read by the cast's pick). */
export function draftSpeakers(script) {
const keys = [];
for (const line of script?.lines || []) {
const key = line.speaker || script.groupKey;
if (key && !keys.includes(key)) keys.push(key);
}
return keys;
}
/**
* When a draft was changed, as a locale key and its values: just now, minutes ago, today at,
* yesterday at, or a date (month and day, with the year when it is not this one).
*/
export function whenKey(time, now = Date.now()) {
const minutes = Math.floor((now - time) / 60000);
if (minutes < 1) return { key: 'draftsJustNow' };
if (minutes < 60) return { key: 'draftsMinutesAgo', values: { count: minutes } };
const then = new Date(time);
const today = new Date(now);
const pad = n => String(n).padStart(2, '0');
const clock = `${pad(then.getHours())}:${pad(then.getMinutes())}`;
const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
if (time >= dayStart) return { key: 'draftsTodayAt', values: { time: clock } };
if (time >= dayStart - DAY) return { key: 'draftsYesterdayAt', values: { time: clock } };
const values = { month: then.getMonth() + 1, day: then.getDate(), year: then.getFullYear() };
return { key: then.getFullYear() === today.getFullYear() ? 'draftsDate' : 'draftsDateYear', values };
}