Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c872ade0 |
@@ -11,6 +11,32 @@ export function lookupLink(graph, linkId) {
|
||||
return graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? null;
|
||||
}
|
||||
|
||||
// The live source of a named input. Bypass forwards a compatible input;
|
||||
// mute, broken links and cycles provide no value.
|
||||
export function linkSource(node, name) {
|
||||
const graph = node.graph;
|
||||
let input = node.inputs?.find((slot) => slot?.widget?.name === name || slot?.name === name);
|
||||
const seen = new Set();
|
||||
const compatible = (a, b) => !a || !b || a === "*" || b === "*"
|
||||
|| String(a).split(",").some((type) => String(b).split(",").includes(type));
|
||||
while (input?.link !== null && input?.link !== undefined) {
|
||||
const link = lookupLink(graph, input.link) ?? graph?._links?.get?.(input.link);
|
||||
if (!link || seen.has(link)) return null;
|
||||
seen.add(link);
|
||||
const source = graph?.getNodeById?.(link.origin_id);
|
||||
if (!source || source.mode === 2) return null;
|
||||
if (source.mode !== 4) return source;
|
||||
const slot = link.origin_slot ?? 0;
|
||||
const outputType = source.outputs?.[slot]?.type;
|
||||
const type = input.type ?? outputType;
|
||||
const inputs = source.inputs ?? [];
|
||||
const matches = (entry) => entry && compatible(entry.type, outputType) && compatible(entry.type, type);
|
||||
if (type === "*" || type === "") input = inputs[slot] ?? inputs[0];
|
||||
else input = matches(inputs[slot]) ? inputs[slot] : inputs.find((entry) => entry.type === type) ?? inputs.find(matches);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wire origin's output slot to target's input slot, both by index. Returns
|
||||
// the new link, or null when it could not be made.
|
||||
//
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// dashed chip, so it reads as optional. The widgets underneath stay the
|
||||
// source of truth: a chip only changes their value.
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, chainCallback } from "./index.mjs";
|
||||
import { BRAND, chainCallback, notifyAusbossChange } from "./index.mjs";
|
||||
import { linkSource } from "./graph_links.mjs";
|
||||
import { liftSocket } from "./widget_card.mjs";
|
||||
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
|
||||
|
||||
@@ -31,15 +32,6 @@ function ensureCss() {
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function linkSource(node, name) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
|
||||
if (slot?.link === null || slot?.link === undefined) return null;
|
||||
const graph = node.graph;
|
||||
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
|
||||
const source = graph?.getNodeById?.(link?.origin_id);
|
||||
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
|
||||
}
|
||||
|
||||
const widgetOf = (node, name) => node.widgets?.find((widget) => widget.name === name) ?? null;
|
||||
|
||||
function chip(className = "") {
|
||||
@@ -63,7 +55,10 @@ function choice(node, name, into) {
|
||||
select.value = widget?.value ?? "";
|
||||
select.title = widget?.options?.tooltip ?? "";
|
||||
select.addEventListener("change", () => {
|
||||
if (widget) commitWidgetValue(node, widget, select.value, app.canvas);
|
||||
if (widget && widget.value !== select.value) {
|
||||
commitWidgetValue(node, widget, select.value, app.canvas);
|
||||
notifyAusbossChange();
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
return (values) => { if (document.activeElement !== select) select.value = values[name] ?? ""; };
|
||||
|
||||
+16
-13
@@ -4,7 +4,8 @@
|
||||
// words the node reads as instructions (@1, 3s:, a cut) stand out.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, chainCallback } from "./index.mjs";
|
||||
import { BRAND, chainCallback, notifyAusbossChange } from "./index.mjs";
|
||||
import { linkSource } from "./graph_links.mjs";
|
||||
import { dragRange, moveMoment, retime, rulerSeconds, stripCaption, stripRows, toggleCut, wiredJobs } from "./moment_strip_math.mjs";
|
||||
import { attachScriptAssist } from "./script_assist.mjs";
|
||||
import { attachScriptColours } from "./script_colours.mjs";
|
||||
@@ -47,16 +48,6 @@ function ensureCss() {
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
// A bypassed or muted source doesn't reach the node when it runs, so it doesn't count.
|
||||
function linkSource(node, name) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
|
||||
if (slot?.link === null || slot?.link === undefined) return null;
|
||||
const graph = node.graph;
|
||||
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
|
||||
const source = graph?.getNodeById?.(link?.origin_id);
|
||||
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
|
||||
}
|
||||
|
||||
function wired(node, name) {
|
||||
return linkSource(node, name) !== null;
|
||||
}
|
||||
@@ -106,6 +97,7 @@ export function momentStrip(node) {
|
||||
const next = change(String(widget.value ?? ""));
|
||||
if (next === widget.value) return;
|
||||
commitWidgetValue(node, widget, next, app.canvas);
|
||||
notifyAusbossChange();
|
||||
sync({ ...lastValues, script: widget.value });
|
||||
};
|
||||
// The left edge of a moment that opens a typed line: drag it to set when
|
||||
@@ -266,6 +258,7 @@ export function momentStrip(node) {
|
||||
let shown = null;
|
||||
let timer = null;
|
||||
let asked = 0;
|
||||
let removed = false;
|
||||
const ask = async (script, seconds, jobs, video) => {
|
||||
const turn = ++asked;
|
||||
try {
|
||||
@@ -283,6 +276,7 @@ export function momentStrip(node) {
|
||||
|
||||
let latest = null;
|
||||
const sync = (values) => {
|
||||
if (removed) return;
|
||||
latest = values;
|
||||
lastValues = values;
|
||||
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
|
||||
@@ -302,13 +296,22 @@ export function momentStrip(node) {
|
||||
lastSeconds = seconds;
|
||||
const jobs = wiredJobs(values, (name) => wired(node, name));
|
||||
const video = wired(node, "video") ? values.video_job : "";
|
||||
const key = JSON.stringify([seconds, script, jobs, video]);
|
||||
const pictures = [1, 2, 3, 4].map((number) => pictureUrl(node, number));
|
||||
const key = JSON.stringify([seconds, script, jobs, video, pictures]);
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
clearTimeout(timer);
|
||||
asked += 1;
|
||||
timer = setTimeout(() => ask(script, seconds, jobs, video), WAIT);
|
||||
};
|
||||
const watch = setInterval(() => latest && sync(latest), WATCH);
|
||||
chainCallback(node, "onRemoved", () => { clearInterval(watch); colours.dispose(); assist.dispose(); });
|
||||
chainCallback(node, "onRemoved", () => {
|
||||
removed = true;
|
||||
clearTimeout(timer);
|
||||
asked += 1;
|
||||
clearInterval(watch);
|
||||
colours.dispose();
|
||||
assist.dispose();
|
||||
});
|
||||
return { element, sync };
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ export function stripRows(report) {
|
||||
}));
|
||||
}
|
||||
|
||||
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
|
||||
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
|
||||
// The timestamp grammar in _h3_script_helpers.py, including clocks and ranges.
|
||||
const TYPED_TIME = /^\[?(?:at\s+)?(?:\d+:\d\d(?:\.\d+)?|\d+(?:\.\d+)?\s*(?:(?:seconds?|secs?|s)\b|(?=[\-–—]\s*\d)))(?:\s*[\-–—]\s*\d+(?:\.\d+)?\s*(?:(?:seconds?|secs?|s)\b)?)?\]?\s*[:,\-]?\s*/i;
|
||||
|
||||
// The script with one line set to start at a second: its old time, if any,
|
||||
// gives way to the new one. line counts typed lines from 0.
|
||||
|
||||
@@ -344,7 +344,7 @@ const CARDS = {
|
||||
{ widget: "sound", label: "Sound", placeholder: "rain on a window" },
|
||||
{ widget: "music", label: "Music", placeholder: "slow piano: music makes it louder" },
|
||||
{ section: "Pictures and settings" },
|
||||
{ panel: castChips, height: 30 },
|
||||
{ panel: castChips, height: 82 },
|
||||
{ widget: "seconds", label: "Length", suffix: "s" },
|
||||
{ widget: "camera", label: "Camera", kind: "select" },
|
||||
{ widget: "look", label: "Look", kind: "select" },
|
||||
|
||||
+26
-13
@@ -100,17 +100,29 @@ def spoken_line(speaker, words, language, voice_label, on_screen, number=1, lips
|
||||
return f"{speaker} (S{number}) says in an off-screen voiceover, {line}{closed}{timbre}"
|
||||
|
||||
|
||||
def snap_cuts(detailed, beats, gap=0.4):
|
||||
def snap_cuts(detailed, beats, gap=0.4, seconds=None):
|
||||
"""Moves every timed moment to the nearest beat, keeping them in order and
|
||||
at least `gap` seconds apart."""
|
||||
`gap` seconds apart, or closer if needed to fit inside the clip."""
|
||||
moments = list(CUT.finditer(detailed))
|
||||
if not moments or not beats:
|
||||
return detailed
|
||||
limit = seconds if seconds is not None else max(
|
||||
*beats, *(int(match.group(2)) * 60 + float(match.group(3)) for match in moments)
|
||||
)
|
||||
gap = min(gap, limit / len(moments))
|
||||
last = 0.0
|
||||
remaining = len(moments)
|
||||
|
||||
def move(match):
|
||||
nonlocal last
|
||||
nonlocal last, remaining
|
||||
remaining -= 1
|
||||
time = int(match.group(2)) * 60 + float(match.group(3))
|
||||
options = [beat for beat in beats if beat >= last + gap]
|
||||
low, high = last + gap, limit - remaining * gap
|
||||
options = [beat for beat in beats if low <= beat <= high]
|
||||
if options:
|
||||
time = min(options, key=lambda beat: abs(beat - time))
|
||||
else:
|
||||
time = min(high, max(low, time))
|
||||
last = time
|
||||
minutes, seconds = divmod(time, 60)
|
||||
return f"{match.group(1)}{int(minutes):02d}:{seconds:06.3f}"
|
||||
@@ -131,14 +143,14 @@ def add_voice(parts, label, speaker=None):
|
||||
)
|
||||
|
||||
|
||||
def cut_on_beat(parts, beats):
|
||||
def cut_on_beat(parts, beats, seconds=None):
|
||||
if beats:
|
||||
parts["detailed_description"] = snap_cuts(parts["detailed_description"], beats).replace(
|
||||
parts["detailed_description"] = snap_cuts(parts["detailed_description"], beats, seconds=seconds).replace(
|
||||
"the camera cuts to", "the camera cuts on the beat to"
|
||||
)
|
||||
|
||||
|
||||
def add_song(parts, label, beats):
|
||||
def add_song(parts, label, beats, seconds=None):
|
||||
parts["subject_definitions"] += f"\n{label} is the complete source soundtrack reused in the target video."
|
||||
parts["summary"] = add_task(parts["summary"], "audio reuse")
|
||||
if beats:
|
||||
@@ -148,14 +160,14 @@ def add_song(parts, label, beats):
|
||||
)
|
||||
parts["overall_soundscape"] = "N/A"
|
||||
parts["non_diegetic_music"] = f"{label} is directly reused as the complete audience-only score."
|
||||
cut_on_beat(parts, beats)
|
||||
cut_on_beat(parts, beats, seconds)
|
||||
|
||||
|
||||
def add_song_under(parts, beats):
|
||||
def add_song_under(parts, beats, seconds=None):
|
||||
"""A song to lay under the finished clip. H3 drops a song it is asked to
|
||||
keep when someone also speaks, so it hears none and makes no music."""
|
||||
parts["non_diegetic_music"] = "N/A"
|
||||
cut_on_beat(parts, beats)
|
||||
cut_on_beat(parts, beats, seconds)
|
||||
|
||||
|
||||
def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="English", voice=False, song_beats=None,
|
||||
@@ -213,9 +225,9 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
|
||||
if voice:
|
||||
add_voice(parts, voice_label, speaker)
|
||||
if song_beats is not None and voice:
|
||||
add_song_under(parts, song_beats)
|
||||
add_song_under(parts, song_beats, seconds)
|
||||
elif song_beats is not None:
|
||||
add_song(parts, f"<Audio {audio}>", song_beats)
|
||||
add_song(parts, f"<Audio {audio}>", song_beats, seconds)
|
||||
return re.sub(r"[ \t]{2,}", " ", join_sections(parts)).replace(" \n", "\n").strip()
|
||||
|
||||
|
||||
@@ -237,7 +249,8 @@ def find_beats(samples, rate, seconds=30.0):
|
||||
[np.abs(np.fft.rfft(samples[index * hop : index * hop + window] * taper)) for index in range(frames)]
|
||||
)
|
||||
rise = np.maximum(np.diff(np.log1p(1000 * spectra), axis=0), 0).sum(axis=1)
|
||||
rise = np.maximum(rise - np.convolve(rise, np.ones(43) / 43, "same"), 0)
|
||||
smooth = min(43, len(rise))
|
||||
rise = np.maximum(rise - np.convolve(rise, np.ones(smooth) / smooth, "same"), 0)
|
||||
if rise.max() <= 0:
|
||||
return []
|
||||
rise = rise / rise.max()
|
||||
|
||||
@@ -298,6 +298,7 @@ def take_label(line, script):
|
||||
|
||||
|
||||
def read_notes(text, script):
|
||||
text, _ = mask_quotes(text)
|
||||
if NO_SOUND.search(text):
|
||||
script.silent = True
|
||||
if NO_MUSIC.search(text):
|
||||
@@ -385,7 +386,7 @@ def read_script(text):
|
||||
if not line or take_label(line, script):
|
||||
continue
|
||||
opens = block_line + offset
|
||||
for piece in split_line(NUMBER.sub("", line).strip()):
|
||||
for piece in split_line(line if TIMED.match(line) else NUMBER.sub("", line).strip()):
|
||||
note = SIDE_NOTE.match(piece)
|
||||
if note: # "no music, just rain" on its own is a note, not a moment.
|
||||
read_notes(piece, script)
|
||||
@@ -408,6 +409,7 @@ def read_script(text):
|
||||
put_in_order(script)
|
||||
if script.one_take or not any(moment.cut for moment in script.moments):
|
||||
for moment in script.moments:
|
||||
moment.cut = False
|
||||
script.camera, script.sound = moment.camera or script.camera, moment.sound or script.sound
|
||||
moment.camera = moment.sound = ""
|
||||
return script
|
||||
|
||||
@@ -88,8 +88,8 @@ def clip_frames(seconds):
|
||||
|
||||
def video_frames(frames):
|
||||
"""A clip's own length, on H3's grid and within what it can make."""
|
||||
longest = LONGEST_SECONDS * FRAME_RATE
|
||||
return max(1, (min(frames, longest) - 5) // 17) * 17 + 5
|
||||
longest = clip_frames(LONGEST_SECONDS)
|
||||
return max(0, (min(frames, longest) - 5) // 17) * 17 + 5
|
||||
|
||||
|
||||
def cut_audio(audio, start, seconds, name):
|
||||
@@ -104,10 +104,14 @@ def cut_audio(audio, start, seconds, name):
|
||||
|
||||
def last_of_clip(video, sound):
|
||||
"""The end of a clip, picture and sound, to carry on from."""
|
||||
frames = min(len(video), KEEP_FRAMES)
|
||||
if frames >= 5:
|
||||
frames = video_frames(frames)
|
||||
if sound is not None:
|
||||
rate = int(sound["sample_rate"])
|
||||
sound = {"waveform": sound["waveform"][..., -int(round(KEEP_FRAMES / FRAME_RATE * rate)) :], "sample_rate": rate}
|
||||
return video[-KEEP_FRAMES:], sound
|
||||
count = int(round(frames / FRAME_RATE * rate))
|
||||
sound = {"waveform": sound["waveform"][..., -count:] if count else sound["waveform"][..., :0], "sample_rate": rate}
|
||||
return video[-frames:] if frames else video[:0], sound
|
||||
|
||||
|
||||
def under_voice(song):
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
linkSlots,
|
||||
linkSource,
|
||||
lookupLink,
|
||||
restoreOutputLinks,
|
||||
snapshotLinks,
|
||||
@@ -62,6 +63,39 @@ test("lookupLink reads Map and plain-object link tables", () => {
|
||||
assert.equal(lookupLink(null, 7), null);
|
||||
});
|
||||
|
||||
test("linkSource follows compatible bypass chains and rejects mute, missing links and cycles", () => {
|
||||
const loader = { id: 1, mode: 0 };
|
||||
const processor = { id: 2, mode: 4, inputs: [{ type: "MASK", link: null }, { type: "IMAGE", link: 2 }], outputs: [{ type: "IMAGE" }] };
|
||||
const pass = { id: 3, mode: 4, inputs: [{ type: "IMAGE", link: 3 }], outputs: [{ type: "IMAGE" }] };
|
||||
const nodes = new Map([[1, loader], [2, processor], [3, pass]]);
|
||||
const graph = { links: new Map([[1, { origin_id: 3, origin_slot: 0 }], [2, { origin_id: 1, origin_slot: 0 }], [3, { origin_id: 2, origin_slot: 0 }]]), getNodeById: (id) => nodes.get(id) };
|
||||
const target = { graph, inputs: [{ name: "picture_1", type: "IMAGE", link: 1 }] };
|
||||
assert.equal(linkSource(target, "picture_1"), loader);
|
||||
loader.mode = 2;
|
||||
assert.equal(linkSource(target, "picture_1"), null);
|
||||
loader.mode = 0;
|
||||
processor.inputs[1].link = null;
|
||||
assert.equal(linkSource(target, "picture_1"), null);
|
||||
processor.inputs[1].link = 1;
|
||||
assert.equal(linkSource(target, "picture_1"), null);
|
||||
processor.inputs[1] = { type: "AUDIO", link: 2 };
|
||||
assert.equal(linkSource(target, "picture_1"), null);
|
||||
target.inputs[0].link = 99;
|
||||
assert.equal(linkSource(target, "picture_1"), null);
|
||||
});
|
||||
|
||||
test("linkSource prefers the matching input at the output's index", () => {
|
||||
const first = { id: 1 }, second = { id: 2 };
|
||||
const bypass = { id: 3, mode: 4, inputs: [{ type: "IMAGE", link: 2 }, { type: "IMAGE", link: 3 }], outputs: [{ type: "IMAGE" }, { type: "IMAGE" }] };
|
||||
const graph = { _links: new Map([[1, { origin_id: 3, origin_slot: 1 }], [2, { origin_id: 1 }], [3, { origin_id: 2 }]]), getNodeById: (id) => [null, first, second, bypass][id] };
|
||||
assert.equal(linkSource({ graph, inputs: [{ name: "picture_1", type: "IMAGE", link: 1 }] }, "picture_1"), second);
|
||||
bypass.inputs[1].type = "MASK";
|
||||
assert.equal(linkSource({ graph, inputs: [{ name: "picture_1", type: "IMAGE", link: 1 }] }, "picture_1"), first);
|
||||
for (const type of ["*", ""]) {
|
||||
assert.equal(linkSource({ graph, inputs: [{ name: "value", type, link: 1 }] }, "value"), second);
|
||||
}
|
||||
});
|
||||
|
||||
test("linkSlots hands the resolved slot objects to connectSlots", () => {
|
||||
const { makeNode } = makeGraph();
|
||||
const source = makeNode(1, [], ["IMAGE", "MASK"]);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import * as math from "../js/shared/moment_strip_math.mjs";
|
||||
import * as links from "../js/shared/graph_links.mjs";
|
||||
import { commitWidgetValue, comboValues } from "../js/shared/widget_card_math.mjs";
|
||||
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.listeners = {};
|
||||
this.style = {};
|
||||
this.className = "";
|
||||
this.value = "";
|
||||
this.classList = {
|
||||
add: (name) => { if (!this.classList.contains(name)) this.className += ` ${name}`; },
|
||||
remove: (name) => { this.className = this.className.split(" ").filter((part) => part !== name).join(" "); },
|
||||
contains: (name) => this.className.split(" ").includes(name),
|
||||
toggle: (name, on) => on ? this.classList.add(name) : this.classList.remove(name),
|
||||
};
|
||||
}
|
||||
append(...children) { for (const child of children) { child.parentElement = this; this.children.push(child); } }
|
||||
set textContent(text) { this.text = text; this.children = []; }
|
||||
get textContent() { return (this.text ?? "") + this.children.map((child) => child.textContent).join(""); }
|
||||
get childElementCount() { return this.children.length; }
|
||||
addEventListener(name, fn) { (this.listeners[name] ??= []).push(fn); }
|
||||
removeEventListener(name, fn) { this.listeners[name] = (this.listeners[name] ?? []).filter((entry) => entry !== fn); }
|
||||
dispatch(name, event = {}) {
|
||||
for (const fn of [...(this.listeners[name] ?? [])]) fn({ button: 0, clientX: 0, preventDefault() {}, stopPropagation() {}, ...event });
|
||||
}
|
||||
getBoundingClientRect() { return { left: 0, width: 100 }; }
|
||||
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((child) => child !== this); }
|
||||
}
|
||||
|
||||
function page() {
|
||||
let now = 0, next = 0, captures = 0;
|
||||
const timers = new Map(), intervals = new Map(), pending = [];
|
||||
const document = { head: new Element("head"), createElement: (tag) => new Element(tag), getElementById: () => null };
|
||||
const app = { canvas: {}, extensionManager: { workflow: { activeWorkflow: { changeTracker: { captureCanvasState() { captures++; } } } } } };
|
||||
const globals = {
|
||||
document, app, console, ...math, ...links, commitWidgetValue, comboValues,
|
||||
api: { fetchApi: (url, options) => new Promise((resolve) => pending.push({ body: JSON.parse(options.body), resolve })) },
|
||||
setTimeout: (fn, delay) => { const id = ++next; timers.set(id, { fn, at: now + delay }); return id; },
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
setInterval: (fn) => { const id = ++next; intervals.set(id, fn); return id; },
|
||||
clearInterval: (id) => intervals.delete(id),
|
||||
queueMicrotask: (fn) => fn(),
|
||||
liftSocket() {},
|
||||
attachScriptColours: () => ({ sync() {}, dispose() {} }),
|
||||
attachScriptAssist: () => ({ dispose() {} }),
|
||||
};
|
||||
// Only browser imports are replaced; the controls and history notifier run as shipped.
|
||||
const load = (file, names) => {
|
||||
const source = readFileSync(new URL(`../js/shared/${file}`, import.meta.url), "utf8")
|
||||
.replace(/^import .*;\n/gm, "").replace(/\bexport (?=(?:function|const)\b)/g, "");
|
||||
return vm.runInNewContext(`${source}\n;({${names.join(",")}})`, { ...globals }, { filename: file });
|
||||
};
|
||||
Object.assign(globals, load("index.mjs", ["BRAND", "chainCallback", "notifyAusbossChange"]));
|
||||
return {
|
||||
...load("h3_cast_chips.mjs", ["castChips"]), ...load("moment_strip.mjs", ["momentStrip"]),
|
||||
pending, intervals, timers, get captures() { return captures; },
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
for (const [id, timer] of [...timers]) if (timer.at <= now) { timers.delete(id); timer.fn(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const find = (element, predicate) => predicate(element) ? element : element.children.map((child) => find(child, predicate)).find(Boolean);
|
||||
const named = (name) => (element) => element.classList.contains(name);
|
||||
const makeNode = (script = "She walks.\nShe stops.") => ({ widgets: [{ name: "script", value: script }], inputs: [], graph: {}, setDirtyCanvas() {} });
|
||||
const values = (node) => ({ script: node.widgets[0].value, seconds: 8, job_1: "person" });
|
||||
const report = (text = "line", moments = [0, 1]) => ({ ok: true, json: async () => ({ seconds: 8, moments: moments.map((line, index) => ({ start: index * 4, end: (index + 1) * 4, text: `${text}${index}`, line })) }) });
|
||||
const flush = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); };
|
||||
function pictureGraph(bypass = false) {
|
||||
const loader = { id: 10, mode: 0, imgs: [{ src: "test.png" }], outputs: [{ type: "IMAGE" }] };
|
||||
const processor = { id: 11, mode: 4, inputs: [{ type: "IMAGE", link: 2 }], outputs: [{ type: "IMAGE" }] };
|
||||
const graph = { links: { 1: { origin_id: bypass ? 11 : 10, origin_slot: 0 }, 2: { origin_id: 10, origin_slot: 0 } }, getNodeById: (id) => id === 10 ? loader : id === 11 ? processor : null };
|
||||
return { graph, loader, processor };
|
||||
}
|
||||
|
||||
test("a chip job change captures history once, after the widget write", () => {
|
||||
const p = page(), { graph } = pictureGraph();
|
||||
const widget = { name: "job_1", value: "person", options: { values: ["person", "place"] } };
|
||||
const node = { graph, widgets: [widget], inputs: [{ name: "picture_1", link: 1 }] };
|
||||
const panel = p.castChips(node);
|
||||
panel.sync({ job_1: "person" });
|
||||
p.advance(120);
|
||||
assert.equal(p.captures, 0, "sync does not dirty the workflow");
|
||||
const select = find(panel.element, (element) => element.tagName === "SELECT");
|
||||
select.value = "place"; select.dispatch("change");
|
||||
assert.equal(widget.value, "place");
|
||||
assert.equal(p.captures, 0, "capture waits for the completed write");
|
||||
p.advance(120);
|
||||
assert.equal(p.captures, 1);
|
||||
select.dispatch("change"); p.advance(120);
|
||||
assert.equal(p.captures, 1, "an unchanged job stays clean");
|
||||
node.onRemoved();
|
||||
});
|
||||
|
||||
test("a completed timeline cut captures history once", async () => {
|
||||
const p = page(), node = makeNode(), panel = p.momentStrip(node);
|
||||
panel.sync(values(node)); p.advance(200);
|
||||
p.pending[0].resolve(report()); await flush();
|
||||
const handle = find(panel.element, named("ausboss-strip-handle"));
|
||||
handle.dispatch("pointerdown"); handle.dispatch("pointerup");
|
||||
assert.equal(node.widgets[0].value, "She walks.\n\nShe stops.");
|
||||
p.advance(120);
|
||||
assert.equal(p.captures, 1);
|
||||
node.onRemoved();
|
||||
});
|
||||
|
||||
test("editing invalidates an old report before the next debounce fires", async () => {
|
||||
const p = page(), node = makeNode(), panel = p.momentStrip(node);
|
||||
panel.sync(values(node)); p.advance(200);
|
||||
node.widgets[0].value = "new"; panel.sync(values(node));
|
||||
p.pending[0].resolve(report("OLD")); await flush();
|
||||
assert.doesNotMatch(panel.element.textContent, /OLD/);
|
||||
p.advance(200);
|
||||
p.pending[1].resolve(report("NEW", [0])); await flush();
|
||||
assert.match(panel.element.textContent, /NEW/);
|
||||
node.onRemoved();
|
||||
});
|
||||
|
||||
test("removal cancels debounce, polling, and in-flight repaint", async () => {
|
||||
const p = page(), node = makeNode(), panel = p.momentStrip(node);
|
||||
panel.sync(values(node)); node.onRemoved(); p.advance(200);
|
||||
assert.equal(p.pending.length, 0, "no request starts after removal");
|
||||
assert.equal(p.intervals.size, 0);
|
||||
const other = makeNode(), otherPanel = p.momentStrip(other);
|
||||
otherPanel.sync(values(other)); p.advance(200); other.onRemoved();
|
||||
p.pending.at(-1).resolve(report("REMOVED")); await flush();
|
||||
assert.doesNotMatch(otherPanel.element.textContent, /REMOVED/);
|
||||
otherPanel.sync({ script: "later", seconds: 8 }); p.advance(200);
|
||||
assert.equal(p.pending.length, 1, "a detached panel stays stopped");
|
||||
});
|
||||
|
||||
test("replacing a loader image refreshes the timeline thumbnail", async () => {
|
||||
const p = page(), node = makeNode("@1 runs."), { graph, loader } = pictureGraph();
|
||||
node.graph = graph; node.inputs = [{ name: "picture_1", link: 1 }];
|
||||
const panel = p.momentStrip(node);
|
||||
panel.sync(values(node)); p.advance(200);
|
||||
p.pending.at(-1).resolve(report("@1 runs", [0])); await flush();
|
||||
assert.equal(find(panel.element, (element) => element.tagName === "IMG").src, "test.png");
|
||||
loader.imgs[0].src = "replacement.png";
|
||||
panel.sync(values(node)); p.advance(200);
|
||||
p.pending.at(-1).resolve(report("@1 runs", [0])); await flush();
|
||||
assert.equal(find(panel.element, (element) => element.tagName === "IMG").src, "replacement.png");
|
||||
node.onRemoved();
|
||||
});
|
||||
|
||||
test("bypassed image processors forward pictures to chips and timeline", async () => {
|
||||
const p = page(), node = makeNode("@1 runs."), { graph, processor } = pictureGraph(true);
|
||||
node.graph = graph; node.inputs = [{ name: "picture_1", link: 1 }];
|
||||
node.widgets.push({ name: "job_1", value: "person", options: { values: ["person"] } });
|
||||
const chips = p.castChips(node), strip = p.momentStrip(node);
|
||||
chips.sync(values(node)); strip.sync(values(node)); p.advance(200);
|
||||
assert.equal(find(chips.element, (element) => element.tagName === "IMG")?.src, "test.png");
|
||||
assert.deepEqual(p.pending.at(-1).body.jobs, { 1: "person" });
|
||||
p.pending.at(-1).resolve(report("@1 runs", [0])); await flush();
|
||||
assert.equal(find(strip.element, (element) => element.tagName === "IMG")?.src, "test.png");
|
||||
processor.mode = 2; chips.sync(values(node)); strip.sync(values(node)); p.advance(200);
|
||||
assert.match(chips.element.textContent, /\+ @1/);
|
||||
assert.deepEqual(p.pending.at(-1).body.jobs, {});
|
||||
node.onRemoved();
|
||||
assert.equal(p.intervals.size, 0);
|
||||
});
|
||||
|
||||
test("the chips row reserves room for three wrapped rows at minimum width", () => {
|
||||
const cards = readFileSync(new URL("../js/widget_cards/index.js", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../js/shared/h3_cast_chips.mjs", import.meta.url), "utf8");
|
||||
const height = Number(cards.match(/panel: castChips, height: (\d+)/)[1]);
|
||||
const chipHeight = Number(css.match(/\.ausboss-chip\{[^}]*height:(\d+)px/)[1]);
|
||||
const gap = Number(css.match(/\.ausboss-chips\{[^}]*gap:(\d+)px/)[1]);
|
||||
assert.ok(height >= 3 * chipHeight + 2 * gap, `${height}px cannot hold three chip rows`);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { stripCaption, stripRows, wiredJobs } from "../js/shared/moment_strip_math.mjs";
|
||||
import { moveMoment, retime, stripCaption, stripRows, wiredJobs } from "../js/shared/moment_strip_math.mjs";
|
||||
|
||||
const row = (start, end, extra = {}) => ({ start, end, text: "She runs", cut: false, speech: false, ...extra });
|
||||
|
||||
@@ -43,3 +43,13 @@ test("only connected pictures send their job", () => {
|
||||
test("a warning goes first", () => {
|
||||
assert.deepEqual(stripCaption({ warning: "Too many.", moments: [row(0, 5)] }), { text: "Too many.", warn: true });
|
||||
});
|
||||
|
||||
test("retiming replaces every timestamp format the script accepts", () => {
|
||||
for (const prefix of ["0:03:", "0:03.500:", "3-6s:", "3s-6s:", "[at 3 seconds]", "at 3 secs,", "2.5s:", "3–6s:"]) {
|
||||
assert.equal(retime(`${prefix} She waves.`, 0, 5), "5s: She waves.", prefix);
|
||||
}
|
||||
});
|
||||
|
||||
test("moving moments leaves clocks and time ranges in their original slots", () => {
|
||||
assert.equal(moveMoment("0:03: She waves.\n3-6s: She runs.", [0, 1], 1, 0), "0:03: She runs.\n3-6s: She waves.");
|
||||
});
|
||||
|
||||
@@ -111,6 +111,26 @@ class ReadingTests(unittest.TestCase):
|
||||
def test_lines_that_all_name_their_second_play_by_the_second(self):
|
||||
self.assertEqual(moments("5s: she sits\n2s: she stands"), ["@0 holds still", "She stands", "She sits"])
|
||||
|
||||
def test_fractional_seconds_are_not_list_numbers(self):
|
||||
for second in (0.5, 2.5):
|
||||
script = read_script(f"She waits.\n{second}s: She waves.")
|
||||
self.assertEqual(script.moments[1].at, second)
|
||||
self.assertEqual(script.moments[1].text, "She waves")
|
||||
|
||||
def test_spoken_directives_do_not_change_the_clip(self):
|
||||
script = read_script('She says "no cuts, no sound, no music."\n\nShe walks.')
|
||||
self.assertFalse(script.one_take)
|
||||
self.assertFalse(script.silent)
|
||||
self.assertNotEqual(script.music, "N/A")
|
||||
self.assertTrue(script.moments[1].cut)
|
||||
self.assertTrue(parts_of('She says "no sound."\n\nsound: rain')["overall_soundscape"].startswith("Rain."))
|
||||
|
||||
def test_no_cuts_clears_effective_cuts_everywhere(self):
|
||||
text = "no cuts\nIt starts to rain.\n\nShe stops."
|
||||
self.assertFalse(any(moment.cut for moment in read_script(text).moments))
|
||||
self.assertFalse(any(moment["cut"] for moment in script_report(text, 8)["moments"]))
|
||||
self.assertIn("The rain keeps falling", write_prompt(text))
|
||||
|
||||
def test_an_opening_and_an_ending_go_where_they_belong(self):
|
||||
script = read_script("ends on her crying\nstarts on a wide shot of the city\nshe walks out")
|
||||
self.assertEqual([moment.text for moment in script.moments], ["A wide shot of the city", "She walks out", "Her crying"])
|
||||
@@ -820,6 +840,17 @@ class SongTests(unittest.TestCase):
|
||||
def test_minutes_carry_over(self):
|
||||
self.assertIn("At 01:00.100,", snap_cuts("[Shot 2] At 00:59.900, a", [60.1]))
|
||||
|
||||
def test_beat_exhaustion_keeps_times_in_order_and_inside_the_clip(self):
|
||||
detail = "\n".join(f"At 00:0{time:.3f}, a" for time in (4.0, 4.1, 4.2))
|
||||
fallback = snap_cuts(detail, [1, 3, 4.5])
|
||||
times = [float(second) for second in re.findall(r"At 00:(\d+\.\d+)", fallback)]
|
||||
self.assertEqual(times, sorted(times))
|
||||
text = snap_cuts(detail, [1, 3, 4.5], seconds=5)
|
||||
times = [float(second) for second in re.findall(r"At 00:(\d+\.\d+)", text)]
|
||||
self.assertEqual(len(times), 3)
|
||||
self.assertTrue(all(b - a >= 0.399 for a, b in zip(times, times[1:])))
|
||||
self.assertLessEqual(times[-1], 5)
|
||||
|
||||
def test_six_parts_take_a_song_and_a_voice_too(self):
|
||||
for voice in (False, True):
|
||||
for song in (None, [2.0, 4.0, 6.0]):
|
||||
@@ -1018,9 +1049,10 @@ class NodeTests(unittest.TestCase):
|
||||
sound = {"waveform": torch.arange(48000.0).reshape(1, 1, -1), "sample_rate": 12000}
|
||||
output = AusBossH3PromptWriter().write("", 6, video=frames, video_sound=sound, video_job="keep going")
|
||||
video, heard = output[5], output[6]
|
||||
self.assertEqual(video.shape[0], 44)
|
||||
self.assertEqual(video.shape[0], 39)
|
||||
self.assertEqual(float(video[0, 0, 0, 0]), 61.0)
|
||||
self.assertEqual(float(video[-1, 0, 0, 0]), 99.0)
|
||||
self.assertEqual(heard["waveform"].shape[-1], 22000)
|
||||
self.assertEqual(heard["waveform"].shape[-1], 19500)
|
||||
self.assertEqual(float(heard["waveform"][0, 0, -1]), 47999.0)
|
||||
|
||||
def test_the_clip_takes_the_shape_of_the_video_or_else_the_first_picture(self):
|
||||
@@ -1032,9 +1064,17 @@ class NodeTests(unittest.TestCase):
|
||||
self.assertEqual(node.write("@2 waves.", 6, picture_2=wide, job_2="person")[-2:], (832, 480))
|
||||
self.assertEqual(node.write("@1 dances.", 6, picture_1=tall, video=video)[-2:], (1280, 720))
|
||||
|
||||
def test_a_short_clip_keeps_all_it_has(self):
|
||||
def test_a_short_clip_keeps_a_valid_tail_and_matching_sound(self):
|
||||
frames = torch.zeros(20, 2, 2, 3)
|
||||
self.assertEqual(AusBossH3PromptWriter().write("", 6, video=frames, video_job="keep going")[5].shape[0], 20)
|
||||
output = AusBossH3PromptWriter().write("", 6, video=frames, video_sound=self.audio(), video_job="keep going")
|
||||
self.assertEqual(output[5].shape[0], 5)
|
||||
self.assertEqual(output[6]["waveform"].shape[-1], round(5 / 24 * 22050))
|
||||
|
||||
def test_a_short_song_segment_does_not_crash(self):
|
||||
song = {"waveform": torch.from_numpy(clicks(100, seconds=10)).reshape(1, 1, -1), "sample_rate": 22050}
|
||||
for duration in (0.15, 0.2, 0.3, 0.4, 0.55):
|
||||
output = AusBossH3PromptWriter().write(LINES, 6, picture_1=PICTURE, song=song, song_start=10 - duration)
|
||||
self.assertEqual(output[SONG]["waveform"].shape[-1], round(duration * 22050))
|
||||
|
||||
def test_a_swap_keeps_the_videos_length(self):
|
||||
frames = torch.zeros(100, 8, 8, 3)
|
||||
@@ -1043,7 +1083,7 @@ class NodeTests(unittest.TestCase):
|
||||
self.assertEqual(output[FRAMES], 90)
|
||||
|
||||
def test_a_video_length_is_one_h3_can_make(self):
|
||||
self.assertEqual([video_frames(count) for count in (124, 125, 141, 5000)], [124, 124, 141, 345])
|
||||
self.assertEqual([video_frames(count) for count in (5, 124, 125, 141, 362, 5000)], [5, 124, 124, 141, 362, 362])
|
||||
|
||||
def test_cut_on_beat_off_keeps_the_times(self):
|
||||
text = AusBossH3PromptWriter().write(LINES, 6, picture_1=PICTURE, song=self.audio(), cut_on_beat=False)[0]
|
||||
|
||||
Reference in New Issue
Block a user