Add video upload, extraction progress, and reusable analysis sources

This commit is contained in:
Olive Vaughn 2026-09-28 09:38:49 -04:00
parent 690de21fa4
commit 686f897401
24 changed files with 927 additions and 137 deletions

View file

@ -88,4 +88,4 @@
(is (= [3 4] (vec (array-seq (wire/typed "int16" (wire/base64 view))))))))
(deftest an-unknown-element-type-is-refused
(is (thrown-with-msg? ExceptionInfo #"int16" (wire/typed "float64" "AA=="))))
(is (thrown-with-msg? ExceptionInfo #"int16" (wire/typed "float16" "AA=="))))

View file

@ -119,9 +119,13 @@
;; the thing it is derived FROM is the thing the biconditional above asserts.
;; These two assertions are what keep the derivation honest in the only two ways
;; it could rot.
(testing "every role in the table is reachable from some knob"
(is (= (set (keys address/block-knobs))
(testing "every knob-dependent role is reachable from some knob"
(is (= (into #{} (keep (fn [[role knobs]] (when (seq knobs) role)))
address/block-knobs)
(into #{} (mapcat val) address/knob-roles))))
(testing "source roles have no knob dependencies"
(doseq [role ["source/dense" "source/detected" "source/crops"]]
(is (= [] (get address/block-knobs role)))))
(testing "every knob a block declares is a knob the registry defines"
;; The other direction of the same edge: a typo in `block-knobs` would name a
;; setting no slider can move, and `block-descriptor` only checks that the

View file

@ -0,0 +1,21 @@
(ns arthur.flow.source-test
(:require [cljs.test :refer [deftest is]]
[arthur.flow.source :as source]))
(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])
input {:dense [face face] :detected [true false]
:crops [{:box {:x 3 :y 4 :w 1 :h 1} :data pixels} nil]}
blocks (source/pack "sha256:analysis" input)
out (source/unpack (source/wire-blocks blocks) [100 80])]
(is (= (set source/roles) (set (keys blocks))))
(is (= (:dense input) (:dense out)))
(is (= (:detected input) (:detected out)))
(is (= [3 4 1 1]
((juxt :x :y :w :h) (:box (first (:crops out))))))
(is (= [12 24 36 255]
(vec (array-seq (:data (first (:crops out)))))))
(is (nil? (second (:crops out))))
(is (= (mapv :key (vals blocks))
(mapv :key (vals (source/pack "sha256:analysis" out)))))))

View file

@ -24,7 +24,7 @@
// Writes a PNG per sampled frame into test/browser/out/ so that "it drew
// something" can be checked by eye as well as by pixel count.
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
@ -356,9 +356,15 @@ async function main() {
check(await page.eval(CLICK('save')), 'save is clickable');
const saved = await statusMatching(/saved r\d+/);
check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS)));
// Eleven blocks the first time. The COUNT is not asserted — that is a fact
// about the freeze, not about saving — but that some went up is.
check(/· [1-9]\d* blocks?/.test(saved ?? ''), 'and its tier 2 went with it', saved ?? '');
// Upload count may be zero when the content-addressed blocks already exist
// on this server. The saved clip must still reference them.
const savedBlocks = await page.eval(`(async () => {
const listed = await fetch('/api/projects').then(r => r.json());
const project = await fetch('/api/projects/' + listed.projects[0].id).then(r => r.json());
return project.clips[0].blocks.length;
})()`);
check(savedBlocks > 0, 'the saved clip references its tier 2 blocks',
`${savedBlocks} blocks`);
// Again, unchanged. Content addressing means the second save uploads nothing
// and rewrites nothing: this is the assertion that the keys are stable across
@ -387,6 +393,33 @@ async function main() {
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
JSON.stringify(back[0].selectedClip));
// 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-'));
try {
const video = join(uploadDir, 'browser-upload.mp4');
const encoded = spawnSync('ffmpeg', [
'-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi',
'-i', 'color=c=blue:s=64x48:r=4:d=1', '-c:v', 'mpeg4', video,
]);
if (encoded.status !== 0) throw new Error(`ffmpeg failed: ${encoded.stderr}`);
const { root } = await page.send('DOM.getDocument');
const { nodeId } = await page.send('DOM.querySelector', {
nodeId: root.nodeId, selector: 'input[type=file]',
});
await page.send('DOM.setFileInputFiles', { files: [video], nodeId });
const extracted = await statusMatching(/video extracted/, 160);
check(extracted !== null, 'the page uploads and extracts a video',
extracted ?? (await page.eval(STATUS)));
const uploadedFootage = await page.eval(`(async () => {
const listed = await fetch('/api/footage').then(r => r.json());
return listed.footage.some(f => f.label === 'browser-upload.mp4' && f.frames === 4);
})()`);
check(uploadedFootage, 'the uploaded footage is selectable');
} finally {
rmSync(uploadDir, { recursive: true, force: true });
}
check(page.logs.length === 0, 'no errors on the console',
page.logs.slice(0, 3).join(' | '));
} finally {