Support multiple regeneratable footage analyses

This commit is contained in:
Your Name 2026-10-03 01:24:45 -04:00
parent 5bcf22e458
commit 15deaea19e
20 changed files with 333 additions and 168 deletions

View file

@ -18,7 +18,7 @@ class ProjectAdmin(admin.ModelAdmin):
@admin.register(Clip) @admin.register(Clip)
class ClipAdmin(admin.ModelAdmin): class ClipAdmin(admin.ModelAdmin):
list_display = ("cid", "project", "name", "footage", "analysis") list_display = ("cid", "project", "name")
list_filter = ("project",) list_filter = ("project",)

View file

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("clips", "0013_palette_track")]
operations = [
migrations.RemoveField(model_name="clip", name="analysis"),
migrations.RemoveField(model_name="clip", name="footage"),
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=5),
),
]

View file

@ -235,7 +235,7 @@ class Project(models.Model):
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects", settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
) )
name = models.CharField(max_length=200, default="untitled") name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=4) schema_version = models.PositiveIntegerField(default=5)
seq = models.PositiveBigIntegerField(default=0) seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default") palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
@ -273,12 +273,6 @@ class Clip(models.Model):
cid = models.SlugField(max_length=64) cid = models.SlugField(max_length=64)
name = models.CharField(max_length=200, blank=True) name = models.CharField(max_length=200, blank=True)
order = models.IntegerField(default=0) order = models.IntegerField(default=0)
footage = models.ForeignKey(
Footage, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips"
)
analysis = models.ForeignKey(
Analysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips"
)
blocks = models.ManyToManyField( blocks = models.ManyToManyField(
Block, blank=True, related_name="clips", Block, blank=True, related_name="clips",
help_text="the tier-2 blocks this clip's channels name", help_text="the tier-2 blocks this clip's channels name",

View file

@ -396,14 +396,25 @@ class DocumentTests(TestCase):
], ],
} }
def save(self, leaves=None, blocks=None): def save(self, leaves=None, blocks=None, analyses=None):
return self.put(f"/api/projects/{self.project.id}", { return self.put(f"/api/projects/{self.project.id}", {
"name": "a project", "name": "a project",
"clips": [{"cid": "c1", "name": "take", "analysis": self.analysis, "clips": [{"cid": "c1", "name": "take",
"analyses": [self.analysis] if analyses is None else analyses,
"leaves": leaves if leaves is not None else self.leaves(), "leaves": leaves if leaves is not None else self.leaves(),
"blocks": blocks if blocks is not None else [self.block]}], "blocks": blocks if blocks is not None else [self.block]}],
}) })
def test_a_clip_declares_the_registered_analyses_its_blocks_name(self):
undeclared = self.save(analyses=[])
self.assertEqual(409, undeclared.status_code)
self.assertIn("every block", undeclared.json()["error"])
unknown = "sha256:" + "f" * 64
missing = self.save(analyses=[self.analysis, unknown])
self.assertEqual(409, missing.status_code)
self.assertEqual([unknown], missing.json()["missing"])
def test_every_saved_symbol_is_listed_across_projects(self): def test_every_saved_symbol_is_listed_across_projects(self):
leaves = self.leaves() leaves = self.leaves()
leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12] leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12]
@ -435,12 +446,12 @@ class DocumentTests(TestCase):
self.assertEqual(5, len(response.json()["written"])) self.assertEqual(5, len(response.json()["written"]))
loaded = self.client.get(f"/api/projects/{self.project.id}").json() loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(4, loaded["schema_version"]) self.assertEqual(5, loaded["schema_version"])
self.assertEqual(1, len(loaded["clips"])) self.assertEqual(1, len(loaded["clips"]))
clip = loaded["clips"][0] clip = loaded["clips"][0]
self.assertEqual("c1", clip["cid"]) self.assertEqual("c1", clip["cid"])
self.assertEqual([self.block], clip["blocks"]) self.assertEqual([self.block], clip["blocks"])
self.assertEqual(self.analysis, clip["analysis"]) self.assertNotIn("analysis", clip)
# The whole point: byte-identical values, including the integer frame keys # The whole point: byte-identical values, including the integer frame keys
# transit writes as "~i0". A JSON round trip that stringified them would # transit writes as "~i0". A JSON round trip that stringified them would
# come back "0" and the part would hold its first pose forever. # come back "0" and the part would hold its first pose forever.
@ -542,7 +553,7 @@ class DocumentTests(TestCase):
def patch(self, base, leaves, removed=()): def patch(self, base, leaves, removed=()):
return self.put(f"/api/projects/{self.project.id}", { return self.put(f"/api/projects/{self.project.id}", {
"base": base, "base": base,
"clips": [{"cid": "c1", "analysis": self.analysis, "leaves": leaves, "clips": [{"cid": "c1", "analyses": [self.analysis], "leaves": leaves,
"removed": list(removed), "blocks": [self.block]}], "removed": list(removed), "blocks": [self.block]}],
}) })

View file

@ -718,8 +718,6 @@ def _project_json(project: Project, user):
{ {
"cid": clip.cid, "cid": clip.cid,
"name": clip.name, "name": clip.name,
"footage": str(clip.footage_id) if clip.footage_id else None,
"analysis": clip.analysis_id,
"blocks": sorted(clip.blocks.values_list("key", flat=True)), "blocks": sorted(clip.blocks.values_list("key", flat=True)),
"leaves": {leaf.path: leaf.value for leaf in leaves if leaf.path.startswith(prefix)}, "leaves": {leaf.path: leaf.value for leaf in leaves if leaf.path.startswith(prefix)},
} }
@ -909,7 +907,19 @@ def _save(project: Project, data, user):
) )
keys = spec.get("blocks") or [] keys = spec.get("blocks") or []
have = set(Block.objects.filter(key__in=keys).values_list("key", flat=True)) analyses = spec.get("analyses") or []
if (not isinstance(analyses, list)
or not all(isinstance(key, str) for key in analyses)
or len(analyses) != len(set(analyses))):
raise Bad("a clip's analyses must be a list of distinct analysis ids")
registered = set(Analysis.objects.filter(key__in=analyses)
.values_list("key", flat=True))
if unknown := [key for key in analyses if key not in registered]:
raise Bad("this clip names analyses the server does not know; register them first",
status=409, missing=unknown)
block_rows = list(Block.objects.filter(key__in=keys))
have = {block.key for block in block_rows}
if missing := [k for k in keys if k not in have]: if missing := [k for k in keys if k not in have]:
# Referential integrity across the tiers, enforced where it can be: # Referential integrity across the tiers, enforced where it can be:
# a document that names blocks the server does not hold would load # a document that names blocks the server does not hold would load
@ -919,6 +929,9 @@ def _save(project: Project, data, user):
"before saving the document that points at them", "before saving the document that points at them",
status=409, missing=missing, status=409, missing=missing,
) )
if undeclared := sorted({block.analysis_id for block in block_rows} - registered):
raise Bad("every block in a clip must name one of that clip's analyses",
status=409, missing=undeclared)
existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)} existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)}
if base is not None: if base is not None:
@ -930,16 +943,12 @@ def _save(project: Project, data, user):
if conflicts: if conflicts:
continue continue
analysis = Analysis.objects.filter(key=spec.get("analysis")).first()
footage = None
if spec.get("footage"):
footage = Footage.objects.filter(id=spec["footage"]).first()
clip, _ = Clip.objects.update_or_create( clip, _ = Clip.objects.update_or_create(
project=project, project=project,
cid=cid, cid=cid,
defaults={"name": spec.get("name") or "", "analysis": analysis, "footage": footage}, defaults={"name": spec.get("name") or ""},
) )
blocks = Block.objects.filter(key__in=keys) blocks = block_rows
if base is None: if base is None:
clip.blocks.set(blocks) clip.blocks.set(blocks)
gone = [path for path in existing if path not in leaves] gone = [path for path in existing if path not in leaves]

View file

@ -11,6 +11,7 @@
so the events that fetch them are only fetching." so the events that fetch them are only fetching."
(:refer-clojure :exclude [take]) (:refer-clojure :exclude [take])
(:require [arthur.domain.clip :as clip] (:require [arthur.domain.clip :as clip]
[arthur.domain.feature :as feature]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[clojure.string :as string])) [clojure.string :as string]))
@ -59,8 +60,8 @@
(keyword (if (seq slug) slug "symbol")))) (keyword (if (seq slug) slug "symbol"))))
(defn take (defn take
"Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns "Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns the
`{:clip :sid :tracked?}`. changed clip, the imported symbol id and the old-to-scoped subject ids.
`frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per `frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per
tracked face. `:main` becomes the named symbol — it is what holds the faces in tracked face. `:main` becomes the named symbol — it is what holds the faces in
@ -75,18 +76,22 @@
symbols. `nest/audio-tracks` collapses simultaneous copies carrying the same symbols. `nest/audio-tracks` collapses simultaneous copies carrying the same
`:media-link`; placing those faces at different times still schedules each one. `:media-link`; placing those faces at different times still schedules each one.
The tracking identities, and the analysis they were measured by, come along The unique imported symbol id scopes every subject id. Thus two takes may both
only when `clip` has no analysis of its own and no face had to be renamed. A arrive with detector subject `:face-1` without colliding in the document."
document holds one analysis, and regeneration finds a face's symbol by its
subject id, so either condition failing means the take comes in as drawings
that play but cannot be re-tuned — `:tracked? false` says so."
[clip frozen label footage-id range] [clip frozen label footage-id range]
(let [{c :clip ids :ids} (symbols clip frozen [:main] {:main (symbol-id label)}) (let [scope (fn [root subject] (keyword (str (name root) "." (name subject))))
root (clip/free-id
(fn [candidate]
(or (contains? (:symbols clip) candidate)
(some #(contains? (:symbols clip) (scope candidate %))
(keys (:subjects frozen)))))
(symbol-id label))
scoped (partial scope root)
wanted (into {:main root} (map (fn [subject] [subject (scoped subject)]))
(keys (:subjects frozen)))
{c :clip ids :ids} (symbols clip frozen [:main] wanted)
sid (ids :main) sid (ids :main)
faces (mapv ids (sort-by str (keys (:subjects frozen)))) faces (mapv ids (sort-by str (keys (:subjects frozen))))
;; Older/plain imported clips have no subject table. They are still a
;; take, so their wrapper remains the only honest owner of the sound.
sound-hosts (if (seq faces) faces [sid])
sound {:id :sound :name "sound" :kind :audio :parent nil sound {:id :sound :name "sound" :kind :audio :parent nil
:z "z-sound" :source {:footage footage-id} :z "z-sound" :source {:footage footage-id}
;; All automatic copies name one recording. The audio walk ;; All automatic copies name one recording. The audio walk
@ -96,18 +101,42 @@
;; Source frame `start` plays on the symbol's 0. ;; Source frame `start` plays on the symbol's 0.
:span range :span range
:time {:mode :map :at (- (first range)) :rate 1}} :time {:mode :map :at (- (first range)) :rate 1}}
tracked? (and (nil? (:analysis clip)) feature-ids (into {}
(every? #(= % (ids %)) (keys (:subjects frozen))))] (map (fn [[id f]]
[id (feature/owned (ids (:subject f)) (keyword (name id)))]))
(:features frozen))
subjects (into {}
(map (fn [[id subject]]
(let [new-id (ids id)]
[new-id (assoc subject :id new-id
:footage footage-id)])))
(:subjects frozen))
features (into {}
(map (fn [[id f]]
(let [new-id (feature-ids id)]
[new-id (-> f
(assoc :id new-id
:subject (ids (:subject f))
:symbol (ids (:symbol f))))])))
(:features frozen))
groups (into {}
(map (fn [[id g]]
(let [new-subject (ids (:subject g))
new-id (feature/owned new-subject (keyword (name id)))]
[new-id (-> g
(assoc :id new-id :subject new-subject)
(update :members #(mapv feature-ids %)))])))
(:groups frozen))]
{:sid sid {:sid sid
:tracked? tracked? :subject-ids (select-keys ids (keys (:subjects frozen)))
:clip (cond-> (-> (reduce (fn [document face] :clip (-> (reduce (fn [document face]
(assoc-in document [:symbols face :nodes :sound] sound)) (assoc-in document [:symbols face :nodes :sound] sound))
c sound-hosts) c faces)
(assoc-in [:symbols sid :name] (str label))) (assoc-in [:symbols sid :name] (str label))
tracked? (-> (assoc :analysis (:analysis frozen)) (update :analyses merge (:analyses frozen))
(update :subjects merge (:subjects frozen)) (update :subjects merge subjects)
(update :features merge (:features frozen)) (update :features merge features)
(update :groups merge (:groups frozen))))})) (update :groups merge groups))}))
(defn placed (defn placed

View file

@ -4,7 +4,7 @@
{:name \"take\" {:name \"take\"
:fps 30 :fps 30
:width 320 :height 200 :width 320 :height 200
:analysis {...} :analyses {analysis-id {...}}
:subjects {...} :features {...} :groups {...} :subjects {...} :features {...} :groups {...}
:symbols {:main {:id :main :frames 229 :nodes {...}}}} :symbols {:main {:id :main :frames 229 :nodes {...}}}}
@ -51,7 +51,7 @@
that loses something on every round trip, which is the one bug a persistence that loses something on every round trip, which is the one bug a persistence
layer must not be able to have. Add the field here and to `leaf/leaves` and layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit." `leaf/clip` in the same commit."
#{:name :fps :analysis :subjects :features :groups :width :height :symbols #{:name :fps :analyses :subjects :features :groups :width :height :symbols
:palettes :default-palette}) :palettes :default-palette})
(defn symbol (defn symbol
@ -574,6 +574,18 @@
(str "clip has a field with no leaf to save it in: " (pr-str k))) (str "clip has a field with no leaf to save it in: " (pr-str k)))
(when-not (map? (:symbols clip)) (when-not (map? (:symbols clip))
[":symbols must be a map of id -> symbol"]) [":symbols must be a map of id -> symbol"])
(when (and (contains? clip :analyses) (not (map? (:analyses clip))))
[":analyses must be a map of analysis id -> analysis"])
(for [[id analysis] (:analyses clip)
:when (not= id (:id analysis))]
(str "analysis under key " (pr-str id) " has :id " (pr-str (:id analysis))))
(for [[id subject] (:subjects clip)
:when (not (contains? (:analyses clip) (:analysis subject)))]
(str "subject " (pr-str id) " names missing analysis "
(pr-str (:analysis subject))))
(for [[id subject] (:subjects clip)
:when (not (keyword? (:source-subject subject)))]
(str "subject " (pr-str id) " has no source subject"))
(when (and (contains? clip :palettes) (not (map? (:palettes clip)))) (when (and (contains? clip :palettes) (not (map? (:palettes clip))))
[":palettes must be a map of id -> palette"]) [":palettes must be a map of id -> palette"])
(when (and (contains? clip :default-palette) (map? (:palettes clip)) (when (and (contains? clip :default-palette) (map? (:palettes clip))

View file

@ -137,7 +137,7 @@
(some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "timing") (select-keys clip [:fps]))
(some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "stage") (select-keys clip [:width :height]))
(some-leaf (at "palette-default") (select-keys clip [:default-palette])) (some-leaf (at "palette-default") (select-keys clip [:default-palette]))
(some-leaf (at "source") (:analysis clip)) (some-leaf (at "analyses") (:analyses clip))
(concat (concat
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
(for [[id v] (:features clip)] {(at "feature" (segment id)) v}) (for [[id v] (:features clip)] {(at "feature" (segment id)) v})
@ -194,7 +194,7 @@
"name" (merge acc v) "name" (merge acc v)
"timing" (merge acc v) "timing" (merge acc v)
"stage" (merge acc v) "stage" (merge acc v)
"source" (assoc acc :analysis v) "analyses" (assoc acc :analyses v)
"palette-default" (merge acc v) "palette-default" (merge acc v)
"palette" (assoc-in acc [:palettes (unsegment a)] v) "palette" (assoc-in acc [:palettes (unsegment a)] v)
"subject" (assoc-in acc [:subjects (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v)
@ -237,7 +237,7 @@
false) false)
(case (count p) (case (count p)
;; The clip's own facts carry no id. ;; The clip's own facts carry no id.
3 (#{"name" "timing" "stage" "source" "palette-default"} (nth p 2)) 3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2))
4 (#{"subject" "feature" "group" "palette"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2))
false))))] false))))]
(vec (vec

View file

@ -29,9 +29,7 @@
(:require [arthur.domain.leaf :as leaf] (:require [arthur.domain.leaf :as leaf]
[arthur.domain.wire :as wire])) [arthur.domain.wire :as wire]))
(def schema-version (def schema-version 5)
"4 separates a symbol's static authoring palette from its palette track."
4)
(defn block-keys (defn block-keys
"Every tier-2 key a leaf map names, in a stable order." "Every tier-2 key a leaf map names, in a stable order."

View file

@ -151,7 +151,8 @@
:detector detector}) :detector detector})
_ (mark! "build-clip: freeze") _ (mark! "build-clip: freeze")
built (:clip frozen) built (:clip frozen)
source-blocks (source/pack-subjects (:id (:analysis built)) subjects) analysis-id (-> built :analyses keys first)
source-blocks (source/pack-subjects analysis-id subjects)
_ (mark! "build-clip: pack source blocks")] _ (mark! "build-clip: pack source blocks")]
(assoc (select-keys built [:fps :width :height]) (assoc (select-keys built [:fps :width :height])
@ -504,7 +505,7 @@
(let [entry (store/entry (:clip/current db)) (let [entry (store/entry (:clip/current db))
uuid (random-uuid) uuid (random-uuid)
fps (get-in db [:clip :fps]) fps (get-in db [:clip :fps])
{:keys [clip sid tracked?]} {:keys [clip sid subject-ids]}
(bring/take (:clip entry) (:clip built) name footage-id range) (bring/take (:clip entry) (:clip built) name footage-id range)
st (merge (:store entry) (:store built)) st (merge (:store entry) (:store built))
imported-frames (clip/output-frames clip sid) imported-frames (clip/output-frames clip sid)
@ -525,9 +526,22 @@
(update :footage merge {:loading? false :status why}))} (update :footage merge {:loading? false :status why}))}
(let [db (edit/edit-entry (let [db (edit/edit-entry
db db
#(cond-> (assoc % :clip (:clip result) :store st) #(let [analysis-id (-> built :clip :analyses keys first)
tracked? (merge (select-keys built [:footage-id :source-blocks remap-subjects
:source-inputs]))))] (fn [inputs]
(update inputs :subjects
(fn [subjects]
(into {} (map (fn [[old new]] [new (get subjects old)]))
subject-ids))))]
(-> (assoc % :clip (:clip result) :store st)
(update-in [:sources analysis-id]
(fn [source]
{:footage-id (:footage-id built)
:source-blocks (:source-blocks built)
:source-inputs
(update (remap-subjects (:source-inputs built))
:subjects merge
(get-in source [:source-inputs :subjects]))})))))]
{:db (-> db {:db (-> db
(update :ui dissoc :convert) (update :ui dissoc :convert)
(ui/selected (ui/selected
@ -536,7 +550,5 @@
{:loading? false {:loading? false
:status (str "made " name " · " imported-frames " frames at " fps " fps" :status (str "made " name " · " imported-frames " frames at " fps " fps"
(when (not= fps source-fps) (when (not= fps source-fps)
(str " · sampled from " source-fps " fps")) (str " · sampled from " source-fps " fps")))}))
(when-not tracked?
" · as drawings: this project already tracks other footage"))}))
:dispatch [::pb/refresh-clock]}))))) :dispatch [::pb/refresh-clock]})))))

View file

@ -102,15 +102,10 @@
(.then (fn [^js created] (.-id created)))))) (.then (fn [^js created] (.-id created))))))
(defn- opened-entry! [^js clip-json] (defn- opened-entry! [^js clip-json]
(let [footage-id (.-footage clip-json)]
(-> (js/Promise.all (-> (js/Promise.all
#js [(js/Promise.all
(into-array (map #(http/GET (str "/api/blocks/" %)) (into-array (map #(http/GET (str "/api/blocks/" %))
(array-seq (.-blocks clip-json))))) (array-seq (.-blocks clip-json)))))
(if footage-id (.then (fn [blocks]
(http/GET (str "/api/footage/" footage-id))
(js/Promise.resolve nil))])
(.then (fn [[blocks ^js footage]]
(let [cid (.-cid clip-json) (let [cid (.-cid clip-json)
loaded (project/load loaded (project/load
cid #js {:leaves (.-leaves clip-json) cid #js {:leaves (.-leaves clip-json)
@ -129,11 +124,9 @@
;; write lands on what does not. ;; write lands on what does not.
:synced (project/tier1 (.-leaves clip-json)) :synced (project/tier1 (.-leaves clip-json))
:clip built :store (:store loaded) :clip built :store (:store loaded)
:footage-id footage-id :audio "/static/arthur/audio.wav"})]
:audio (if footage (.-audio footage)
"/static/arthur/audio.wav")})]
(-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry)) (-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry))
(.then (fn [audio] (assoc entry :audio audio))))))))))) (.then (fn [audio] (assoc entry :audio audio))))))))))
(defn- saved-clip! (defn- saved-clip!
"Promise of clip `cid` of saved project `pid`, as `{:clip :store}`: its "Promise of clip `cid` of saved project `pid`, as `{:clip :store}`: its
@ -240,46 +233,60 @@
:removed (into-array (remove #(contains? local %) (keys synced)))}) :removed (into-array (remove #(contains? local %) (keys synced)))})
#js {:leaves (.-leaves doc)})) #js {:leaves (.-leaves doc)}))
(defonce ^:private on-server (defonce ^:private uploaded-blocks
;; Analyses and block keys this page has already put on the server. Content ;; Block keys this page has already put on the server. Content addressed, so
;; addressed, so once there they are there: a save of a moved vertex asks for ;; once there they are there: a save of a moved vertex asks for none again.
;; none of it again, and is one request.
(atom #{})) (atom #{}))
(defonce ^:private linked-analyses (atom #{}))
(defn- upload-new! [^js doc] (defn- upload-new! [^js doc]
(let [keys (array-seq (block-keys doc))] (let [keys (array-seq (block-keys doc))]
(if (every? @on-server keys) (if (every? @uploaded-blocks keys)
(js/Promise.resolve 0) (js/Promise.resolve 0)
(.then (upload-missing! doc) (fn [n] (swap! on-server into keys) n))))) (.then (upload-missing! doc) (fn [n] (swap! uploaded-blocks into keys) n)))))
(defn- analyses-of [entry]
(vals (get-in entry [:clip :analyses])))
(defn- upload-sources! [sources]
(reduce
(fn [chain [analysis-id {:keys [source-blocks]}]]
(.then chain
(fn [_]
(when (and (seq source-blocks)
(not (@linked-analyses analysis-id)))
(-> (upload-missing! #js {:blocks (source/upload-blocks source-blocks)})
(.then #(http/PUT
(str "/api/analyses/" analysis-id)
#js {:source_blocks
(into-array (source/block-keys source-blocks))}))
(.then (fn [answer]
(swap! linked-analyses conj analysis-id)
answer)))))))
(js/Promise.resolve nil)
sources))
(rf/reg-fx (rf/reg-fx
::save! ::save!
(fn [{:keys [id cid label clip base]}] (fn [{:keys [id cid label clip base]}]
(let [analysis (:analysis (:clip clip)) (let [entry clip
doc (project/save cid clip) analyses (analyses-of entry)
local (leaf/leaves cid (:clip clip)) doc (project/save cid entry)
base (when (and id (:synced clip)) base) local (leaf/leaves cid (:clip entry))
source-blocks (:source-blocks clip)] base (when (and id (:synced entry)) base)
sources (:sources entry)]
(-> (ensure-project! id label) (-> (ensure-project! id label)
(.then (fn [pid] (.then (fn [pid]
(-> (if (and analysis (not (@on-server (:id analysis)))) (-> (reduce (fn [chain one]
(http/POST "/api/analyses" (analysis-payload analysis)) (.then chain
(js/Promise.resolve nil)) #(http/POST "/api/analyses"
(analysis-payload one))))
(js/Promise.resolve nil)
analyses)
(.then (fn [_] (.then (fn [_]
(when (and (seq source-blocks) (not (@on-server (:id analysis)))) (upload-sources! sources)))
(-> (upload-missing!
#js {:blocks (source/upload-blocks source-blocks)})
(.then (fn [_] (.then (fn [_]
(http/PUT
(str "/api/analyses/" (:id analysis))
;; One set per tracked subject, in
;; the order `source/unpack` does
;; not depend on.
#js {:source_blocks
(into-array
(source/block-keys source-blocks))})))))))
(.then (fn [_]
(when analysis (swap! on-server conj (:id analysis)))
(upload-new! doc))) (upload-new! doc)))
(.then (fn [uploaded] (.then (fn [uploaded]
(-> (http/PUT (str "/api/projects/" pid) (-> (http/PUT (str "/api/projects/" pid)
@ -288,11 +295,11 @@
:clips #js [(js/Object.assign :clips #js [(js/Object.assign
#js {:cid cid #js {:cid cid
:name label :name label
:analysis (:id analysis) :analyses (into-array
:footage (:footage-id clip) (keys (get-in entry [:clip :analyses])))
:blocks (block-keys doc)} :blocks (block-keys doc)}
(clip-payload doc base local (clip-payload doc base local
(:synced clip)))]}) (:synced entry)))]})
(.then (fn [^js saved] (.then (fn [^js saved]
(rf/dispatch [::saved pid cid label (rf/dispatch [::saved pid cid label
(.-seq saved) (.-seq saved)
@ -439,39 +446,38 @@
(assoc measured :interior-key block-key)))))))) (assoc measured :interior-key block-key))))))))
(throw error))))))) (throw error)))))))
(defn- source-for! [entry] (defn- source-for! [entry edit]
(if-let [inputs (:source-inputs entry)] (let [subject (:subject (regenerate/plan (:clip entry) edit))
(js/Promise.resolve inputs) subject-record (get-in entry [:clip :subjects subject])
(let [analysis (get-in entry [:clip :analysis])] analysis-id (:analysis subject-record)
(if (= (:id analysis) (:id @retained-source)) analysis (get-in entry [:clip :analyses analysis-id])
source-subject (:source-subject subject-record)
local (get-in entry [:sources analysis-id :source-inputs :subjects subject])]
(if local
(js/Promise.resolve {:subjects {subject local}})
(if (= [analysis-id subject] (:key @retained-source))
(:promise @retained-source) (:promise @retained-source)
(let [promise (let [promise
(if (= "synth" (:detector analysis)) (if (= "synth" (:detector analysis))
;; The synthetic take tracks one face and regenerating it reads
;; that face's landmarks, so it arrives in the same shape real
;; footage does rather than in a flat one only this branch uses.
(js/Promise.resolve (js/Promise.resolve
{:subjects {:subjects
{:face-1 {:dense (synth/synth-dense (:frames analysis) {subject {:dense (synth/synth-dense (:frames analysis)
{:seed (:seed analysis)})}}}) {:seed (:seed analysis)})}}})
(-> (ingest/manifest! (:footage-id entry)) (.then (ingest/manifest! (:footage subject-record))
(.then (fn [manifest] (fn [manifest]
(-> (footage/saved-source! (:id analysis) (.then (footage/saved-source!
[(:width manifest) analysis-id [(:width manifest) (:height manifest)])
(:height manifest)]) (fn [inputs]
(.then (fn [inputs]
(when-not inputs (when-not inputs
(throw (ex-info "saved analysis has no source blocks" {}))) (throw (ex-info "saved analysis has no source blocks" {})))
(update inputs :subjects {:subjects
(fn [subjects] {subject
(into {} (assoc (get-in inputs [:subjects source-subject])
(map (fn [[id one]] :presence
[id (assoc one :presence (footage/presence-for manifest
(footage/presence-for source-subject))}})))))]
manifest id))]))
subjects))))))))))]
(do (do
(reset! retained-source {:id (:id analysis) :promise promise}) (reset! retained-source {:key [analysis-id subject] :promise promise})
promise)))))) promise))))))
(defn- inputs-for-edit! (defn- inputs-for-edit!
@ -484,18 +490,19 @@
one (get-in inputs [:subjects subject])] one (get-in inputs [:subjects subject])]
(if (and teeth (:crops one)) (if (and teeth (:crops one))
(let [settings (merge take/knobs (feature/effective-params clip teeth)) (let [settings (merge take/knobs (feature/effective-params clip teeth))
analysis (get-in clip [:analysis :id]) analysis (get-in clip [:subjects subject :analysis])
source-subject (get-in clip [:subjects subject :source-subject])
frames (count (:crops one)) frames (count (:crops one))
key (source/interior-key analysis subject settings frames) key (source/interior-key analysis source-subject settings frames)
done (fn [measured] (assoc-in inputs [:subjects subject] measured))] done (fn [measured] (assoc-in inputs [:subjects subject] measured))]
(if (and (:interior one) (if (and (:interior one)
(or (= (:interior-key one) key) (or (= (:interior-key one) key)
(and (nil? (:interior-key one)) (and (nil? (:interior-key one))
(= key (source/interior-key analysis subject take/knobs frames))))) (= key (source/interior-key analysis source-subject take/knobs frames)))))
(js/Promise.resolve inputs) (js/Promise.resolve inputs)
(.then (if (= key (:key @retained-interior)) (.then (if (= key (:key @retained-interior))
(:promise @retained-interior) (:promise @retained-interior)
(let [promise (retained-interior! analysis subject settings one)] (let [promise (retained-interior! analysis source-subject settings one)]
(reset! retained-interior {:key key :promise promise}) (reset! retained-interior {:key key :promise promise})
promise)) promise))
done))) done)))
@ -504,7 +511,7 @@
(rf/reg-fx (rf/reg-fx
::preview-settings! ::preview-settings!
(fn [{:keys [id entry edit request]}] (fn [{:keys [id entry edit request]}]
(-> (source-for! entry) (-> (source-for! entry edit)
(.then (fn [inputs] (inputs-for-edit! entry edit inputs))) (.then (fn [inputs] (inputs-for-edit! entry edit inputs)))
(.then (fn [inputs] (.then (fn [inputs]
(regenerate/change (assoc entry :source-inputs inputs) edit))) (regenerate/change (assoc entry :source-inputs inputs) edit)))
@ -519,7 +526,7 @@
(fn [{:keys [db]} [_ edit]] (fn [{:keys [db]} [_ edit]]
(let [id (:clip/current db) (let [id (:clip/current db)
entry (store/entry id)] entry (store/entry id)]
(if (or (get-in db [:project :busy?]) (nil? (:analysis (:clip entry)))) (if (or (get-in db [:project :busy?]) (empty? (:analyses (:clip entry))))
{} {}
(let [plan (regenerate/plan (:clip entry) edit) (let [plan (regenerate/plan (:clip entry) edit)
report (select-keys plan [:features :roles]) report (select-keys plan [:features :roles])

View file

@ -762,11 +762,15 @@
{:frames (vec lengths)}))) {:frames (vec lengths)})))
nf (first lengths) nf (first lengths)
merged (fn [k] (into {} (mapcat (comp k second)) parts)) merged (fn [k] (into {} (mapcat (comp k second)) parts))
built {:name name :fps fps :analysis (:analysis params) analysis (:analysis params)
built {:name name :fps fps :analyses {(:id analysis) analysis}
:width (first stage) :height (second stage) :width (first stage) :height (second stage)
:palettes {pal/default-id pal/default-palette} :palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id :default-palette pal/default-id
:subjects (into {} (map (fn [[id _]] [id {:id id :params {}}])) ordered) :subjects (into {} (map (fn [[id _]]
[id {:id id :params {}
:analysis (:id analysis)
:source-subject id}])) ordered)
:features (merged :features) :groups (merged :groups) :features (merged :features) :groups (merged :groups)
:symbols :symbols
(into {:main (into {:main

View file

@ -142,6 +142,11 @@
teeth are dirty, retained pixel measurements. No IO or app-db here." teeth are dirty, retained pixel measurements. No IO or app-db here."
[{:keys [clip source-inputs] :as entry} edit] [{:keys [clip source-inputs] :as entry} edit]
(let [{:keys [changed subject features]} (plan clip edit) (let [{:keys [changed subject features]} (plan clip edit)
analysis-id (get-in changed [:subjects subject :analysis])
analysis (get-in changed [:analyses analysis-id])
_ (when-not analysis
(throw (ex-info "subject names a missing analysis"
{:subject subject :analysis analysis-id})))
;; THE EDITED SUBJECT'S OWN LANDMARKS. Retained source is per subject — ;; THE EDITED SUBJECT'S OWN LANDMARKS. Retained source is per subject —
;; one video, one dense track per tracked face — so re-measuring the ;; one video, one dense track per tracked face — so re-measuring the
;; second face through the first one's anchor is the mistake this lookup ;; second face through the first one's anchor is the mistake this lookup
@ -152,8 +157,8 @@
{:subject subject}))) {:subject subject})))
base-params (merge take/knobs base-params (merge take/knobs
{:fps (or (get-in changed [:symbols subject :fps]) (:fps changed)) {:fps (or (get-in changed [:symbols subject :fps]) (:fps changed))
:aspect (get-in changed [:analysis :aspect]) :aspect (:aspect analysis)
:analysis (:analysis changed)}) :analysis analysis})
;; One conditioned anchor for the whole edit, and it is the SUBJECT's. ;; One conditioned anchor for the whole edit, and it is the SUBJECT's.
;; `:anchor-avg` is a subject setting, so the shared upstream measurement ;; `:anchor-avg` is a subject setting, so the shared upstream measurement
;; is not read off whichever dirty feature happened to sort first — and ;; is not read off whichever dirty feature happened to sort first — and

View file

@ -212,7 +212,6 @@
;; row is not on stage, so neither is its footage. ;; row is not on stage, so neither is its footage.
(let [solo (filter #(placed? document open %) solo)] (let [solo (filter #(placed? document open %) solo)]
{:document document :store store {:document document :store store
:footage-id (:footage-id (footage/entry id))
:opacity (or opacity trace/opacity-default) :opacity (or opacity trace/opacity-default)
:traces (cond->> (when document (trace/shown document open faces)) :traces (cond->> (when document (trace/shown document open faces))
(seq solo) (filterv (fn [{:keys [path]}] (seq solo) (filterv (fn [{:keys [path]}]

View file

@ -156,7 +156,8 @@
;; than kept beside it, so a sound placed from footage makes that footage the ;; than kept beside it, so a sound placed from footage makes that footage the
;; project's without anything else being told. ;; project's without anything else being told.
(let [entry (store/entry id) (let [entry (store/entry id)
used (into (set (keep identity [(:footage-id entry)])) uploaded) used (into (set uploaded)
(keep :footage (vals (get-in entry [:clip :subjects]))))
used (into used (for [[_ sym] (get-in entry [:clip :symbols]) used (into used (for [[_ sym] (get-in entry [:clip :symbols])
[_ n] (:nodes sym) [_ n] (:nodes sym)
:let [f (get-in n [:source :footage])] :let [f (get-in n [:source :footage])]

View file

@ -129,7 +129,7 @@
says where each face's head went and which of its frames it was on, so this says where each face's head went and which of its frames it was on, so this
reads the frame rather than resolving it again. `on-ready` is called when a reads the frame rather than resolving it again. `on-ready` is called when a
still or a manifest that was missing arrives, to paint again." still or a manifest that was missing arrives, to paint again."
[{:keys [document store footage-id traces opacity width playing?]} resolver on-ready] [{:keys [document store traces opacity width playing?]} resolver on-ready]
(when-let [^js canvas (:canvas @state)] (when-let [^js canvas (:canvas @state)]
(let [ctx (.getContext canvas "2d") (let [ctx (.getContext canvas "2d")
zoom (/ (.-width canvas) width)] zoom (/ (.-width canvas) width)]
@ -138,17 +138,20 @@
;; What each face is showing, forgotten for the faces switched off. Before ;; What each face is showing, forgotten for the faces switched off. Before
;; the early exits, so switching them all off forgets all of them. ;; the early exits, so switching them all off forgets all of them.
(swap! state update :last select-keys (map :path traces)) (swap! state update :last select-keys (map :path traces))
(when-let [us (and (seq traces) footage-id (urls footage-id on-ready))] (when (seq traces)
(let [start (first (get-in document [:analysis :range] [0])) (let [;; Read once: each face writes only its own entry below, and what it
;; Read once: each face writes only its own entry below, and what it ;; was showing is what it falls back to.
;; was showing is what it falls back to. A face that has been off
;; stage for a few frames still has the still it went away with.
was (:last @state)] was (:last @state)]
;; ONE OPACITY for all of them, set once: how strongly the reference ;; ONE OPACITY for all of them, set once: how strongly the reference
;; draws is a property of looking at the stage, not of a face. ;; draws is a property of looking at the stage, not of a face.
(set! (.-globalAlpha ctx) opacity) (set! (.-globalAlpha ctx) opacity)
(doseq [{:keys [path face]} traces] (doseq [{:keys [path face]} traces
(let [at (conj path :head) :let [subject (get-in document [:subjects face])
analysis (get-in document [:analyses (:analysis subject)])
us (urls (:footage subject) on-ready)]
:when us]
(let [start (first (get analysis :range [0]))
at (conj path :head)
world (symbol/world-of resolver at) world (symbol/world-of resolver at)
frame (symbol/frame-of resolver at)] frame (symbol/frame-of resolver at)]
(when (and world (number? frame)) (when (and world (number? frame))

View file

@ -26,3 +26,34 @@
(vals (get-in clip [:symbols :take :nodes])))))) (vals (get-in clip [:symbols :take :nodes]))))))
"and the copy's instance follows its renamed symbol") "and the copy's instance follows its renamed symbol")
(is (empty? (clip/problems clip))))) (is (empty? (clip/problems clip)))))
(defn- tracked-take [analysis-id]
{:analyses {analysis-id {:id analysis-id :detector "test" :version "1"}}
:subjects {:face-1 {:id :face-1 :analysis analysis-id
:source-subject :face-1 :params {}}}
:features {:face-1/mouth {:id :face-1/mouth :subject :face-1
:symbol :face-1 :area :mouth :nodes [:mouth] :params {}}}
:groups {}
:symbols {:main {:id :main :fps 30 :frames 10
:nodes {:face-1 {:id :face-1 :kind :instance :z "a1"
:source {:symbol :face-1}}}}
:face-1 {:id :face-1 :fps 30 :frames 10
:nodes {:head {:id :head :kind :group :z "a1"
:measured {[:xform :pos]
{:animated? false :value [0 0]}}}
:mouth {:id :mouth :kind :poly :z "a2"}}}}})
(deftest every-import-is-tracked-under-its-unique-symbol-scope
(let [first (bring/take (clip/blank) (tracked-take "analysis-a")
"close up" "footage-a" [0 10])
second (bring/take (:clip first) (tracked-take "analysis-b")
"close up" "footage-b" [0 10])
c (:clip second)]
(is (= #{:close-up.face-1 :close-up-2.face-1} (set (keys (:subjects c)))))
(is (= #{"analysis-a" "analysis-b"} (set (keys (:analyses c)))))
(is (= "analysis-a" (get-in c [:subjects :close-up.face-1 :analysis])))
(is (= "analysis-b" (get-in c [:subjects :close-up-2.face-1 :analysis])))
(is (= :face-1 (get-in c [:subjects :close-up-2.face-1 :source-subject])))
(is (= #{:close-up.face-1/mouth :close-up-2.face-1/mouth}
(set (keys (:features c)))))
(is (empty? (clip/problems c)))))

View file

@ -90,8 +90,17 @@
(is (or (zero? f) (< (clip/shown-frame doc :main (dec f)) n)))))))) (is (or (zero? f) (< (clip/shown-frame doc :main (dec f)) n))))))))
(deftest imported-footage-uses-selection-for-picture-and-real-speed-for-audio (deftest imported-footage-uses-selection-for-picture-and-real-speed-for-audio
(let [{doc :clip sid :sid} (bring/take (clip/set-fps (clip/blank) 12) (let [tracked (-> footage
footage "take" "video" [15 75]) (assoc :analyses {"a" {:id "a"}}
:subjects {:face-1 {:id :face-1 :analysis "a"
:source-subject :face-1}}
:features {} :groups {}
:symbols {:main {:id :main :fps 30 :frames 60
:nodes {:face-1 {:id :face-1 :kind :instance
:z "a" :source {:symbol :face-1}}}}
:face-1 (get-in footage [:symbols :main])}))
{doc :clip sid :sid} (bring/take (clip/set-fps (clip/blank) 12)
tracked "take" "video" [15 75])
;; A host authored at 12, with a 30fps source starting half a second in. ;; A host authored at 12, with a 30fps source starting half a second in.
doc (-> doc (assoc-in [:symbols :main :fps] 12) doc (-> doc (assoc-in [:symbols :main :fps] 12)
(clip/place-symbol store :main sid 6 :insert nil)) (clip/place-symbol store :main sid 6 :insert nil))
@ -99,21 +108,16 @@
draw (clip/resolver doc :main store pal/index-of nil) draw (clip/resolver doc :main store pal/index-of nil)
[sound] (nest/audio-tracks doc :main)] [sound] (nest/audio-tracks doc :main)]
(is (= 60 (clip/frames doc sid))) (is (= 60 (clip/frames doc sid)))
(is (= 1 (get-in doc [:symbols sid :nodes :sound :time :rate]))) (is (= 1 (get-in doc [:symbols :take.face-1 :nodes :sound :time :rate])))
(is (= [0 24] (:span n))) (is (= [0 24] (:span n)))
(is (empty? (draw 5))) (is (empty? (draw 5)))
(is (= 8 (:size (first (draw 9))))) (is (= 8 (:size (first (draw 9)))))
(is (= 7 (:frame (nest/inside doc store :main [:insert :mark] 9)))) (is (= 7 (:frame (nest/inside doc store :main [:insert :face-1 :mark] 9))))
(is (= [-4 -4 4 4] ((pick/bounds-of doc store :main n) 3))) (is (= [-4 -4 4 4] ((pick/bounds-of doc store :main n) 3)))
(is (= [6 30] (node/placed-span sound))) (is (= [6 30] (node/placed-span sound)))
(is (= 15 (node/local-frame sound 6))) (is (= 15 (node/local-frame sound 6)))
(is (= 1 (* (get-in sound [:time :rate]) (/ (:fps doc) (:fps sound))))) (is (= 1 (* (get-in sound [:time :rate]) (/ (:fps doc) (:fps sound)))))
(is (= [6 30] (:span (first (timeline/rows doc :main #{[:insert]}))))) (is (= [6 30] (:span (first (timeline/rows doc :main #{[:insert]})))))
(testing "an off-grid closed mouth can win without modifying the dense source"
(let [pinned (pose/put-cut doc :main :insert :mouth 7 6)
r (clip/resolver pinned :main store pal/index-of nil)]
(is (= 7 (:size (first (r 9)))))
(is (= (get-in doc [:symbols sid]) (get-in pinned [:symbols sid])))))
(testing "a deliberate half speed still retimes audio" (testing "a deliberate half speed still retimes audio"
(let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5) (let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5)
[track] (nest/audio-tracks slow :main)] [track] (nest/audio-tracks slow :main)]
@ -121,7 +125,9 @@
(deftest generated-faces-own-their-footage-sound (deftest generated-faces-own-their-footage-sound
(let [face (fn [id] {:id id :fps 30 :frames 20 :nodes {}}) (let [face (fn [id] {:id id :fps 30 :frames 20 :nodes {}})
frozen {:fps 30 :subjects {:face-1 {} :face-2 {}} frozen {:fps 30 :analyses {"a" {:id "a"}}
:subjects {:face-1 {:id :face-1 :analysis "a" :source-subject :face-1}
:face-2 {:id :face-2 :analysis "a" :source-subject :face-2}}
:symbols {:main {:id :main :fps 30 :frames 20 :symbols {:main {:id :main :fps 30 :frames 20
:nodes {:face-1 {:id :face-1 :kind :instance :z "a1" :nodes {:face-1 {:id :face-1 :kind :instance :z "a1"
:source {:symbol :face-1}} :source {:symbol :face-1}}
@ -130,11 +136,11 @@
:face-1 (face :face-1) :face-1 (face :face-1)
:face-2 (face :face-2)}} :face-2 (face :face-2)}}
{doc :clip take :sid} (bring/take (clip/blank) frozen "take" "video" [3 13]) {doc :clip take :sid} (bring/take (clip/blank) frozen "take" "video" [3 13])
alone (clip/place-symbol doc nil :main :face-1 4 :placed-face nil)] alone (clip/place-symbol doc nil :main :take.face-1 4 :placed-face nil)]
(is (nil? (get-in doc [:symbols take :nodes :sound])) (is (nil? (get-in doc [:symbols take :nodes :sound]))
"the wrapper does not own a sound the face would lose") "the wrapper does not own a sound the face would lose")
(is (= {:footage "video"} (is (= {:footage "video"}
(get-in doc [:symbols :face-1 :nodes :sound :source]))) (get-in doc [:symbols :take.face-1 :nodes :sound :source])))
(is (= 1 (count (nest/audio-tracks doc take))) (is (= 1 (count (nest/audio-tracks doc take)))
"the same take recording is not mixed once per detected face") "the same take recording is not mixed once per detected face")
(is (= 1 (count (nest/audio-tracks alone :main))) (is (= 1 (count (nest/audio-tracks alone :main)))

View file

@ -45,7 +45,7 @@
(let [ls (leaf/leaves :c7 @take/clip)] (let [ls (leaf/leaves :c7 @take/clip)]
(is (contains? ls "clip/c7/timing")) (is (contains? ls "clip/c7/timing"))
(is (contains? ls "clip/c7/stage")) (is (contains? ls "clip/c7/stage"))
(is (contains? ls "clip/c7/source")) (is (contains? ls "clip/c7/analyses"))
;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be ;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be
;; addressed by the same path shape as the clip's own. `main` is the root. ;; addressed by the same path shape as the clip's own. `main` is the root.
(is (contains? ls "clip/c7/symbol/main")) (is (contains? ls "clip/c7/symbol/main"))

View file

@ -2,6 +2,7 @@
(:require [cljs.test :refer [deftest is testing]] (:require [cljs.test :refer [deftest is testing]]
[clojure.walk :as walk] [clojure.walk :as walk]
[arthur.demo.stage :as stage] [arthur.demo.stage :as stage]
[arthur.domain.bring :as bring]
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.params :as params] [arthur.domain.params :as params]
@ -29,13 +30,41 @@
(merge take/knobs (merge take/knobs
{:name "regen" :fps 30 :aspect 1 :stage [320 200] {:name "regen" :fps 30 :aspect 1 :stage [320 200]
:expose 1 :head :free :expose 1 :head :free
:analysis (get-in @initial [:clip :analysis])} :analysis (-> @initial :clip :analyses vals first)}
change) change)
{:face-1 @inputs})) {:face-1 @inputs}))
(defn- channel [entry node path] (defn- channel [entry node path]
(get-in entry [:clip :symbols :face-1 :nodes node :channels path])) (get-in entry [:clip :symbols :face-1 :nodes node :channels path]))
(deftest a-second-import-regenerates-through-its-own-analysis
(let [built (fn [seed]
(let [input {:dense (synth/synth-dense frames {:seed seed})}
analysis (address/analysis
{:detector "synth" :version "mulberry32"
:seed seed :frames frames :fps 30 :aspect 1})]
[(take/build (merge take/knobs
{:name "take" :fps 30 :aspect 1 :stage [320 200]
:expose 1 :head :free :analysis analysis})
{:face-1 input})
input]))
[a input-a] (built 3)
[b input-b] (built 7)
first (bring/take (clip/blank) (:clip a) "shot" "footage-a" [0 frames])
second (bring/take (:clip first) (:clip b) "shot" "footage-b" [0 frames])
entry {:clip (:clip second) :store (merge (:store a) (:store b))
:source-inputs {:subjects {:shot.face-1 input-a
:shot-2.face-1 input-b}}}
fid :shot-2.face-1/mouth
changed (regenerate/change entry
{:scope :feature :id fid :knob :verts :value 10})
analysis-id (get-in changed [:clip :subjects :shot-2.face-1 :analysis])
generated (get-in changed [:clip :symbols :shot-2.face-1 :nodes :mouth
:channels [:geom :pts] :generated])]
(is (= (get-in b [:clip :subjects :face-1 :analysis]) analysis-id))
(is (= analysis-id (:analysis generated)))
(is (= 10 (get-in generated [:params :verts])))))
(deftest eye-rebuild-is-confined-to-the-edited-feature (deftest eye-rebuild-is-confined-to-the-edited-feature
(let [before @initial (let [before @initial
after (regenerate/change before after (regenerate/change before
@ -170,7 +199,7 @@
(defn- part-at [area overrides] (defn- part-at [area overrides]
(let [p (merge take/knobs (let [p (merge take/knobs
{:fps 30 :aspect 1 :analysis (get-in @initial [:clip :analysis])} {:fps 30 :aspect 1 :analysis (-> @initial :clip :analyses vals first)}
overrides) overrides)
inputs (assoc @inputs :interior @interior-track)] inputs (assoc @inputs :interior @interior-track)]
(freeze/part :face-1 area p (take/measure-part area p inputs (take/anchor-base p inputs))))) (freeze/part :face-1 area p (take/measure-part area p inputs (take/anchor-base p inputs)))))
@ -212,8 +241,8 @@
;; document that cannot be previewed fails in the suite rather than as a slider ;; document that cannot be previewed fails in the suite rather than as a slider
;; that silently does nothing in the browser. ;; that silently does nothing in the browser.
(let [entry (staged)] (let [entry (staged)]
(is (some? (:analysis (:clip entry))) (is (seq (:analyses (:clip entry)))
"the composed stage keeps the analysis the edit needs") "the composed stage keeps the analyses its edits need")
(is (some? (:source-inputs entry))) (is (some? (:source-inputs entry)))
(testing "and the features still say which timeline they live in" (testing "and the features still say which timeline they live in"
(is (every? #(= :face-1 (:symbol %)) (is (every? #(= :face-1 (:symbol %))