Files
video-gen/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py
T
2026-07-11 12:48:29 +08:00

340 lines
13 KiB
Python

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,
*,
allow_provider_input: bool,
) -> bool:
# 是否作为供应商直接输入由服务端调用链决定,不能信任客户端附件字段。
if not allow_provider_input:
return False
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,
*,
allow_provider_input: bool = True,
) -> 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,
allow_provider_input=allow_provider_input,
)
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