fix(example-images): read real dimensions for imported videos, fixes #1115

Example videos added through the "Add examples" flow were stored with a
hardcoded 720x1280 entry. The dimension probe next to it only ran for
images (PIL cannot open .mp4/.webm files), so every video entry stayed
portrait regardless of the source. The showcase viewer then sizes its
container straight from that value (--media-aspect in showcase.css), so
landscape clips were letterboxed inside a 9:16 box. CivitAI-sourced
examples were unaffected because their dimensions come from the API.

PIL cannot read video containers, so add a dependency-free reader that
parses the container headers instead: moov/trak/tkhd for ISO base media
(with the sample description as a fallback), Segment/Tracks/Pixel* for
WebM/Matroska, and RIFF/WebP for animated examples saved with a video
extension. The sniffed signature decides which reader runs, so a .mp4
that is really WebM still reports the right size; the extension is only
a fallback. Both readers seek past mdat rather than reading it, so a
large file costs the same as a small one.

Imported entries now record the file's real size and keep the previous
placeholder only when the file cannot be parsed.

Existing libraries keep their wrong entries, so backfill them once via
the existing naming migration: bump CURRENT_NAMING_VERSION to 3 and
repair each model's empty-url entries from the files on disk, then sync
the scanner cache. Only entries with no remote url are touched -- those
have no other source, which makes the rewrite lossless -- and entries
already carrying the right size are left byte-identical, so the pass is
idempotent and a no-op for libraries that never imported a video.
This commit is contained in:
Will Miao
2026-09-17 21:42:32 +08:00
parent eba03800b9
commit 7d963b27b5
8 changed files with 1699 additions and 32 deletions
+147 -3
View File
@@ -15,12 +15,20 @@ from ..utils.example_images_paths import (
)
from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.example_images_metadata import update_cache_from_metadata
from ..utils.example_images_metadata import (
repair_local_video_dimensions,
update_cache_from_metadata,
)
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
CURRENT_NAMING_VERSION = 3 # Increment this when naming conventions change
# Example files worth inspecting during the dimension repair.
_REPAIRABLE_EXTENSIONS = frozenset(
SUPPORTED_MEDIA_EXTENSIONS["images"] + SUPPORTED_MEDIA_EXTENSIONS["videos"]
)
class _SettingsProxy:
@@ -185,6 +193,9 @@ class ExampleImagesMigration:
if from_version < 2 and to_version >= 2:
await ExampleImagesMigration._migrate_to_v2(model_folders)
if from_version < 3 and to_version >= 3:
await ExampleImagesMigration._migrate_to_v3(example_images_path, model_folders)
# Update version in progress file
progress_file = os.path.join(example_images_path, '.download_progress.json')
try:
@@ -437,4 +448,137 @@ class ExampleImagesMigration:
logger.error(f"Error migrating folder {folder}: {e}")
migration_errors += 1
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
@staticmethod
def _build_local_file_map(folder):
"""Map entry markers to their files inside a model's example folder.
Keys are the marker alone (``custom_<id>`` → ``<id>``,
``image_<index>`` → ``<index>``) so they line up with the metadata
entries' ``id``/positional index without any prefix ambiguity.
"""
local_files = {}
try:
entries = os.listdir(folder)
except OSError as exc:
logger.debug("Could not list example folder %s: %s", folder, exc)
return local_files
for name in entries:
stem, ext = os.path.splitext(name)
if ext.lower() not in _REPAIRABLE_EXTENSIONS:
continue
if stem.startswith("custom_"):
local_files[stem[len("custom_"):]] = os.path.join(folder, name)
elif stem.startswith("image_"):
local_files[stem[len("image_"):]] = os.path.join(folder, name)
return local_files
@staticmethod
async def _find_scanner_for_hash(model_hash):
"""Return the scanner owning ``model_hash``, or ``None``."""
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
for scanner in (lora_scanner, checkpoint_scanner, embedding_scanner):
if scanner is None:
continue
try:
if scanner.has_hash(model_hash):
return scanner
except Exception as exc: # pragma: no cover - defensive
logger.debug("has_hash check failed for %s: %s", type(scanner).__name__, exc)
return None
@staticmethod
async def _migrate_to_v3(example_images_path, model_folders):
"""Backfill real dimensions for locally imported example videos.
Imported videos were stored with a hardcoded ``720x1280`` placeholder
(issue #1115), so landscape clips were rendered inside a portrait
container. Only entries with an empty ``url`` are touched — those have
no remote source, which makes the local file authoritative and the
rewrite lossless. Entries already carrying the right size are left
untouched, so re-running this migration is a no-op.
This runs once per library via the ``naming_version`` gate in
``run_migrations``; it is deliberately not wired into any request path.
"""
repaired_entries = 0
updated_models = 0
migration_errors = 0
logger.info(
"Starting v3 migration (local example video dimensions) for %d model folders",
len(model_folders),
)
for folder in model_folders:
try:
model_hash = os.path.basename(folder)
if not model_hash or len(model_hash) != 64:
continue
local_files = ExampleImagesMigration._build_local_file_map(folder)
if not local_files:
continue
scanner = await ExampleImagesMigration._find_scanner_for_hash(model_hash)
if scanner is None:
logger.debug(
"Model %s not found in any scanner cache, skipping dimension repair",
model_hash,
)
continue
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get("sha256") == model_hash:
model_data = item
break
if not model_data:
continue
file_path = model_data.get("file_path")
if not file_path:
continue
payload = await MetadataManager.load_metadata_payload(file_path)
if not isinstance(payload, dict):
continue
repaired = repair_local_video_dimensions(payload, local_files)
if repaired <= 0:
continue
# The model cache shape differs from the on-disk payload, so
# persist the file first and let the cache sync re-read it.
await MetadataManager.save_metadata(file_path, payload)
await update_cache_from_metadata(scanner, file_path, payload)
repaired_entries += repaired
updated_models += 1
except Exception as exc:
logger.error(
"Failed to repair example video dimensions for %s: %s",
folder,
exc,
)
migration_errors += 1
logger.info(
"Migration to v3 complete: repaired %d example entr(ies) across %d model(s) "
"with %d error(s)",
repaired_entries,
updated_models,
migration_errors,
)