The media extension contract asks for a scene snapshot a future image or video provider could be handed: location, who is present, what they hold, what must stay true, and where in the story it sits. Building one was the milestone's obvious first task, and it was the wrong one. That snapshot has existed since M5. `narrative_state["scene"]` holds the summary, the location, the cast and the coordinate it was written at; a validated `set_scene` event writes it, every position snapshots it, and every head move restores it. It survives Undo, Redo, Retry, divergence, Save Point restore and a process restart because it is the authoritative state rather than a copy of it. So there is no scenes table here. A second scene store would have been a second answer to "where is the story now", with its own lineage rules to get wrong — and the lineage rules are the expensive part, which is the argument for reusing the ones that already work rather than against it. The Scene Packet is derived on read, and its identity is computed from the campaign and the position rather than allocated: the same position yields the same id in another process, after a restart, and after the packet is thrown away and rebuilt, with no row to keep in step. That is the part of a future media_assets table that would be expensive to retrofit, so it is fixed now even though the table is not built. One table, then: visual_profiles, the only thing the contract's scene list asks for that nothing already stored. Campaign-scoped and not per-position, because a character does not change appearance when the story forks — a reader who diverged would otherwise lose their cast, and the same descriptors would land in every per-position snapshot, measured at 245 copies of 367 bytes in a 120-turn campaign to say something that never varies. Keyed by the M5 entity key rather than a new identity namespace, and one table for characters, locations and items alike, because a location is an entity with a type and splitting them would reintroduce the genre shape M5 spent a milestone removing. What the packet leaves out is the more interesting half. Not the transcript, and not imported knowledge — none of it, not merely the sources marked hidden. The rule is what the story established at this position, not everything the narrator was told, and drawing it by class is what makes it hold for a secret nobody thought to mark. A hidden Canon source proves it, with a positive control showing the narrator did receive the sentinel the packet does not carry. Once a validated event puts the observer in the room, the observer is in the packet: that is no longer narrator-only knowledge, and a packet that hid it would be hiding the story from itself. The providers are contracts and nothing else. Protocols for image, video, audio, speech and transcription, an empty registry, no adapter, no dependency, no socket, and no media setting to point anywhere — a setting that exists can be pointed at a cloud by mistake. A future provider endpoint must be loopback, stricter than narration's trusted-LAN allowance, because a picture of a scene carries the scene with it. Transcription returns an editable draft with no commit method, so STT structurally cannot bypass the authoritative path. Nothing here can write the story. Not by convention: no module under media/ imports the code that writes state, no media event type exists in the state vocabulary, and every test in the authority suite compares the authoritative document byte for byte either side of a media operation — including one where a provider insists Alice is in a red coat in a corridor, and the campaign goes on disagreeing. One defect, found by the milestone's own tests. M10 first added a migration creating an index that create_all already builds from the column, so an upgraded database ended up with two indexes and a fresh install with one. Comparing the two schemas is what caught it; neither database examined alone would have. The migration is gone rather than renamed, and the right number of migrations for a new table whose indexes are declared on its columns is zero. Backend 1,191 passed / 14 skipped / 0 failed, 89 of them M10's. Frontend 145 passed. Lint, production build and Docker build clean. No frontend file changed: M10 adds no reader-facing surface, and ordinary play — turns, state, memory, knowledge, Undo, Redo, Retry, Save Point restore, restart — runs with no media configuration, no warning, no connection attempt and no media row written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
333 lines
13 KiB
Python
333 lines
13 KiB
Python
"""M10: what a future media provider must satisfy, and nothing that satisfies it.
|
|
|
|
No provider is implemented here, none is registered by default, and nothing in
|
|
this module opens a socket. What it defines is the shape of the boundary, so
|
|
that adding a real image, video, audio, TTS or STT provider later is writing an
|
|
adapter rather than editing the story engine.
|
|
|
|
## The rule these types exist to enforce
|
|
|
|
`MEDIA-EXTENSION-CONTRACT.md` §3: the Story Engine must not call ComfyUI, Stable
|
|
Diffusion, a video pipeline, a TTS engine or a third-party media API. It states
|
|
that as a recommendation; this module makes it structural. Everything crossing
|
|
the boundary is expressed in this vocabulary:
|
|
|
|
MediaKind image | video | audio | tts | stt
|
|
MediaRequest a scene packet, a kind, and neutral hints
|
|
MediaResult bytes-or-path, a type, and provenance
|
|
DraftTranscription STT's deliberately different answer (see below)
|
|
|
|
**No provider vocabulary appears anywhere in this file or in any story module.**
|
|
There is no workflow JSON, no sampler name, no CFG scale, no LoRA, no
|
|
`num_inference_steps`, no Whisper option and no voice id. A provider adapter
|
|
owns that translation, in its own package, and the story engine never learns it.
|
|
`test_m10_providers.py` greps the story modules for that vocabulary so the rule
|
|
cannot rot quietly.
|
|
|
|
## Why Protocols rather than base classes
|
|
|
|
A future adapter should not have to import from here to be usable — it should
|
|
merely have to *fit*. `typing.Protocol` gives a structural contract that a test
|
|
double satisfies as readily as a real ComfyUI adapter, which keeps the seam
|
|
honest: if the only way to satisfy the interface were to inherit from it, the
|
|
interface would be describing this codebase rather than the boundary.
|
|
|
|
## STT is deliberately shaped differently, and that is the point
|
|
|
|
Every other provider returns a `MediaResult` — a depiction of something the
|
|
story already established. STT returns a `DraftTranscription`, which is a
|
|
different type on purpose, because it flows the other way:
|
|
|
|
audio -> local STT -> draft text -> the reader edits it -> normal submission
|
|
|
|
`MEDIA-EXTENSION-CONTRACT.md` §24A states the rule as *"STT output is draft user
|
|
input, not an accepted story event."* A shared return type would have made it
|
|
possible to hand a transcription to something expecting a finished artefact, and
|
|
the asymmetry would have survived only as a comment. `DraftTranscription`
|
|
carries `editable = True` and has no path into the turn pipeline: the reader's
|
|
edited text enters through the ordinary action endpoint like anything they
|
|
typed, and is validated, refereed and snapshotted exactly the same way.
|
|
|
|
M10 implements no microphone capture and no transcription. The type boundary is
|
|
the deliverable.
|
|
|
|
## Endpoints: loopback only, and stricter than the narrator's on purpose
|
|
|
|
`endpoints.py` already decides which *inference* endpoints this product will
|
|
talk to, and allows an explicitly configured trusted LAN as well as loopback
|
|
(ADR 011). Media is not given that latitude. `MEDIA-EXTENSION-CONTRACT.md` §27
|
|
and §28 set the media default at loopback, with any future LAN extension
|
|
explicit and user-controlled — so `check_endpoint` below reuses the existing,
|
|
tested address machinery and then applies the stricter rule on top.
|
|
|
|
Reusing rather than reimplementing matters: a second endpoint validator would be
|
|
a second place for the policy to be wrong, and this one inherits the property
|
|
that makes the first one hard to talk around — it judges the address a host
|
|
actually resolves to, not the name.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
from .. import endpoints
|
|
|
|
#: The kinds of media this architecture is required to accommodate. A string
|
|
#: enum rather than free text, so a typo is a failure here rather than a request
|
|
#: nothing will ever service.
|
|
IMAGE = "image"
|
|
VIDEO = "video"
|
|
AUDIO = "audio"
|
|
TTS = "tts"
|
|
STT = "stt"
|
|
|
|
MEDIA_KINDS: tuple[str, ...] = (IMAGE, VIDEO, AUDIO, TTS, STT)
|
|
|
|
|
|
def is_media_kind(value) -> bool:
|
|
return isinstance(value, str) and value in MEDIA_KINDS
|
|
|
|
|
|
class MediaProviderError(RuntimeError):
|
|
"""A provider could not do what was asked.
|
|
|
|
Deliberately its own type, and deliberately not caught anywhere in the story
|
|
path: nothing in a turn calls a provider, so there is no code path where
|
|
this could reach an accepted narration. If a future coordinator catches it,
|
|
it does so on its own side of the boundary — a failed depiction must leave
|
|
the story exactly as it was (`MEDIA-EXTENSION-CONTRACT.md` §50).
|
|
"""
|
|
|
|
|
|
class EndpointRejected(endpoints.EndpointRejected):
|
|
"""A media endpoint outside the loopback-only media policy.
|
|
|
|
Subclasses the inference rejection so that a caller which already handles
|
|
"this endpoint is not allowed" keeps working, while a caller that wants to
|
|
tell the two policies apart still can.
|
|
"""
|
|
|
|
|
|
def endpoint_rejection_reason(url: str) -> str | None:
|
|
"""Why this URL may not be a media endpoint, or `None` if it may.
|
|
|
|
Two rules, in order, and the first is somebody else's:
|
|
|
|
1. the existing inference policy — an address in an allowed private network,
|
|
judged by resolution rather than by name (`endpoints.py`);
|
|
2. **and** loopback specifically, which is the media contract's stricter
|
|
default (§27, §28).
|
|
|
|
So a trusted-LAN address that an Ollama may legitimately use is refused here.
|
|
That is not an oversight: narrator inference is a deployment the user has
|
|
already reasoned about and configured, whereas a media endpoint is a new
|
|
surface with no v1 use, and the safe default for a surface nobody needs yet
|
|
is the narrowest one. A future milestone may widen it, explicitly and off by
|
|
default, which is what §27 requires of any such change.
|
|
"""
|
|
reason = endpoints.rejection_reason(url)
|
|
if reason is not None:
|
|
return reason
|
|
if not endpoints.is_loopback(url):
|
|
return (
|
|
"A media provider endpoint must be on this machine. "
|
|
f"{url!r} resolves somewhere else — media generation has no "
|
|
"trusted-LAN mode, and adding one would be an explicit, "
|
|
"off-by-default change rather than a setting."
|
|
)
|
|
return None
|
|
|
|
|
|
def check_endpoint(url: str) -> None:
|
|
"""Raises `EndpointRejected` unless `url` is an allowed media endpoint."""
|
|
reason = endpoint_rejection_reason(url)
|
|
if reason is not None:
|
|
raise EndpointRejected(reason)
|
|
|
|
|
|
# ----------------------------------------------------------------- the types
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderCapabilities:
|
|
"""What one provider can do, in neutral terms.
|
|
|
|
Deliberately small. `MEDIA-EXTENSION-CONTRACT.md` §25 shows a richer example
|
|
— seeds, reference images, inpainting — and M10 does not model those,
|
|
because every one of them is a guess until a provider exists to be asked.
|
|
What is here is what a coordinator would need in order to choose *whether*
|
|
to route to this provider at all; anything finer belongs to the adapter and
|
|
its own capability document.
|
|
"""
|
|
|
|
provider_id: str
|
|
kinds: tuple[str, ...] = ()
|
|
#: Free-form, provider-owned, and never interpreted by story code. It exists
|
|
#: so an adapter can advertise what it supports without this module growing
|
|
#: a field per feature the ecosystem invents.
|
|
details: dict = field(default_factory=dict)
|
|
|
|
def supports(self, kind: str) -> bool:
|
|
return kind in self.kinds
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MediaRequest:
|
|
"""What a coordinator would hand a provider: a scene, a kind, and hints.
|
|
|
|
`scene` is a Scene Packet (`packet.build`) — a bounded description of one
|
|
accepted scene, not the transcript. That is the whole point of the packet
|
|
existing (`MEDIA-EXTENSION-CONTRACT.md` §12): a provider is given what it
|
|
needs to depict a moment and no more, which bounds prompt size, keeps
|
|
providers interchangeable, and means swapping one does not hand a new
|
|
process the campaign's history.
|
|
|
|
`hints` is provider-neutral and optional — an aspect ratio, a duration, a
|
|
count. It is **not** where a workflow graph or a sampler setting goes; those
|
|
belong to the adapter, which knows what it is talking to.
|
|
"""
|
|
|
|
kind: str
|
|
scene: dict
|
|
hints: dict = field(default_factory=dict)
|
|
|
|
def __post_init__(self):
|
|
if not is_media_kind(self.kind):
|
|
raise ValueError(
|
|
f"{self.kind!r} is not one of {', '.join(MEDIA_KINDS)}"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MediaResult:
|
|
"""What a provider hands back: a depiction, and where it came from.
|
|
|
|
Bytes *or* a path, never both, and the caller says which it wanted. Neither
|
|
is interpreted here; M10 registers no provider, so nothing constructs one of
|
|
these outside a test.
|
|
|
|
`provenance` carries the scene identity the request named, so that a future
|
|
asset can always be traced to the accepted position it depicts
|
|
(`MEDIA-EXTENSION-CONTRACT.md` §48). It is a record of what was asked for —
|
|
it does not make the depiction true.
|
|
"""
|
|
|
|
kind: str
|
|
media_type: str
|
|
provenance: dict = field(default_factory=dict)
|
|
data: bytes | None = None
|
|
path: str | None = None
|
|
details: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DraftTranscription:
|
|
"""STT's answer, and deliberately not a `MediaResult`.
|
|
|
|
See the module docstring. This is **draft user input**: text the reader is
|
|
expected to read, correct and submit themselves. It is not an accepted turn,
|
|
not a state event, not canon, and it has no route into the story that the
|
|
reader's own typing does not also take.
|
|
|
|
`editable` is `True` and there is no constructor that sets it otherwise —
|
|
it is a statement about what this type *is* rather than a setting, and a
|
|
reader that finds it false has been handed something that is not a draft.
|
|
"""
|
|
|
|
text: str
|
|
editable: bool = True
|
|
confidence: float | None = None
|
|
details: dict = field(default_factory=dict)
|
|
|
|
|
|
# ------------------------------------------------------------- the protocols
|
|
|
|
@runtime_checkable
|
|
class MediaProvider(Protocol):
|
|
"""Anything that can depict an accepted scene.
|
|
|
|
One protocol covers image, video and audio because the boundary is the same
|
|
for all three: a bounded scene in, a depiction out, nothing written to the
|
|
story. What differs between them is entirely inside the adapter.
|
|
"""
|
|
|
|
def capabilities(self) -> ProviderCapabilities: ...
|
|
|
|
async def generate(self, request: MediaRequest) -> MediaResult: ...
|
|
|
|
|
|
@runtime_checkable
|
|
class SpeechProvider(Protocol):
|
|
"""Text to speech: still a depiction, of prose the story already accepted."""
|
|
|
|
def capabilities(self) -> ProviderCapabilities: ...
|
|
|
|
async def speak(self, text: str, hints: dict | None = None) -> MediaResult: ...
|
|
|
|
|
|
@runtime_checkable
|
|
class TranscriptionProvider(Protocol):
|
|
"""Speech to text, which runs the other way and returns a draft.
|
|
|
|
The signature is the asymmetry: it takes audio and returns
|
|
`DraftTranscription`, so no coordinator can hand its output to something
|
|
expecting a finished artefact, and nothing can mistake it for an accepted
|
|
turn.
|
|
"""
|
|
|
|
def capabilities(self) -> ProviderCapabilities: ...
|
|
|
|
async def transcribe(
|
|
self, audio: bytes, hints: dict | None = None
|
|
) -> DraftTranscription: ...
|
|
|
|
|
|
# -------------------------------------------------------------- the registry
|
|
|
|
#: Registered providers, by id. **Empty, and empty on purpose.**
|
|
#:
|
|
#: M10 ships no provider, so nothing is registered at import, nothing is
|
|
#: required at startup, and no configuration is read. `test_m10_no_media.py`
|
|
#: asserts this is empty after the application has been imported and a campaign
|
|
#: has been played — media readiness has to be inert until something explicitly
|
|
#: uses it.
|
|
_REGISTRY: dict[str, object] = {}
|
|
|
|
|
|
def register(provider_id: str, provider: object) -> None:
|
|
"""Makes a provider available to a future coordinator.
|
|
|
|
Exists to prove the claim in M10's Definition of Done — that a provider can
|
|
be added *without modifying story authority or history* — by being the only
|
|
thing an adapter has to call. Nothing in `app/routers`, `app/narrative`,
|
|
`app/context` or `app/tree` imports this module, so registering one cannot
|
|
reach them.
|
|
"""
|
|
if not isinstance(provider_id, str) or not provider_id.strip():
|
|
raise ValueError("a provider needs an id")
|
|
_REGISTRY[provider_id] = provider
|
|
|
|
|
|
def unregister(provider_id: str) -> None:
|
|
_REGISTRY.pop(provider_id, None)
|
|
|
|
|
|
def registered() -> dict[str, object]:
|
|
"""The registry, copied — callers must not mutate it in place."""
|
|
return dict(_REGISTRY)
|
|
|
|
|
|
def for_kind(kind: str) -> list[object]:
|
|
"""Every registered provider advertising `kind`. Empty in v1."""
|
|
out = []
|
|
for provider in _REGISTRY.values():
|
|
caps = getattr(provider, "capabilities", None)
|
|
if caps is None:
|
|
continue
|
|
try:
|
|
if caps().supports(kind):
|
|
out.append(provider)
|
|
except Exception: # noqa: BLE001 - a broken adapter is not this layer's
|
|
continue
|
|
return out
|