app, core: a project of scenes, and the timeline that draws it
The app could run a pipeline and had nowhere to keep what it was making. A project is an ordered list of scenes, one JSON file each, and it is the only part of the app that has to survive a reload. A scene owns its prompt text and the result it produced, and nothing else. v4's Scene grew three dozen fields; each belongs to the feature that needs it. Per scene length and fps are deliberately absent — a regenerate uses the project's values, so a scene cropped on the timeline comes back whole. The prompt box edits the current scene rather than holding its own copy, so what is on screen and what a run uses cannot disagree. app: the region toggles are named buttons in the timeline bar v4's arrangement. The kit's fallback is one glyph on a rail at each edge of the window, which is right for a kit that cannot know what its panels are called and wrong here, where they have names and a bar to put them in. A pressed toggle was styled only inside a button group, so a standalone one carried aria-pressed and looked identical either way. gallery.strip is selectable like its siblings: a filmstrip is the one shape whose whole job is saying which of these you are on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d3b0ba0fa9
commit
9dcd94c156
@@ -28,3 +28,6 @@ node_modules/
|
||||
# Playwright: browsers live in a shared cache, results and traces are per-run
|
||||
app/test-results/
|
||||
app/playwright-report/
|
||||
|
||||
# User projects — their content, not ours.
|
||||
projects/
|
||||
|
||||
+44
-2
@@ -15,6 +15,8 @@ import { wire } from "./shell/session.js";
|
||||
import { check, load, describe, search } from "./shell/pipeline.js";
|
||||
import { open as openPipeline } from "./shell/pipeline_window.js";
|
||||
import { createPrompts } from "./shell/prompt.js";
|
||||
import { createProject } from "./shell/projects.js";
|
||||
import { createTimeline } from "./shell/timeline.js";
|
||||
|
||||
const root = document.querySelector("#app");
|
||||
|
||||
@@ -35,6 +37,22 @@ async function start() {
|
||||
// inputs those are.
|
||||
let prompts = null;
|
||||
|
||||
// The project, and the strip that draws it. Both exist before the manifest is
|
||||
// read so a failure to reach the server leaves an empty timeline rather than
|
||||
// an undefined one.
|
||||
let timeline = null;
|
||||
const project = createProject({
|
||||
onChange: () => { if (timeline) timeline.draw(); },
|
||||
onError: (err) => page.transport.say(`The project could not be saved: ${err.message}`),
|
||||
});
|
||||
|
||||
// A scene's text and the prompt box are the same value seen twice. The box is
|
||||
// the editor; the scene is where it lives.
|
||||
const showScene = (scene) => {
|
||||
const box = prompts && prompts.at("generation.prompt");
|
||||
if (box) box.setValue(scene ? scene.text : "");
|
||||
};
|
||||
|
||||
const page = build(root, {
|
||||
onGenerate: () => session.generate(),
|
||||
onCancel: () => run.cancel(),
|
||||
@@ -55,6 +73,10 @@ async function start() {
|
||||
slots: () => slots, values: allValues,
|
||||
inputs: () => (prompts ? prompts.overrides() : {}) });
|
||||
|
||||
// Saved on the way out. A debounce that has not fired yet is work the user
|
||||
// did and cannot see anywhere, and a reload is exactly when it is lost.
|
||||
window.addEventListener("pagehide", () => { project.flush(); });
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = await fetchManifest();
|
||||
@@ -74,7 +96,15 @@ async function start() {
|
||||
// After the modules, because a region has to exist before anything can be put
|
||||
// in it, and a module may be sharing the region a role names.
|
||||
try {
|
||||
prompts = createPrompts((await load()).slots);
|
||||
prompts = createPrompts((await load()).slots, {
|
||||
// Typing in the box writes to the scene it belongs to. Without this the
|
||||
// prompt is a value the run uses and the project never hears about, so a
|
||||
// reload shows a timeline whose scenes are all empty.
|
||||
onChange: () => {
|
||||
const box = prompts && prompts.at("generation.prompt");
|
||||
if (box && project.selectedId) project.setText(project.selectedId, box.value);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Not fatal and not silent: the app still runs on the server's own
|
||||
// defaults, and an empty prompt panel with no explanation is the failure
|
||||
@@ -85,6 +115,18 @@ async function start() {
|
||||
// Everything that mounts has now had its turn, so a region still holding its
|
||||
// stand-in is a region nothing wanted.
|
||||
settle();
|
||||
|
||||
// After the prompt exists, so the first scene's text has somewhere to go.
|
||||
try {
|
||||
await project.start();
|
||||
timeline = createTimeline({ project, onSelect: showScene });
|
||||
page.timelineBody.set([page.transport.warning, timeline]);
|
||||
showScene(project.selected);
|
||||
} catch (err) {
|
||||
console.warn(`[FunPack] no project: ${err.message}`);
|
||||
page.transport.say(`The project could not be opened: ${err.message}`);
|
||||
}
|
||||
|
||||
const failed = manifest.failed || [];
|
||||
|
||||
console.info(`[FunPack] ${mounted.length} module(s) mounted`,
|
||||
@@ -97,7 +139,7 @@ async function start() {
|
||||
for (const { where, why } of failed) console.warn(`[FunPack] ${where} did not load: ${why}`);
|
||||
|
||||
window.FunPack = {
|
||||
manifest, values: allValues, failed, hidden, run, bin: page.bin,
|
||||
manifest, values: allValues, failed, hidden, run, bin: page.bin, project,
|
||||
prompts: () => (prompts ? prompts.overrides() : {}),
|
||||
mounted: mounted.map((m) => m.id),
|
||||
};
|
||||
|
||||
@@ -59,8 +59,14 @@
|
||||
.cx-btn-group .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); margin-left: 0; }
|
||||
.cx-btn-group .cx-btn:last-child { border-radius: 0 var(--ctl-r) var(--ctl-r) 0; }
|
||||
.cx-btn-group .cx-btn:only-child { border-radius: var(--ctl-r); }
|
||||
.cx-btn-group .cx-btn.cx-on {
|
||||
/* A pressed toggle, grouped or not. Scoping this to .cx-btn-group meant a
|
||||
standalone toggle -- the region buttons in the timeline bar -- carried
|
||||
aria-pressed and looked identical either way: a control that reports its state
|
||||
to a screen reader and not to the eye. */
|
||||
.cx-btn.cx-on {
|
||||
background: var(--accent-soft); border-color: var(--accent); color: var(--accent-hi);
|
||||
}
|
||||
.cx-btn-group .cx-btn.cx-on {
|
||||
position: relative; z-index: 1; /* its border wins over its neighbours' */
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,18 @@ import { roving } from "../internals/focus.js";
|
||||
|
||||
const TONES = new Set(["neutral", "primary", "danger", "ghost"]);
|
||||
|
||||
function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title } = {}) {
|
||||
function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title, pressed } = {}) {
|
||||
if (!TONES.has(tone)) throw new RangeError(`Unknown button tone "${tone}". Known: ${[...TONES].join(", ")}.`);
|
||||
|
||||
const node = el("button", {
|
||||
cls: ["cx-btn", `cx-btn-${size}`, `cx-btn-${tone}`, "cx-focusable"],
|
||||
attrs: { type, disabled: disabled || busy, title, "aria-busy": busy || undefined },
|
||||
attrs: { type, disabled: disabled || busy, title, "aria-busy": busy || undefined,
|
||||
// A toggle says so, or a screen reader reads "Assets" the same
|
||||
// whether the panel is open or shut. Absent unless the caller
|
||||
// passes one: most buttons are not toggles and must not claim to be.
|
||||
"aria-pressed": pressed === undefined ? undefined : String(Boolean(pressed)) },
|
||||
});
|
||||
if (pressed) node.classList.add("cx-on");
|
||||
|
||||
const spinner = el("span", { cls: "cx-btn-spin", attrs: { "aria-hidden": "true" } });
|
||||
const glyph = icon ? el("span", { cls: "cx-btn-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null;
|
||||
@@ -47,6 +52,10 @@ function base(size, { label, onClick, tone = "neutral", icon, disabled = false,
|
||||
else { node.removeAttribute("aria-busy"); spinner.remove(); }
|
||||
},
|
||||
setDisabled(value) { isDisabled = Boolean(value); sync(); },
|
||||
setPressed(value) {
|
||||
node.setAttribute("aria-pressed", String(Boolean(value)));
|
||||
node.classList.toggle("cx-on", Boolean(value));
|
||||
},
|
||||
get disabled() { return isDisabled; },
|
||||
get busy() { return isBusy; },
|
||||
destroy: () => node.remove(),
|
||||
|
||||
@@ -182,18 +182,33 @@ define("gallery", "cards", ({ items = [], value, onActivate } = {}) => {
|
||||
return { node, get value() { return current; }, destroy: () => node.remove() };
|
||||
});
|
||||
|
||||
/** A single scrolling row: filmstrips, segment strips, recent items. */
|
||||
define("gallery", "strip", ({ items = [], onActivate, label } = {}) => {
|
||||
const node = el("div", { cls: "cx-strip", attrs: { role: "list", "aria-label": label } });
|
||||
for (const item of items) {
|
||||
const cell = el("button", { cls: ["cx-strip-cell", "cx-focusable"],
|
||||
attrs: { type: "button", role: "listitem", title: item.label } });
|
||||
/**
|
||||
* A single scrolling row: filmstrips, segment strips, recent items.
|
||||
*
|
||||
* Selectable like its siblings, and through the same `collection` -- a strip is
|
||||
* the one shape whose whole job is "which of these am I on", and one that could
|
||||
* only be clicked THROUGH left the caller drawing its own current-item marker
|
||||
* over a row that did not have the concept.
|
||||
*/
|
||||
define("gallery", "strip", (props = {}) => {
|
||||
const node = el("div", { cls: "cx-strip", attrs: { role: "listbox", "aria-label": props.label } });
|
||||
|
||||
const api = collection(props, node, (item, on) => {
|
||||
const cell = el("button", { cls: ["cx-strip-cell", on ? "cx-on" : null, "cx-focusable"],
|
||||
attrs: option(on, { title: item.label, "aria-label": item.label }) });
|
||||
if (item.thumb) cell.append(el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } }));
|
||||
else cell.append(el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } }));
|
||||
cell.addEventListener("click", () => { if (onActivate) onActivate(item); });
|
||||
node.append(cell);
|
||||
}
|
||||
return { node, destroy: () => node.remove() };
|
||||
if (item.badge) cell.append(el("span", { cls: "cx-cell-badge", text: item.badge }));
|
||||
return cell;
|
||||
});
|
||||
|
||||
return {
|
||||
node,
|
||||
get value() { return api.value; },
|
||||
setValue: api.setValue,
|
||||
setItems: api.setItems,
|
||||
destroy: () => node.remove(),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -324,7 +324,7 @@ define("frame", "app", ({ header, main, footer } = {}) => {
|
||||
define("workspace", "docked", ({
|
||||
id = "workspace", centre, left, right,
|
||||
leftLabel = "Assets", rightLabel = "Properties",
|
||||
leftOpen = true, rightOpen = true, onToggle,
|
||||
leftOpen = true, rightOpen = true, onToggle, rails = true,
|
||||
} = {}) => {
|
||||
const node = el("div", { cls: "cx-workspace" });
|
||||
|
||||
@@ -375,8 +375,13 @@ define("workspace", "docked", ({
|
||||
"aria-expanded": String(state[which]) },
|
||||
on: { click: () => set(which, !state[which]) } });
|
||||
|
||||
const rail = el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`],
|
||||
children: button });
|
||||
// The rail is one glyph on the outer edge of the window. It is the fallback,
|
||||
// not the design: where the app has somewhere to put a NAMED toggle, it
|
||||
// passes rails:false and drives open/close through this handle instead --
|
||||
// "▎" on an edge is a control whose meaning has to be discovered.
|
||||
const rail = rails
|
||||
? el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`], children: button })
|
||||
: null;
|
||||
|
||||
panels[which] = panel;
|
||||
toggles[which] = button;
|
||||
@@ -423,7 +428,7 @@ define("workspace", "docked", ({
|
||||
|
||||
const l = side("left", leftLabel, left);
|
||||
const r = side("right", rightLabel, right);
|
||||
node.append(l.rail, l.panel, main, r.panel, r.rail);
|
||||
node.append(...[l.rail, l.panel, main, r.panel, r.rail].filter(Boolean));
|
||||
set("left", state.left, { remember: false });
|
||||
set("right", state.right, { remember: false });
|
||||
fit();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import { composer } from "../composer/composer.js";
|
||||
|
||||
export function createConstructor({ title = "Constructor" } = {}) {
|
||||
export function createConstructor({ title = "Constructor", onChange } = {}) {
|
||||
const empty = composer.emptyState.default({
|
||||
icon: "✎",
|
||||
title: "Nothing to write yet",
|
||||
@@ -40,8 +40,9 @@ export function createConstructor({ title = "Constructor" } = {}) {
|
||||
body: host,
|
||||
actions: [composer.button.md({ label: "Done", tone: "primary",
|
||||
onClick: () => window_ && window_.close("done") })],
|
||||
onClose: () => { window_ = null; },
|
||||
onClose: () => { window_ = null; if (onChange) onChange(); },
|
||||
});
|
||||
if (onChange) onChange();
|
||||
return window_;
|
||||
}
|
||||
|
||||
|
||||
+41
-7
@@ -28,7 +28,9 @@ export function build(root, handlers = {}) {
|
||||
|
||||
// Built here rather than on first open: a mount point has to exist when
|
||||
// modules mount, which is long before anyone opens a window.
|
||||
const constructor = createConstructor();
|
||||
// onChange, so the Constructor toggle is right when the window closes ITSELF --
|
||||
// its own Done button, Escape, or a click on the backdrop.
|
||||
const constructor = createConstructor({ onChange: () => syncRegions() });
|
||||
|
||||
// Centre: the result, and the timeline under it.
|
||||
const viewer = composer.viewer.media({ empty: "The result of the last run appears here." });
|
||||
@@ -57,23 +59,49 @@ export function build(root, handlers = {}) {
|
||||
// is a sentence, not a chip, and it has to sit under the button it is about
|
||||
// -- an inert setting is said where the run is started, not where it was
|
||||
// switched on.
|
||||
// The zone's body is held rather than built inline: boot replaces the stand-in
|
||||
// with the real timeline once there is a project, and layout stays ignorant of
|
||||
// what a scene is. The warning stays put -- it is about the run, not the list.
|
||||
const timelineEmpty = composer.emptyState.default({
|
||||
icon: "▭",
|
||||
title: "Nothing on the timeline",
|
||||
hint: "What you generate lands here, in the order it plays.",
|
||||
});
|
||||
// The region toggles, v4's arrangement: named buttons, grouped, in the bar
|
||||
// above the timeline. The kit's own fallback is a glyph on a rail at each
|
||||
// outer edge of the window -- fine for a kit that cannot know what its panels
|
||||
// are called, wrong here, where they have names and a bar to put them in. A
|
||||
// control whose meaning has to be discovered is not a control.
|
||||
//
|
||||
// They are toggles and say so (aria-pressed), and the workspace stays the one
|
||||
// source of truth for what is open: these ask it to toggle and are told back
|
||||
// through onToggle, so a panel closed by a narrow window updates its button.
|
||||
let ws = null;
|
||||
const regionButtons = [];
|
||||
const syncRegions = () => { for (const r of regionButtons) r.button.setPressed(r.isOn()); };
|
||||
const regionToggle = (label, isOn, act) => {
|
||||
const button = composer.button.sm({ label, pressed: isOn(), onClick: () => { act(); syncRegions(); } });
|
||||
regionButtons.push({ button, isOn });
|
||||
return button;
|
||||
};
|
||||
|
||||
const timelineBody = composer.region.stack({ gap: "sm", fill: true,
|
||||
children: [transport.warning, timelineEmpty] });
|
||||
const timeline = composer.panel.zone({
|
||||
title: "Timeline",
|
||||
actions: [
|
||||
...transport.actions,
|
||||
composer.button.sm({
|
||||
label: "Constructor",
|
||||
onClick: () => { if (handlers.onConstructor) handlers.onConstructor(); },
|
||||
regionToggle("Assets", () => Boolean(ws && ws.isOpen("left")),
|
||||
() => ws && ws.toggle("left")),
|
||||
regionToggle("Properties", () => Boolean(ws && ws.isOpen("right")),
|
||||
() => ws && ws.toggle("right")),
|
||||
regionToggle("Constructor", () => constructor.isOpen, () => {
|
||||
if (constructor.isOpen) constructor.close();
|
||||
else if (handlers.onConstructor) handlers.onConstructor();
|
||||
}),
|
||||
],
|
||||
status: transport.status,
|
||||
body: composer.region.stack({ gap: "sm", fill: true,
|
||||
children: [transport.warning, timelineEmpty] }),
|
||||
body: timelineBody,
|
||||
});
|
||||
|
||||
// The stand-ins below are EMPTY STATES, not labels: they say a region is
|
||||
@@ -127,7 +155,12 @@ export function build(root, handlers = {}) {
|
||||
left: assets,
|
||||
centre,
|
||||
right: properties,
|
||||
// Named toggles live in the timeline bar instead -- see regionToggle above.
|
||||
rails: false,
|
||||
onToggle: () => syncRegions(),
|
||||
});
|
||||
ws = workspace;
|
||||
syncRegions();
|
||||
|
||||
|
||||
// The menu bar: who this is, what is not a zone, and whether ComfyUI is
|
||||
@@ -154,7 +187,8 @@ export function build(root, handlers = {}) {
|
||||
offer(`generation.${point}`, generation.node, generationEmpty.node);
|
||||
}
|
||||
|
||||
return { workspace, assets, bin, preview, viewer, timeline, generation, settings,
|
||||
return { workspace, assets, bin, preview, viewer, timeline, timelineBody,
|
||||
timelineEmpty, generation, settings, syncRegions,
|
||||
properties, transport, constructor, menubar,
|
||||
connection: menubar.connection, bar, page };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// The project: an ordered list of scenes, and the only thing in the app that
|
||||
// has to survive a reload.
|
||||
//
|
||||
// Held here rather than in the timeline that draws it, for the reason the
|
||||
// pipeline is held in boot: one store of live values. A timeline that owned the
|
||||
// project would make "what is on screen" and "what a run uses" two questions
|
||||
// with two answers.
|
||||
//
|
||||
// Saving is DEBOUNCED and last-write-wins per project. Typing a prompt is one
|
||||
// edit per keystroke and each is a PUT of the whole project; without this the
|
||||
// server answers them out of order and an older body lands last.
|
||||
|
||||
const BASE = "/funpack/api/projects";
|
||||
const SAVE_AFTER = 600;
|
||||
|
||||
/**
|
||||
* A scene id the server will keep.
|
||||
*
|
||||
* Twelve hex characters is what core/projects.py generates and the only shape it
|
||||
* accepts back; anything else it replaces with one of its own. Minting a valid
|
||||
* one here means a new scene is addressable the moment it appears, instead of
|
||||
* being unselectable until a save round trip has answered.
|
||||
*/
|
||||
function newId() {
|
||||
const bytes = new Uint8Array(6);
|
||||
(globalThis.crypto || {}).getRandomValues?.(bytes);
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function json(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try { detail = ((await res.json()).problems || [])[0] || ""; } catch { /* not JSON */ }
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
export const list = () => json("GET", BASE).then((d) => d.projects || []);
|
||||
export const create = (name) => json("POST", BASE, { name });
|
||||
export const read = (id) => json("GET", `${BASE}/${encodeURIComponent(id)}`);
|
||||
export const remove = (id) => json("DELETE", `${BASE}/${encodeURIComponent(id)}`);
|
||||
const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}`, project);
|
||||
|
||||
/**
|
||||
* createProject({ onChange, onError }) -> the live project.
|
||||
*
|
||||
* `onChange` fires for anything that alters what is on screen, including the
|
||||
* selection. Nothing here draws; the timeline subscribes.
|
||||
*/
|
||||
export function createProject({ onChange, onError } = {}) {
|
||||
let project = null;
|
||||
let selected = null;
|
||||
let timer = null;
|
||||
let saving = null; // the PUT in flight, so a queued one waits for it
|
||||
let dirty = false;
|
||||
|
||||
const changed = () => { if (onChange) onChange(); };
|
||||
|
||||
function scheduleSave() {
|
||||
dirty = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, SAVE_AFTER);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (!project || !dirty) return;
|
||||
// One PUT at a time. Two overlapping writes of a whole project can land in
|
||||
// either order, and the loser is a version of the project the user has
|
||||
// already moved past.
|
||||
if (saving) { await saving.catch(() => {}); if (!dirty) return; }
|
||||
dirty = false;
|
||||
const body = { ...project };
|
||||
saving = put(body).then((saved) => {
|
||||
// Only the fields the server owns are taken back: replacing the whole
|
||||
// project would overwrite whatever was typed while the PUT was in flight.
|
||||
if (project && saved && project.id === saved.id) project.updated_at = saved.updated_at;
|
||||
}).catch((err) => {
|
||||
dirty = true; // it is still unsaved, so say so
|
||||
if (onError) onError(err);
|
||||
}).finally(() => { saving = null; });
|
||||
return saving;
|
||||
}
|
||||
|
||||
const sceneAt = (id) => (project ? (project.scenes || []).find((s) => s.id === id) : null);
|
||||
|
||||
return {
|
||||
get project() { return project; },
|
||||
get scenes() { return project ? project.scenes || [] : []; },
|
||||
get selectedId() { return selected; },
|
||||
get selected() { return sceneAt(selected); },
|
||||
|
||||
/** The most recent project, or a new one when there are none. */
|
||||
async start() {
|
||||
const found = await list();
|
||||
project = found.length ? await read(found[0].id) : await create("Untitled");
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
changed();
|
||||
return project;
|
||||
},
|
||||
|
||||
async open(id) {
|
||||
await flush();
|
||||
project = await read(id);
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
changed();
|
||||
return project;
|
||||
},
|
||||
|
||||
async newProject(name) {
|
||||
await flush();
|
||||
project = await create(name);
|
||||
selected = (project.scenes || [])[0]?.id ?? null;
|
||||
changed();
|
||||
return project;
|
||||
},
|
||||
|
||||
select(id) {
|
||||
if (!sceneAt(id) || id === selected) return;
|
||||
selected = id;
|
||||
changed(); // selection is not saved: it is a view
|
||||
},
|
||||
|
||||
/** The text of one scene. The prompt box on the main window edits this. */
|
||||
setText(id, text) {
|
||||
const scene = sceneAt(id);
|
||||
if (!scene || scene.text === text) return;
|
||||
scene.text = text;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
addScene() {
|
||||
if (!project) return null;
|
||||
// A new scene lands AFTER the selected one, not at the end: adding partway
|
||||
// through a timeline is how a scene gets inserted, and appending silently
|
||||
// would put it somewhere the user was not looking.
|
||||
const at = project.scenes.findIndex((s) => s.id === selected);
|
||||
const scene = { id: newId(), text: "", result: null };
|
||||
project.scenes.splice(at < 0 ? project.scenes.length : at + 1, 0, scene);
|
||||
selected = scene.id; // you add a scene in order to fill it
|
||||
scheduleSave();
|
||||
changed();
|
||||
return scene;
|
||||
},
|
||||
|
||||
removeScene(id) {
|
||||
if (!project || !sceneAt(id)) return;
|
||||
const at = project.scenes.findIndex((s) => s.id === id);
|
||||
project.scenes.splice(at, 1);
|
||||
if (selected === id) {
|
||||
const next = project.scenes[at] || project.scenes[at - 1];
|
||||
selected = next ? next.id : null;
|
||||
}
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
move(id, by) {
|
||||
if (!project) return;
|
||||
const at = project.scenes.findIndex((s) => s.id === id);
|
||||
const to = at + by;
|
||||
if (at < 0 || to < 0 || to >= project.scenes.length) return;
|
||||
const [scene] = project.scenes.splice(at, 1);
|
||||
project.scenes.splice(to, 0, scene);
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
/** Attach what a run produced to the scene it was started from. */
|
||||
setResult(id, result) {
|
||||
const scene = sceneAt(id);
|
||||
if (!scene) return;
|
||||
scene.result = result;
|
||||
scheduleSave();
|
||||
changed();
|
||||
},
|
||||
|
||||
flush,
|
||||
get unsaved() { return dirty; },
|
||||
};
|
||||
}
|
||||
+14
-2
@@ -58,7 +58,7 @@ export function createPrompts(slots = [], { onChange } = {}) {
|
||||
control,
|
||||
});
|
||||
host.appendChild(field.node);
|
||||
fields.push({ slot: slot.id, input: role.input, control, field });
|
||||
fields.push({ slot: slot.id, input: role.input, at: role.at, control, field });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,19 @@ export function createPrompts(slots = [], { onChange } = {}) {
|
||||
*/
|
||||
sync(next) { build(next); },
|
||||
|
||||
get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input })); },
|
||||
get fields() { return fields.map((f) => ({ slot: f.slot, input: f.input, at: f.at })); },
|
||||
|
||||
/**
|
||||
* The control mounted at a place, or null.
|
||||
*
|
||||
* What makes a scene's text editable: the timeline owns the text and this is
|
||||
* the box it is typed in, so the two are bound rather than each holding
|
||||
* their own copy of a prompt.
|
||||
*/
|
||||
at(point) {
|
||||
const found = fields.find((f) => f.at === point);
|
||||
return found ? found.control : null;
|
||||
},
|
||||
destroy() { build([]); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// The timeline: the project's scenes, in the order they play.
|
||||
//
|
||||
// It draws and it dispatches. The project itself lives in projects.js, so this
|
||||
// file holds no scene state of its own -- a timeline that kept its own copy is
|
||||
// how "what is on screen" and "what a run uses" become two questions.
|
||||
//
|
||||
// One scene is always current, and the prompt box on the main window edits THAT
|
||||
// scene. v4 had one prompt for the whole project and split it by markers in the
|
||||
// text, which meant the text was both the content and the structure: editing a
|
||||
// scene could re-cut every other one.
|
||||
|
||||
import { composer } from "../composer/composer.js";
|
||||
|
||||
const EMPTY = {
|
||||
icon: "▭",
|
||||
title: "Nothing on the timeline",
|
||||
hint: "What you generate lands here, in the order it plays.",
|
||||
};
|
||||
|
||||
/**
|
||||
* createTimeline({ project, onSelect }) -> { node, draw, destroy }
|
||||
*
|
||||
* `project` is the store from projects.js. `onSelect` fires whenever the current
|
||||
* scene changes, including when a removal moves it -- the prompt box follows it.
|
||||
*/
|
||||
export function createTimeline({ project, onSelect } = {}) {
|
||||
const host = composer.region.stack({ gap: "sm", fill: true });
|
||||
const empty = composer.emptyState.default(EMPTY);
|
||||
let strip = null;
|
||||
|
||||
// Rebuilt on every draw, so a stale handler cannot act on a scene that has
|
||||
// moved. The buttons that act on "the current scene" read it at click time
|
||||
// rather than closing over it for the same reason.
|
||||
const controls = composer.toolbar.default({
|
||||
label: "Scenes",
|
||||
items: [
|
||||
composer.button.sm({ label: "Add scene", onClick: () => add() }),
|
||||
composer.button.sm({ label: "Remove", onClick: () => remove() }),
|
||||
composer.iconButton.sm({ icon: "◀", label: "Move earlier", onClick: () => move(-1) }),
|
||||
composer.iconButton.sm({ icon: "▶", label: "Move later", onClick: () => move(1) }),
|
||||
],
|
||||
});
|
||||
|
||||
function add() {
|
||||
project.addScene();
|
||||
announce();
|
||||
}
|
||||
|
||||
function remove() {
|
||||
const id = project.selectedId;
|
||||
// The last scene stays. A project with no scenes has nowhere to type, and
|
||||
// the Add button would be the only thing on the timeline that did anything.
|
||||
if (!id || project.scenes.length <= 1) return;
|
||||
project.removeScene(id);
|
||||
announce();
|
||||
}
|
||||
|
||||
function move(by) {
|
||||
if (project.selectedId) project.move(project.selectedId, by);
|
||||
}
|
||||
|
||||
function announce() {
|
||||
if (onSelect) onSelect(project.selected || null);
|
||||
}
|
||||
|
||||
function items() {
|
||||
return project.scenes.map((scene, i) => ({
|
||||
id: scene.id,
|
||||
// The number is what a scene is called before it has any text, and the
|
||||
// text is what it is called after -- a strip of "Scene 1..8" is a strip
|
||||
// nobody can read their own project off.
|
||||
label: scene.text ? `${i + 1}. ${scene.text}` : `Scene ${i + 1}`,
|
||||
badge: String(i + 1),
|
||||
thumb: scene.result || null,
|
||||
icon: "▦",
|
||||
}));
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const scenes = project.scenes;
|
||||
if (!scenes.length) {
|
||||
host.set([empty]);
|
||||
strip = null;
|
||||
return;
|
||||
}
|
||||
if (!strip) {
|
||||
strip = composer.gallery.strip({
|
||||
label: "Scenes",
|
||||
items: items(),
|
||||
selection: project.selectedId ? [project.selectedId] : [],
|
||||
onActivate: (item) => { project.select(item.id); announce(); },
|
||||
});
|
||||
host.set([controls, strip]);
|
||||
return;
|
||||
}
|
||||
// setItems then setValue: setValue redraws against whatever items are
|
||||
// current, so seeding the selection first marks a row that is about to be
|
||||
// replaced and the strip comes back with nothing on.
|
||||
strip.setItems(items());
|
||||
strip.setValue(project.selectedId ? [project.selectedId] : []);
|
||||
}
|
||||
|
||||
draw();
|
||||
|
||||
return {
|
||||
node: host.node,
|
||||
draw,
|
||||
destroy() {
|
||||
if (strip) strip.destroy();
|
||||
controls.destroy();
|
||||
empty.destroy();
|
||||
host.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,3 +14,8 @@ UI_PREFIX = "/funpack"
|
||||
# a module cannot ship a stylesheet even if someone writes one.
|
||||
APP_EXTS = frozenset({".js", ".css", ".html", ".woff2", ".svg"})
|
||||
MODULE_EXTS = frozenset({".js"})
|
||||
|
||||
# Where projects are kept. Inside the pack rather than ComfyUI's output tree: a
|
||||
# project is FunPack's own state, and clearing outputs must not take a user's
|
||||
# edits with it.
|
||||
PROJECTS_DIR = ROOT / "projects"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Projects: an ordered list of scenes, one JSON file each.
|
||||
|
||||
A scene owns its prompt text and the result it last produced. Nothing else about
|
||||
a scene lives here. v4's Scene accumulated three dozen fields over its life --
|
||||
transitions, effects, audio gain, per-scene frames, guide sources -- and each of
|
||||
those belongs to the feature that needs it, added when that feature lands, not to
|
||||
the model every part of the app has to load.
|
||||
|
||||
Per-scene length and fps are deliberately absent: regeneration uses the PROJECT's
|
||||
values, never a scene's own, so a scene cropped on the timeline and regenerated
|
||||
comes back whole. A crop is a timeline decision; a regenerate is a new scene.
|
||||
|
||||
Synchronous, one file per project, stdlib json. Projects are small.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
from . import config
|
||||
|
||||
#: A generated id, never a user-supplied name, is what reaches the filesystem.
|
||||
_ID = re.compile(r"\A[0-9a-f]{12}\Z")
|
||||
|
||||
MAX_NAME = 120
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def is_id(value) -> bool:
|
||||
"""True for something this module generated. Everything from a request is
|
||||
checked against this before it is used to build a path."""
|
||||
return isinstance(value, str) and bool(_ID.match(value))
|
||||
|
||||
|
||||
def _clean_name(raw, fallback="Untitled") -> str:
|
||||
name = (raw if isinstance(raw, str) else "").strip()
|
||||
return name[:MAX_NAME] or fallback
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scene:
|
||||
id: str = field(default_factory=_new_id)
|
||||
text: str = ""
|
||||
#: The asset this scene last produced, so a reload shows the timeline the
|
||||
#: user left rather than an empty one they have to regenerate.
|
||||
result: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d) -> "Scene":
|
||||
d = d if isinstance(d, dict) else {}
|
||||
sid = d.get("id")
|
||||
result = d.get("result")
|
||||
return Scene(
|
||||
id=sid if is_id(sid) else _new_id(),
|
||||
text=d.get("text") if isinstance(d.get("text"), str) else "",
|
||||
result=result if isinstance(result, str) else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
id: str = field(default_factory=_new_id)
|
||||
name: str = "Untitled"
|
||||
scenes: list[Scene] = field(default_factory=list)
|
||||
updated_at: float = 0.0
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d) -> "Project":
|
||||
d = d if isinstance(d, dict) else {}
|
||||
pid = d.get("id")
|
||||
raw = d.get("scenes")
|
||||
return Project(
|
||||
id=pid if is_id(pid) else _new_id(),
|
||||
name=_clean_name(d.get("name")),
|
||||
scenes=[Scene.from_dict(s) for s in (raw if isinstance(raw, list) else [])],
|
||||
updated_at=float(d.get("updated_at") or 0.0),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _dir():
|
||||
config.PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return config.PROJECTS_DIR
|
||||
|
||||
|
||||
def _path(project_id: str):
|
||||
# is_id, not a sanitiser: a name that merely survives cleaning can still be
|
||||
# "..", and a store that repairs a bad id quietly is one that writes
|
||||
# somewhere nobody asked for.
|
||||
if not is_id(project_id):
|
||||
raise ValueError(f"not a project id: {project_id!r}")
|
||||
return _dir() / f"{project_id}.json"
|
||||
|
||||
|
||||
def listing() -> list[dict]:
|
||||
"""Every project, newest first, as the little the picker needs."""
|
||||
out = []
|
||||
for p in _dir().glob("*.json"):
|
||||
if not is_id(p.stem):
|
||||
continue
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
continue # an unreadable file is not a reason to have no list
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
out.append({
|
||||
"id": p.stem,
|
||||
"name": _clean_name(d.get("name"), p.stem),
|
||||
"scenes": len(d.get("scenes") or []),
|
||||
"updated_at": float(d.get("updated_at") or 0.0),
|
||||
})
|
||||
out.sort(key=lambda x: x["updated_at"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def get(project_id: str) -> Project | None:
|
||||
try:
|
||||
path = _path(project_id)
|
||||
except ValueError:
|
||||
return None
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return Project.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def save(project: Project) -> Project:
|
||||
project.updated_at = time.time()
|
||||
path = _path(project.id)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(project.to_dict(), indent=2), encoding="utf-8")
|
||||
tmp.replace(path) # a concurrent read never sees a half-written project
|
||||
return project
|
||||
|
||||
|
||||
def create(name=None) -> Project:
|
||||
"""A new project with one empty scene: a timeline with nothing on it cannot
|
||||
be typed into, so a brand-new project would need an Add before it could be
|
||||
used at all."""
|
||||
return save(Project(name=_clean_name(name), scenes=[Scene()]))
|
||||
|
||||
|
||||
def delete(project_id: str) -> bool:
|
||||
try:
|
||||
path = _path(project_id)
|
||||
except ValueError:
|
||||
return False
|
||||
if not path.exists():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
+51
-2
@@ -7,8 +7,8 @@ thin adapters over pure functions in `serve`.
|
||||
|
||||
import json
|
||||
|
||||
from . import (config, graph as graph_mod, log, registry as registry_mod,
|
||||
serve as static, widgets)
|
||||
from . import (config, graph as graph_mod, log, projects,
|
||||
registry as registry_mod, serve as static, widgets)
|
||||
from .contract import CONTRACT_VERSION
|
||||
from .relations import order
|
||||
from .traits import split
|
||||
@@ -277,6 +277,55 @@ def register(routes, prefix=None):
|
||||
return web.json_response(
|
||||
widgets.search(req.query.get("q", ""), max(1, min(limit, 200))))
|
||||
|
||||
# ── projects ──────────────────────────────────────────────────────────
|
||||
# What the timeline IS. A run produces one clip; a project is the ordered
|
||||
# list of them the user is making, and the only part of the app that has to
|
||||
# survive a reload.
|
||||
|
||||
async def _body(req):
|
||||
"""The request's JSON object, or None. A body that is not an object is
|
||||
not a project edit -- `[]` and `"x"` both reach `.get` otherwise."""
|
||||
try:
|
||||
data = await req.json()
|
||||
except Exception: # noqa: BLE001 - a malformed body is the caller's
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
@routes.get(P + "/api/projects")
|
||||
async def _projects_list(_req):
|
||||
return web.json_response({"projects": projects.listing()})
|
||||
|
||||
@routes.post(P + "/api/projects")
|
||||
async def _projects_create(req):
|
||||
body = await _body(req) or {}
|
||||
return web.json_response(projects.create(body.get("name")).to_dict())
|
||||
|
||||
@routes.get(P + "/api/projects/{pid}")
|
||||
async def _projects_get(req):
|
||||
found = projects.get(req.match_info["pid"])
|
||||
if found is None:
|
||||
return web.json_response({"problems": ["no such project"]}, status=404)
|
||||
return web.json_response(found.to_dict())
|
||||
|
||||
@routes.put(P + "/api/projects/{pid}")
|
||||
async def _projects_save(req):
|
||||
pid = req.match_info["pid"]
|
||||
body = await _body(req)
|
||||
if body is None:
|
||||
return web.json_response({"problems": ["expected a project object"]}, status=400)
|
||||
if projects.get(pid) is None:
|
||||
return web.json_response({"problems": ["no such project"]}, status=404)
|
||||
# The id comes from the URL, never the body: a PUT that names its own
|
||||
# target is a PUT that can write over a different project.
|
||||
return web.json_response(
|
||||
projects.save(projects.Project.from_dict({**body, "id": pid})).to_dict())
|
||||
|
||||
@routes.delete(P + "/api/projects/{pid}")
|
||||
async def _projects_delete(req):
|
||||
if not projects.delete(req.match_info["pid"]):
|
||||
return web.json_response({"problems": ["no such project"]}, status=404)
|
||||
return web.json_response({"deleted": True})
|
||||
|
||||
@routes.get(P + "/api/log")
|
||||
async def _log(req):
|
||||
level = req.query.get("level") or None
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""The project store, and the same store over HTTP.
|
||||
|
||||
A project is the one thing in the app that has to survive a reload, so the tests
|
||||
that matter here are about what happens to it when the input is not what the app
|
||||
would have sent: a hand-edited file, a body that is not an object, an id that is
|
||||
really a path.
|
||||
"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from core import config, projects
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "PROJECTS_DIR", tmp_path / "projects")
|
||||
return tmp_path / "projects"
|
||||
|
||||
|
||||
# ── the model ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_new_project_can_be_typed_into(store):
|
||||
"""One empty scene, not none: a timeline with nothing on it has nowhere to
|
||||
put a prompt, so an empty new project would need an Add before it was usable."""
|
||||
made = projects.create("First")
|
||||
assert made.name == "First"
|
||||
assert len(made.scenes) == 1
|
||||
assert made.scenes[0].text == ""
|
||||
|
||||
|
||||
def test_a_project_survives_a_round_trip(store):
|
||||
made = projects.create("Round trip")
|
||||
made.scenes.append(projects.Scene(text="a wolf on a ridge"))
|
||||
projects.save(made)
|
||||
|
||||
read = projects.get(made.id)
|
||||
assert [s.text for s in read.scenes] == ["", "a wolf on a ridge"]
|
||||
assert read.name == "Round trip"
|
||||
|
||||
|
||||
def test_scenes_keep_their_order(store):
|
||||
made = projects.create("Ordered")
|
||||
made.scenes = [projects.Scene(text=str(i)) for i in range(6)]
|
||||
projects.save(made)
|
||||
assert [s.text for s in projects.get(made.id).scenes] == [str(i) for i in range(6)]
|
||||
|
||||
|
||||
def test_a_hand_edited_file_does_not_crash_the_app(store):
|
||||
"""The store reads files a person can edit. Every one of these used to be an
|
||||
attribute error on a dict that was a list, or a str that was None."""
|
||||
made = projects.create("Edited")
|
||||
path = store / f"{made.id}.json"
|
||||
|
||||
for body in ('{"scenes": "not a list"}', '{"scenes": [null, 3, "x"]}',
|
||||
'{"name": 42}', '[]', '"just a string"', '{}'):
|
||||
path.write_text(body, encoding="utf-8")
|
||||
read = projects.get(made.id)
|
||||
assert read is not None, body
|
||||
assert isinstance(read.scenes, list)
|
||||
assert isinstance(read.name, str)
|
||||
|
||||
|
||||
def test_unreadable_json_is_not_a_project(store):
|
||||
made = projects.create("Broken")
|
||||
(store / f"{made.id}.json").write_text("{oh no", encoding="utf-8")
|
||||
assert projects.get(made.id) is None
|
||||
assert projects.listing() == [] # and it does not take the list down with it
|
||||
|
||||
|
||||
def test_a_scene_without_an_id_gets_one(store):
|
||||
"""Ids address a scene from the client. Two scenes sharing one, or a scene
|
||||
with none, is a selection that lands on the wrong row."""
|
||||
p = projects.Project.from_dict({"scenes": [{"text": "a"}, {"text": "b"}]})
|
||||
ids = [s.id for s in p.scenes]
|
||||
assert all(projects.is_id(i) for i in ids)
|
||||
assert len(set(ids)) == 2
|
||||
|
||||
|
||||
def test_an_id_from_a_request_cannot_be_a_path(store):
|
||||
"""The filename is a generated id or the call fails. A sanitiser that
|
||||
repairs "../../x" writes somewhere nobody asked for and says it saved."""
|
||||
for bad in ("../escape", "a/b", "", "..", "x" * 64, None, 5, "ABCDEF123456"):
|
||||
assert projects.is_id(bad) is False
|
||||
assert projects.get(bad) is None
|
||||
assert projects.delete(bad) is False
|
||||
|
||||
|
||||
def test_the_listing_is_newest_first(store):
|
||||
a = projects.create("A")
|
||||
b = projects.create("B")
|
||||
a.name = "A again"
|
||||
projects.save(a) # touches updated_at
|
||||
assert [p["id"] for p in projects.listing()] == [a.id, b.id]
|
||||
assert [p["name"] for p in projects.listing()][0] == "A again"
|
||||
|
||||
|
||||
def test_a_long_name_is_cut_not_refused(store):
|
||||
made = projects.create("x" * 500)
|
||||
assert len(made.name) == projects.MAX_NAME
|
||||
|
||||
|
||||
def test_delete_removes_it(store):
|
||||
made = projects.create("Doomed")
|
||||
assert projects.delete(made.id) is True
|
||||
assert projects.get(made.id) is None
|
||||
assert projects.delete(made.id) is False
|
||||
|
||||
|
||||
def test_a_save_is_atomic(store):
|
||||
"""A reader must never see half a project. The temp file is what makes that
|
||||
true, and a leftover .tmp in the directory would be read as one."""
|
||||
made = projects.create("Atomic")
|
||||
projects.save(made)
|
||||
assert list(store.glob("*.tmp")) == []
|
||||
assert len(projects.listing()) == 1
|
||||
|
||||
|
||||
# ── over HTTP ────────────────────────────────────────────────────────────
|
||||
|
||||
pytest.importorskip("aiohttp")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(store):
|
||||
from aiohttp import web as aioweb
|
||||
from core import routes
|
||||
|
||||
app = aioweb.Application()
|
||||
table = aioweb.RouteTableDef()
|
||||
routes.register(table, prefix="/funpack")
|
||||
app.add_routes(table)
|
||||
|
||||
holder = {"ready": threading.Event()}
|
||||
|
||||
def run():
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
runner = aioweb.AppRunner(app)
|
||||
loop.run_until_complete(runner.setup())
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
loop.run_until_complete(site.start())
|
||||
holder["port"] = site._server.sockets[0].getsockname()[1]
|
||||
holder["loop"] = loop
|
||||
holder["ready"].set()
|
||||
loop.run_forever()
|
||||
|
||||
thread = threading.Thread(target=run, daemon=True)
|
||||
thread.start()
|
||||
assert holder["ready"].wait(20), "the test server did not start"
|
||||
yield holder["port"]
|
||||
holder["loop"].call_soon_threadsafe(holder["loop"].stop)
|
||||
|
||||
|
||||
def _request(port, method, path, body=None):
|
||||
payload = b"" if body is None else json.dumps(body).encode()
|
||||
head = (f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
|
||||
f"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {len(payload)}\r\nConnection: close\r\n\r\n").encode()
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=30) as sock:
|
||||
sock.sendall(head + payload)
|
||||
chunks = []
|
||||
while True:
|
||||
got = sock.recv(65536)
|
||||
if not got:
|
||||
break
|
||||
chunks.append(got)
|
||||
header, _, rest = b"".join(chunks).partition(b"\r\n\r\n")
|
||||
status = int(header.split()[1])
|
||||
try:
|
||||
return status, json.loads(rest.decode() or "{}")
|
||||
except ValueError:
|
||||
return status, {"raw": rest.decode(errors="replace")}
|
||||
|
||||
|
||||
def test_create_list_read_save_delete(server):
|
||||
status, made = _request(server, "POST", "/funpack/api/projects", {"name": "HTTP"})
|
||||
assert status == 200 and made["name"] == "HTTP"
|
||||
pid = made["id"]
|
||||
|
||||
status, listed = _request(server, "GET", "/funpack/api/projects")
|
||||
assert status == 200 and [p["id"] for p in listed["projects"]] == [pid]
|
||||
assert listed["projects"][0]["scenes"] == 1
|
||||
|
||||
made["scenes"].append({"text": "a second scene"})
|
||||
status, saved = _request(server, "PUT", f"/funpack/api/projects/{pid}", made)
|
||||
assert status == 200 and len(saved["scenes"]) == 2
|
||||
|
||||
status, read = _request(server, "GET", f"/funpack/api/projects/{pid}")
|
||||
assert status == 200 and read["scenes"][1]["text"] == "a second scene"
|
||||
|
||||
assert _request(server, "DELETE", f"/funpack/api/projects/{pid}")[0] == 200
|
||||
assert _request(server, "GET", f"/funpack/api/projects/{pid}")[0] == 404
|
||||
|
||||
|
||||
def test_a_put_cannot_rename_its_own_target(server):
|
||||
"""The id comes from the URL. A body that could name a different project is
|
||||
a write over someone else's work, reported as a success."""
|
||||
_, first = _request(server, "POST", "/funpack/api/projects", {"name": "First"})
|
||||
_, second = _request(server, "POST", "/funpack/api/projects", {"name": "Second"})
|
||||
|
||||
_request(server, "PUT", f"/funpack/api/projects/{first['id']}",
|
||||
{**first, "id": second["id"], "name": "Overwritten"})
|
||||
|
||||
_, untouched = _request(server, "GET", f"/funpack/api/projects/{second['id']}")
|
||||
assert untouched["name"] == "Second"
|
||||
_, target = _request(server, "GET", f"/funpack/api/projects/{first['id']}")
|
||||
assert target["name"] == "Overwritten"
|
||||
|
||||
|
||||
def test_a_body_that_is_not_an_object_is_refused(server):
|
||||
_, made = _request(server, "POST", "/funpack/api/projects", {"name": "Shape"})
|
||||
for body in ([], "text", 7):
|
||||
status, _ = _request(server, "PUT", f"/funpack/api/projects/{made['id']}", body)
|
||||
assert status == 400, body
|
||||
|
||||
|
||||
def test_saving_something_that_is_not_there_is_a_404(server):
|
||||
status, _ = _request(server, "PUT", "/funpack/api/projects/0123456789ab", {"name": "Ghost"})
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_a_path_shaped_id_is_refused_not_served(server):
|
||||
for pid in ("..", "%2e%2e", "a/b"):
|
||||
status, _ = _request(server, "GET", f"/funpack/api/projects/{pid}")
|
||||
assert status in (404, 400), pid
|
||||
Reference in New Issue
Block a user