Files
ausboss 9ba82c45b4 H3 Prompt Writer: timeline, colored words, Sound and Music boxes
- Words light up as you type (@1, times, cuts, quotes, labels), with @
  autocomplete and a ? cheat sheet
- Timeline bar shows every picture a line names; drag a block to reorder,
  drag its edge to set its time, click an edge for a cut, click a block to
  jump to its line
- Sound and Music boxes (new optional inputs, appended) fill H3's
  overall_soundscape and non_diegetic_music
- Pictures and video as chips with their jobs; Length, Camera and Look stay
  plain rows so they can still be wired
- Help page and changelog updated; tests for the colors, autocomplete,
  timeline edits and the new inputs
2026-11-03 11:09:26 -06:00

93 lines
4.1 KiB
JavaScript

// Pure decisions behind js/shared/script_assist.mjs: what the help card says,
// and what to suggest at the caret in H3 Prompt Writer's script box. No DOM,
// so tests run it under node:test.
// The help card: each thing the node reads in the words, in plain words.
export const CHEAT_SHEET = [
{ code: "@1 to @4", text: "A picture you plugged in. @1 is Picture 1." },
{ code: "3s:", text: "At the start of a line: this moment starts 3 seconds in. Lines with no time share what is left evenly. Dragging a block's left edge on the bar types this for you." },
{ code: "blank line", text: "A cut to a new shot. No blank lines means one take." },
{ code: "\"in quotes\"", text: "Said out loud, word for word." },
{ code: "sound:", text: "On its own line: what the clip sounds like. Same as the Sound box." },
{ code: "music:", text: "On its own line: the music. Music makes the clip louder." },
{ code: "camera:", text: "On its own line: how the camera moves, like push in or handheld." },
{ code: "look:", text: "On its own line: a style, like anime or watercolour." },
{ code: "no cuts", text: "Anywhere in the words: keep it all one shot." },
];
const LABELS = [
{ word: "sound", hint: "what the clip sounds like" },
{ word: "music", hint: "the music, makes it louder" },
{ word: "camera", hint: "how the camera moves" },
{ word: "look", hint: "a style for the whole clip" },
];
const starts = (value, typed) => value.toLowerCase().startsWith(typed.toLowerCase());
// What to offer at the caret, or null.
// pictures: [{ number, job, plugged }] for @1 to @4
// cameras, looks: the choices the node knows, for camera: and look: lines
// seconds: the clip's length, so a time past the end isn't offered
// The answer replaces text[from, to) with an item's insert. `strong` means
// the person typed a trigger (@, camera:, look:), so Enter may take an item;
// a guess from the first letters of a line only takes Tab or a click, so
// Enter still starts a new line.
export function suggestAt(text, caret, { pictures = [], cameras = [], looks = [], seconds = 8 } = {}) {
const value = String(text ?? "");
const before = value.slice(0, caret);
const line = before.slice(before.lastIndexOf("\n") + 1);
const at = before.match(/(?:^|[^\w@])@(\d?)$/);
if (at) {
const typed = at[1];
const plugged = pictures.filter((picture) => picture.plugged);
const offer = (plugged.length ? plugged : pictures).filter((picture) => String(picture.number).startsWith(typed));
if (!offer.length) return null;
return {
from: caret - typed.length - 1,
to: caret,
strong: true,
items: offer.map((picture) => ({
label: `@${picture.number}`,
insert: `@${picture.number}`,
hint: picture.plugged ? picture.job || "" : "not plugged in",
number: picture.number,
})),
};
}
const choice = line.match(/^\s*(camera|look)\s*:\s*([\w ]*)$/i);
if (choice) {
const typed = choice[2];
const list = choice[1].toLowerCase() === "camera" ? cameras : looks;
const offer = list.filter((name) => starts(name, typed) && name.toLowerCase() !== typed.toLowerCase());
if (!offer.length) return null;
return { from: caret - typed.length, to: caret, strong: true, items: offer.map((name) => ({ label: name, insert: name, hint: "" })) };
}
const time = line.match(/^(\s*)(\d{1,2})$/);
if (time) {
const second = Number(time[2]);
if (second < 1 || second >= seconds) return null;
return {
from: caret - time[2].length,
to: caret,
strong: false,
items: [{ label: `${second}s:`, insert: `${second}s: `, hint: `this moment starts ${second} seconds in` }],
};
}
const word = line.match(/^(\s*)([a-z]{2,6})$/i);
if (word) {
const offer = LABELS.filter((label) => starts(label.word, word[2]) && label.word !== word[2].toLowerCase());
if (!offer.length) return null;
return {
from: caret - word[2].length,
to: caret,
strong: false,
items: offer.map((label) => ({ label: `${label.word}:`, insert: `${label.word}: `, hint: label.hint })),
};
}
return null;
}