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 +48 -0
- avdm/budget.py +34 -0
- avdm/evidence.py +239 -0
- avdm/model.py +369 -0
- avdm/observe.py +185 -0
- avdm/render.py +577 -0
- avdm/schema/avdm-0.2.schema.json +418 -0
- avdm/vocabulary.py +163 -0
- avdm-0.2.2.dist-info/METADATA +60 -0
- avdm-0.2.2.dist-info/RECORD +11 -0
- avdm-0.2.2.dist-info/WHEEL +4 -0
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
|
+
]
|