Skip to content

Commit b0e6f65

Browse files
committed
Release AbstractCore 2.13.29
1 parent e2cd478 commit b0e6f65

24 files changed

Lines changed: 2247 additions & 127 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.13.29] - 2026-05-26
11+
12+
### Added
13+
- **Video generation through Core**: added Python `generate(..., output={"task":"text_to_video"|"image_to_video"})` callback forwarding for AbstractVision progress events, plus OpenAI-compatible `/v1/videos/generations`, `/v1/videos/edits`, and async `/v1/vision/jobs/videos/*` routes.
14+
- **Video job progress**: async video jobs now capture normalized backend progress events in `progress.last_event` while preserving step/frame counters for polling clients.
15+
16+
### Changed
17+
- **Vision plugin floor**: raised AbstractVision integration requirements to `abstractvision>=0.3.16` so Core installs pick up MLX-Gen 0.18.6, exact model id routing, and text/image-to-video support.
18+
19+
### Fixed
20+
- **Generated media callback boundary**: top-level progress callbacks supplied to multimodal `generate(...)` calls are attached to generated image/video output specs instead of leaking into the text-provider kwargs path.
21+
22+
### Verified
23+
- `pytest tests/test_packaging_extras.py tests/test_output_specs.py tests/test_multimodal_generate_output.py tests/server/test_server_vision_image_endpoints.py tests/capabilities/test_vision_catalog_helper.py tests/server/test_server_model_residency_control_plane.py -q`
24+
1025
## [2.13.28] - 2026-05-26
1126

1227
### Changed

README.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@ First-class support for:
4949
(**) Optional visual-text compression: render long text/PDFs into images and process them with a vision model to reduce token usage. See [Glyph Visual-Text Compression](docs/glyphs.md) (install `pip install "abstractcore[compression]"`; for PDFs also install `pip install "abstractcore[media]"`).
5050

5151
Generative vision uses `abstractvision` when installed. In server mode, omit
52-
`model` only when the server has a configured image default, or use explicit
52+
`model` only when the server has a configured default, or use explicit
5353
provider/model ids such as `diffusers/default`, `diffusers/<huggingface-repo>`,
54-
`sdcpp/default`, or `openai-compatible/<model>`.
54+
`mlx-gen/AbstractFramework/qwen-image-2512-4bit`,
55+
`mlx-gen/Wan-AI/Wan2.2-TI2V-5B-Diffusers`, `sdcpp/default`, or
56+
`openai-compatible/<model>`. Quantized MLX-Gen models are selected by their
57+
published repo id; Core does not add a separate quant override.
5558

5659
Docs: [Getting Started](docs/getting-started.md) · [FAQ](docs/faq.md) · [Docs Index](docs/README.md) · https://lpalbou.github.io/AbstractCore
5760

@@ -337,6 +340,37 @@ png_bytes = image.outputs["image"][0].data
337340
# Image edit: image media + image output infers image-to-image.
338341
edited = llm.generate("Make the mug blue.", media="mug.png", output="image")
339342

343+
def progress(event):
344+
print("video progress", event)
345+
346+
# Text-to-video via abstractvision. The callback is forwarded to the plugin.
347+
video = llm.generate(
348+
"A red fox walking through a snowy forest, cinematic.",
349+
on_progress=progress,
350+
output={
351+
"modality": "video",
352+
"task": "text_to_video",
353+
"provider": "mlx-gen",
354+
"model": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
355+
"num_frames": 121,
356+
"fps": 24,
357+
},
358+
)
359+
mp4_bytes = video.outputs["video"][0].data
360+
361+
# Image-to-video uses image media plus task="image_to_video".
362+
i2v = llm.generate(
363+
"Slow camera push-in.",
364+
media={"type": "image", "path": "first-frame.png", "role": "source"},
365+
output={
366+
"task": "image_to_video",
367+
"provider": "mlx-gen",
368+
"model": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
369+
"num_frames": 121,
370+
"fps": 24,
371+
},
372+
)
373+
340374
# TTS via abstractvoice.
341375
speech = llm.generate(text="Hello from AbstractCore.", output="voice")
342376
wav_bytes = speech.outputs["voice"][0].data
@@ -377,6 +411,11 @@ The HTTP server exposes equivalent discovery at
377411
`/v1/audio/speech/models`, `/v1/audio/transcriptions/models`, and
378412
`/v1/voice/clone/providers`, plus `/v1/audio/music/providers` and
379413
`/v1/audio/music/models`.
414+
Generated media HTTP routes include `/v1/images/generations`,
415+
`/v1/images/edits`, `/v1/videos/generations`, `/v1/videos/edits`, and
416+
async polling routes under `/v1/vision/jobs/images/*` and
417+
`/v1/vision/jobs/videos/*`; video jobs include the latest backend progress event
418+
when the selected backend reports it.
380419
`/v1/models` remains focused on LLM and embedding provider models.
381420

382421
## HTTP server (OpenAI-compatible gateway)

abstractcore/capabilities/vision_catalog.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,8 @@ def _cached_hf_snapshot(repo_id: str, cache_dirs: list[Path]) -> Optional[Path]:
247247

248248

249249
def _mlx_gen_selector_for_download(download: Any) -> str:
250-
key = str(getattr(download, "key", "") or "").strip()
251250
repo_id = str(getattr(download, "repo_id", "") or "").strip()
252-
bits = getattr(download, "bits", None)
253-
if bits == 4 and key:
254-
return key
255-
return repo_id or key
251+
return repo_id
256252

257253

258254
def _discover_cached_hf_diffusers_models(cache_dirs: list[Path]) -> list[str]:
@@ -438,7 +434,7 @@ def get_local_vision_cache_catalog() -> Dict[str, Any]:
438434
for model_id in model_ids:
439435
spec = registry.get(model_id)
440436
supported_tasks = sorted(spec.tasks.keys())
441-
if "text_to_image" not in spec.tasks and "image_to_image" not in spec.tasks:
437+
if not {"text_to_image", "image_to_image", "text_to_video", "image_to_video"}.intersection(spec.tasks):
442438
continue
443439

444440
for download in list(getattr(spec, "downloads", []) or []):

abstractcore/core/output_specs.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
"transcript",
1212
"transcription",
1313
"image",
14+
"video",
15+
"t2v",
16+
"i2v",
17+
"text_to_video",
18+
"image_to_video",
19+
"video_generation",
1420
"voice",
1521
"speech",
1622
"tts",
@@ -33,6 +39,12 @@
3339
"t2i",
3440
"i2i",
3541
"image_to_image",
42+
"video",
43+
"video_generation",
44+
"t2v",
45+
"i2v",
46+
"text_to_video",
47+
"image_to_video",
3648
"voice",
3749
"speech",
3850
"tts",
@@ -58,6 +70,12 @@
5870
"i2i": ("image", "image_edit"),
5971
"image_to_image": ("image", "image_edit"),
6072
"image_edit": ("image", "image_edit"),
73+
"video": ("video", "video_generation"),
74+
"video_generation": ("video", "video_generation"),
75+
"t2v": ("video", "text_to_video"),
76+
"text_to_video": ("video", "text_to_video"),
77+
"i2v": ("video", "image_to_video"),
78+
"image_to_video": ("video", "image_to_video"),
6179
"music": ("music", "music_generation"),
6280
"song": ("music", "music_generation"),
6381
"t2m": ("music", "music_generation"),
@@ -74,6 +92,8 @@
7492
"t2i": "image_generation",
7593
"i2i": "image_edit",
7694
"image_to_image": "image_edit",
95+
"t2v": "text_to_video",
96+
"i2v": "image_to_video",
7797
"song": "music_generation",
7898
"t2m": "music_generation",
7999
"text_to_music": "music_generation",
@@ -85,6 +105,9 @@
85105
"transcription": "text",
86106
"image_generation": "image",
87107
"image_edit": "image",
108+
"video_generation": "video",
109+
"text_to_video": "video",
110+
"image_to_video": "video",
88111
"tts": "voice",
89112
"voice_clone": "voice",
90113
"music_generation": "music",

abstractcore/providers/base.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,43 @@ def _normalize_output_specs(output: Any) -> List[Dict[str, Any]]:
16401640
def _normalize_output_spec(output: Any) -> Dict[str, Any]:
16411641
return normalize_output_spec(output)
16421642

1643+
@classmethod
1644+
def _output_request_with_progress_callbacks(
1645+
cls,
1646+
output: Any,
1647+
callbacks: Dict[str, Any],
1648+
) -> Tuple[Any, bool]:
1649+
"""Attach top-level generated-media progress callbacks to output specs."""
1650+
effective_callbacks = {
1651+
str(key): value
1652+
for key, value in callbacks.items()
1653+
if value is not None
1654+
}
1655+
if not effective_callbacks or not cls._is_acore_output_request(output):
1656+
return output, False
1657+
1658+
specs = cls._normalize_output_specs(output)
1659+
changed = False
1660+
patched_specs: List[Dict[str, Any]] = []
1661+
for spec in specs:
1662+
patched = dict(spec)
1663+
modality = str(patched.get("modality") or "").strip().lower()
1664+
if modality in {"image", "video"}:
1665+
nested_extra = patched.get("extra")
1666+
nested_keys = set(nested_extra) if isinstance(nested_extra, dict) else set()
1667+
for key, value in effective_callbacks.items():
1668+
if key in patched or key in nested_keys:
1669+
continue
1670+
patched[key] = value
1671+
changed = True
1672+
patched_specs.append(patched)
1673+
1674+
if not changed:
1675+
return output, False
1676+
if isinstance(output, (list, tuple)):
1677+
return patched_specs, True
1678+
return patched_specs[0], True
1679+
16431680
@staticmethod
16441681
def _coerce_media_items(media: Any) -> List[Any]:
16451682
if media is None:
@@ -1948,6 +1985,9 @@ def _run_multimodal_spec(
19481985
if modality == "image":
19491986
self._run_image_output(result=result, spec=spec, prompt=prompt, media=media, artifact_store=artifact_store)
19501987
return
1988+
if modality == "video":
1989+
self._run_video_output(result=result, spec=spec, prompt=prompt, media=media, artifact_store=artifact_store)
1990+
return
19511991
if modality == "voice":
19521992
self._run_voice_output(result=result, spec=spec, prompt=prompt, media=media, artifact_store=artifact_store)
19531993
return
@@ -2046,6 +2086,86 @@ def _run_image_output(
20462086
)
20472087
result.add_output("image", item)
20482088

2089+
def _run_video_output(
2090+
self,
2091+
*,
2092+
result: MultimodalGenerateResponse,
2093+
spec: Dict[str, Any],
2094+
prompt: str,
2095+
media: Any,
2096+
artifact_store: Optional[Any],
2097+
) -> None:
2098+
items = self._coerce_media_items(media)
2099+
images = [item for item in items if self._media_type(item, fallback="image" if isinstance(item, (bytes, bytearray)) else None) == "image"]
2100+
roles = [(item, self._media_role(item)) for item in images]
2101+
source_items = [item for item, role in roles if role == "source"]
2102+
mask_items = [item for item, role in roles if role == "mask"]
2103+
reference_like = [item for item, role in roles if role in {"reference", "style", "context"}]
2104+
unroled = [item for item, role in roles if role is None]
2105+
task = str(spec.get("task") or "").lower()
2106+
2107+
if mask_items:
2108+
raise ValueError("Video generation does not support mask media in v1.")
2109+
if len(source_items) > 1:
2110+
raise ValueError("Image-to-video supports at most one source image in v1.")
2111+
2112+
should_i2v = task == "image_to_video"
2113+
if task == "text_to_video":
2114+
should_i2v = False
2115+
elif not should_i2v:
2116+
if source_items:
2117+
should_i2v = True
2118+
elif len(unroled) == 1 and not reference_like:
2119+
should_i2v = True
2120+
elif len(unroled) > 1:
2121+
raise ValueError("Multiple image media items require explicit roles for image-to-video.")
2122+
2123+
kwargs = self._output_plugin_kwargs(
2124+
spec,
2125+
exclude={"format", "content_type", "mime_type", "provider", "response_format"},
2126+
)
2127+
if spec.get("provider") is not None:
2128+
kwargs["provider"] = spec.get("provider")
2129+
if artifact_store is not None:
2130+
kwargs["artifact_store"] = artifact_store
2131+
2132+
if should_i2v:
2133+
source = source_items[0] if source_items else (unroled[0] if unroled else None)
2134+
if source is None:
2135+
raise ValueError("Image-to-video requires one source image.")
2136+
raw = self.vision.i2v(self._media_payload(source), prompt=prompt, **kwargs)
2137+
task_name = "image_to_video"
2138+
else:
2139+
raw = self.vision.t2v(prompt, **kwargs)
2140+
task_name = "text_to_video"
2141+
2142+
data, artifact_ref, metadata = self._artifact_or_data(raw)
2143+
fmt = str(spec.get("format") or "mp4").strip().lower() or "mp4"
2144+
content_type = str(metadata.get("content_type") or metadata.get("mime_type") or f"video/{fmt}")
2145+
if artifact_ref is None:
2146+
data, stored_ref = self._store_generated_data(
2147+
data,
2148+
artifact_store=artifact_store,
2149+
content_type=content_type,
2150+
spec=spec,
2151+
)
2152+
artifact_ref = stored_ref
2153+
result.add_output(
2154+
"video",
2155+
GeneratedItem(
2156+
modality="video",
2157+
task=task_name,
2158+
data=data,
2159+
artifact_ref=artifact_ref,
2160+
content_type=content_type,
2161+
format=fmt,
2162+
backend_id=getattr(self.vision, "backend_id", None),
2163+
provider=str(spec.get("provider") or getattr(self.vision, "backend_id", None) or self.__class__.__name__),
2164+
model=str(spec.get("model") or self.model),
2165+
metadata=metadata,
2166+
),
2167+
)
2168+
20492169
def _run_voice_output(
20502170
self,
20512171
*,
@@ -2298,6 +2418,18 @@ def generate_with_telemetry(self,
22982418

22992419
if is_acore_output:
23002420
output_request = kwargs.pop("output")
2421+
output_request, progress_injected = self._output_request_with_progress_callbacks(
2422+
output_request,
2423+
{
2424+
"on_progress": kwargs.get("on_progress"),
2425+
"progress_event_callback": kwargs.get("progress_event_callback"),
2426+
"progress_callback": kwargs.get("progress_callback"),
2427+
},
2428+
)
2429+
if progress_injected:
2430+
kwargs.pop("on_progress", None)
2431+
kwargs.pop("progress_event_callback", None)
2432+
kwargs.pop("progress_callback", None)
23012433
artifact_store = kwargs.pop("artifact_store", None)
23022434
partial = bool(kwargs.pop("partial", False))
23032435
return self._generate_multimodal_response(
@@ -5471,6 +5603,18 @@ async def agenerate(self,
54715603

54725604
if is_acore_output:
54735605
output_request = kwargs.pop("output")
5606+
output_request, progress_injected = self._output_request_with_progress_callbacks(
5607+
output_request,
5608+
{
5609+
"on_progress": kwargs.get("on_progress"),
5610+
"progress_event_callback": kwargs.get("progress_event_callback"),
5611+
"progress_callback": kwargs.get("progress_callback"),
5612+
},
5613+
)
5614+
if progress_injected:
5615+
kwargs.pop("on_progress", None)
5616+
kwargs.pop("progress_event_callback", None)
5617+
kwargs.pop("progress_callback", None)
54745618
response_model = kwargs.pop("response_model", None)
54755619
artifact_store = kwargs.pop("artifact_store", None)
54765620
partial = bool(kwargs.pop("partial", False))

0 commit comments

Comments
 (0)