Split the shell into topbar, pool, stage, timeline and params panes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ddabfbeaa8
commit
179770d7d4
24 changed files with 2138 additions and 611 deletions
|
|
@ -185,33 +185,90 @@ const PROBE = `(() => {
|
|||
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
|
||||
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
|
||||
hash: h >>> 0,
|
||||
frame: document.querySelector('.readout span')?.textContent ?? '',
|
||||
selectedClip: [...document.querySelectorAll('.transport .row button')]
|
||||
.filter((b) => b.classList.contains('on')).map((b) => b.textContent),
|
||||
frame: document.querySelector('.time .pane-head .dim')?.textContent ?? '',
|
||||
// What the top bar says about the document. The media pool holds the OPEN
|
||||
// document's library now and no longer names documents at all, so the status
|
||||
// line is the page's own answer to "what am I looking at".
|
||||
doc: document.querySelector('.top .status')?.textContent ?? '',
|
||||
};
|
||||
})()`;
|
||||
|
||||
// The scrubber is the timeline's ruler — there is no range input any more — so a
|
||||
// seek is a pointer event at the frame's own column. The ruler maps x to
|
||||
// floor(x / width * frames), and a mark that names one frame sits at its centre,
|
||||
// so (f + 0.5) lands inside f and nowhere near its neighbours.
|
||||
//
|
||||
// The frame count comes off the readout rather than being passed in: the caller
|
||||
// knows which frame it wants, not how long the clip it is looking at happens to
|
||||
// be, and reading it here is one place instead of every call site.
|
||||
const SEEK = (f) => `(() => {
|
||||
const el = document.querySelector('input.scrub');
|
||||
// React installs its own value setter on the element, so assigning .value
|
||||
// directly updates the DOM and not React's idea of it, and onChange never
|
||||
// fires. The prototype-level setter plus a bubbling 'input' event is what
|
||||
// React's synthetic onChange actually listens for.
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
||||
set.call(el, '${f}');
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return el.value;
|
||||
const read = document.querySelector('.time .pane-head .dim').textContent;
|
||||
// Split, not a regex: this is inside a template literal, where an escaped
|
||||
// slash collapses to a bare one and the two together open a line comment that
|
||||
// eats the rest of the statement. The readout is "12 / 229" and nothing else.
|
||||
const frames = Number(read.split('/')[1]);
|
||||
if (!frames) return 'no readout';
|
||||
const ruler = document.querySelector('.tl-ruler');
|
||||
const box = ruler.getBoundingClientRect();
|
||||
ruler.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, pointerId: 1,
|
||||
clientX: box.left + (${f} + 0.5) * box.width / frames,
|
||||
clientY: box.top + box.height / 2,
|
||||
}));
|
||||
ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
|
||||
return document.querySelector('.time .pane-head .dim').textContent;
|
||||
})()`;
|
||||
|
||||
// Everything the page has to say about loading, saving and opening. Read off the
|
||||
// page rather than out of app-db, for the same reason the playhead is: what the
|
||||
// page SHOWS is what a person would check.
|
||||
const STATUS = `[...document.querySelectorAll('.load-status')].map((d) => d.textContent).join(' | ')`;
|
||||
const STATUS = `[...document.querySelectorAll('.top .status, .pool .pane-body > .dim')]
|
||||
.map((d) => d.textContent).join(' | ')`;
|
||||
|
||||
// Any button on the page, by its own label. The controls are spread across five
|
||||
// panes now — the transport is in the timeline's header, save and open are in the
|
||||
// top bar, the clips are rows in the media pool — and a helper that knew which
|
||||
// pane each one lived in would be a second copy of the layout.
|
||||
//
|
||||
// `firstChild` is the label: a media-pool row has a second line in a child span,
|
||||
// so matching on textContent would never find "take".
|
||||
const CLICK = (label) => `(() => {
|
||||
const b = [...document.querySelectorAll('.transport button')]
|
||||
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
|
||||
if (!b) return false;
|
||||
const b = [...document.querySelectorAll('button')]
|
||||
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)});
|
||||
if (!b || b.disabled) return false;
|
||||
b.click();
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
// Documents are behind File -> Open now, not in the media pool: the pool holds
|
||||
// the OPEN document's library, and a whole project is not a thing you put on a
|
||||
// stage. So reaching a built-in scene or a saved project is two clicks with a
|
||||
// turn of the event loop between them, as it is for anybody using the app.
|
||||
const MENU_OPEN = `(() => {
|
||||
if (document.querySelector('.menu')) return true;
|
||||
const b = [...document.querySelectorAll('button')]
|
||||
.find((b) => b.textContent.trim() === 'open \u25be');
|
||||
if (!b || b.disabled) return false;
|
||||
b.click();
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
// Scoped to `.example`, and it has to be: the local server accumulates a saved
|
||||
// project called "take" on every run of this suite, so an unscoped match by
|
||||
// label picks whichever of those sorted first and the fixture is never reached.
|
||||
const MENU_PICK = (label) => `(() => {
|
||||
const b = [...document.querySelectorAll('.menu .menu-item.example')]
|
||||
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)});
|
||||
if (!b || b.disabled) return false;
|
||||
b.click();
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
// The first row under PROJECTS, which the server orders by -updated: this is
|
||||
// what "open" meant before there was a list to pick from.
|
||||
const MENU_PICK_NEWEST = `(() => {
|
||||
const b = document.querySelector('.menu .menu-item:not(.example)');
|
||||
if (!b || b.disabled) return false;
|
||||
b.click();
|
||||
return true;
|
||||
})()`;
|
||||
|
|
@ -223,24 +280,44 @@ async function main() {
|
|||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const page = await connect();
|
||||
const fromMenu = async (what) => {
|
||||
if (!(await page.eval(MENU_OPEN))) return false;
|
||||
await sleep(150);
|
||||
return page.eval(what);
|
||||
};
|
||||
try {
|
||||
// Mounted, and painting. Polled rather than waited on a fixed delay: the
|
||||
// canvas :ref fires after the loop starts, so there genuinely is a window in
|
||||
// which the page is up and the canvas is blank.
|
||||
// Mounted. Polled rather than waited on a fixed delay: the canvas :ref fires
|
||||
// after the loop starts, so there genuinely is a window in which the page is
|
||||
// up and there is nothing to measure.
|
||||
//
|
||||
// It is NOT polled for pixels any more. The app opens on a blank document —
|
||||
// no clip is loaded until one is asked for — so "the canvas has drawn
|
||||
// something" is now a thing this suite makes happen rather than a thing it
|
||||
// waits for.
|
||||
let probe = null;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
probe = await page.eval(PROBE);
|
||||
if (probe && probe.drawn > 0) break;
|
||||
if (probe && probe.w) break;
|
||||
await sleep(100);
|
||||
}
|
||||
if (!probe) throw new Error('no canvas.stage on the page — ' +
|
||||
(page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?'));
|
||||
|
||||
console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`);
|
||||
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc}`);
|
||||
|
||||
check(probe.selectedClip.includes('take'), 'the take is the clip that opens');
|
||||
check(probe.drawn === 0 && /new document/.test(probe.doc),
|
||||
'the app opens on a blank document',
|
||||
`${probe.drawn} px drawn — ${probe.doc}`);
|
||||
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
|
||||
`${probe.w}x${probe.h}`);
|
||||
|
||||
// Everything below is about the frozen take, so open it out of the pool.
|
||||
check(await fromMenu(MENU_PICK('take')), 'the take opens from the open menu');
|
||||
for (let i = 0; i < 100; i++) {
|
||||
probe = await page.eval(PROBE);
|
||||
if (probe.drawn > 0) break;
|
||||
await sleep(100);
|
||||
}
|
||||
check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`);
|
||||
|
||||
// --- it is a mouth: two tones, one inside the other ---
|
||||
|
|
@ -283,7 +360,7 @@ async function main() {
|
|||
'as filmed, the head carries the mouth across the stage',
|
||||
`centroid x spans ${(Math.max(...xs) - Math.min(...xs)).toFixed(1)} px`);
|
||||
|
||||
check(await page.eval(CLICK('locked')), 'the locked take is selectable');
|
||||
check(await fromMenu(MENU_PICK('locked')), 'the locked take is selectable');
|
||||
await sleep(200);
|
||||
const locked = [];
|
||||
for (const f of [0, 40, 80, 120, 160, 200]) {
|
||||
|
|
@ -304,7 +381,7 @@ async function main() {
|
|||
'and it is still a performance, not a still frame');
|
||||
|
||||
// --- it PLAYS, against the audio clock ---
|
||||
check(await page.eval(CLICK('take')), 'back to the take');
|
||||
check(await fromMenu(MENU_PICK('take')), 'back to the take');
|
||||
await sleep(150);
|
||||
await page.eval(SEEK(0));
|
||||
await sleep(150);
|
||||
|
|
@ -312,7 +389,7 @@ async function main() {
|
|||
const during = [];
|
||||
for (let i = 0; i < 8; i++) { await sleep(180); during.push(await page.eval(PROBE)); }
|
||||
await page.eval(CLICK('pause'));
|
||||
// The readout is "frame 12 / 229", so the first run of digits is the
|
||||
// The readout is "12 / 229", so the first run of digits is the
|
||||
// playhead. Parsed rather than reached for in app-db on purpose: what the
|
||||
// page SHOWS is what a person would check, and the readout agreeing with the
|
||||
// picture is half of what the transport is for.
|
||||
|
|
@ -351,7 +428,7 @@ async function main() {
|
|||
}
|
||||
|
||||
const FRAMES = [0, 10, 28, 80, 160];
|
||||
check(await page.eval(CLICK('take')), 'back to the take, for the round trip');
|
||||
check(await fromMenu(MENU_PICK('take')), 'back to the take, for the round trip');
|
||||
await sleep(150);
|
||||
const sent = await sample(FRAMES);
|
||||
|
||||
|
|
@ -377,7 +454,7 @@ async function main() {
|
|||
check(resaved !== null, 'saving an unchanged document writes nothing',
|
||||
resaved ?? (await page.eval(STATUS)));
|
||||
|
||||
check(await page.eval(CLICK('open')), 'open is clickable');
|
||||
check(await fromMenu(MENU_PICK_NEWEST), 'open is clickable');
|
||||
const opened = await statusMatching(/opened /);
|
||||
check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS)));
|
||||
|
||||
|
|
@ -392,8 +469,8 @@ async function main() {
|
|||
'and the open mouth still has an interior on the far side');
|
||||
// The reopened clip is not one of the built-ins: this is the document that came
|
||||
// back from the server, not the one that was in the page all along.
|
||||
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
|
||||
JSON.stringify(back[0].selectedClip));
|
||||
check(/opened /.test(back[0].doc), 'the picture is the reopened document',
|
||||
back[0].doc);
|
||||
|
||||
// Paint through the visible controls. This exercises the SVG pointer path,
|
||||
// the authored node, the raster preview and the document round trip.
|
||||
|
|
@ -404,10 +481,20 @@ async function main() {
|
|||
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
|
||||
return [...d];
|
||||
})()`);
|
||||
const painted = await page.eval(`(() => {
|
||||
const button = [...document.querySelectorAll('.paint-tools button')]
|
||||
.find(b => b.textContent === 'new polygon');
|
||||
// Arming the tool and clicking the vertices are two evals with a turn of the
|
||||
// event loop between them, and they have to be: picking the tool dispatches a
|
||||
// re-frame event, which is queued rather than applied, so a pointerdown in the
|
||||
// same synchronous block arrives while the tool is still unset and is dropped.
|
||||
// A person cannot click twice inside one microtask; a test should not either.
|
||||
const armed = await page.eval(`(() => {
|
||||
const button = [...document.querySelectorAll('.palette-bar button')]
|
||||
.find(b => b.textContent === 'polygon');
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(100);
|
||||
const painted = armed && await page.eval(`(() => {
|
||||
const svg = document.querySelector('.paint-overlay');
|
||||
const box = svg.getBoundingClientRect();
|
||||
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
|
||||
|
|
@ -420,8 +507,8 @@ async function main() {
|
|||
})()`);
|
||||
await sleep(100);
|
||||
const finished = await page.eval(`(() => {
|
||||
const button = [...document.querySelectorAll('.paint-tools button')]
|
||||
.find(b => b.textContent === 'finish shape');
|
||||
const button = [...document.querySelectorAll('.palette-bar button')]
|
||||
.find(b => b.textContent === 'finish');
|
||||
if (!button || button.disabled) return false;
|
||||
button.click();
|
||||
return true;
|
||||
|
|
@ -438,8 +525,8 @@ async function main() {
|
|||
await page.eval(SEEK(f));
|
||||
await sleep(100);
|
||||
check(await page.eval(`(() => {
|
||||
const button = [...document.querySelectorAll('.paint-tools button')]
|
||||
.find(b => b.textContent === 'new drawing key');
|
||||
const button = [...document.querySelectorAll('.params button')]
|
||||
.find(b => b.textContent === 'drawing key here');
|
||||
if (!button || button.disabled) return false;
|
||||
button.click();
|
||||
return true;
|
||||
|
|
@ -449,7 +536,7 @@ async function main() {
|
|||
await page.eval(SEEK(8));
|
||||
await sleep(100);
|
||||
const tweenSelected = await page.eval(`(() => {
|
||||
const label = [...document.querySelectorAll('.paint-tools label')]
|
||||
const label = [...document.querySelectorAll('.params label')]
|
||||
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||
const select = label?.querySelector('select');
|
||||
if (!select) return false;
|
||||
|
|
@ -459,14 +546,14 @@ async function main() {
|
|||
})()`);
|
||||
await sleep(100);
|
||||
check(tweenSelected && await page.eval(`(() => {
|
||||
const label = [...document.querySelectorAll('.paint-tools label')]
|
||||
const label = [...document.querySelectorAll('.params label')]
|
||||
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||
return label?.querySelector('select')?.value === 'linear';
|
||||
})()`), 'the second drawing gap can be set to tween');
|
||||
check(await page.eval(CLICK('save')), 'the painted document can be saved');
|
||||
check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null,
|
||||
'the painted shape is saved', await page.eval(STATUS));
|
||||
check(await page.eval(CLICK('open')), 'the painted document can be reopened');
|
||||
check(await fromMenu(MENU_PICK_NEWEST), 'the painted document can be reopened');
|
||||
check((await statusMatching(/opened /)) !== null,
|
||||
'the painted shape is reopened', await page.eval(STATUS));
|
||||
await sleep(150);
|
||||
|
|
@ -479,7 +566,7 @@ async function main() {
|
|||
await page.eval(SEEK(8));
|
||||
await sleep(100);
|
||||
check(await page.eval(`(() => {
|
||||
const label = [...document.querySelectorAll('.paint-tools label')]
|
||||
const label = [...document.querySelectorAll('.params label')]
|
||||
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||
return label?.querySelector('select')?.value === 'linear';
|
||||
})()`), 'the per-gap tween setting survives the project round trip');
|
||||
|
|
@ -489,11 +576,11 @@ async function main() {
|
|||
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
|
||||
.then(r => r.ok)`);
|
||||
if (stageSource) {
|
||||
check(await page.eval(CLICK('stage 8625')), 'the 8625 stage loads');
|
||||
check(await fromMenu(MENU_PICK('8625 stage study')), 'the 8625 stage loads');
|
||||
const stageLoaded = await statusMatching(/loaded 8625 stage study/, 160);
|
||||
check(stageLoaded !== null, 'the 8625 stage is ready', stageLoaded ?? (await page.eval(STATUS)));
|
||||
const eyeSelected = await page.eval(`(() => {
|
||||
const select = document.querySelector('.controls select');
|
||||
const select = document.querySelector('.params .section:last-child select');
|
||||
// Settings belong to tracked features shared by the stage placements.
|
||||
const option = [...select.options]
|
||||
.find(o => o.textContent.trim().endsWith('feature · face-1/eye-r'));
|
||||
|
|
@ -505,8 +592,8 @@ async function main() {
|
|||
check(eyeSelected, 'a stage instance exposes its tracked right eye');
|
||||
await sleep(100);
|
||||
const irisSlider = await page.eval(`(() => {
|
||||
const row = [...document.querySelectorAll('.control-row')]
|
||||
.find(row => row.querySelector('span')?.textContent === 'iris-size');
|
||||
const row = [...document.querySelectorAll('.params .knob')]
|
||||
.find(row => row.querySelector('.name')?.textContent === 'iris-size');
|
||||
if (!row) return null;
|
||||
// Scrolled into view FIRST, because the click below is dispatched at
|
||||
// viewport coordinates: a control panel that has grown past the fold
|
||||
|
|
@ -520,7 +607,7 @@ async function main() {
|
|||
check(irisSlider !== null, 'the stage eye has an iris-size slider');
|
||||
if (irisSlider) {
|
||||
check(await page.eval(CLICK('play')), 'the stage starts playing');
|
||||
const startFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`);
|
||||
const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`);
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
||||
});
|
||||
|
|
@ -532,12 +619,12 @@ async function main() {
|
|||
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
|
||||
check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
|
||||
'the panel reports the affected feature and tier', debug);
|
||||
const endFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`);
|
||||
const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`);
|
||||
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
|
||||
await page.eval(CLICK('pause'));
|
||||
}
|
||||
const subjectSelected = await page.eval(`(() => {
|
||||
const select = document.querySelector('.controls select');
|
||||
const select = document.querySelector('.params .section:last-child select');
|
||||
const option = [...select.options]
|
||||
.find(o => o.textContent.includes('subject · face-1'));
|
||||
if (!option) return false;
|
||||
|
|
@ -549,8 +636,8 @@ async function main() {
|
|||
if (subjectSelected) {
|
||||
await sleep(100);
|
||||
const anchorSlider = await page.eval(`(() => {
|
||||
const row = [...document.querySelectorAll('.control-row')]
|
||||
.find(row => row.querySelector('span')?.textContent === 'anchor-avg');
|
||||
const row = [...document.querySelectorAll('.params .knob')]
|
||||
.find(row => row.querySelector('.name')?.textContent === 'anchor-avg');
|
||||
if (!row) return null;
|
||||
// Scrolled into view FIRST, because the click below is dispatched at
|
||||
// viewport coordinates: a control panel that has grown past the fold
|
||||
|
|
@ -586,7 +673,7 @@ async function main() {
|
|||
}
|
||||
}
|
||||
const teethSelected = await page.eval(`(() => {
|
||||
const select = document.querySelector('.controls select');
|
||||
const select = document.querySelector('.params .section:last-child select');
|
||||
const option = [...select.options]
|
||||
.find(o => o.textContent.trim().endsWith('feature · face-1/teeth'));
|
||||
if (!option) return false;
|
||||
|
|
@ -598,8 +685,8 @@ async function main() {
|
|||
if (teethSelected) {
|
||||
await sleep(100);
|
||||
const slider = await page.eval(`(() => {
|
||||
const row = [...document.querySelectorAll('.control-row')]
|
||||
.find(row => row.querySelector('span')?.textContent === 'cavity-erode');
|
||||
const row = [...document.querySelectorAll('.params .knob')]
|
||||
.find(row => row.querySelector('.name')?.textContent === 'cavity-erode');
|
||||
if (!row) return null;
|
||||
// Scrolled into view FIRST, because the click below is dispatched at
|
||||
// viewport coordinates: a control panel that has grown past the fold
|
||||
|
|
@ -623,10 +710,10 @@ async function main() {
|
|||
let debug = '';
|
||||
for (let i = 0; i < 160; i++) {
|
||||
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||
if (debug.includes('dirty features: [:face-1/teeth]')) break;
|
||||
if (debug.includes('dirty: [:face-1/teeth]')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
check(debug.includes('dirty features: [:face-1/teeth]'),
|
||||
check(debug.includes('dirty: [:face-1/teeth]'),
|
||||
'the pixel setting invalidates only teeth', debug);
|
||||
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||
check(preview !== null, 'the teeth edit finishes previewing',
|
||||
|
|
@ -650,7 +737,19 @@ async function main() {
|
|||
nodeId: root.nodeId, selector: 'input[type=file]',
|
||||
});
|
||||
await page.send('DOM.setFileInputFiles', { files: [video], nodeId });
|
||||
const extracted = await statusMatching(/video extracted/, 160);
|
||||
// Waited for in the MEDIA POOL rather than in the status line. Dropping or
|
||||
// choosing a video now runs straight on into detection, so "video
|
||||
// extracted" is a state the app passes through in one turn of the event
|
||||
// loop and not one a poller can be relied on to catch. The row appearing in
|
||||
// the pool is the durable evidence, and it is what a person would look at.
|
||||
let extracted = null;
|
||||
for (let i = 0; i < 160 && extracted === null; i++) {
|
||||
await sleep(250);
|
||||
if (await page.eval(`[...document.querySelectorAll('.pool-item')]
|
||||
.some((b) => (b.firstChild?.textContent ?? '').trim() === 'browser-upload.mp4')`)) {
|
||||
extracted = 'listed in the media pool';
|
||||
}
|
||||
}
|
||||
check(extracted !== null, 'the page uploads and extracts a video',
|
||||
extracted ?? (await page.eval(STATUS)));
|
||||
const uploadedFootage = await page.eval(`(async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue