from __future__ import annotations import hashlib import json import re from decimal import Decimal from typing import Any, Iterable, Mapping from urllib.parse import urlsplit, urlunsplit from app.services.model_pricing.usage_normalizer import ( extract_image_output_items, parse_size, safe_bool, safe_float, safe_int, safe_json_dict, sanitize_output_items, ) MEDIA_TYPES = {"image", "video", "audio"} def _safe_json(value: Any) -> Any: if value in (None, ""): return None if isinstance(value, (dict, list)): return value if isinstance(value, str): try: return json.loads(value) except Exception: return value return value def _normalize_url(value: str | None) -> tuple[str | None, str | None]: if not value: return None, None text = str(value).strip() try: parts = urlsplit(text) if parts.scheme and parts.netloc: normalized = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", "")) else: normalized = text.split("?", 1)[0].split("#", 1)[0] except Exception: normalized = text.split("?", 1)[0].split("#", 1)[0] normalized = normalized[:1024] digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() return normalized, digest def _guess_media_type(item: Mapping[str, Any]) -> str | None: value = str(item.get("type") or item.get("media_type") or item.get("resource_type") or "").lower().strip() if value in MEDIA_TYPES: return value url = str(item.get("url") or item.get("path") or item.get("display_url") or "").lower() if re.search(r"\.(png|jpe?g|webp|gif|bmp)(?:\?|$)", url): return "image" if re.search(r"\.(mp4|mov|m4v|webm|avi|mkv)(?:\?|$)", url): return "video" if re.search(r"\.(mp3|wav|aac|m4a|flac|ogg)(?:\?|$)", url): return "audio" return None def _walk_reference_items(value: Any) -> Iterable[Mapping[str, Any]]: parsed = _safe_json(value) if isinstance(parsed, list): for item in parsed: yield from _walk_reference_items(item) return if isinstance(parsed, dict): if _guess_media_type(parsed) or any(k in parsed for k in ("url", "path", "resource_id", "private_asset_id")): yield parsed return for child in parsed.values(): if isinstance(child, (dict, list, str)): yield from _walk_reference_items(child) def _billable_input(raw: Mapping[str, Any], media_type: str) -> bool: if "billable_input" in raw: return safe_bool(raw.get("billable_input"), True) role = str(raw.get("role") or raw.get("label") or raw.get("reference_role") or "").lower() if role in {"cover", "preview", "display_only", "generated_output", "output"}: return False return media_type in MEDIA_TYPES def build_attachment_snapshot(media_references: Any) -> tuple[dict[str, Any], dict[str, Any]]: items: list[dict[str, Any]] = [] image_count = video_count = audio_count = 0 provider_input_image_count = 0 provider_input_video_count = 0 provider_input_audio_count = 0 video_duration = Decimal("0") audio_duration = Decimal("0") seen: set[str] = set() for raw in _walk_reference_items(media_references): media_type = _guess_media_type(raw) if media_type not in MEDIA_TYPES: continue raw_url = raw.get("url") or raw.get("path") or raw.get("display_url") or raw.get("preview_url") safe_url, url_hash = _normalize_url(str(raw_url) if raw_url else None) identity = str( raw.get("resource_id") or raw.get("upload_resource_id") or raw.get("private_asset_id") or url_hash or f"{media_type}:{len(items)}" ) dedupe_key = f"{media_type}:{identity}" if dedupe_key in seen: continue seen.add(dedupe_key) duration = max(0.0, safe_float(raw.get("duration"), safe_float(raw.get("duration_seconds")))) billable = _billable_input(raw, media_type) item = { "type": media_type, "role": raw.get("role") or raw.get("label") or raw.get("reference_role"), "source": raw.get("source"), "billable_input": billable, "resource_id": raw.get("resource_id") or raw.get("upload_resource_id"), "private_asset_id": raw.get("private_asset_id"), "name": raw.get("name") or raw.get("filename"), "duration_seconds": duration or None, "file_size": safe_int(raw.get("file_size"), safe_int(raw.get("size"))) or None, "safe_url": safe_url, "url_sha256": url_hash, } items.append({k: v for k, v in item.items() if v is not None}) if media_type == "image": image_count += 1 provider_input_image_count += int(billable) elif media_type == "video": video_count += 1 provider_input_video_count += int(billable) video_duration += Decimal(str(duration)) else: audio_count += 1 provider_input_audio_count += int(billable) audio_duration += Decimal(str(duration)) counts = { "attachment_image_count": image_count, "attachment_video_count": video_count, "attachment_audio_count": audio_count, "attachment_total_count": image_count + video_count + audio_count, "attachment_video_duration_seconds": video_duration, "attachment_audio_duration_seconds": audio_duration, "provider_input_image_count": provider_input_image_count, "provider_input_video_count": provider_input_video_count, "provider_input_audio_count": provider_input_audio_count, } snapshot = { "schema_version": 1, "items": items, "counts": { "image": image_count, "video": video_count, "audio": audio_count, "total": image_count + video_count + audio_count, "provider_input_image": provider_input_image_count, "provider_input_video": provider_input_video_count, "provider_input_audio": provider_input_audio_count, }, "durations": { "video_seconds": str(video_duration), "audio_seconds": str(audio_duration), }, } return snapshot, counts def parse_dimensions(*values: Any, resolution: str | None = None, aspect_ratio: str | None = None) -> tuple[int, int]: """仅解析明确像素;resolution/aspect_ratio 不再映射为猜测尺寸。""" del resolution, aspect_ratio for value in values: width, height = parse_size(value) if width > 0 and height > 0: return width, height return 0, 0 def _engine_snapshot(owner: Any) -> dict[str, Any]: return safe_json_dict(getattr(owner, "engine_snapshot_json", None)) def build_generation_snapshot( owner: Any, *, provider_response: Any = None, stage: str | None = None, ) -> tuple[dict[str, Any], dict[str, int], dict[str, Any]]: """构建请求/Provider/资源快照。 图片只读取同步接口明确的 data 输出条目;不会递归扫描 provider response 中的通用 URL。 """ gen_type = str(getattr(owner, "gen_type", None) or getattr(owner, "media_type", None) or "").lower().strip() response = safe_json_dict(provider_response if provider_response is not None else getattr(owner, "provider_response_json", None)) engine_snapshot = _engine_snapshot(owner) aspect_ratio = getattr(owner, "aspect_ratio", None) or getattr(owner, "image_proportion", None) resolution = getattr(owner, "resolution", None) width, height = parse_dimensions( response.get("size"), getattr(owner, "image_px", None), engine_snapshot.get("selected_px"), engine_snapshot.get("image_px"), ) dimension_source = "unavailable" if parse_dimensions(response.get("size")) != (0, 0): dimension_source = "provider_response" elif parse_dimensions(getattr(owner, "image_px", None)) != (0, 0): dimension_source = "request_explicit" elif parse_dimensions(engine_snapshot.get("selected_px"), engine_snapshot.get("image_px")) != (0, 0): dimension_source = "engine_snapshot" output_items: list[dict[str, Any]] = [] generated_image_count = 0 generated_video_count = 0 if gen_type == "image": output_items = extract_image_output_items(response) if width <= 0 or height <= 0: first_sized = next( (item for item in output_items if safe_int(item.get("width")) > 0 and safe_int(item.get("height")) > 0), None, ) if first_sized: width = safe_int(first_sized.get("width")) height = safe_int(first_sized.get("height")) dimension_source = "provider_response" if width > 0 and height > 0: for item in output_items: if not item.get("width") or not item.get("height"): item.update( { "width": width, "height": height, "pixels": width * height, "size_source": dimension_source, } ) output_items = sanitize_output_items(output_items) generated_image_count = len(output_items) if generated_image_count == 0 and stage == "resource_download_completed" and getattr(owner, "image_url", None): generated_image_count = 1 output_items = sanitize_output_items( [{"index": 0, "url": getattr(owner, "image_url"), "size_source": "resource_snapshot"}] ) elif gen_type == "video": video_url = response.get("video_url") or response.get("url") if stage == "resource_download_completed": video_url = getattr(owner, "video_url", None) or video_url generated_video_count = 1 if video_url else 0 if video_url: output_items = sanitize_output_items([{"index": 0, "url": video_url, "type": "video"}]) pricing_meta = response.get("pricing_meta") if isinstance(response.get("pricing_meta"), Mapping) else {} requested_output_count = max( 1, safe_int( pricing_meta.get("requested_output_count"), safe_int(getattr(owner, "output_count", None), safe_int(getattr(owner, "count", None), 1)), ), ) output_duration = max(0.0, safe_float(getattr(owner, "duration", None))) fps = max(0.0, safe_float(getattr(owner, "fps", None), safe_float(getattr(owner, "frame_rate", None)))) generate_audio = safe_bool(getattr(owner, "generate_audio", None), safe_bool(response.get("generate_audio"))) inference_mode = str( getattr(owner, "inference_mode", None) or getattr(owner, "service_tier", None) or response.get("service_tier") or "online" ).lower() counts = { "requested_output_count": requested_output_count, "generated_image_count": generated_image_count, "generated_video_count": generated_video_count, "generated_total_count": generated_image_count + generated_video_count, } snapshot = { "schema_version": 1, "stage": stage or "unknown", "gen_type": gen_type, "requested_output_count": requested_output_count, "generated_image_count": generated_image_count, "generated_video_count": generated_video_count, "generated_total_count": generated_image_count + generated_video_count, "output_items": output_items, "duration_seconds": output_duration or None, "resolution": resolution, "aspect_ratio": aspect_ratio, "width": width or None, "height": height or None, "dimension_source": dimension_source, "fps": fps or None, "generate_audio": generate_audio, "inference_mode": inference_mode, } usage = { "requested_output_count": requested_output_count, "generated_image_count": generated_image_count, "generated_video_count": generated_video_count, "successful_output_count": generated_image_count if gen_type == "image" else generated_video_count, "output_items": output_items, "output_width": width, "output_height": height, "dimension_source": dimension_source, "output_video_duration_seconds": output_duration, "resolution": str(resolution or "").lower(), "aspect_ratio": str(aspect_ratio or ""), "fps": fps, "generate_audio": generate_audio, "inference_mode": inference_mode, "usage_stage": stage or "unknown", } return snapshot, counts, usage