avdm 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
avdm/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """AVDM: the Audiovisual Scene Data Model (AVDM-001), revision 0.2.
2
+
3
+ One canonical document for one shot, observed in existing media or planned for
4
+ generation, and the serializers that render it into a model's text register.
5
+ See AGENTS.md for adoption.
6
+ """
7
+
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from avdm.budget import H3_TOKEN_LIMIT, over_budget, token_count
12
+ from avdm.evidence import DEFAULT_POLICY, EvidencePolicy, assemble, assess, validate
13
+ from avdm.model import * # noqa: F403
14
+ from avdm.model import __all__ as _model_all
15
+ from avdm.observe import (
16
+ FrameClaims,
17
+ PersonClaims,
18
+ apply_frame_claims,
19
+ frame_claims_schema,
20
+ )
21
+ from avdm.render import UnrenderableError, to_h3, to_prose, to_ste
22
+
23
+
24
+ def schema_path() -> Path:
25
+ """The JSON Schema file that is the source of truth for these models."""
26
+ return Path(str(files("avdm") / "schema" / "avdm-0.2.schema.json"))
27
+
28
+
29
+ __all__ = [
30
+ *_model_all,
31
+ "DEFAULT_POLICY",
32
+ "H3_TOKEN_LIMIT",
33
+ "EvidencePolicy",
34
+ "FrameClaims",
35
+ "PersonClaims",
36
+ "UnrenderableError",
37
+ "apply_frame_claims",
38
+ "assemble",
39
+ "assess",
40
+ "frame_claims_schema",
41
+ "over_budget",
42
+ "schema_path",
43
+ "to_h3",
44
+ "to_prose",
45
+ "to_ste",
46
+ "token_count",
47
+ "validate",
48
+ ]
avdm/budget.py ADDED
@@ -0,0 +1,34 @@
1
+ """Caption length against a trainer's text-token limit.
2
+
3
+ ai-toolkit truncates an H3 caption at 512 tokens from the end, so the last
4
+ fields (the soundscape and the music) are lost first. This module warns; it
5
+ never truncates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Sequence
11
+
12
+ #: ai-toolkit's `max_text_length` default for MiniMax H3.
13
+ H3_TOKEN_LIMIT = 512
14
+
15
+
16
+ def token_count(text: str, tokenize: Callable[[str], Sequence[object]] | None = None) -> int:
17
+ """Tokens in *text* under *tokenize*, or a whitespace word count without one.
18
+
19
+ The word count is an underestimate for most tokenizers. Pass the target
20
+ model's tokenizer when the limit matters.
21
+ """
22
+ return len(tokenize(text)) if tokenize else len(text.split())
23
+
24
+
25
+ def over_budget(
26
+ text: str,
27
+ limit: int = H3_TOKEN_LIMIT,
28
+ tokenize: Callable[[str], Sequence[object]] | None = None,
29
+ ) -> bool:
30
+ """Whether *text* exceeds *limit* tokens."""
31
+ return token_count(text, tokenize) > limit
32
+
33
+
34
+ __all__ = ["H3_TOKEN_LIMIT", "over_budget", "token_count"]
avdm/evidence.py ADDED
@@ -0,0 +1,239 @@
1
+ """Evidence rules: what an observation may claim, given what was inspected.
2
+
3
+ Applied by `assemble()` to an observed document and checked by `validate()`.
4
+ Planned documents state intent, not evidence, so these rules do not apply to
5
+ them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+
13
+ from avdm.model import (
14
+ Aspect,
15
+ EntityAttribute,
16
+ Observation,
17
+ SceneDocument,
18
+ ShotAttribute,
19
+ Status,
20
+ )
21
+
22
+ #: Aspects that only audio evidence can establish (REQ-OBS-003).
23
+ AUDIO_ASPECTS: frozenset[Aspect] = frozenset({"dialogue", "sound", "music"})
24
+
25
+ #: Aspects about change over time.
26
+ MOTION_ASPECTS: frozenset[Aspect] = frozenset({"subject_motion", "camera_motion"})
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EvidencePolicy:
31
+ """Thresholds for motion seen through sampled frames.
32
+
33
+ Sampled frames are evidence of motion, but not complete evidence of what
34
+ happens between them (AVDM §15.4). Their weight depends on density: the
35
+ sample rate, and the fraction of the clip that the samples span. A model's
36
+ own confidence is not used, because a model score is not a probability.
37
+ """
38
+
39
+ min_motion_fps: float = 2.0
40
+ min_motion_coverage: float = 0.8
41
+
42
+
43
+ DEFAULT_POLICY = EvidencePolicy()
44
+
45
+
46
+ def assess(observation: Observation, policy: EvidencePolicy = DEFAULT_POLICY) -> Observation:
47
+ """*observation* with its status lowered to unknown when its evidence cannot carry it.
48
+
49
+ The reason is added to `limitations`. A status of unknown or not_applicable
50
+ is returned unchanged.
51
+ """
52
+ if observation.status != "specified":
53
+ return observation
54
+ reason = _insufficient(observation, policy)
55
+ if reason is None:
56
+ return observation
57
+ return observation.model_copy(
58
+ update={"status": "unknown", "limitations": [*observation.limitations, reason]}
59
+ )
60
+
61
+
62
+ def _insufficient(observation: Observation, policy: EvidencePolicy) -> str | None:
63
+ scope = observation.evidence_scope
64
+ if observation.aspect in AUDIO_ASPECTS and "audio" not in scope.modalities:
65
+ return f"{observation.aspect} needs audio evidence; inspected {list(scope.modalities)}"
66
+ if observation.aspect in MOTION_ASPECTS:
67
+ if scope.sampling == "continuous":
68
+ return None
69
+ if scope.sampling != "sparse_frames":
70
+ return f"{observation.aspect} needs a sequence of frames; sampling was {scope.sampling}"
71
+ rate, coverage = scope.sample_rate_fps or 0.0, scope.coverage or 0.0
72
+ if rate < policy.min_motion_fps or coverage < policy.min_motion_coverage:
73
+ return (
74
+ f"{observation.aspect} from {rate:g} fps over {coverage:.0%} of the clip; "
75
+ f"needs {policy.min_motion_fps:g} fps over {policy.min_motion_coverage:.0%}"
76
+ )
77
+ return None
78
+
79
+
80
+ def assemble(document: SceneDocument, policy: EvidencePolicy = DEFAULT_POLICY) -> SceneDocument:
81
+ """An observed *document* with each observation assessed and coverage derived.
82
+
83
+ An aspect is specified when an observation of it survives assessment,
84
+ not_applicable when every observation says so, and unknown otherwise. An
85
+ aspect with no observation is left out of coverage, which also means unknown.
86
+ """
87
+ if document.information_class != "observed":
88
+ return document
89
+ observations = [assess(o, policy) for o in document.observations]
90
+ carried = {o.id for o in observations if o.status == "specified"}
91
+ coverage: dict[Aspect, Status] = {}
92
+ for aspect in {o.aspect for o in observations}:
93
+ statuses = {o.status for o in observations if o.aspect == aspect}
94
+ if "specified" in statuses:
95
+ coverage[aspect] = "specified"
96
+ elif statuses == {"not_applicable"}:
97
+ coverage[aspect] = "not_applicable"
98
+ else:
99
+ coverage[aspect] = "unknown"
100
+ shot = document.shot.model_copy(
101
+ update={"attributes": [_carried(a, carried) for a in document.shot.attributes]}
102
+ )
103
+ entities = [
104
+ e.model_copy(update={"attributes": [_carried(a, carried) for a in e.attributes]})
105
+ for e in document.entities
106
+ ]
107
+ return document.model_copy(
108
+ update={
109
+ "observations": observations,
110
+ "coverage": coverage,
111
+ "shots": [shot],
112
+ "entities": entities,
113
+ }
114
+ )
115
+
116
+
117
+ def _carried[A: (EntityAttribute, ShotAttribute)](attribute: A, carried: set[str]) -> A:
118
+ """*attribute*, made unknown when its observation does not survive assessment.
119
+
120
+ A claim is only as good as the evidence it names. Built anew rather than
121
+ copied, so the model's own rules are checked on the result.
122
+ """
123
+ if attribute.status != "specified" or attribute.observation_ref in carried:
124
+ return attribute
125
+ return type(attribute).model_validate(
126
+ {
127
+ "name": attribute.name,
128
+ "status": "unknown",
129
+ "reason": "unclear",
130
+ "observation_ref": attribute.observation_ref,
131
+ }
132
+ )
133
+
134
+
135
+ def validate(document: SceneDocument, policy: EvidencePolicy = DEFAULT_POLICY) -> list[str]:
136
+ """Every rule *document* breaks, as messages. An empty list means valid."""
137
+ errors: list[str] = []
138
+ ids = _ids(document, errors)
139
+ assets = {a.id: a for a in document.assets}
140
+ events = {e.id for e in document.events}
141
+ entities = {e.id for e in document.entities}
142
+
143
+ def need(ref: str | None, pool: set[str], what: str, where: str) -> None:
144
+ if ref is not None and ref not in pool:
145
+ errors.append(f"{where}: {what} {ref!r} does not exist")
146
+
147
+ for ref in document.shot.event_refs:
148
+ need(ref, events, "event", document.shot.id)
149
+ for event in document.events:
150
+ for participant in event.participants:
151
+ need(participant.entity_ref, entities, "entity", event.id)
152
+ for cue in document.audio_cues:
153
+ need(cue.speaker_ref, entities, "speaker", cue.id)
154
+ need(cue.sync_event_ref, events, "event", cue.id)
155
+ if cue.category in {"dialogue", "lyrics"} and not cue.exact_text:
156
+ errors.append(f"{cue.id}: {cue.category} needs its exact text (REQ-AUD-004)")
157
+ for asset in document.assets:
158
+ if asset.derived_from is not None:
159
+ need(asset.derived_from.asset_ref, set(assets), "asset", asset.id)
160
+ for observation in document.observations:
161
+ need(observation.asset_ref, set(assets), "asset", observation.id)
162
+ for ref in observation.target_refs:
163
+ need(ref, ids, "object", observation.id)
164
+ observations = {o.id for o in document.observations}
165
+ owners: list[tuple[str, Sequence[EntityAttribute | ShotAttribute]]] = [
166
+ (document.shot.id, document.shot.attributes),
167
+ *((e.id, e.attributes) for e in document.entities),
168
+ ]
169
+ for owner, attributes in owners:
170
+ for attribute in attributes:
171
+ where = f"{owner}.{attribute.name}"
172
+ need(attribute.observation_ref, observations, "observation", where)
173
+ if (
174
+ document.information_class == "observed"
175
+ and attribute.status == "specified"
176
+ and attribute.observation_ref is None
177
+ ):
178
+ errors.append(f"{where}: an observed claim must name its observation")
179
+
180
+ errors.extend(_frame_ranges(document, assets))
181
+
182
+ if document.information_class == "observed":
183
+ assessed = [assess(o, policy) for o in document.observations]
184
+ for aspect, status in document.coverage.items():
185
+ supported = any(o.aspect == aspect and o.status == "specified" for o in assessed)
186
+ if status == "specified" and not supported:
187
+ errors.append(f"coverage: {aspect} is specified but no evidence supports it")
188
+ return errors
189
+
190
+
191
+ def _ids(document: SceneDocument, errors: list[str]) -> set[str]:
192
+ seen: set[str] = set()
193
+ groups = (
194
+ document.assets,
195
+ document.entities,
196
+ document.shots,
197
+ document.events,
198
+ document.audio_cues,
199
+ document.observations,
200
+ )
201
+ for group in groups:
202
+ for item in group:
203
+ if item.id in seen:
204
+ errors.append(f"id {item.id!r} is used twice")
205
+ seen.add(item.id)
206
+ return seen
207
+
208
+
209
+ def _frame_ranges(document: SceneDocument, assets: dict) -> list[str]:
210
+ """A source range in an asset's own frame rate must stay inside its frames (T12)."""
211
+ errors = []
212
+ ranges = [
213
+ *(a.derived_from.range for a in document.assets if a.derived_from),
214
+ *(e.range for e in document.events if e.range),
215
+ *(c.range for c in document.audio_cues if c.range),
216
+ *(o.evidence_scope.range for o in document.observations if o.evidence_scope.range),
217
+ *([document.shot.range] if document.shot.range else []),
218
+ ]
219
+ for r in ranges:
220
+ asset = assets.get(r.ref)
221
+ meta = asset.technical_metadata if asset else None
222
+ if r.domain != "source" or meta is None or meta.frame_count is None:
223
+ continue
224
+ if meta.frame_rate == r.rate and r.end > meta.frame_count:
225
+ errors.append(
226
+ f"range ends at frame {r.end} of {r.ref!r}, which has {meta.frame_count} frames"
227
+ )
228
+ return errors
229
+
230
+
231
+ __all__ = [
232
+ "AUDIO_ASPECTS",
233
+ "DEFAULT_POLICY",
234
+ "MOTION_ASPECTS",
235
+ "EvidencePolicy",
236
+ "assemble",
237
+ "assess",
238
+ "validate",
239
+ ]
avdm/model.py ADDED
@@ -0,0 +1,369 @@
1
+ """Typed AVDM 0.2 objects, mirroring `schema/avdm-0.2.schema.json` by hand.
2
+
3
+ The JSON Schema is the source of truth. `tests/contract` keeps the two in step:
4
+ the same property names per definition, and the same verdict on every fixture.
5
+
6
+ A missing optional field means "no assertion" (AVDM §5.3). `coverage` is where
7
+ "we looked and found none" is told apart from "we did not look". An attribute
8
+ says the same for one claim: `unknown` with a reason is "we looked and cannot
9
+ tell"; an absent attribute is "we did not look".
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from fractions import Fraction
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
20
+
21
+ from avdm.vocabulary import ENTITY_TERMS, FREE_MAX_LENGTH, SHOT_TERMS, Term
22
+
23
+ AVDM_VERSION = "0.2"
24
+
25
+ Aspect = Literal["depiction", "subject_motion", "camera_motion", "dialogue", "sound", "music"]
26
+ Status = Literal["specified", "unknown", "not_applicable"]
27
+ Modality = Literal["image", "video", "audio", "text"]
28
+ #: Why an attribute is unknown: the observer could not tell, or could not see the part.
29
+ Reason = Literal["hidden", "unclear"]
30
+
31
+ #: The names in `vocabulary.ENTITY_TERMS`, in order (a contract test keeps them equal).
32
+ EntityAttributeName = Literal[
33
+ "figure",
34
+ "position",
35
+ "facing",
36
+ "hair_colour",
37
+ "hair_length",
38
+ "facial_hair",
39
+ "headwear",
40
+ "upper_garment",
41
+ "outer_garment",
42
+ "lower_garment",
43
+ "armour",
44
+ "accessory",
45
+ "held_object",
46
+ "expression",
47
+ "gaze",
48
+ "pose",
49
+ "action",
50
+ ]
51
+
52
+ #: The names in `vocabulary.SHOT_TERMS`, in order.
53
+ ShotAttributeName = Literal[
54
+ "shot_size",
55
+ "camera_angle",
56
+ "location",
57
+ "background",
58
+ "light_level",
59
+ "light_direction",
60
+ "visible_text",
61
+ ]
62
+
63
+ ASPECTS: tuple[Aspect, ...] = (
64
+ "depiction",
65
+ "subject_motion",
66
+ "camera_motion",
67
+ "dialogue",
68
+ "sound",
69
+ "music",
70
+ )
71
+
72
+
73
+ class _Model(BaseModel):
74
+ model_config = ConfigDict(extra="forbid", frozen=True)
75
+
76
+
77
+ class TimeBase(_Model):
78
+ """Ticks per second as the exact ratio num/den (AVDM §10.2)."""
79
+
80
+ num: int = Field(ge=1)
81
+ den: int = Field(ge=1)
82
+
83
+ def seconds(self, ticks: int) -> Fraction:
84
+ return Fraction(ticks * self.den, self.num)
85
+
86
+
87
+ class TimeRange(_Model):
88
+ """`start <= t < start + duration`, in ticks of *rate*, in one domain (§10.3)."""
89
+
90
+ domain: Literal["source", "shot", "edit", "story"]
91
+ ref: str = Field(min_length=1)
92
+ rate: TimeBase
93
+ start: int = Field(ge=0)
94
+ duration: int = Field(ge=0)
95
+
96
+ @property
97
+ def end(self) -> int:
98
+ return self.start + self.duration
99
+
100
+
101
+ class TechnicalMetadata(_Model):
102
+ """What the media actually is, measured, never requested (REQ-AST-002)."""
103
+
104
+ width_px: int | None = Field(default=None, ge=1)
105
+ height_px: int | None = Field(default=None, ge=1)
106
+ frame_rate: TimeBase | None = None
107
+ frame_count: int | None = Field(default=None, ge=1)
108
+ has_audio: bool | None = None
109
+ audio_sample_rate_hz: int | None = Field(default=None, ge=1)
110
+
111
+
112
+ class Derivation(_Model):
113
+ """The source range an asset was cut from, kept apart from its own time (T27)."""
114
+
115
+ asset_ref: str = Field(min_length=1)
116
+ range: TimeRange
117
+
118
+
119
+ class Asset(_Model):
120
+ id: str = Field(min_length=1)
121
+ media_type: Literal["image", "video", "audio"]
122
+ locator: str = Field(min_length=1)
123
+ content_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
124
+ availability: Literal["available", "unavailable", "pending"]
125
+ technical_metadata: TechnicalMetadata | None = None
126
+ derived_from: Derivation | None = None
127
+
128
+ @classmethod
129
+ def from_path(
130
+ cls,
131
+ path: Path,
132
+ *,
133
+ id: str, # noqa: A002 -- the field's own name
134
+ media_type: Literal["image", "video", "audio"],
135
+ locator: str | None = None,
136
+ technical_metadata: TechnicalMetadata | None = None,
137
+ derived_from: Derivation | None = None,
138
+ ) -> Asset:
139
+ """An available asset for a file that exists, hashed from its bytes.
140
+
141
+ Raises FileNotFoundError for a path that does not exist: a described
142
+ file is not an asset (REQ-AST-001, test T22).
143
+ """
144
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
145
+ return cls(
146
+ id=id,
147
+ media_type=media_type,
148
+ locator=locator or path.name,
149
+ content_hash=f"sha256:{digest}",
150
+ availability="available",
151
+ technical_metadata=technical_metadata,
152
+ derived_from=derived_from,
153
+ )
154
+
155
+
156
+ def _check_attribute(
157
+ name: str, status: Status, value: str | None, reason: str | None, term: Term
158
+ ) -> None:
159
+ """One claim's rules: a value only when specified, from the closed list if there is one."""
160
+ if status == "specified":
161
+ if value is None:
162
+ raise ValueError(f"{name}: a specified attribute needs a value")
163
+ if reason is not None:
164
+ raise ValueError(f"{name}: a specified attribute has no reason")
165
+ if term.values is not None and value not in term.values:
166
+ raise ValueError(f"{name}: {value!r} is not one of {list(term.values)}")
167
+ return
168
+ if value is not None:
169
+ raise ValueError(f"{name}: an attribute that is {status} has no value")
170
+ if reason == "hidden" and not term.hideable:
171
+ raise ValueError(f"{name}: cannot be hidden")
172
+
173
+
174
+ class EntityAttribute(_Model):
175
+ """One claim about an entity, such as its headwear (AVDM 0.2).
176
+
177
+ `unknown` with `reason` is "looked, cannot tell"; it is never a default value.
178
+ `observation_ref` names the observation whose evidence carries the claim.
179
+ """
180
+
181
+ name: EntityAttributeName
182
+ status: Status
183
+ value: str | None = Field(default=None, min_length=1, max_length=FREE_MAX_LENGTH)
184
+ reason: Reason | None = None
185
+ observation_ref: str | None = None
186
+
187
+ @model_validator(mode="after")
188
+ def _rules(self) -> EntityAttribute:
189
+ _check_attribute(self.name, self.status, self.value, self.reason, ENTITY_TERMS[self.name])
190
+ return self
191
+
192
+
193
+ class ShotAttribute(_Model):
194
+ """One claim about the shot as a whole, such as its size or its light (AVDM 0.2)."""
195
+
196
+ name: ShotAttributeName
197
+ status: Status
198
+ value: str | None = Field(default=None, min_length=1, max_length=FREE_MAX_LENGTH)
199
+ reason: Reason | None = None
200
+ observation_ref: str | None = None
201
+
202
+ @model_validator(mode="after")
203
+ def _rules(self) -> ShotAttribute:
204
+ _check_attribute(self.name, self.status, self.value, self.reason, SHOT_TERMS[self.name])
205
+ return self
206
+
207
+
208
+ class Entity(_Model):
209
+ id: str = Field(min_length=1)
210
+ kind: str | None = None
211
+ label: str | None = None
212
+ attributes: list[EntityAttribute] = []
213
+
214
+
215
+ class Description(_Model):
216
+ """Free text for a picture that has no attribute claims, such as a planned shot."""
217
+
218
+ text: str = Field(min_length=1)
219
+
220
+
221
+ class CameraMove(_Model):
222
+ """One camera operation. Translation, rotation and zoom stay distinct (REQ-CAM-001)."""
223
+
224
+ term: Literal[
225
+ "pan", "tilt", "roll", "push", "pull", "truck", "pedestal", "arc", "track", "zoom"
226
+ ]
227
+ direction: str | None = None
228
+
229
+
230
+ class Camera(_Model):
231
+ """The camera's moves. No moves, with camera_motion specified, is a static camera."""
232
+
233
+ moves: list[CameraMove]
234
+
235
+
236
+ class Shot(_Model):
237
+ """One shot. Its picture is its attribute claims when any is specified, else `description`."""
238
+
239
+ id: str = Field(min_length=1)
240
+ range: TimeRange | None = None
241
+ description: Description | None = None
242
+ camera: Camera | None = None
243
+ event_refs: list[str] = []
244
+ attributes: list[ShotAttribute] = []
245
+
246
+
247
+ class Action(_Model):
248
+ text: str = Field(min_length=1)
249
+ term: str | None = None
250
+
251
+
252
+ class Participant(_Model):
253
+ entity_ref: str = Field(min_length=1)
254
+ role: Literal["performer", "target", "instrument", "recipient", "observer"]
255
+
256
+
257
+ class Event(_Model):
258
+ id: str = Field(min_length=1)
259
+ action: Action
260
+ participants: list[Participant] = []
261
+ range: TimeRange | None = None
262
+
263
+
264
+ class AudioCue(_Model):
265
+ """One audible occurrence. Category, story relation and source visibility
266
+ are separate properties (REQ-AUD-002): an off-screen radio is diegetic music.
267
+ """
268
+
269
+ id: str = Field(min_length=1)
270
+ category: Literal["dialogue", "lyrics", "non_verbal", "ambience", "effect", "music"]
271
+ story_relation: Literal["diegetic", "non_diegetic"]
272
+ source_visibility: Literal["on_screen", "off_screen", "mixed", "not_applicable"] | None = None
273
+ description: str | None = None
274
+ exact_text: str | None = None
275
+ speaker_ref: str | None = None
276
+ language: str | None = Field(default=None, pattern=r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$")
277
+ sync_event_ref: str | None = None
278
+ range: TimeRange | None = None
279
+
280
+
281
+ class EvidenceScope(_Model):
282
+ """What was inspected: modalities, how the frames were sampled, and how much."""
283
+
284
+ modalities: list[Modality]
285
+ sampling: Literal["none", "single_frame", "sparse_frames", "continuous"]
286
+ frame_count: int | None = Field(default=None, ge=0)
287
+ sample_rate_fps: float | None = Field(default=None, ge=0)
288
+ coverage: float | None = Field(default=None, ge=0, le=1)
289
+ range: TimeRange | None = None
290
+
291
+
292
+ class Method(_Model):
293
+ producer: str = Field(min_length=1)
294
+ params: dict[str, Any] = {}
295
+ human: bool = False
296
+
297
+
298
+ class Observation(_Model):
299
+ """An evidence-based statement about one asset (AVDM §15.4)."""
300
+
301
+ id: str = Field(min_length=1)
302
+ aspect: Aspect
303
+ status: Status
304
+ target_refs: list[str] = []
305
+ asset_ref: str = Field(min_length=1)
306
+ evidence_scope: EvidenceScope
307
+ method: Method
308
+ basis: Literal["explicit", "observed", "necessary_inference", "creative_default", "measurement"]
309
+ recorded_at: str | None = None
310
+ value_text: str | None = None
311
+ limitations: list[str] = []
312
+
313
+
314
+ class SceneDocument(_Model):
315
+ """One shot, observed in existing media or planned for generation."""
316
+
317
+ avdm_version: Literal["0.2"] = AVDM_VERSION
318
+ id: str = Field(min_length=1)
319
+ information_class: Literal["observed", "planned"]
320
+ assets: list[Asset]
321
+ entities: list[Entity] = []
322
+ shots: list[Shot] = Field(min_length=1, max_length=1)
323
+ events: list[Event] = []
324
+ audio_cues: list[AudioCue] = []
325
+ observations: list[Observation] = []
326
+ coverage: dict[Aspect, Status] = {}
327
+
328
+ @property
329
+ def shot(self) -> Shot:
330
+ return self.shots[0]
331
+
332
+ def status(self, aspect: Aspect) -> Status:
333
+ """An aspect's coverage. Absent means unknown, never none (REQ-VAL-001)."""
334
+ return self.coverage.get(aspect, "unknown")
335
+
336
+ def to_json(self) -> str:
337
+ return self.model_dump_json(exclude_none=True, indent=2)
338
+
339
+
340
+ __all__ = [
341
+ "ASPECTS",
342
+ "AVDM_VERSION",
343
+ "Action",
344
+ "Aspect",
345
+ "Asset",
346
+ "AudioCue",
347
+ "Camera",
348
+ "CameraMove",
349
+ "Derivation",
350
+ "Description",
351
+ "Entity",
352
+ "EntityAttribute",
353
+ "EntityAttributeName",
354
+ "Event",
355
+ "EvidenceScope",
356
+ "Method",
357
+ "Modality",
358
+ "Observation",
359
+ "Participant",
360
+ "Reason",
361
+ "SceneDocument",
362
+ "Shot",
363
+ "ShotAttribute",
364
+ "ShotAttributeName",
365
+ "Status",
366
+ "TechnicalMetadata",
367
+ "TimeBase",
368
+ "TimeRange",
369
+ ]