Add scoped feature regeneration and retained pixel measurements
This commit is contained in:
parent
39ee37db02
commit
3058b9a5f2
15 changed files with 889 additions and 81 deletions
101
frontend/test/arthur/flow/regenerate_test.cljs
Normal file
101
frontend/test/arthur/flow/regenerate_test.cljs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
(ns arthur.flow.regenerate-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[arthur.domain.project :as project]
|
||||
[arthur.flow.address :as address]
|
||||
[arthur.flow.regenerate :as regenerate]
|
||||
[arthur.flow.take :as take]
|
||||
[arthur.synth :as synth]))
|
||||
|
||||
(def ^:private frames 40)
|
||||
(def ^:private inputs (delay {:dense (synth/synth-dense frames {:seed 9})}))
|
||||
(def ^:private initial
|
||||
(delay
|
||||
(let [p (merge take/knobs
|
||||
{:name "regen" :fps 30 :aspect 1 :stage [320 200]
|
||||
:expose 1 :head :as-filmed
|
||||
:analysis (address/analysis
|
||||
{:detector "synth" :version "mulberry32"
|
||||
:seed 9 :frames frames :fps 30 :aspect 1})})]
|
||||
(assoc (take/build p @inputs) :source-inputs @inputs))))
|
||||
|
||||
(defn- full-at [change]
|
||||
(take/build
|
||||
(merge take/knobs
|
||||
{:name "regen" :fps 30 :aspect 1 :stage [320 200]
|
||||
:expose 1 :head :as-filmed
|
||||
:analysis (get-in @initial [:clip :analysis])}
|
||||
change)
|
||||
@inputs))
|
||||
|
||||
(defn- channel [entry node path]
|
||||
(get-in entry [:clip :timelines :main :nodes node :channels path]))
|
||||
|
||||
(deftest eye-rebuild-is-confined-to-the-edited-feature
|
||||
(let [before @initial
|
||||
after (regenerate/change before
|
||||
{:scope :feature :id :eye-r :knob :gaze-gain :value 2})]
|
||||
(is (not= (channel before :iris-r [:xform :pos])
|
||||
(channel after :iris-r [:xform :pos])))
|
||||
(is (= (channel before :iris-l [:xform :pos])
|
||||
(channel after :iris-l [:xform :pos])))
|
||||
(is (= (channel before :mouth [:geom :pts])
|
||||
(channel after :mouth [:geom :pts])))
|
||||
(is (= (get-in before [:clip :timelines :main :nodes :face])
|
||||
(get-in after [:clip :timelines :main :nodes :face])))
|
||||
(is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos])
|
||||
(channel after :iris-r [:xform :pos])))))
|
||||
|
||||
(deftest framed-eye-size-needs-no-new-dense-block
|
||||
(let [before @initial
|
||||
after (regenerate/change before
|
||||
{:scope :feature :id :eye-r :knob :iris-size :value 0.6})]
|
||||
(is (not= (channel before :iris-r [:geom :radius])
|
||||
(channel after :iris-r [:geom :radius])))
|
||||
(is (= (channel before :iris-l [:geom :radius])
|
||||
(channel after :iris-l [:geom :radius])))
|
||||
(is (= (channel (full-at {:iris-size 0.6}) :iris-r [:geom :radius])
|
||||
(channel after :iris-r [:geom :radius])))
|
||||
(is (= (set (keys (:store before))) (set (keys (:store after)))))))
|
||||
|
||||
(deftest mouth-edit-leaves-the-head-and-eye-alone
|
||||
(let [before @initial
|
||||
after (regenerate/change before
|
||||
{:scope :feature :id :mouth :knob :verts :value 10})]
|
||||
(is (not= (channel before :mouth [:geom :pts])
|
||||
(channel after :mouth [:geom :pts])))
|
||||
(is (= (get-in before [:clip :timelines :main :nodes :head])
|
||||
(get-in after [:clip :timelines :main :nodes :head])))
|
||||
(is (= (channel before :eye-r [:geom :pts])
|
||||
(channel after :eye-r [:geom :pts])))
|
||||
(is (= (channel (full-at {:verts 10}) :mouth [:geom :pts])
|
||||
(channel after :mouth [:geom :pts])))))
|
||||
|
||||
(deftest subject-edit-recomputes-head-and-keeps-authored-placement
|
||||
(let [before @initial
|
||||
after (regenerate/change before
|
||||
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
|
||||
full (full-at {:anchor-avg 4})]
|
||||
(is (not= (get-in before [:clip :timelines :main :nodes :head :measured])
|
||||
(get-in after [:clip :timelines :main :nodes :head :measured])))
|
||||
(is (= (get-in full [:clip :timelines :main :nodes :head :measured])
|
||||
(get-in after [:clip :timelines :main :nodes :head :measured])))
|
||||
(is (= (get-in before [:clip :timelines :main :nodes :face])
|
||||
(get-in after [:clip :timelines :main :nodes :face])))))
|
||||
|
||||
(deftest contour-edit-does-not-rebuild-head-or-teeth
|
||||
(let [before @initial
|
||||
after (regenerate/change before
|
||||
{:scope :subject :id :face-1 :knob :contour-avg :value 3})]
|
||||
(is (= (get-in before [:clip :timelines :main :nodes :head])
|
||||
(get-in after [:clip :timelines :main :nodes :head])))
|
||||
(is (= (channel before :teeth [:geom :pts])
|
||||
(channel after :teeth [:geom :pts])))))
|
||||
|
||||
(deftest edited-take-still-round-trips-through-normal-save
|
||||
(let [after (regenerate/change @initial
|
||||
{:scope :group :id :eyes-1 :knob :iris-size :value 0.6})
|
||||
wire (project/save "regen" after)
|
||||
loaded (project/load "regen" (js/JSON.parse (js/JSON.stringify wire)))]
|
||||
(is (= (:clip after) (:clip loaded)))
|
||||
(is (= (channel after :iris-r [:geom :radius])
|
||||
(channel loaded :iris-r [:geom :radius])))))
|
||||
|
|
@ -1,7 +1,25 @@
|
|||
(ns arthur.flow.source-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[arthur.domain.params :as params]
|
||||
[arthur.domain.wire :as wire]
|
||||
[arthur.flow.source :as source]))
|
||||
|
||||
(deftest pixel-measurements-have-their-own-addressed-block
|
||||
(let [settings params/defaults
|
||||
ring (mapv (fn [i] {:x (/ i 10) :y (/ (+ i 1) 20)})
|
||||
(range (:teeth-verts settings)))
|
||||
measures [{:contour ring :contrast 0.7 :area 42}
|
||||
{:contour nil :contrast 0 :area 0}]
|
||||
block (source/interior-block "sha256:analysis" settings measures)
|
||||
response #js {:descriptor (:descriptor block)
|
||||
:data (wire/base64 (:data block))}]
|
||||
(is (= (:key block)
|
||||
(source/interior-key "sha256:analysis" settings 2)))
|
||||
(is (= measures (source/unpack-interior response settings 2)))
|
||||
(is (not= (:key block)
|
||||
(source/interior-key "sha256:analysis"
|
||||
(assoc settings :cavity-erode 0.2) 2)))))
|
||||
|
||||
(deftest source-blocks-round-trip-without-source-images
|
||||
(let [face (vec (repeat 478 {:x 0.25 :y 0.5 :z -0.125}))
|
||||
pixels (js/Uint8ClampedArray. #js [12 24 36 255])
|
||||
|
|
@ -19,3 +37,12 @@
|
|||
(is (nil? (second (:crops out))))
|
||||
(is (= (mapv :key (vals blocks))
|
||||
(mapv :key (vals (source/pack "sha256:analysis" out)))))))
|
||||
|
||||
(deftest fresh-source-includes-its-pixel-measurement-block
|
||||
(let [face (vec (repeat 478 {:x 0.25 :y 0.5 :z -0.125}))
|
||||
input {:dense [face] :detected [false] :crops [nil]
|
||||
:interior [{:contour nil :contrast 0 :area 0}]}
|
||||
blocks (source/pack "sha256:analysis" input)]
|
||||
(is (contains? blocks "source/interior"))
|
||||
(is (= 4 (.-length (source/upload-blocks blocks))))
|
||||
(is (= 3 (count source/roles)))))
|
||||
|
|
|
|||
|
|
@ -233,7 +233,8 @@ async function main() {
|
|||
if (probe && probe.drawn > 0) break;
|
||||
await sleep(100);
|
||||
}
|
||||
if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?');
|
||||
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)}`);
|
||||
|
||||
|
|
@ -393,6 +394,138 @@ async function main() {
|
|||
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
|
||||
JSON.stringify(back[0].selectedClip));
|
||||
|
||||
// The local 8625 study is optional in a fresh test database. When present,
|
||||
// tune its shared symbol while its stage is playing.
|
||||
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');
|
||||
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 option = [...select.options]
|
||||
.find(o => o.textContent.trim() === 'left / feature · eye-r');
|
||||
if (!option) return false;
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
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');
|
||||
if (!row) return null;
|
||||
const r = row.querySelector('input').getBoundingClientRect();
|
||||
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
|
||||
})()`);
|
||||
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])`);
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
||||
});
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
||||
});
|
||||
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||
const debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
|
||||
check(debug.includes(':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])`);
|
||||
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 option = [...select.options]
|
||||
.find(o => o.textContent.includes('subject · face-1'));
|
||||
if (!option) return false;
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
check(subjectSelected, 'the stage exposes its tracked subject');
|
||||
if (subjectSelected) {
|
||||
await sleep(100);
|
||||
const anchorSlider = await page.eval(`(() => {
|
||||
const row = [...document.querySelectorAll('.control-row')]
|
||||
.find(row => row.querySelector('span')?.textContent === 'anchor-avg');
|
||||
if (!row) return null;
|
||||
const r = row.querySelector('input').getBoundingClientRect();
|
||||
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
|
||||
})()`);
|
||||
check(anchorSlider !== null, 'the subject has an anchor slider');
|
||||
if (anchorSlider) {
|
||||
const anchorStart = Date.now();
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', x: anchorSlider.x, y: anchorSlider.y,
|
||||
button: 'left', clickCount: 1,
|
||||
});
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased', x: anchorSlider.x, y: anchorSlider.y,
|
||||
button: 'left', clickCount: 1,
|
||||
});
|
||||
let debug = '';
|
||||
for (let i = 0; i < 160; i++) {
|
||||
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||
if (debug.includes('head-pos')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
check(debug.includes('head-pos') && debug.includes(':teeth'),
|
||||
'anchor invalidates the head and teeth', debug);
|
||||
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||
check(preview !== null, 'the anchor edit finishes previewing',
|
||||
preview ? `${Date.now() - anchorStart}ms` : (await page.eval(STATUS)));
|
||||
}
|
||||
}
|
||||
const teethSelected = await page.eval(`(() => {
|
||||
const select = document.querySelector('.controls select');
|
||||
const option = [...select.options]
|
||||
.find(o => o.textContent.trim() === 'left / feature · teeth');
|
||||
if (!option) return false;
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
check(teethSelected, 'the stage exposes its tracked teeth');
|
||||
if (teethSelected) {
|
||||
await sleep(100);
|
||||
const slider = await page.eval(`(() => {
|
||||
const row = [...document.querySelectorAll('.control-row')]
|
||||
.find(row => row.querySelector('span')?.textContent === 'cavity-erode');
|
||||
if (!row) return null;
|
||||
const r = row.querySelector('input').getBoundingClientRect();
|
||||
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
|
||||
})()`);
|
||||
check(slider !== null, 'the teeth have a pixel-setting slider');
|
||||
if (slider) {
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', x: slider.x, y: slider.y,
|
||||
button: 'left', clickCount: 1,
|
||||
});
|
||||
await page.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased', x: slider.x, y: slider.y,
|
||||
button: 'left', clickCount: 1,
|
||||
});
|
||||
let debug = '';
|
||||
for (let i = 0; i < 160; i++) {
|
||||
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||
if (debug.includes('dirty features: [:teeth]')) break;
|
||||
await sleep(250);
|
||||
}
|
||||
check(debug.includes('dirty features: [:teeth]'),
|
||||
'the pixel setting invalidates only teeth', debug);
|
||||
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||
check(preview !== null, 'the teeth edit finishes previewing',
|
||||
preview ?? (await page.eval(STATUS)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the actual file input and FormData path, then wait for the
|
||||
// extraction job's footage to appear in the server list.
|
||||
const uploadDir = mkdtempSync(join(tmpdir(), 'arthur-upload-'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue