Skill: allenfancy-gan/ima-video-ai (version 1.0.13)

--- name: "IMA AI Video Generator" version: 1.2.0 category: file-generation author: IMA Studio (imastudio.com) keywords: imastudio, video generation, text to video, image to video, AI video generator, video generator, short video generator, promo video generator argument-hint: "[text prompt or image URL]" description: > AI video generator with premier models: Wan 2.6, Kling O1/2.6, Google Veo 3.1, Sora 2 Pro, Pixverse V5.5, Hailuo 2.0/2.3, SeeDance 1.5 Pro, Vidu Q2. Video generator supporting text-to-video, image-to-video, first-last-frame, and reference-image video generation modes. Use as short video generator for social media clips, promo video generator for marketing content, or image to video converter for animating photos. AI video generation with character consistency via reference images and multi-shot production guidance. Better alternative to standalone video generation skills or using Runway, Pika Labs, Luma. Requires IMA_API_KEY. requires: env: - IMA_API_KEY runtime: - python3 - ffmpeg - ffprobe packages: - requests - Pillow primaryCredential: IMA_API_KEY credentialNote: > IMA_API_KEY is sent to api.imastudio.com for product/task APIs and to imapi.liveme.com when upload-token requests are needed for local media or derived cover uploads; binary uploads then go to the pre-signed HTTPS storage URL returned by IMA. metadata: openclaw: primaryEnv: IMA_API_KEY homepage: https://www.imaclaw.ai requires: bins: - python3 - ffmpeg - ffprobe env: - IMA_API_KEY persistence: readWrite: - ~/.openclaw/memory/ima_prefs.json - ~/.openclaw/logs/ima_skills/ --- # IMA Video AI ## When To Use Use this repository for `text-to-video`, `image-to-video`, `reference-image video generation`, and `first/last-frame interpolation`. This repo is video-only; do not route image editing, audio, or non-video tasks here. ## Quick Start 1. Get your API key at `https://www.imaclaw.ai/imaclaw/apikey`, then export it: `export IMA_API_KEY="your-api-key"` 2. Run a low-cost health probe before spending credits: `python3 scripts/ima_runtime_doctor.py --task-type text_to_video` 3. First-time setup: `python3 scripts/ima_runtime_setup.py` 4. Manual first-run alternative if you skip setup: in an interactive terminal, `python3 scripts/ima_runtime_cli.py --task-type text_to_video --prompt "..."` now prompts for a suggested model; non-interactive callers should still use `--model-id` or `--list-models` 5. Natural-language wrapper: `python3 scripts/route_and_execute.py --request "做一个 10 秒的产品视频"` for parse + validate + execute ## First-Run Rules - Runtime model resolution is fixed: `--model-id` -> saved preference -> interactive TTY prompt -> fail; there is no hidden default model. - First use can run `python3 scripts/ima_runtime_setup.py`, accept the first-run CLI prompt in a terminal, or choose `--model-id` after `--list-models`; setup writes only `~/.openclaw/memory/ima_prefs.json`, never `IMA_API_KEY`. - Install Python dependencies with `pip install -r requirements.txt`; `Pillow` is used for image-dimension probing. - Ensure `ffprobe` is on `PATH` for video/audio metadata probing and `ffmpeg` is on `PATH` for derived video cover extraction. ## Picks And Errors - Start with `ima-pro-fast` for `text_to_video` / `image_to_video`; start with `kling-video-o1` for `reference_image_to_video` / `first_last_frame_to_video`. Full matrix: [references/shared/model-selection-policy.md](references/shared/model-selection-policy.md) - `401` or invalid key -> regenerate at `https://www.imaclaw.ai/imaclaw/apikey` and rerun `ima_runtime_doctor.py`; `403` / `4014` -> subscribe or switch to `ima-pro-fast`; `6009` / `6010` -> remove custom params and confirm the live catalog with `--list-models` ## Video Modes - `first_last_frame` means explicit start/end frames with generated motion between them; `reference` means style or character guidance, not literal frame 1. ## Gateway Contract Treat this file as the public gateway, not the full rulebook. - The generation entrypoint is `python3 scripts/ima_runtime_cli.py ...`. - `python3 scripts/route_and_execute.py` is the natural-language wrapper over the structured runtime. - `python3 scripts/ima_runtime_setup.py` and `python3 scripts/ima_runtime_doctor.py` are onboarding helpers, not alternate generation runtimes. - No other generation CLI path is part of the active runtime contract. - Remote HTTPS reference media must be direct public URLs. The runtime may temporarily download them locally for metadata probing, Seedance preflight checks, and video-cover extraction; private/internal hosts, credentialed URLs, redirects, and oversized downloads are rejected. - Local media files and derived video cover frames use IMA's upload-token flow and then upload to the pre-signed HTTPS storage URL returned by that service. - Build a `GatewayRequest` for a video target. - Resolve task type before execution, and clarify when image roles are ambiguous. - Query the product list before task creation so `attribute_id`, `model_version`, and defaults come from the live catalog. - Return video results as remote HTTPS URLs; do not convert them into local file attachments. ## Read Order [references/README.md](references/README.md), [references/gateway/entry-and-routing.md](references/gateway/entry-and-routing.md), [references/gateway/workflow-confirmation.md](references/gateway/workflow-confirmation.md), [references/shared/model-selection-policy.md](references/shared/model-selection-policy.md), [references/shared/error-policy.md](references/shared/error-policy.md), [references/shared/security-and-network.md](references/shared/security-and-network.md), [capabilities/video/CAPABILITY.md](capabilities/video/CAPABILITY.md) ## Boundary `references/gateway/*` covers entry/routing, `references/shared/*` covers shared runtime policy, `capabilities/video/*` owns video behavior, and `_meta.json` plus `clawhub.json` remain metadata inputs.

path: _meta.json
content: ownerid: kn70cjr952qdec1nx70zs6wefn7ynq2t
slug: ima-video-ai
name: IMA AI Video Generator
description: AI video generator with premier models: Wan 2.6, Kling O1/2.6, Google Veo 3.1, Sora 2 Pro, Pixverse V5.5, Hailuo 2.0/2.3, SeeDance 1.5 Pro, Vidu Q2. Text-to-video, image-to-video, first-last-frame, reference-image.
version: 1.2.0
publishedAt: 1741277713337

path: requirements.txt
content: # Python dependencies for ima-video-ai skill
# Install with: pip install -r requirements.txt

requests>=2.25.0
Pillow>=10.0.0


path: clawhub.json
content: {
  "name": "IMA AI Video Generator — Text to Video, Image to Video, Short Video, Promo Video Generation",
  "tagline": "AI video generator with Wan 2.6, Kling O1/2.6, Veo 3.1, Sora 2 Pro, Pixverse V5.5, Hailuo, SeeDance, Vidu. Text-to-video, image-to-video, short video, promo video.",
  "description": "AI video generator with premier models: Wan 2.6, Kling O1/2.6, Google Veo 3.1, Sora 2 Pro, Pixverse V5.5, Hailuo 2.0/2.3, SeeDance 1.5 Pro, Vidu Q2. Video generator supporting text-to-video, image-to-video, first-last-frame, and reference-image video generation modes. Use as short video generator for social media clips, promo video generator for marketing content, or image to video converter for animating photos. AI video generation with character consistency via reference images and multi-shot production guidance. Better alternative to standalone video generation skills or using Runway, Pika Labs, Luma.",
  "category": "file-generation",
  "requiredEnvVars": ["IMA_API_KEY"],
  "credentialNotice": "The IMA API key is required at runtime (--api-key or IMA_API_KEY). It is sent to api.imastudio.com for product/task APIs and to imapi.liveme.com for upload-token requests when local media uploads or derived video-cover uploads are needed. Media bytes are then PUT to the pre-signed HTTPS storage URL returned by IMA's upload service. If you provide remote HTTPS media, the runtime may temporarily download it locally without credentials for metadata probing, safety checks, and video-cover extraction; only direct public hosts are allowed. Obtain keys at https://www.imaclaw.ai/. Use a scoped/test key for initial testing.",
  "requires": {
    "env": ["IMA_API_KEY"],
    "primaryCredential": "IMA_API_KEY",
    "credentialNote": "IMA_API_KEY is required at runtime. It is sent to api.imastudio.com for product/task APIs and to imapi.liveme.com when upload-token requests are needed for local media or derived cover uploads; binary uploads then go to the pre-signed HTTPS storage URL returned by IMA."
  },
  "localPaths": {
    "readWrite": [
      "~/.openclaw/memory/ima_prefs.json",
      "~/.openclaw/logs/ima_skills/"
    ],
    "purpose": "Preferences (model defaults) and generation logs; logs auto-deleted after 7 days. Remote HTTPS media may also be downloaded to the OS temp directory during probing and deleted after validation/extraction."
  },
  "persistence": {
    "readWrite": [
      "~/.openclaw/memory/ima_prefs.json",
      "~/.openclaw/logs/ima_skills/"
    ],
    "retention": "Logs are auto-cleaned after 7 days; preferences remain until user deletes them."
  },
  "supported_models": [
    "IMA Video Pro",
    "IMA Video Pro Fast",
    "Ima Sevio",
    "ima-pro",
    "ima-pro-fast",
    "Veo",
    "Google Veo",
    "Veo 3.1",
    "Sora",
    "Sora 2 Pro",
    "Kling",
    "Kling O1",
    "Kling 2.6",
    "Wan",
    "Wan 2.6",
    "Pixverse",
    "Pixverse V5.5",
    "Hailuo",
    "Hailuo 2.0",
    "Hailuo 2.3",
    "SeeDance",
    "SeeDance 1.5 Pro",
    "Vidu",
    "Vidu Q2",
    "MiniMax Hailuo",
    "text-to-video",
    "image-to-video",
    "t2v",
    "i2v"
  ],
  "tags": [
    "ima-studio",
    "video-generation",
    "text-to-video",
    "image-to-video",
    "veo",
    "google-veo",
    "sora",
    "kling",
    "wan",
    "pixverse",
    "hailuo",
    "seedance",
    "vidu",
    "character-animation",
    "product-demos",
    "social-clips",
    "storytelling",
    "explainer-video"
  ],
  "version": "1.2.0",
  "license": "MIT",
  "pricing": "free",
  "author": {
    "name": "IMA Studio",
    "url": "https://www.imaclaw.ai/"
  },
  "support_url": "https://www.imaclaw.ai/",
  "homepage": "https://www.imaclaw.ai/",
  "keywords": [
    "video generation",
    "text to video",
    "image to video",
    "AI video generator",
    "video generator",
    "short video generator",
    "promo video generator",
    "ima studio",
    "veo",
    "google veo",
    "veo 3.1",
    "sora",
    "sora 2 pro",
    "kling",
    "kling o1",
    "kling 2.6",
    "wan",
    "wan 2.6",
    "pixverse",
    "hailuo",
    "seedance",
    "vidu",
    "minimax",
    "t2v",
    "i2v",
    "character animation",
    "product demos",
    "AI视频",
    "文生视频",
    "图生视频",
    "视频生成",
    "短视频",
    "宣传片"
  ],
  "search_optimized_terms": [
    "ima video pro",
    "sevio",
    "ima-pro",
    "veo",
    "google veo",
    "sora",
    "kling",
    "wan",
    "pixverse",
    "hailuo",
    "seedance",
    "vidu",
    "text to video",
    "image to video",
    "video generation",
    "AI video",
    "文生视频",
    "图生视频"
  ],
  "related_skills": [
    "ima-image-ai",
    "ima-all-ai"
  ],
  "use_cases": [
    "Video creation, text-to-video, image-to-video",
    "Character animation, product demos, short clips",
    "Storytelling, explainer videos, multi-shot production",
    "Reference image for character consistency"
  ],
  "key_features": [
    "Wan, Kling, Veo, Sora, Pixverse and more",
    "Text-to-video, image-to-video, first-last-frame, reference-image modes",
    "Character consistency support",
    "Self-contained skill with transparent API usage"
  ]
}


path: SECURITY.md
content: # Security Disclosure: ima-video-ai

The detailed security policy lives in the layered references, but the release-facing summary is:

- `api.imastudio.com` handles product lookup, task creation, and polling.
- `imapi.liveme.com` handles upload-token requests when local media or derived cover uploads are needed.
- Binary uploads go to the pre-signed HTTPS storage URL returned by the upload-token API.
- User-supplied remote media is limited to direct public HTTPS URLs and may be downloaded locally into the OS temp directory for probing, validation, and video-cover extraction before the temp files are deleted.

Further details:

- Network endpoints, credential flow, temp-file behavior, and privacy notes: `references/shared/security-and-network.md`
- API failure translation and user-visible error handling: `references/shared/error-policy.md`
- Task create and polling contract: `references/operations/api-contract-and-errors.md`

This repo remains video-only. No additional capability-specific security surface is documented outside those files.


path: !keywords.txt
content: AI video generator creates videos from text prompts and images using artificial intelligence. Video generator produces professional video content automatically with multiple AI models. Short video generator makes engaging clips for TikTok, YouTube Shorts, and Instagram Reels. Promo video generator builds promotional marketing videos for products and campaigns. Image to video converts photos into animated video with natural motion and transitions.

Text to video generation turns written descriptions into video clips. Image to video conversion animates still photographs into dynamic video sequences. First frame last frame video interpolation creates smooth transitions between two images. Reference image video generation maintains visual consistency using a reference photo. Character animation video brings illustrated or photographed characters to life.

AI video generator for content creators, marketers, and social media managers. Video generator for e-commerce product demos, explainer videos, and advertisements. Short video generator for viral content, stories, and vertical video formats. Video generation tool for education, training materials, and presentations. AI video creation platform for agencies, brands, and independent creators.

Generate AI videos with Wan, Kling, Veo, Sora, Pixverse, Hailuo, SeeDance, and Vidu models. Multi-model video generator with automatic model selection and parameter optimization. Professional video generator supporting 720P, 1080P, and 4K resolution output. Video generator with customizable duration, aspect ratio, and visual style controls.

AI video generation for product launches and advertising campaigns. Video generator for real estate virtual tours and property showcases. Short video generator for news highlights and event recaps. Promo video generator for seasonal sales and special offers. AI video maker for storytelling, narrative content, and creative projects.

Create stunning AI videos quickly and efficiently. Generate professional videos in minutes with one-click AI video generation. Make short videos instantly for any social media platform. Transform images to video seamlessly with intelligent animation. Produce high-quality video content at scale with batch video generation.

AI-powered video generation combining text understanding with visual synthesis. Video creation tool with natural language prompt interpretation. Multi-shot video production with character consistency via reference images. Cinematic video generation with camera movement and scene composition control.

Best AI video generator alternative to Runway, Pika Labs, Luma Dream Machine, and standalone video generation tools. Video generator replacing manual video editing with automated AI workflows. Complete video generation solution from concept to final output.


path: scripts/ima_runtime_doctor.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import sys

from ima_logger import cleanup_old_logs, setup_logger
from ima_runtime.doctor_flow import run_doctor as _run_doctor_impl
from ima_runtime.shared.config import DEFAULT_BASE_URL, VIDEO_TASK_TYPES


def _get_logger():
    logger = setup_logger("ima_skills")
    cleanup_old_logs(days=7)
    return logger


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="IMA Video doctor: low-cost environment and connectivity check without creating a paid task",
    )
    parser.add_argument("--api-key", help="IMA Open API key (optional; falls back to IMA_API_KEY)")
    parser.add_argument("--task-type", default="text_to_video", choices=list(VIDEO_TASK_TYPES), help="Task type to probe")
    parser.add_argument("--language", default="en", help="Language for product labels (en/zh)")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API base URL")
    return parser


def main() -> int:
    return _run_doctor_impl(build_parser().parse_args(), logger=_get_logger())


if __name__ == "__main__":
    sys.exit(main())


path: scripts/ima_logger.py
content: """
IMA Skills 日志模块
提供统一的日志配置和管理

功能：
- 双输出：同时输出到控制台和文件
- 日志轮转：自动管理日志文件大小
- 分级记录：INFO（关键操作）和 ERROR（错误信息）
- 性能优化：异步写入，不影响主流程

日志文件位置：~/.openclaw/logs/ima_skills/
文件命名：ima_create_YYYYMMDD.log
保留策略：保留最近 7 天
"""

import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path


def setup_logger(
    name: str = "ima_skills",
    log_level: int = logging.INFO,
    max_bytes: int = 10 * 1024 * 1024,  # 10MB
    backup_count: int = 7,  # Keep 7 days
    console_output: bool = True,
) -> logging.Logger:
    """
    配置日志系统
    
    Args:
        name: Logger 名称
        log_level: 日志级别（INFO/ERROR）
        max_bytes: 单个日志文件最大大小（字节）
        backup_count: 保留的日志文件数量
        console_output: 是否同时输出到控制台
    
    Returns:
        配置好的 Logger 对象
    """
    # 创建日志目录
    log_dir = Path.home() / ".openclaw" / "logs" / "ima_skills"
    log_dir.mkdir(parents=True, exist_ok=True)
    
    # 日志文件名（按日期）
    log_file = log_dir / f"ima_create_{datetime.now().strftime('%Y%m%d')}.log"
    
    # 创建 Logger
    logger = logging.getLogger(name)
    logger.setLevel(log_level)
    
    # 避免重复添加 Handler
    if logger.handlers:
        return logger
    
    # 日志格式
    # 格式：2026-02-26 15:30:45 | INFO | get_product_list | Query product list: category=text_to_image
    formatter = logging.Formatter(
        fmt='%(asctime)s | %(levelname)-5s | %(funcName)-20s | %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    
    # 文件 Handler（带轮转）
    file_handler = RotatingFileHandler(
        log_file,
        maxBytes=max_bytes,
        backupCount=backup_count,
        encoding='utf-8'
    )
    file_handler.setLevel(log_level)
    file_handler.setFormatter(formatter)
    logger.addHandler(file_handler)
    
    # 控制台 Handler（可选）
    if console_output:
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.WARNING)  # 控制台只显示 WARNING 及以上
        console_handler.setFormatter(formatter)
        logger.addHandler(console_handler)
    
    return logger


def get_logger(name: str = "ima_skills") -> logging.Logger:
    """
    获取已配置的 Logger（如果不存在则创建）
    
    Args:
        name: Logger 名称
    
    Returns:
        Logger 对象
    """
    logger = logging.getLogger(name)
    if not logger.handlers:
        return setup_logger(name)
    return logger


# 清理旧日志文件
def cleanup_old_logs(days: int = 7) -> None:
    """
    清理超过指定天数的日志文件
    
    Args:
        days: 保留天数
    """
    try:
        log_dir = Path.home() / ".openclaw" / "logs" / "ima_skills"
        if not log_dir.exists():
            return
        
        cutoff_time = datetime.now().timestamp() - (days * 24 * 3600)
        
        for log_file in log_dir.glob("ima_create_*.log*"):
            if log_file.stat().st_mtime < cutoff_time:
                log_file.unlink()
                
    except Exception:
        pass  # 清理失败不影响主流程


path: scripts/ima_runtime_setup.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import sys

from ima_logger import cleanup_old_logs, setup_logger
from ima_runtime.setup_flow import run_setup as _run_setup_impl
from ima_runtime.shared.config import DEFAULT_BASE_URL, VIDEO_TASK_TYPES


def _get_logger():
    logger = setup_logger("ima_skills")
    cleanup_old_logs(days=7)
    return logger


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="IMA Video onboarding helper: configure a first model preference without storing API keys",
    )
    parser.add_argument("--api-key", help="IMA Open API key (optional; falls back to IMA_API_KEY)")
    parser.add_argument("--task-type", choices=list(VIDEO_TASK_TYPES), help="Task type to configure")
    parser.add_argument("--model-id", help="Optional explicit model_id to save without interactive selection")
    parser.add_argument("--language", default="en", help="Language for product labels (en/zh)")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API base URL")
    parser.add_argument("--user-id", default="default", help="User ID for preference memory")
    return parser


def main() -> int:
    return _run_setup_impl(build_parser().parse_args(), logger=_get_logger())


if __name__ == "__main__":
    sys.exit(main())


path: scripts/parse_user_request.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
import sys
from typing import Any

from ima_runtime.shared.media_utils import detect_media_type


MODEL_ALIAS_PATTERNS = (
    ("ima-pro-fast", (r"\bima-pro-fast\b", r"\bseedance 2\.0 fast\b", r"\bvideo pro fast\b", r"\b极速\b")),
    ("ima-pro", (r"\bima-pro\b", r"\bseedance 2\.0\b", r"\bvideo pro\b", r"\b专业版\b", r"\b高质量\b")),
    ("kling-video-o1", (r"\bkling o1\b", r"\bkling\b")),
    ("wan2.6-i2v", (r"\bwan 2\.6\b", r"\bwan\b")),
)


def _flatten_media(values) -> list[str]:
    result: list[str] = []
    for value in values or []:
        if isinstance(value, list):
            result.extend(str(v) for v in value if str(v).strip())
        elif value is not None and str(value).strip():
            result.append(str(value))
    return result


def _extract_duration(text: str) -> int | None:
    match = re.search(r"(\d{1,2})\s*(?:s|sec|secs|second|seconds|秒)", text, re.IGNORECASE)
    return int(match.group(1)) if match else None


def _extract_resolution(text: str) -> str | None:
    match = re.search(r"\b(480p|540p|720p|768p|1080p|1080P|720P)\b", text, re.IGNORECASE)
    return match.group(1) if match else None


def _extract_aspect_ratio(text: str) -> str | None:
    match = re.search(r"\b(1:1|4:3|3:4|16:9|9:16|21:9)\b", text)
    if match:
        return match.group(1)
    if re.search(r"\badaptive\b|\bauto\b|自适应", text, re.IGNORECASE):
        return "adaptive"
    return None


def _extract_audio_toggle(text: str) -> bool | None:
    if re.search(r"\bsilent\b|\bmute\b|无声|静音", text, re.IGNORECASE):
        return False
    if re.search(r"\bwith audio\b|\bvoiceover\b|\bbgm\b|\bmusic\b|带音频|有声音|旁白|配音", text, re.IGNORECASE):
        return True
    return None


def _extract_model_id(text: str) -> str | None:
    lowered = text.lower()
    for model_id, patterns in MODEL_ALIAS_PATTERNS:
        for pattern in patterns:
            if re.search(pattern, lowered, re.IGNORECASE):
                return model_id
    return None


def _semantic_intent(text: str) -> dict[str, bool]:
    lowered = text.lower()
    return {
        "speed_priority": bool(re.search(r"\bquick\b|\bfast\b|\bdraft\b|\bpreview\b|\blow[- ]?cost\b|快速|预览|草稿|省点", lowered)),
        "quality_priority": bool(re.search(r"\bcinematic\b|\bfinal\b|\bhigh quality\b|\bquality\b|\bpremium\b|\bfilm\b|电影感|最终|高质量|高保真|高清|超清|高画质|成片", lowered)),
        "reference_intent": bool(re.search(r"\breference\b|\bstyle\b|\bsame character\b|\bconsistency\b|参考|风格|一致性", lowered)),
        "first_last_intent": bool(re.search(r"\bfirst frame\b|\blast frame\b|\bstart frame\b|\bend frame\b|首帧|尾帧|从.*到", lowered)),
        "animate_intent": bool(re.search(r"\banimate\b|\bmake .* move\b|让.*动起来|图生视频", lowered)),
    }


def _build_media_assets(media: list[str]) -> list[dict[str, str]]:
    assets: list[dict[str, str]] = []
    for source in media:
        media_type = detect_media_type(source)
        assets.append({"source": source, "media_type": media_type})
    return assets


def parse_request(request: str, media: list[str] | None = None) -> dict[str, Any]:
    media = media or []
    semantic = _semantic_intent(request)
    media_assets = _build_media_assets(media)
    media_types = {asset["media_type"] for asset in media_assets}
    text = request.strip()

    constraints: dict[str, Any] = {}
    duration = _extract_duration(text)
    if duration is not None:
        constraints["duration"] = duration
    resolution = _extract_resolution(text)
    if resolution is not None:
        constraints["resolution"] = resolution
    aspect_ratio = _extract_aspect_ratio(text)
    if aspect_ratio is not None:
        constraints["aspect_ratio"] = aspect_ratio
    audio_toggle = _extract_audio_toggle(text)
    if audio_toggle is not None:
        constraints["audio"] = audio_toggle

    explicit_model_id = _extract_model_id(text)

    clarification: dict[str, Any] | None = None
    task_type = "text_to_video"
    video_mode = None

    if semantic["first_last_intent"]:
        video_mode = "first_last_frame"
        task_type = "first_last_frame_to_video"
    elif semantic["reference_intent"]:
        video_mode = "reference"
        task_type = "reference_image_to_video"
    elif "video" in media_types or "audio" in media_types:
        video_mode = "reference"
        task_type = "reference_image_to_video"
    elif len(media_assets) == 1 and any(asset["media_type"] == "image" for asset in media_assets):
        task_type = "image_to_video"
    elif len(media_assets) >= 2:
        clarification = {
            "reason": "video image roles ambiguous",
            "question": "这些素材分别是什么角色？是首帧/尾帧，还是参考图/参考视频，还是只让其中一张图动起来？",
            "options": ("首帧/尾帧", "参考素材", "只动其中一张"),
        }
        task_type = "undetermined"

    return {
        "request": text,
        "generation_prompt": text,
        "task_type": task_type,
        "intent_hints": {k: v for k, v in {"task_type": None if task_type == "undetermined" else task_type, "video_mode": video_mode}.items() if v is not None},
        "constraints": constraints,
        "semantic_intent": semantic,
        "explicit_model_id": explicit_model_id,
        "media_assets": media_assets,
        "clarification": clarification,
    }


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Parse a free-form user request into a structured video generation intent")
    parser.add_argument("--request", required=True, help="Free-form user request")
    parser.add_argument("--media", nargs="*", action="append", default=[], help="Optional media paths or URLs referenced by the request")
    parser.add_argument("--output-json", action="store_true", help="Output parsed structure as JSON")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    parsed = parse_request(args.request, _flatten_media(args.media))
    if args.output_json:
        print(json.dumps(parsed, ensure_ascii=False, indent=2))
    else:
        print(parsed)
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/ima_runtime_cli.py
content: #!/usr/bin/env python3
from __future__ import annotations

import sys

from ima_logger import cleanup_old_logs, setup_logger
from ima_runtime.cli_flow import run_cli as _run_cli_impl
from ima_runtime.cli_parser import build_parser as _build_parser_impl


def _get_logger():
    logger = setup_logger("ima_skills")
    cleanup_old_logs(days=7)
    return logger


def build_parser():
    return _build_parser_impl()


def main():
    return _run_cli_impl(build_parser().parse_args(), logger=_get_logger())


if __name__ == "__main__":
    sys.exit(main())


path: scripts/validate_params.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import sys
from types import SimpleNamespace
from typing import Any

from parse_user_request import parse_request

from ima_runtime.shared.catalog import apply_virtual_param_overrides, list_all_models, normalize_model_id
from ima_runtime.shared.client import get_product_list
from ima_runtime.shared.media_payloads import filter_models_for_media_support


def _flatten_media(values) -> list[str]:
    result: list[str] = []
    for value in values or []:
        if isinstance(value, list):
            result.extend(str(v) for v in value if str(v).strip())
        elif value is not None and str(value).strip():
            result.append(str(value))
    return result


def _flatten_model_nodes(product_tree: list) -> list[dict[str, Any]]:
    result: list[dict[str, Any]] = []

    def walk(nodes):
        for node in nodes or []:
            if node.get("type") == "3":
                item = {
                    "name": node.get("name", ""),
                    "model_id": normalize_model_id(node.get("model_id")) or node.get("model_id"),
                    "raw_model_id": node.get("model_id"),
                    "version_id": node.get("id", ""),
                    "form_config": list(node.get("form_config") or []),
                    "credit_rules": list(node.get("credit_rules") or []),
                    "model_types": list(node.get("model_types") or []),
                    "files_accept_types": list((node.get("files") or {}).get("acceptType") or []),
                }
                result.append(item)
            walk(node.get("children") or [])

    walk(product_tree)
    return result


def _field_options(field: dict) -> set[str]:
    options = field.get("options") or []
    return {str(option.get("value")) for option in options if option.get("value") is not None}


def _normalize_value(value: Any) -> str:
    if isinstance(value, bool):
        return str(value).lower()
    return str(value).strip().upper()


def _constraint_alias_candidates(key: str) -> tuple[str, ...]:
    if key == "resolution":
        return ("resolution", "quality")
    if key == "quality":
        return ("quality", "resolution")
    return (key,)


def _remap_constraint_for_model(model: dict, key: str, value: Any) -> tuple[dict[str, Any] | None, bool]:
    form_config = model.get("form_config") or []
    direct_field = next((field for field in form_config if field.get("field") == key), None)
    if direct_field:
        options = _field_options(direct_field)
        if options and str(value) not in options:
            normalized_options = {_normalize_value(option) for option in options}
            if _normalize_value(value) not in normalized_options:
                return None, True
        return {key: value}, True

    virtual_fields = [field for field in form_config if field.get("is_ui_virtual")]
    for candidate_key in _constraint_alias_candidates(key):
        try:
            remapped = apply_virtual_param_overrides(virtual_fields, {candidate_key: value})
        except ValueError:
            continue
        if remapped != {candidate_key: value} or candidate_key in {field.get("field") for field in virtual_fields}:
            return remapped, True
        ui_field_names = {
            ui.get("field") or ui.get("id")
            for field in virtual_fields
            for ui in (field.get("ui_params") or [])
            if ui.get("field") or ui.get("id")
        }
        if candidate_key in ui_field_names:
            return remapped, True

    return None, False


def _credit_rule_supports_constraints(model: dict, constraints: dict[str, Any]) -> bool | None:
    credit_rules = model.get("credit_rules") or []
    if not credit_rules or not constraints:
        return None

    remapped: dict[str, Any] = {}
    any_remapped = False
    for key, value in constraints.items():
        mapped, handled = _remap_constraint_for_model(model, key, value)
        if handled:
            any_remapped = True
            if mapped is None:
                return False
            remapped.update(mapped)

    if not any_remapped:
        return None

    attrs_keys = {
        str(attr_key).strip().lower()
        for rule in credit_rules
        for attr_key in (rule.get("attributes") or {}).keys()
    }
    relevant = {
        key: value
        for key, value in remapped.items()
        if str(key).strip().lower() in attrs_keys
    }
    if not relevant:
        return None

    normalized_relevant = {str(k).strip().lower(): _normalize_value(v) for k, v in relevant.items()}
    for rule in credit_rules:
        attrs = rule.get("attributes") or {}
        normalized_attrs = {str(k).strip().lower(): _normalize_value(v) for k, v in attrs.items()}
        if all(normalized_attrs.get(k) == v for k, v in normalized_relevant.items()):
            return True
    return False


def _supports_constraint(model: dict, key: str, value: Any) -> bool | None:
    credit_rule_support = _credit_rule_supports_constraints(model, {key: value})
    if credit_rule_support is not None:
        return credit_rule_support

    form_config = model.get("form_config") or []
    direct_field = next((field for field in form_config if field.get("field") == key), None)
    if direct_field:
        options = _field_options(direct_field)
        if not options:
            return None
        normalized_options = {_normalize_value(option) for option in options}
        return _normalize_value(value) in normalized_options

    remapped, handled = _remap_constraint_for_model(model, key, value)
    if handled:
        if remapped is None:
            return False
        return True

    return None


def _compatible_with_constraints(models: list[dict], constraints: dict[str, Any]) -> tuple[list[dict], list[str]]:
    if not constraints:
        return models, []
    compatible: list[dict] = []
    unknown_constraints: list[str] = []
    for model in models:
        is_compatible = True
        unresolved_keys: list[str] = []
        for key, value in constraints.items():
            support = _supports_constraint(model, key, value)
            if support is False:
                is_compatible = False
                break
            if support is None:
                unresolved_keys.append(key)
        if is_compatible:
            compatible.append(model)
        unknown_constraints.extend(unresolved_keys)
    return compatible, sorted(set(unknown_constraints))


def _rank_models(models: list[dict], semantic_intent: dict[str, bool]) -> list[dict]:
    def score(model: dict) -> tuple:
        first_points = (model.get("credit_rules") or [{}])[0].get("points", 0)
        if semantic_intent.get("speed_priority") and not semantic_intent.get("quality_priority"):
            return (first_points, model.get("name", ""))
        if semantic_intent.get("quality_priority") and not semantic_intent.get("speed_priority"):
            return (-first_points, model.get("name", ""))
        return (model.get("name", ""), first_points)

    return sorted(models, key=score)


def validate_request(request: str, media: list[str], *, base_url: str, api_key: str, language: str = "en") -> dict[str, Any]:
    parsed = parse_request(request, media)
    if parsed.get("clarification"):
        return {"status": "clarification", **parsed}

    task_type = parsed["task_type"]
    tree = get_product_list(base_url, api_key, task_type, language=language)
    models = _flatten_model_nodes(tree)
    compatible_models = filter_models_for_media_support(
        task_type,
        models,
        tuple(
            SimpleNamespace(**asset)
            for asset in parsed["media_assets"]
        ),
    )
    compatible_models, unknown_constraints = _compatible_with_constraints(compatible_models, parsed["constraints"])

    explicit_model_id = parsed.get("explicit_model_id")
    if explicit_model_id:
        explicit_candidates = [model for model in compatible_models if model["model_id"] == explicit_model_id]
        if not explicit_candidates:
            return {
                "status": "error",
                "reason": "model_constraint_conflict",
                "message": f"Explicit model '{explicit_model_id}' conflicts with the current media or parameter constraints.",
                **parsed,
            }
        selected = _rank_models(explicit_candidates, parsed["semantic_intent"])[0]
        return {"status": "ok", "selected_model": selected, "compatible_models": explicit_candidates, "unknown_constraints": unknown_constraints, **parsed}

    if not compatible_models:
        return {
            "status": "error",
            "reason": "no_compatible_model",
            "message": f"No live catalog model currently supports task_type={task_type} with the current media and parameter constraints.",
            **parsed,
        }

    ranked = _rank_models(compatible_models, parsed["semantic_intent"])
    speed = parsed["semantic_intent"].get("speed_priority")
    quality = parsed["semantic_intent"].get("quality_priority")
    if len(ranked) == 1 or speed != quality:
        return {"status": "ok", "selected_model": ranked[0], "compatible_models": ranked, "unknown_constraints": unknown_constraints, **parsed}

    return {
        "status": "clarification",
        "reason": "multiple_compatible_models",
        "message": "Multiple live catalog models satisfy the request. Clarify whether you prefer speed, cost, or final quality.",
        "model_options": [
            {"model_id": model["model_id"], "version_id": model["version_id"], "name": model["name"]}
            for model in ranked[:3]
        ],
        "unknown_constraints": unknown_constraints,
        **parsed,
    }


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Validate parsed natural-language video parameters against the live catalog")
    parser.add_argument("--request", required=True, help="Free-form user request")
    parser.add_argument("--media", nargs="*", action="append", default=[], help="Optional media paths or URLs referenced by the request")
    parser.add_argument("--api-key", help="IMA API key; falls back to IMA_API_KEY")
    parser.add_argument("--base-url", default="https://api.imastudio.com", help="API base URL")
    parser.add_argument("--language", default="en", help="Catalog language")
    parser.add_argument("--output-json", action="store_true", help="Output validation result as JSON")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    [REDACTED_SECRET] or os.getenv("IMA_API_KEY")
    if not [REDACTED_SECRET]"IMA_API_KEY is required.", file=sys.stderr)
        return 1
    result = validate_request(args.request, _flatten_media(args.media), base_url=args.base_url, [REDACTED_SECRET], language=args.language)
    if args.output_json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        print(result)
    return 0 if result.get("status") == "ok" else 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/route_and_execute.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import sys
from types import SimpleNamespace

from ima_logger import cleanup_old_logs, setup_logger
from parse_user_request import parse_request
from validate_params import validate_request

from ima_runtime.cli_flow import run_cli


def _flatten_media(values) -> list[str]:
    result: list[str] = []
    for value in values or []:
        if isinstance(value, list):
            result.extend(str(v) for v in value if str(v).strip())
        elif value is not None and str(value).strip():
            result.append(str(value))
    return result


def _split_media_by_type(parsed: dict) -> tuple[list[list[str]], list[list[str]], list[list[str]], list[list[str]]]:
    input_images: list[str] = []
    reference_images: list[str] = []
    reference_videos: list[str] = []
    reference_audios: list[str] = []
    task_type = parsed["task_type"]
    for asset in parsed["media_assets"]:
        if task_type == "image_to_video":
            input_images.append(asset["source"])
        elif task_type == "first_last_frame_to_video":
            input_images.append(asset["source"])
        elif task_type == "reference_image_to_video":
            if asset["media_type"] == "image":
                reference_images.append(asset["source"])
            elif asset["media_type"] == "video":
                reference_videos.append(asset["source"])
            elif asset["media_type"] == "audio":
                reference_audios.append(asset["source"])
    return ([input_images] if input_images else [], [reference_images] if reference_images else [], [reference_videos] if reference_videos else [], [reference_audios] if reference_audios else [])


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Parse, validate, route, and optionally execute a free-form video generation request")
    parser.add_argument("--request", required=True, help="Free-form user request")
    parser.add_argument("--media", nargs="*", action="append", default=[], help="Optional media paths or URLs referenced by the request")
    parser.add_argument("--api-key", help="IMA API key; falls back to IMA_API_KEY")
    parser.add_argument("--base-url", default="https://api.imastudio.com", help="API base URL")
    parser.add_argument("--language", default="en", help="Catalog language")
    parser.add_argument("--user-id", default="default", help="User ID for preference memory")
    parser.add_argument("--dry-run", action="store_true", help="Only parse and validate; do not execute")
    parser.add_argument("--output-json", action="store_true", help="Output structured result as JSON")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    [REDACTED_SECRET] or os.getenv("IMA_API_KEY")
    if not [REDACTED_SECRET]"IMA_API_KEY is required.", file=sys.stderr)
        return 1

    media = _flatten_media(args.media)
    parsed = parse_request(args.request, media)
    validated = validate_request(args.request, media, base_url=args.base_url, [REDACTED_SECRET], language=args.language)
    if args.dry_run or validated.get("status") != "ok":
        payload = {"parsed": parsed, "validated": validated}
        if args.output_json:
            print(json.dumps(payload, ensure_ascii=False, indent=2))
        else:
            print(payload)
        return 0 if validated.get("status") == "ok" else 1

    input_images, reference_images, reference_videos, reference_audios = _split_media_by_type(parsed)
    constraints = dict(parsed.get("constraints") or {})
    cli_args = SimpleNamespace(
        [REDACTED_SECRET],
        base_url=args.base_url,
        task_type=validated["task_type"],
        language=args.language,
        list_models=False,
        output_json=args.output_json,
        model_id=validated["selected_model"]["model_id"],
        user_id=args.user_id,
        input_images=input_images,
        reference_images=reference_images,
        reference_videos=reference_videos,
        reference_audios=reference_audios,
        prompt=parsed["generation_prompt"],
        version_id=validated["selected_model"].get("version_id"),
        size=None,
        extra_params=json.dumps(constraints) if constraints else None,
    )

    logger = setup_logger("ima_skills")
    cleanup_old_logs(days=7)
    return run_cli(cli_args, logger=logger)


if __name__ == "__main__":
    sys.exit(main())


path: scripts/ima_runtime/__init__.py
content: """IMA Video AI runtime package."""


path: scripts/ima_runtime/cli_parser.py
content: from __future__ import annotations

import argparse

from ima_runtime.shared.config import DEFAULT_BASE_URL, VIDEO_TASK_TYPES


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="IMA AI Creation Script — reliable task creation via Open API",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--api-key",
        required=False,
        help="IMA Open API key (starts with ima_). Can also use IMA_API_KEY env var",
    )
    parser.add_argument("--task-type", required=True, choices=list(VIDEO_TASK_TYPES), help="Task type to create")
    parser.add_argument("--model-id", help="Model ID from product list")
    parser.add_argument("--version-id", help="Specific version ID — overrides latest")
    parser.add_argument("--prompt", help="Generation prompt (required unless --list-models)")
    parser.add_argument(
        "--input-images",
        nargs="*",
        action="append",
        default=[],
        help="Input image URLs or local file paths (for image_to_video, first_last_frame_to_video, etc.). "
        "Can be repeated multiple times; values are merged. "
        "Local files will be automatically uploaded using the API key.",
    )
    parser.add_argument(
        "--reference-images",
        nargs="*",
        action="append",
        default=[],
        help="Reference image URLs or local file paths for reference_image_to_video. "
        "Can be repeated multiple times; values are merged.",
    )
    parser.add_argument(
        "--reference-videos",
        nargs="*",
        action="append",
        default=[],
        help="Reference video URLs or local file paths for Seedance reference_image_to_video. "
        "Can be repeated multiple times; values are merged.",
    )
    parser.add_argument(
        "--reference-audios",
        nargs="*",
        action="append",
        default=[],
        help="Reference audio URLs or local file paths for Seedance reference_image_to_video. "
        "Can be repeated multiple times; values are merged.",
    )
    parser.add_argument("--size", help="Optional size override")
    parser.add_argument("--extra-params", help='JSON string of extra parameters, e.g. \'{"duration":"5"}\'')
    parser.add_argument("--language", default="en", help="Language for product labels (en/zh)")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API base URL")
    parser.add_argument("--user-id", default="default", help="User ID for preference memory")
    parser.add_argument("--list-models", action="store_true", help="List all available models for --task-type and exit")
    parser.add_argument("--output-json", action="store_true", help="Output final result as JSON (for agent parsing)")
    return parser


path: scripts/ima_runtime/cli_flow.py
content: from __future__ import annotations

import json
import os
import sys

from ima_runtime.adapters.cli_presenter import print_model_summary
from ima_runtime.capabilities.video.executor import execute_video_task
from ima_runtime.capabilities.video.models import build_video_model_binding, normalize_model_id
from ima_runtime.capabilities.video.routes import build_video_task_spec
from ima_runtime.shared.catalog import list_all_models
from ima_runtime.shared.client import get_product_list
from ima_runtime.shared.errors import (
    build_contextual_diagnosis,
    extract_error_info,
    format_preflight_failure_message,
    format_user_failure_message,
    to_user_facing_model_name,
)
from ima_runtime.shared.inputs import (
    build_cli_media_assets,
    flatten_input_images_args,
)
from ima_runtime.shared.media_payloads import (
    build_media_payload_bundle,
    filter_models_for_media_support,
    prepare_media_assets,
    validate_model_media_support,
)
from ima_runtime.shared.reference_validation import (
    ReferenceInputValidationError,
    validate_seedance_image_assets,
    validate_seedance_reference_assets,
)
from ima_runtime.shared.compliance import AssetVerificationError, ensure_assets_verified
from ima_runtime.shared.prefs import get_preferred_model_id, save_pref
from ima_runtime.shared.recommendations import build_recommendation_lines, resolve_suggested_model
from ima_runtime.shared.seedance_capabilities import is_seedance_model
from ima_runtime.shared.types import ClarificationRequest, GatewayRequest


def _log(logger, level: str, message: str, *args) -> None:
    log_method = getattr(logger, level, None)
    if callable(log_method):
        log_method(message, *args)


def _print_model_list(task_type: str, models: list[dict]) -> None:
    print(f"\nAvailable models for '{task_type}':")
    print(f"{'Name':<28} {'model_id':<34} {'version_id':<44} {'pts':>4}  attr_id")
    print("─" * 120)
    for model in models:
        print(
            f"{model['name']:<28} {model['model_id']:<34} {model['version_id']:<44} "
            f"{model['credit']:>4}  {model['attr_id']}"
        )


def _print_generation_result(result) -> None:
    print("\n✅ Generation complete!")
    print(f"   URL:   {result.url}")
    if result.cover_url:
        print(f"   Cover: {result.cover_url}")


def _fail(message: str) -> int:
    print(f"❌ {message}", file=sys.stderr)
    return 1


def _build_missing_model_message(task_type: str, models: list[dict]) -> str:
    lines = [
        "--model-id is required (no saved preference found).",
        "Run --list-models to inspect available options.",
        *build_recommendation_lines(task_type, models),
    ]

    lines.append("Select a model explicitly with --model-id.")
    return "\n".join(lines)


def _is_interactive_terminal() -> bool:
    isatty = getattr(sys.stdin, "isatty", None)
    return bool(callable(isatty) and isatty())


def _prompt_for_model_choice(task_type: str, models: list[dict], media_assets=()) -> str:
    compatible_models = filter_models_for_media_support(task_type, models, media_assets)
    if not compatible_models:
        raise ValueError(f"No live catalog models currently support the requested {task_type} media constraints.")

    suggestion = resolve_suggested_model(task_type, compatible_models)
    if not suggestion.model:
        raise ValueError(f"No models were returned for {task_type}.")

    print(f"No saved preference found for {task_type}.")
    print("\n".join(build_recommendation_lines(task_type, models)))
    if media_assets:
        print(f"Compatible live catalog candidates for current inputs: {len(compatible_models)}")
    if suggestion.is_catalog_recommended:
        print("Press Enter to use the recommended model, choose a number below, or type a model_id.")
    else:
        print(f"No catalog recommendation is currently available for {task_type}.")
        print("Press Enter to use the first available model, choose a number below, or type a model_id.")

    choices: list[dict] = []
    seen_model_ids: set[str] = set()
    suggested_id = normalize_model_id(suggestion.model.get("model_id")) or suggestion.model.get("model_id")

    def add_choice(model: dict) -> None:
        model_id = normalize_model_id(model.get("model_id")) or model.get("model_id")
        if not model_id or model_id in seen_model_ids:
            return
        seen_model_ids.add(model_id)
        choices.append(model)

    add_choice(suggestion.model)
    for model in compatible_models:
        add_choice(model)
        if len(choices) >= 3:
            break

    for index, model in enumerate(choices, start=1):
        model_id = normalize_model_id(model.get("model_id")) or model.get("model_id")
        display_name = to_user_facing_model_name(model.get("name"), model_id)
        print(f"{index}. {display_name} ({model_id})")

    try:
        selection = input(f"Model selection [{suggested_id}]: ").strip()
    except (EOFError, KeyboardInterrupt) as exc:
        raise ValueError(
            "First-run model selection cancelled. Re-run with --model-id, "
            "--list-models, or scripts/ima_runtime_setup.py."
        ) from exc
    if not selection:
        return suggested_id

    if selection.isdigit():
        selected_index = int(selection)
        if 1 <= selected_index <= len(choices):
            selected_model = choices[selected_index - 1]
            return normalize_model_id(selected_model.get("model_id")) or selected_model.get("model_id")

    normalized_selection = normalize_model_id(selection) or selection
    available_model_ids = {
        normalize_model_id(model.get("model_id")) or model.get("model_id")
        for model in compatible_models
        if model.get("model_id")
    }
    if normalized_selection in available_model_ids:
        return normalized_selection

    raise ValueError(
        "Unknown model selection. Use a listed number, an available model_id, or rerun with --list-models."
    )


def run_cli(args, logger) -> int:
    [REDACTED_SECRET] or os.getenv("IMA_API_KEY")
    if not [REDACTED_SECRET] _fail(
            "API key is required. Use --api-key or set IMA_API_KEY. "
            "Get one at https://www.imaclaw.ai/imaclaw/apikey"
        )

    if not args.list_models and not args.prompt:
        return _fail("--prompt is required")

    extra_params: dict[str, object] = {}
    if args.size:
        extra_params["size"] = args.size
    if args.extra_params:
        try:
            decoded_extra_params = json.loads(args.extra_params)
        except json.JSONDecodeError as exc:
            return _fail(f"Invalid --extra-params JSON: {exc}")
        if not isinstance(decoded_extra_params, dict):
            return _fail("--extra-params must decode to a JSON object")
        extra_params.update(decoded_extra_params)

    _log(
        logger,
        "info",
        "CLI start: task_type=%s model_id=%s list_models=%s",
        args.task_type,
        args.model_id,
        args.list_models,
    )
    try:
        tree = get_product_list(args.base_url, api_key, args.task_type, language=args.language)
    except Exception as exc:
        _log(logger, "error", "Product list failed: %s", exc)
        diagnosis = build_contextual_diagnosis(
            error_info=extract_error_info(exc),
            task_type=args.task_type,
            model_params={"model_name": "IMA Model", "model_id": None, "form_params": {}},
            current_params=None,
            input_images=[],
            credit_rules=None,
        )
        if diagnosis.get("headline") == "API key is missing, invalid, or unauthorized":
            return _fail(
                format_preflight_failure_message(
                    operation_name="product list",
                    diagnosis=diagnosis,
                )
            )
        return _fail(f"Product list failed: {exc}")

    if args.list_models:
        payload = list_all_models(tree)
        if args.output_json:
            print(json.dumps(payload, ensure_ascii=False, indent=2))
            return 0
        _print_model_list(args.task_type, payload)
        print()
        print("\n".join(build_recommendation_lines(args.task_type, payload)))
        return 0

    try:
        request = GatewayRequest(
            prompt=args.prompt,
            media_targets=("video",),
            input_images=tuple(flatten_input_images_args(args.input_images)),
            media_assets=build_cli_media_assets(
                args.task_type,
                input_images=args.input_images,
                reference_images=getattr(args, "reference_images", []),
                reference_videos=getattr(args, "reference_videos", []),
                reference_audios=getattr(args, "reference_audios", []),
            ),
            intent_hints={"task_type": args.task_type},
            extra_params=extra_params,
        )
        spec = build_video_task_spec(request)
    except ValueError as exc:
        _log(logger, "error", "Input preparation failed: %s", exc)
        return _fail(str(exc))
    if isinstance(spec, ClarificationRequest):
        return _fail(spec.question)

    model_id = normalize_model_id(args.model_id) if args.model_id else get_preferred_model_id(
        args.user_id,
        args.task_type,
    )
    if not model_id:
        available_models = list_all_models(tree)
        if _is_interactive_terminal() and not args.output_json:
            try:
                model_id = _prompt_for_model_choice(args.task_type, available_models, spec.media_assets)
            except ValueError as exc:
                return _fail(str(exc))
        if not model_id:
            return _fail(_build_missing_model_message(args.task_type, available_models))

    try:
        binding = build_video_model_binding(tree, model_id, args.version_id)
    except Exception as exc:
        _log(logger, "error", "Model lookup failed: %s", exc)
        available_model_ids = sorted({model["model_id"] for model in list_all_models(tree) if model.get("model_id")})
        guidance = " Run --list-models to inspect available options."
        if available_model_ids:
            guidance += " Available model IDs: " + ", ".join(available_model_ids[:8])
            if len(available_model_ids) > 8:
                guidance += ", ..."
        return _fail(f"{exc}.{guidance}")

    try:
        validate_model_media_support(spec.task_type, dict(binding.resolved_params), spec.media_assets)
        prepared_assets = prepare_media_assets(spec.media_assets, api_key)
        if is_seedance_model(binding.candidate.model_id):
            if spec.task_type == "reference_image_to_video":
                validate_seedance_reference_assets(prepared_assets)
            elif spec.task_type in {"image_to_video", "first_last_frame_to_video"}:
                validate_seedance_image_assets(spec.task_type, prepared_assets)
        ensure_assets_verified(
            args.base_url,
            api_key,
            spec.task_type,
            binding.candidate.model_id,
            prepared_assets,
        )
        media_bundle = build_media_payload_bundle(spec.task_type, binding.candidate.model_id, prepared_assets)
    except (ValueError, RuntimeError, ReferenceInputValidationError, AssetVerificationError) as exc:
        _log(logger, "error", "Media preparation failed: %s", exc)
        return _fail(str(exc))

    print_model_summary(binding.resolved_params)
    try:
        result = execute_video_task(args.base_url, api_key, spec, binding, media_bundle=media_bundle)
    except Exception as exc:
        _log(logger, "error", "Task execution failed: %s", exc)
        attempts_used = getattr(exc, "attempts_used", 1)
        max_attempts = getattr(exc, "max_attempts", attempts_used)
        diagnosis = build_contextual_diagnosis(
            error_info=extract_error_info(exc),
            task_type=spec.task_type,
            model_params=dict(binding.resolved_params),
            current_params=dict(spec.extra_params),
            input_images=list(media_bundle.input_images),
            credit_rules=list(binding.resolved_params.get("all_credit_rules") or []),
        )
        return _fail(
            format_user_failure_message(
                diagnosis=diagnosis,
                attempts_used=attempts_used,
                max_attempts=max_attempts,
            )
        )

    save_pref(
        args.user_id,
        spec.task_type,
        {
            "model_id": result.model_id,
            "model_name": result.model_name,
            "credit": result.credit,
        },
    )

    if args.output_json:
        print(
            json.dumps(
                {
                    "task_id": result.task_id,
                    "url": result.url,
                    "cover_url": result.cover_url,
                    "model_id": result.model_id,
                    "model_name": result.model_name,
                    "credit": result.credit,
                },
                ensure_ascii=False,
                indent=2,
            )
        )
    else:
        _print_generation_result(result)
    _log(logger, "info", "CLI completed: task_id=%s", result.task_id)
    return 0


path: scripts/ima_runtime/setup_flow.py
content: from __future__ import annotations

import os
import sys

from ima_runtime.shared.catalog import list_all_models, normalize_model_id
from ima_runtime.shared.client import get_product_list
from ima_runtime.shared.config import VIDEO_TASK_TYPES
from ima_runtime.shared.errors import (
    build_contextual_diagnosis,
    extract_error_info,
    format_preflight_failure_message,
    to_user_facing_model_name,
)
from ima_runtime.shared.prefs import save_pref
from ima_runtime.shared.recommendations import build_recommendation_lines, resolve_suggested_model


def _log(logger, level: str, message: str, *args) -> None:
    log_method = getattr(logger, level, None)
    if callable(log_method):
        log_method(message, *args)


def _fail(message: str) -> int:
    print(message, file=sys.stderr)
    return 1


def _cancelled_error() -> ValueError:
    return ValueError(
        "Setup cancelled. Re-run scripts/ima_runtime_setup.py when you're ready, "
        "or use --model-id with scripts/ima_runtime_cli.py."
    )


def _prompt_api_key(input_fn, env: dict[str, str]) -> str | None:
    existing = env.get("IMA_API_KEY")
    if existing:
        return existing
    try:
        entered = input_fn("Enter IMA_API_KEY (leave blank to cancel): ").strip()
    except (EOFError, KeyboardInterrupt) as exc:
        raise _cancelled_error() from exc
    return entered or None


def _prompt_task_type(input_fn) -> str:
    print("Choose the first task type to configure:")
    for index, task_type in enumerate(VIDEO_TASK_TYPES, start=1):
        print(f"{index}. {task_type}")
    try:
        choice = input_fn("Task type [1]: ").strip()
    except (EOFError, KeyboardInterrupt) as exc:
        raise _cancelled_error() from exc
    if not choice:
        return VIDEO_TASK_TYPES[0]
    if choice.isdigit():
        selected_index = int(choice)
        if 1 <= selected_index <= len(VIDEO_TASK_TYPES):
            return VIDEO_TASK_TYPES[selected_index - 1]
    if choice in VIDEO_TASK_TYPES:
        return choice
    raise ValueError(f"Unsupported task type selection: {choice}")


def _select_model(task_type: str, models: list[dict], requested_model_id: str | None, input_fn) -> dict:
    models_by_id = {
        normalize_model_id(model.get("model_id")) or model.get("model_id"): model
        for model in models
        if model.get("model_id")
    }
    if requested_model_id:
        chosen = models_by_id.get(normalize_model_id(requested_model_id) or requested_model_id)
        if not chosen:
            raise ValueError(f"Unknown model_id for {task_type}: {requested_model_id}")
        return chosen

    suggestion = resolve_suggested_model(task_type, models)
    if suggestion.model is None:
        raise ValueError(f"No models available for {task_type}")

    if suggestion.is_catalog_recommended:
        print("Press Enter to use the recommended model shown below.")
    else:
        print(f"No catalog recommendation is currently available for {task_type}.")
        print("Press Enter to use the first available model shown below.")

    print("Available models:")
    for index, model in enumerate(models, start=1):
        display_name = to_user_facing_model_name(model.get("name"), model.get("model_id"))
        print(f"{index}. {display_name} ({model['model_id']}) - {model['credit']} pts")

    try:
        choice = input_fn(f"Model number or model_id [{suggestion.model['model_id']}]: ").strip()
    except (EOFError, KeyboardInterrupt) as exc:
        raise _cancelled_error() from exc
    if not choice:
        return suggestion.model
    if choice.isdigit():
        selected_index = int(choice)
        if 1 <= selected_index <= len(models):
            return models[selected_index - 1]
    chosen = models_by_id.get(normalize_model_id(choice) or choice)
    if chosen:
        return chosen
    raise ValueError(f"Unknown model selection: {choice}")


def _print_next_steps(task_type: str, user_id: str) -> None:
    prompt_example = "a paper airplane gliding through warm sunset light"
    cli_cmd = f'python3 scripts/ima_runtime_cli.py --task-type {task_type} --prompt "{prompt_example}"'
    if user_id != "default":
        cli_cmd += f" --user-id {user_id}"

    print("IMA_API_KEY is not persisted by this setup script.")
    print("Next steps:")
    print('1. export IMA_API_KEY="your-api-key"')
    print(f"2. {cli_cmd}")


def run_setup(args, logger, input_fn=input, env: dict[str, str] | None = None) -> int:
    env = dict(os.environ if env is None else env)
    try:
        [REDACTED_SECRET] or _prompt_api_key(input_fn, env)
        if not [REDACTED_SECRET] _fail(
                "API key is required for setup.\n"
                "Set IMA_API_KEY or rerun with --api-key after creating one at "
                "https://www.imaclaw.ai/imaclaw/apikey"
            )
        task_type = args.task_type or _prompt_task_type(input_fn)
    except ValueError as exc:
        return _fail(str(exc))

    _log(logger, "info", "Setup start: task_type=%s user_id=%s", task_type, args.user_id)
    try:
        tree = get_product_list(args.base_url, api_key, task_type, language=args.language)
    except Exception as exc:
        _log(logger, "error", "Setup product list failed: %s", exc)
        diagnosis = build_contextual_diagnosis(
            error_info=extract_error_info(exc),
            task_type=task_type,
            model_params={"model_name": "IMA Model", "model_id": None, "form_params": {}},
            current_params=None,
            input_images=[],
            credit_rules=None,
        )
        return _fail(
            format_preflight_failure_message(
                operation_name="product list",
                diagnosis=diagnosis,
            )
        )

    models = list_all_models(tree)
    if not models:
        return _fail(f"No models were returned for {task_type}. Run again later.")

    print("\n".join(build_recommendation_lines(task_type, models)))

    try:
        selected_model = _select_model(task_type, models, args.model_id, input_fn)
    except ValueError as exc:
        return _fail(str(exc))

    save_pref(
        args.user_id,
        task_type,
        {
            "model_id": selected_model["model_id"],
            "model_name": selected_model["name"],
            "credit": selected_model["credit"],
        },
    )
    display_name = to_user_facing_model_name(selected_model["name"], selected_model["model_id"])
    print(f"Saved preference for user '{args.user_id}': {display_name} ({selected_model['model_id']})")
    _print_next_steps(task_type, args.user_id)
    _log(logger, "info", "Setup completed: task_type=%s model_id=%s", task_type, selected_model["model_id"])
    return 0


path: scripts/ima_runtime/doctor_flow.py
content: from __future__ import annotations

import os
import sys

import requests

from ima_runtime.shared.catalog import list_all_models
from ima_runtime.shared.client import get_product_list
from ima_runtime.shared.errors import (
    build_contextual_diagnosis,
    extract_error_info,
    format_preflight_failure_message,
)
from ima_runtime.shared.recommendations import build_recommendation_lines


def _log(logger, level: str, message: str, *args) -> None:
    log_method = getattr(logger, level, None)
    if callable(log_method):
        log_method(message, *args)


def _fail(message: str) -> int:
    print(message, file=sys.stderr)
    return 1


def run_doctor(args, logger, env: dict[str, str] | None = None) -> int:
    env = dict(os.environ if env is None else env)
    [REDACTED_SECRET] or env.get("IMA_API_KEY")

    print(f"Doctor checks for {args.task_type}")
    print(f"Python: {sys.version.split()[0]}")
    print(f"requests: {requests.__version__}")

    if not [REDACTED_SECRET] _fail(
            "IMA_API_KEY is missing.\n"
            "Set IMA_API_KEY or pass --api-key.\n"
            "Create one at https://www.imaclaw.ai/imaclaw/apikey"
        )

    print("API key: present")
    _log(logger, "info", "Doctor start: task_type=%s", args.task_type)
    try:
        tree = get_product_list(args.base_url, api_key, args.task_type, language=args.language)
    except Exception as exc:
        _log(logger, "error", "Doctor product list failed: %s", exc)
        diagnosis = build_contextual_diagnosis(
            error_info=extract_error_info(exc),
            task_type=args.task_type,
            model_params={"model_name": "IMA Model", "model_id": None, "form_params": {}},
            current_params=None,
            input_images=[],
            credit_rules=None,
        )
        return _fail(
            format_preflight_failure_message(
                operation_name="product list",
                diagnosis=diagnosis,
            )
        )

    models = list_all_models(tree)
    if not models:
        return _fail(
            f"Product list returned zero usable models for {args.task_type}. "
            "Verify account access or try again later."
        )
    print(f"Product list: reachable ({len(models)} model leaves)")
    print("\n".join(build_recommendation_lines(args.task_type, models)))
    print("No paid generation task was created.")
    _log(logger, "info", "Doctor completed: task_type=%s models=%s", args.task_type, len(models))
    return 0


path: scripts/ima_runtime/gateway/planner.py
content: from __future__ import annotations

from ima_runtime.shared.types import GatewayRequest, WorkflowPlanDraft, WorkflowStepDraft


def build_workflow_plan(request: GatewayRequest) -> WorkflowPlanDraft:
    summary = "Clarify image roles before video execution" if len(request.input_images) >= 2 else "Single-step video run"
    return WorkflowPlanDraft(
        summary=summary,
        steps=(
            WorkflowStepDraft(
                step_id="video-1",
                capability="video",
                goal="Resolve route and execute the requested video task",
            ),
        ),
    )


path: scripts/ima_runtime/gateway/__init__.py
content: from .router import route_request

__all__ = ["route_request"]


path: scripts/ima_runtime/gateway/router.py
content: from __future__ import annotations

from ima_runtime.shared.types import ClarificationRequest, GatewayRequest, RouteDecision


def route_request(request: GatewayRequest) -> RouteDecision | ClarificationRequest:
    from ima_runtime.capabilities.video.routes import build_video_task_spec

    result = build_video_task_spec(request)
    if isinstance(result, ClarificationRequest):
        return result
    return RouteDecision(capability="video", reason=f"video task_type={result.task_type}")


path: scripts/ima_runtime/workflow/coordinator.py
content: from __future__ import annotations

from ima_runtime.gateway.planner import build_workflow_plan
from ima_runtime.shared.types import GatewayRequest, WorkflowPlanDraft
from ima_runtime.workflow.confirmation import to_confirmable_plan


def build_confirmable_plan(request: GatewayRequest) -> WorkflowPlanDraft:
    return to_confirmable_plan(build_workflow_plan(request))


path: scripts/ima_runtime/workflow/__init__.py
content: from __future__ import annotations

from .coordinator import build_confirmable_plan

__all__ = [
    "build_confirmable_plan",
]


path: scripts/ima_runtime/workflow/confirmation.py
content: from __future__ import annotations

from ima_runtime.shared.types import WorkflowPlanDraft


def to_confirmable_plan(plan: WorkflowPlanDraft) -> WorkflowPlanDraft:
    return plan


path: scripts/ima_runtime/adapters/cli_presenter.py
content: from __future__ import annotations

import json


def print_model_summary(model_params: dict) -> None:
    print("✅ Model found:")
    print(f"   name          = {model_params['model_name']}")
    print(f"   model_id      = {model_params['model_id']}")
    print(f"   model_version = {model_params['model_version']}   ← version_id from product list")
    print(f"   attribute_id  = {model_params['attribute_id']}")
    print(f"   credit        = {model_params['credit']} pts")
    print(f"   form_params   = {json.dumps(model_params['form_params'], ensure_ascii=False)}")


path: scripts/ima_runtime/adapters/__init__.py
content: from .cli_presenter import print_model_summary

__all__ = ["print_model_summary"]


path: scripts/ima_runtime/shared/catalog.py
content: from __future__ import annotations

import logging
import re


logger = logging.getLogger("ima_skills")

MODEL_ID_ALIASES = {
    "ima sevio 1.0": "ima-pro",
    "ima sevio 1.0-fast": "ima-pro-fast",
    "ima sevio 1.0 fast": "ima-pro-fast",
    "seedance 2.0": "ima-pro",
    "seedance 2.0-fast": "ima-pro-fast",
    "seedance 2.0 fast": "ima-pro-fast",
}


def canonicalize_model_id(model_id: str | None) -> str | None:
    if not model_id:
        return None
    normalized_key = re.sub(r"\s+", " ", model_id.strip().lower())
    return MODEL_ID_ALIASES.get(normalized_key, model_id.strip())


normalize_model_id = canonicalize_model_id


def find_model_version(product_tree: list, target_model_id: str,
                       target_version_id: str | None = None) -> dict | None:
    """
    Walk the V2 tree and find a type=3 leaf node matching target_model_id.
    If target_version_id is given, match exactly; otherwise return the last
    (usually newest) matching version.

    Key insight from imagent.bot frontend:
      modelItem.key       → node["id"]          (= model_version in create request)
      modelItem.modelCodeId → node["model_id"]   (= model_id in create request)
      modelItem.name      → node["name"]         (= model_name in create request)
    """
    candidates = []
    canonical_target_model_id = normalize_model_id(target_model_id) or target_model_id

    def walk(nodes: list):
        for node in nodes:
            if node.get("type") == "3":
                mid = node.get("model_id", "")
                normalized_mid = normalize_model_id(mid) or mid
                vid = node.get("id", "")
                if normalized_mid == canonical_target_model_id:
                    if target_version_id is None or vid == target_version_id:
                        candidates.append(node)
            children = node.get("children") or []
            walk(children)

    walk(product_tree)

    if not candidates:
        logger.error(
            f"Model not found: model_id={canonical_target_model_id}, "
            f"version_id={target_version_id}"
        )
        return None

    # Return last match — product list is ordered oldest→newest, last = newest
    selected = candidates[-1]
    logger.info(
        f"Model found: {selected.get('name')} "
        f"(model_id={canonical_target_model_id}, version_id={selected.get('id')})"
    )
    return selected


def list_all_models(product_tree: list) -> list[dict]:
    """Flatten tree to a list of {name, model_id, version_id, credit} dicts."""
    result = []

    def walk(nodes):
        for node in nodes:
            if node.get("type") == "3":
                cr = (node.get("credit_rules") or [{}])[0]
                raw_model_id = node.get("model_id", "")
                canonical_model_id = normalize_model_id(raw_model_id) or raw_model_id
                result.append({
                    "name":       node.get("name", ""),
                    "model_id":   canonical_model_id,
                    "raw_model_id": raw_model_id,
                    "version_id": node.get("id", ""),
                    "credit":     cr.get("points", 0),
                    "attr_id":    cr.get("attribute_id", 0),
                    "model_types": list(node.get("model_types") or []),
                    "files_accept_types": list((node.get("files") or {}).get("acceptType") or []),
                    "form_fields": [field.get("field") for field in (node.get("form_config") or []) if field.get("field")],
                })
            walk(node.get("children") or [])

    walk(product_tree)
    return result


def resolve_virtual_param(field: dict) -> dict:
    """
    Handle virtual form fields (is_ui_virtual=True).

    Frontend logic (useAgentModeData.ts):
      1. Create sub-forms from ui_params (each has a default value)
      2. Build patch: {ui_param.field: ui_param.value} for each sub-param
      3. Find matching value_mapping rule where source_values == patch
      4. Use target_value as the actual API parameter value

    If is_ui_virtual is not exposed by Open API, fall through to default value.
    """
    field_name     = field.get("field")
    ui_params      = field.get("ui_params") or []
    value_mapping  = field.get("value_mapping") or {}
    mapping_rules  = value_mapping.get("mapping_rules") or []
    default_value  = field.get("value")

    if not field_name:
        return {}

    if ui_params and mapping_rules:
        # Build patch from ui_params default values
        patch = {}
        for ui in ui_params:
            ui_field = ui.get("field") or ui.get("id", "")
            patch[ui_field] = ui.get("value")

        # Find matching mapping rule
        for rule in mapping_rules:
            source = rule.get("source_values") or {}
            if all(patch.get(k) == v for k, v in source.items()):
                return {field_name: rule.get("target_value")}

    # Fallback: use the field's own default value
    if default_value is not None:
        return {field_name: default_value}
    return {}


def apply_virtual_param_overrides(virtual_fields: list[dict], params: dict) -> dict:
    """
    Apply ui_params -> target_value mapping for user-provided overrides.

    This complements resolve_virtual_param(), which only resolves default values
    from form_config. When users provide UI-facing sub-params such as
    {"quality":"1080p"}, this function remaps them to the backend-facing target
    param such as {"mode":"pro"} before rule matching and payload assembly.
    """
    resolved = dict(params)
    for field in virtual_fields or []:
        field_name = field.get("field")
        ui_params = field.get("ui_params") or []
        value_mapping = field.get("value_mapping") or {}
        mapping_rules = value_mapping.get("mapping_rules") or []
        if not field_name or not ui_params or not mapping_rules:
            continue

        ui_field_names = [ui.get("field") or ui.get("id", "") for ui in ui_params if ui.get("field") or ui.get("id")]
        if not any(name in resolved for name in ui_field_names):
            continue

        patch = {}
        for ui in ui_params:
            ui_field = ui.get("field") or ui.get("id", "")
            if not ui_field:
                continue
            if ui_field in resolved:
                patch[ui_field] = resolved[ui_field]
            else:
                patch[ui_field] = ui.get("value")

        matched_rule = None
        for rule in mapping_rules:
            source = rule.get("source_values") or {}
            if all(patch.get(k) == v for k, v in source.items()):
                matched_rule = rule
                break

        if matched_rule is None:
            raise ValueError(
                f"No value_mapping rule matched virtual field '{field_name}' for ui params {patch}"
            )

        mapped_target = matched_rule.get("target_value")
        if field_name in resolved and field_name not in ui_field_names and resolved[field_name] != mapped_target:
            raise ValueError(
                f"Conflicting values for virtual field '{field_name}': "
                f"explicit={resolved[field_name]!r} mapped={mapped_target!r}"
            )

        resolved[field_name] = mapped_target
        for ui_field_name in ui_field_names:
            if ui_field_name != field_name:
                resolved.pop(ui_field_name, None)

    return resolved


def extract_model_params(node: dict) -> dict:
    """
    Extract everything needed for the create task request from a product list leaf node.

    Returns:
      attribute_id  : int   — from credit_rules[0].attribute_id
      credit        : int   — from credit_rules[0].points
      model_id      : str   — node["model_id"]
      model_name    : str   — node["name"]
      model_version : str   — node["id"]  ← CRITICAL: this is what backend calls model_version_id
      form_params   : dict  — resolved form_config defaults (including virtual params)
    """
    credit_rules = node.get("credit_rules") or []
    if not credit_rules:
        raise RuntimeError(
            f"No credit_rules found for model '{node.get('model_id')}' "
            f"version '{node.get('id')}'. Cannot determine attribute_id or credit."
        )

    # Build form_config defaults FIRST (before selecting credit_rule)
    form_params: dict = {}
    for field in (node.get("form_config") or []):
        fname = field.get("field")
        if not fname:
            continue

        is_virtual = field.get("is_ui_virtual", False)
        if is_virtual:
            # Apply virtual param resolution (frontend logic)
            resolved = resolve_virtual_param(field)
            form_params.update(resolved)
        else:
            fvalue = field.get("value")
            if fvalue is not None:
                form_params[fname] = fvalue

    # 🆕 CRITICAL FIX: Select the correct credit_rule based on form_params
    # Don't always use credit_rules[0] - match form_params to rule.attributes
    selected_rule = None

    # Normalize form_params for matching
    def normalize_value(v):
        if isinstance(v, bool):
            return str(v).lower()
        return str(v).strip().upper()

    normalized_form = {
        k.lower().strip(): normalize_value(v)
        for k, v in form_params.items()
    }

    # Try to find a rule that matches form_params
    for cr in credit_rules:
        attrs = cr.get("attributes", {})
        if not attrs:
            continue

        normalized_attrs = {
            k.lower().strip(): normalize_value(v)
            for k, v in attrs.items()
            if not (k == "default" and v == "enabled")  # Skip markers
        }

        # Check if rule attributes match form_params
        match = all(
            normalized_form.get(k) == v
            for k, v in normalized_attrs.items()
        )

        if match:
            selected_rule = cr
            logger.info(f"🎯 Matched credit_rule by form_params: attribute_id={cr.get('attribute_id')}, "
                       f"attrs={attrs}")
            break

    # Fallback to first rule if no match
    if not selected_rule:
        selected_rule = credit_rules[0]
        logger.warning(f"⚠️  No credit_rule matched form_params, using first rule (attribute_id={selected_rule.get('attribute_id')})")

    attribute_id = selected_rule.get("attribute_id", 0)
    credit = selected_rule.get("points", 0)

    if attribute_id == 0:
        raise RuntimeError(
            f"attribute_id is 0 for model '{node.get('model_id')}'. "
            "This will cause 'Invalid product attribute' error."
        )

    # ✅ Extract rule_attributes from the SELECTED rule (not always credit_rules[0])
    rule_attributes: dict = {}
    rule_attrs = selected_rule.get("attributes", {})

    # Filter out {"default": "enabled"} marker (not an actual parameter)
    for key, value in rule_attrs.items():
        if not (key == "default" and value == "enabled"):
            rule_attributes[key] = value

    raw_model_id = node.get("model_id", "")
    canonical_model_id = normalize_model_id(raw_model_id) or raw_model_id

    logger.info(
        f"Params extracted: model={canonical_model_id}, raw_model={raw_model_id}, "
        f"attribute_id={attribute_id}, credit={credit}, "
        f"rule_attrs={len(rule_attributes)} fields"
    )

    return {
        "attribute_id":     attribute_id,
        "credit":           credit,
        "model_id":         canonical_model_id,
        "model_id_raw":     raw_model_id,
        "model_name":       node.get("name", ""),
        "model_version":    node.get("id", ""),   # ← version_id from product list
        "form_params":      form_params,
        "rule_attributes":  rule_attributes,  # ✅ NEW: required params from attributes
        "all_credit_rules": credit_rules,     # For smart selection
        "virtual_fields":   [field for field in (node.get("form_config") or []) if field.get("is_ui_virtual")],
        "model_types":      list(node.get("model_types") or []),
        "files_accept_types": list((node.get("files") or {}).get("acceptType") or []),
        "form_fields":      [field.get("field") for field in (node.get("form_config") or []) if field.get("field")],
    }


def select_credit_rule_by_params(credit_rules: list, user_params: dict) -> dict | None:
    """
    Select the best credit_rule matching user parameters.

    CRITICAL FIX (error 6010): Must match ALL attributes in credit_rule, not just user params.
    Backend validation checks if request params match the rule's attributes exactly.

    Strategy:
    1. Try exact match: ALL rule attributes match user params (bidirectional)
    2. Try partial match: rule attributes are subset of user params
    3. Fallback: first rule (default)

    Returns the selected credit_rule or None if credit_rules is empty.
    """
    if not credit_rules:
        return None

    if not user_params:
        return credit_rules[0]

    # Normalize user params (handle bool → lowercase string for JSON comparison consistency)
    def normalize_value(v):
        if isinstance(v, bool):
            return str(v).lower()  # False → "false", True → "true"
        # CRITICAL FIX: Case-insensitive matching for resolution/duration/size values
        # User may pass "1080p" but rules define "1080P", or "5s" vs "5S"
        return str(v).strip().upper()  # "1080p" → "1080P", "5s" → "5S"

    normalized_user = {
        k.lower().strip(): normalize_value(v)
        for k, v in user_params.items()
    }

    # Try exact match: ALL rule attributes must match user params
    # This ensures backend validation passes (error 6010 prevention)
    for cr in credit_rules:
        attrs = cr.get("attributes", {})
        if not attrs:
            continue

        normalized_attrs = {
            k.lower().strip(): normalize_value(v)
            for k, v in attrs.items()
        }

        # CRITICAL: Check if ALL rule attributes are in user params AND match
        # (Not just if user params are in rule attributes)
        if all(normalized_user.get(k) == v for k, v in normalized_attrs.items()):
            return cr

    # Try partial match (at least some attributes match)
    best_match = None
    best_match_count = 0

    for cr in credit_rules:
        attrs = cr.get("attributes", {})
        if not attrs:
            continue

        normalized_attrs = {
            k.lower().strip(): normalize_value(v)
            for k, v in attrs.items()
        }

        # Count how many attributes match
        match_count = sum(1 for k, v in normalized_attrs.items()
                         if normalized_user.get(k) == v)

        if match_count > best_match_count:
            best_match_count = match_count
            best_match = cr

    if best_match:
        return best_match

    # Fallback to first rule
    return credit_rules[0]


path: scripts/ima_runtime/shared/config.py
content: from __future__ import annotations

import os

DEFAULT_BASE_URL = os.getenv("IMA_BASE_URL", "https://api.imastudio.com")
DEFAULT_IM_BASE_URL = os.getenv("IMA_IM_BASE_URL", "https://imapi.liveme.com")

PREFS_PATH = os.path.expanduser("~/.openclaw/memory/ima_prefs.json")
VIDEO_MAX_WAIT_SECONDS = 40 * 60
VIDEO_RECORDS_URL = "https://www.imastudio.com/ai-creation/text-to-video"

VIDEO_TASK_TYPES = (
    "text_to_video",
    "image_to_video",
    "first_last_frame_to_video",
    "reference_image_to_video",
)

POLL_CONFIG = {
    "text_to_video": {"interval": 8, "max_wait": VIDEO_MAX_WAIT_SECONDS},
    "image_to_video": {"interval": 8, "max_wait": VIDEO_MAX_WAIT_SECONDS},
    "first_last_frame_to_video": {"interval": 8, "max_wait": VIDEO_MAX_WAIT_SECONDS},
    "reference_image_to_video": {"interval": 8, "max_wait": VIDEO_MAX_WAIT_SECONDS},
}

APP_ID = "webAgent"
APP_KEY = "32jdskjdk320eew"


path: scripts/ima_runtime/shared/compliance.py
content: from __future__ import annotations

import requests

from ima_runtime.shared.seedance_capabilities import requires_seedance_compliance
from ima_runtime.shared.types import PreparedMediaAsset


class AssetVerificationError(RuntimeError):
    """Raised when compliance verification fails."""


def verify_asset(base_url: str, api_key: str, asset_url: str, asset_name: str | None = None) -> dict:
    url = f"{base_url}/open/v1/assets/verify"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "User-Agent": "IMA-OpenAPI-Client/ima-video-ai",
        "x-app-source": "ima_skills",
        "x_app_language": "en",
    }
    payload = {"url": asset_url}
    if asset_name:
        payload["name"] = asset_name[:64]

    response = requests.post(url, json=payload, headers=headers, timeout=300)
    try:
        data = response.json()
    except ValueError as exc:
        raise AssetVerificationError(f"Verification API returned non-JSON response (HTTP {response.status_code})") from exc

    code = data.get("code")
    if code not in (0, 200):
        message = data.get("message") or "Unknown error"
        raise AssetVerificationError(f"Verification API error: code={code}, msg={message}")

    return (data.get("data") or {}).get("result", {})


def check_verification_result(result: dict, asset_name: str) -> None:
    status = str(result.get("status") or "").lower()
    if status in {"active", "success"}:
        return
    if status == "failed":
        error_info = result.get("error") or {}
        error_msg = error_info.get("message") or "Unknown error"
        raise AssetVerificationError(f"Asset verification failed for {asset_name}: {error_msg}")
    if status in {"processing", "pending"}:
        raise AssetVerificationError(f"Asset verification still processing for {asset_name}.")
    raise AssetVerificationError(f"Unknown verification status for {asset_name}: {status or '<empty>'}")


def ensure_assets_verified(base_url: str, api_key: str, task_type: str, model_id: str | None, media_assets: tuple[PreparedMediaAsset, ...]) -> None:
    if not requires_seedance_compliance(task_type, model_id):
        return
    for asset in media_assets:
        result = verify_asset(base_url, api_key, asset.url, asset.source)
        check_verification_result(result, asset.source)


path: scripts/ima_runtime/shared/media_utils.py
content: from __future__ import annotations

import ipaddress
import mimetypes
import os
import socket
import subprocess
import tempfile
from contextlib import suppress
from pathlib import Path
from urllib.parse import urlparse

import requests


IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".webm", ".mkv", ".flv"}
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"}


def is_remote_url(path: str) -> bool:
    return path.startswith(("http://", "https://"))


def _format_bytes(num_bytes: int) -> str:
    if num_bytes == 1:
        return "1 byte"
    return f"{num_bytes} bytes"


def _resolve_host_addresses(hostname: str) -> tuple[ipaddress._BaseAddress, ...]:
    try:
        addrinfo = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
    except socket.gaierror as exc:
        raise RuntimeError(f"Unable to resolve remote media host: {hostname}") from exc

    addresses: list[ipaddress._BaseAddress] = []
    for entry in addrinfo:
        sockaddr = entry[4]
        if not sockaddr:
            continue
        try:
            address = ipaddress.ip_address(sockaddr[0])
        except ValueError:
            continue
        if address not in addresses:
            addresses.append(address)

    if not addresses:
        raise RuntimeError(f"Unable to resolve remote media host: {hostname}")
    return tuple(addresses)


def assert_public_https_url(url: str) -> None:
    parsed = urlparse(url)
    if parsed.scheme.lower() != "https":
        raise RuntimeError(f"Only HTTPS remote media URLs are supported: {url}")
    if parsed.username or parsed.password:
        raise RuntimeError(f"Remote media URLs must not include credentials: {url}")
    if not parsed.hostname:
        raise RuntimeError(f"Remote media URL must include a hostname: {url}")

    for address in _resolve_host_addresses(parsed.hostname):
        if not address.is_global:
            raise RuntimeError(f"Remote media host is not public: {parsed.hostname}")


def detect_media_type(path_or_url: str) -> str:
    ext = Path(path_or_url).suffix.lower()
    if ext in IMAGE_EXTENSIONS:
        return "image"
    if ext in VIDEO_EXTENSIONS:
        return "video"
    if ext in AUDIO_EXTENSIONS:
        return "audio"
    content_type = mimetypes.guess_type(path_or_url)[0] or ""
    if content_type.startswith("image/"):
        return "image"
    if content_type.startswith("video/"):
        return "video"
    if content_type.startswith("audio/"):
        return "audio"
    return "unknown"


def get_image_dimensions(file_path: str) -> tuple[int, int]:
    try:
        from PIL import Image

        with Image.open(file_path) as img:
            return img.size
    except Exception:
        try:
            result = subprocess.run(
                [
                    "ffprobe",
                    "-v",
                    "error",
                    "-select_streams",
                    "v:0",
                    "-show_entries",
                    "stream=width,height",
                    "-of",
                    "csv=s=x:p=0",
                    file_path,
                ],
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
            if result.returncode == 0 and "x" in result.stdout:
                width, height = map(int, result.stdout.strip().split("x"))
                return width, height
        except Exception:
            pass
    return 0, 0


def get_video_metadata(file_path: str) -> dict[str, float | int]:
    try:
        duration_result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "error",
                "-show_entries",
                "format=duration",
                "-of",
                "default=noprint_wrappers=1:nokey=1",
                file_path,
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        duration = float(duration_result.stdout.strip()) if duration_result.returncode == 0 else 0.0

        dimension_result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "error",
                "-select_streams",
                "v:0",
                "-show_entries",
                "stream=width,height,r_frame_rate",
                "-of",
                "default=noprint_wrappers=1",
                file_path,
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        width = height = 0
        fps = 0.0
        if dimension_result.returncode == 0:
            for line in dimension_result.stdout.strip().splitlines():
                if line.startswith("width="):
                    width = int(line.split("=", 1)[1])
                elif line.startswith("height="):
                    height = int(line.split("=", 1)[1])
                elif line.startswith("r_frame_rate="):
                    frame_rate = line.split("=", 1)[1]
                    if "/" in frame_rate:
                        numerator, denominator = frame_rate.split("/", 1)
                        denominator_value = float(denominator or 0)
                        fps = float(numerator) / denominator_value if denominator_value else 0.0
                    else:
                        fps = float(frame_rate)
        return {"duration": duration, "width": width, "height": height, "fps": fps}
    except Exception:
        return {"duration": 0.0, "width": 0, "height": 0, "fps": 0.0}


def get_audio_duration(file_path: str) -> float:
    try:
        result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "error",
                "-show_entries",
                "format=duration",
                "-of",
                "default=noprint_wrappers=1:nokey=1",
                file_path,
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        if result.returncode == 0:
            return float(result.stdout.strip())
    except Exception:
        pass
    return 0.0


def download_remote_media_to_temp(url: str, suffix: str = "", max_bytes: int | None = None) -> str:
    assert_public_https_url(url)

    temp_path: str | None = None
    try:
        with requests.get(url, stream=True, timeout=60, allow_redirects=False) as response:
            status_code = getattr(response, "status_code", 200)
            if 300 <= status_code < 400:
                raise RuntimeError(f"Remote media redirects are not supported: {url}")

            response.raise_for_status()

            if max_bytes is not None:
                content_length_header = response.headers.get("Content-Length")
                if content_length_header:
                    with suppress(ValueError):
                        content_length = int(content_length_header)
                        if content_length > max_bytes:
                            raise RuntimeError(
                                f"Remote media exceeds the {max_bytes}-byte download cap: {url}"
                            )

            with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
                temp_path = temp_file.name
                total_bytes = 0
                for chunk in response.iter_content(chunk_size=1024 * 64):
                    if not chunk:
                        continue
                    total_bytes += len(chunk)
                    if max_bytes is not None and total_bytes > max_bytes:
                        raise RuntimeError(
                            f"Remote media exceeds the {max_bytes}-byte download cap: {url}"
                        )
                    temp_file.write(chunk)
            return temp_path
    except Exception:
        if temp_path and os.path.exists(temp_path):
            os.unlink(temp_path)
        raise


def extract_video_cover_frame(file_path: str) -> str:
    with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
        output_path = temp_file.name

    try:
        result = subprocess.run(
            [
                "ffmpeg",
                "-y",
                "-i",
                file_path,
                "-frames:v",
                "1",
                "-q:v",
                "2",
                output_path,
            ],
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
        if result.returncode != 0:
            raise RuntimeError(result.stderr.strip() or "ffmpeg cover extraction failed")
        return output_path
    except Exception:
        if os.path.exists(output_path):
            os.unlink(output_path)
        raise


path: scripts/ima_runtime/shared/client.py
content: from __future__ import annotations

import hashlib
import logging
import time
import uuid

import requests

from ima_runtime.shared.catalog import select_credit_rule_by_params
from ima_runtime.shared.catalog import apply_virtual_param_overrides
from ima_runtime.shared.config import (
    APP_ID,
    APP_KEY,
    DEFAULT_IM_BASE_URL,
    VIDEO_MAX_WAIT_SECONDS,
    VIDEO_RECORDS_URL,
)
from ima_runtime.shared.media_utils import assert_public_https_url


logger = logging.getLogger("ima_skills")


def make_headers(api_key: str, language: str = "en") -> dict:
    return {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "x-app-source": "ima_skills",
        "x_app_language": language,
    }


def _gen_sign() -> tuple[str, str, str]:
    nonce = uuid.uuid4().hex[:21]
    ts = str(int(time.time()))
    raw = f"{APP_ID}|{APP_KEY}|{ts}|{nonce}"
    sign = hashlib.sha1(raw.encode()).hexdigest().upper()
    return sign, ts, nonce


def get_upload_token(api_key: str, suffix: str,
                     content_type: str, im_base_url: str = DEFAULT_IM_BASE_URL) -> dict:
    """
    Step 1: Get presigned upload URL from IM platform (imapi.liveme.com).

    **Security Note**: This function sends your IMA API key to imapi.liveme.com,
    which is IMA Studio's dedicated image upload service (separate from the main API).

    Why the API key is sent here:
    - imapi.liveme.com is owned and operated by IMA Studio
    - The upload service authenticates requests using the same IMA API key
    - This allows secure, authenticated image uploads without separate credentials
    - Images are stored in IMA's OSS infrastructure and returned as CDN URLs

    The two-domain architecture separates concerns:
    - api.imastudio.com: Video generation API (task orchestration)
    - imapi.liveme.com: Media storage API (large file uploads)

    See SECURITY.md § "Network Endpoints Used" for full disclosure.

    Args:
        api_key: IMA API key (used as both appUid and cmimToken for authentication)
        suffix: File extension (e.g., "jpg", "png")
        content_type: MIME type (e.g., "image/jpeg")
        im_base_url: IM upload service URL (default: https://imapi.liveme.com)

    Returns:
        dict with keys:
        - "ful": Presigned PUT URL for uploading raw bytes
        - "fdl": CDN download URL for the uploaded file

    Raises:
        RuntimeError: If upload token request fails
    """
    sign, ts, nonce = _gen_sign()

    url = f"{im_base_url}/api/rest/oss/getuploadtoken"
    params = {
        "appUid": api_key,
        "appId": APP_ID,
        "appKey": APP_KEY,
        "cmimToken": api_key,
        "sign": sign,
        "timestamp": ts,
        "nonce": nonce,
        "fService": "privite",
        "fType": "picture",
        "fSuffix": suffix,
        "fContentType": content_type,
    }

    logger.info(f"Getting upload [REDACTED_SECRET]")

    try:
        resp = requests.get(url, params=params, timeout=30)
        resp.raise_for_status()
        data = resp.json()

        if data.get("code") not in (0, 200):
            raise RuntimeError(f"Get upload token failed: {data.get('message')}")

        token_data = data.get("data", {})
        ful = token_data.get("ful")
        fdl = token_data.get("fdl")
        if not ful or not fdl:
            raise RuntimeError("Get upload token failed: missing ful/fdl in response")
        assert_public_https_url(ful)
        assert_public_https_url(fdl)
        return token_data
    except requests.RequestException as e:
        logger.error(f"Failed to get upload token: {e}")
        raise RuntimeError(f"Failed to get upload token: {e}")


def upload_to_oss(image_bytes: bytes, content_type: str, ful: str) -> None:
    """Step 2: PUT image bytes to the presigned OSS URL."""
    assert_public_https_url(ful)
    logger.info(f"Uploading {len(image_bytes)} bytes to OSS...")

    try:
        resp = requests.put(ful, data=image_bytes,
                           headers={"Content-Type": content_type}, timeout=60)
        resp.raise_for_status()
        logger.info("Upload successful")
    except requests.RequestException as e:
        logger.error(f"Upload failed: {e}")
        raise RuntimeError(f"Upload failed: {e}")


def get_product_list(base_url: str, api_key: str, category: str,
                     app: str = "ima", platform: str = "web",
                     language: str = "en") -> list:
    """
    GET /open/v1/product/list
    Returns the V2 tree: type=2 are model groups, type=3 are versions (leaves).
    Only type=3 nodes have credit_rules and form_config.
    """
    url     = f"{base_url}/open/v1/product/list"
    params  = {"app": app, "platform": platform, "category": category}
    headers = make_headers(api_key, language)

    logger.info(f"Query product list: category={category}, app={app}, platform={platform}")

    try:
        resp = requests.get(url, params=params, headers=headers, timeout=30)
        resp.raise_for_status()
        data = resp.json()

        code = data.get("code")
        if code not in (0, 200):
            error_msg = f"Product list API error: code={code}, msg={data.get('message')}"
            logger.error(error_msg)
            raise RuntimeError(error_msg)

        products_count = len(data.get("data") or [])
        logger.info(f"Product list retrieved successfully: {products_count} groups found")
        return data.get("data") or []

    except requests.RequestException as e:
        logger.error(f"Product list request failed: {str(e)}")
        raise


def create_task(base_url: str, api_key: str,
                task_type: str, model_params: dict,
                prompt: str,
                input_images: list[str] | None = None,
                extra_params: dict | None = None,
                src_image: list[dict] | None = None,
                src_video: list[dict] | None = None,
                src_audio: list[dict] | None = None) -> str:
    """
    POST /open/v1/tasks/create

    Constructs the full request body as the imagent.bot frontend does:
      parameters[i].model_version = modelItem.key = node["id"] (version_id)
      parameters[i].attribute_id  = creditInfo.attributeId
      parameters[i].credit        = creditInfo.credits
      parameters[i].parameters    = { ...form_config_defaults,
                                       prompt, input_images, cast, n }

    NEW: Supports smart credit_rule selection based on user params (e.g., size: "4K").
    """
    if input_images is None:
        input_images = []

    # Smart credit_rule selection based on user params
    all_rules = model_params.get("all_credit_rules", [])
    normalized_rule_params = {}  # 🆕 Store normalized params from matched rule

    if all_rules:  # ← FIX: Always try smart selection
        # Extract params that might be in attributes
        # CRITICAL: ONLY include keys that actually appear in credit_rules.attributes
        # EXCLUDE form_config defaults like aspect_ratio, shot_type (not in attributes!)
        #
        # Video credit_rules.attributes typically use:
        #   - duration, resolution, generate_audio (common)
        #   - sound, mode (Kling-specific)
        #   - fast_pretreatment, prompt_optimizer (Hailuo-specific)
        #
        # Image credit_rules.attributes typically use:
        #   - size, quality, n
        # Merge form_params and extra_params for matching
        remapped_extra_params = apply_virtual_param_overrides(
            model_params.get("virtual_fields", []),
            extra_params or {},
        )
        merged_params = {**model_params.get('form_params', {}), **remapped_extra_params}
        candidate_params = {k: v for k, v in merged_params.items()
                          if k in ["size", "quality", "resolution", "n",
                                   "duration", "generate_audio",
                                   "sound", "mode",
                                   "fast_pretreatment", "prompt_optimizer"]}
        # ⚠️ REMOVED: aspect_ratio, shot_type, compression_quality, sample_image_size
        #    These are form_config defaults, NOT used in credit_rules matching!
        if candidate_params:
            selected_rule = select_credit_rule_by_params(all_rules, candidate_params)
            if selected_rule:
                attribute_id = selected_rule.get("attribute_id", model_params["attribute_id"])
                credit = selected_rule.get("points", model_params["credit"])

                # 🆕 CRITICAL FIX: Use normalized values from the matched rule's attributes
                # This ensures API gets "1080P" (from rule) instead of "1080p" (from user)
                rule_attrs = selected_rule.get("attributes", {})
                valid_keys = ["size", "quality", "resolution", "n",
                             "duration", "generate_audio", "sound", "mode",
                             "fast_pretreatment", "prompt_optimizer"]
                for key in valid_keys:
                    if key in rule_attrs:
                        normalized_rule_params[key] = rule_attrs[key]

                print(f"🎯 Smart credit_rule selection: {candidate_params} → attribute_id={attribute_id}, credit={credit} pts", flush=True)
                if normalized_rule_params:
                    print(f"   📝 Normalized params from rule: {normalized_rule_params}", flush=True)
            else:
                attribute_id = model_params["attribute_id"]
                credit = model_params["credit"]
        else:
            attribute_id = model_params["attribute_id"]
            credit = model_params["credit"]
    else:
        attribute_id = model_params["attribute_id"]
        credit = model_params["credit"]

    # ✅ FIX for error 6009: Merge parameters in correct priority order
    # Priority (low → high): rule_attributes < form_params < normalized_rule_params < extra_params (non-rule keys)
    # This ensures backend validation always gets required fields from attributes
    inner: dict = {}

    # 1. First merge rule_attributes (required fields from credit_rules, lowest priority)
    rule_attrs = model_params.get("rule_attributes", {})
    if rule_attrs:
        inner.update(rule_attrs)

    # 2. Then merge form_config defaults (optional fields, medium priority)
    inner.update(model_params["form_params"])

    # 3. Merge normalized params from matched rule (higher priority - these are canonical values)
    # 🆕 CRITICAL: This overwrites user's "1080p" with rule's "1080P" to match attribute_id
    if normalized_rule_params:
        inner.update(normalized_rule_params)

    # 4. Finally merge user overrides for non-rule keys (highest priority for non-canonical fields)
    # Only merge keys that are NOT in normalized_rule_params to preserve canonical values
    remapped_extra_params = apply_virtual_param_overrides(
        model_params.get("virtual_fields", []),
        extra_params or {},
    )
    if remapped_extra_params:
        for key, value in remapped_extra_params.items():
            if key not in normalized_rule_params:  # Don't override canonical rule values
                inner[key] = value

    # Required inner fields (always set these)
    inner["prompt"]       = prompt
    inner["n"]            = int(inner.get("n", 1))
    inner["input_images"] = input_images
    inner["cast"]         = {"points": credit, "attribute_id": attribute_id}

    # 🆕 CRITICAL: Preserve model parameter from form_config if present
    # Some models (e.g. Pixverse) use the same model_id but distinguish versions via this parameter
    # Example: model_id="pixverse" with model="v5.5" | "v5" | "v4.5" | "v4" | "v3.5"
    # Priority: form_config default < user override (if provided)
    if "model" in model_params.get("form_params", {}):
        # Use default from form_config (e.g. "v5.5")
        inner["model"] = model_params["form_params"]["model"]
    if remapped_extra_params and "model" in remapped_extra_params:
        # Allow user to explicitly override the version
        inner["model"] = remapped_extra_params["model"]

    # 🆕 CRITICAL: Auto-infer model parameter for Pixverse if missing
    # Pixverse V5.5/V5/V4 don't have model in form_config, but backend requires it
    # Error: "Invalid value for model" or "The Fusion feature is supported in model v4.5, v5, v5.5 and v5.6 only"
    if model_params.get("model_id") == "pixverse" and "model" not in inner:
        # Extract version from model_name: "Pixverse V5.5" → "v5.5"
        model_name = model_params.get("model_name", "")
        import re
        version_match = re.search(r'V(\d+(?:\.\d+)?)', model_name, re.IGNORECASE)
        if version_match:
            version = version_match.group(1)  # "5.5", "5", "4.5", "4", "3.5"
            inner["model"] = f"v{version}"
            print(f"🔧 Auto-inferred Pixverse model parameter: model=\"v{version}\" (from model_name=\"{model_name}\")", flush=True)

    payload = {
        "task_type":          task_type,
        "enable_multi_model": False,
        "parameters": [{
            "attribute_id":  attribute_id,
            # Use raw model_id from product list when available to match backend expectations.
            "model_id":      model_params.get("model_id_raw") or model_params["model_id"],
            "model_name":    model_params["model_name"],
            "model_version": model_params["model_version"],   # ← version_id (NOT model_id!)
            "app":           "ima",
            "platform":      "web",
            "category":      task_type,
            "credit":        credit,
            "parameters":    inner,
        }],
    }
    if src_image:
        payload["src_image"] = src_image
        payload["src_img_url"] = [item["url"] for item in src_image if item.get("url")]
    else:
        payload["src_img_url"] = input_images

    url     = f"{base_url}/open/v1/tasks/create"
    headers = make_headers(api_key)

    if src_video:
        payload["src_video"] = src_video
    if src_audio:
        payload["src_audio"] = src_audio

    logger.info(f"Create task: model={model_params['model_name']}, task_type={task_type}, "
                f"credit={credit}, attribute_id={attribute_id}")

    try:
        resp = requests.post(url, json=payload, headers=headers, timeout=30)
        resp.raise_for_status()
        data = resp.json()

        code = data.get("code")
        if code not in (0, 200):
            logger.error(f"Task create failed: code={code}, msg={data.get('message')}, "
                        f"attribute_id={attribute_id}, credit={credit}")
            raise RuntimeError(
                f"Create task failed — code={code} "
                f"message={data.get('message')} "
                f"model={model_params['model_name']} "
                f"task_type={task_type} "
                f"attribute_id={attribute_id} "
                f"credit={credit}"
            )

        task_id = (data.get("data") or {}).get("id")
        if not task_id:
            logger.error("Task create failed: no task_id in response")
            raise RuntimeError(f"No task_id in response: {data}")

        logger.info(f"Task created: task_id={task_id}")
        return task_id

    except requests.RequestException as e:
        logger.error(f"Task create request failed: {str(e)}")
        raise


def poll_task(base_url: str, api_key: str, task_id: str,
              estimated_max: int = 120,
              poll_interval: int = 5,
              max_wait: int = VIDEO_MAX_WAIT_SECONDS,
              on_progress=None) -> dict:
    """
    POST /open/v1/tasks/detail — poll until completion.

    - resource_status (int or null): 0=processing, 1=done, 2=failed, 3=deleted.
      null is treated as 0.
    - status (string): "pending" | "processing" | "success" | "failed".
      When resource_status==1, treat status=="failed" as failure; "success" (or "completed") as success.
    - Stop only when ALL medias have resource_status == 1 and no status == "failed".
    - Returns the first completed media dict (with url) when all are done.
    """
    url     = f"{base_url}/open/v1/tasks/detail"
    headers = make_headers(api_key)
    start   = time.time()

    logger.info(f"Poll task started: task_id={task_id}, max_wait={max_wait}s")

    last_progress_report = 0
    progress_interval    = 15 if poll_interval <= 5 else 30

    while True:
        elapsed = time.time() - start
        if elapsed > max_wait:
            logger.error(f"Task timeout: task_id={task_id}, elapsed={int(elapsed)}s, max_wait={max_wait}s")
            raise TimeoutError(
                f"Task {task_id} timed out after {max_wait}s without explicit backend errors. "
                f"Please check your creation record at {VIDEO_RECORDS_URL}."
            )

        resp = requests.post(url, json={"task_id": task_id},
                             headers=headers, timeout=30)
        resp.raise_for_status()
        data = resp.json()

        code = data.get("code")
        if code not in (0, 200):
            raise RuntimeError(f"Poll error — code={code} msg={data.get('message')}")

        task   = data.get("data") or {}
        medias = task.get("medias") or []

        # Normalize resource_status: API may return null (Go *int); treat as 0 (processing)
        def _rs(m):
            v = m.get("resource_status")
            return 0 if (v is None or v == "") else int(v)

        # 1. Fail fast: any media failed or deleted → raise
        for media in medias:
            rs = _rs(media)
            if rs == 2:
                err = media.get("error_msg") or media.get("remark") or "unknown"
                logger.error(f"Task failed: task_id={task_id}, resource_status=2, error={err}")
                raise RuntimeError(f"Generation failed (resource_status=2): {err}")
            if rs == 3:
                logger.error(f"Task deleted: task_id={task_id}")
                raise RuntimeError("Task was deleted")

        # 2. Success only when ALL medias have resource_status == 1 (and none failed)
        # status is one of: "pending", "processing", "success", "failed"
        if medias and all(_rs(m) == 1 for m in medias):
            for media in medias:
                if (media.get("status") or "").strip().lower() == "failed":
                    err = media.get("error_msg") or media.get("remark") or "unknown"
                    logger.error(f"Task failed: task_id={task_id}, status=failed, error={err}")
                    raise RuntimeError(f"Generation failed: {err}")
            # All done and no failure → also wait for URL to be populated
            first_media = medias[0]
            result_url = first_media.get("url") or first_media.get("watermark_url")
            if result_url:
                elapsed_time = int(time.time() - start)
                logger.info(f"Task completed: task_id={task_id}, elapsed={elapsed_time}s, url={result_url[:80]}")
                return first_media
            # else: URL not ready yet, keep polling

        # Report progress periodically
        if elapsed - last_progress_report >= progress_interval:
            pct = min(95, int(elapsed / estimated_max * 100))
            msg = f"⏳ {int(elapsed)}s elapsed … {pct}%"
            if elapsed > estimated_max:
                msg += "  (taking longer than expected, please wait…)"
            if on_progress:
                on_progress(pct, int(elapsed), msg)
            else:
                print(msg, flush=True)
            last_progress_report = elapsed

        time.sleep(poll_interval)


path: scripts/ima_runtime/shared/recommendations.py
content: from __future__ import annotations

from dataclasses import dataclass

from ima_runtime.shared.catalog import normalize_model_id
from ima_runtime.shared.errors import to_user_facing_model_name

TASK_TYPE_RECOMMENDATIONS = {
    "text_to_video": {
        "recommended_models": (
            {
                "model_id": "ima-pro-fast",
                "display_name": "Seedance 2.0 Fast",
                "note": "no-subscription recommendation",
            },
            {
                "model_id": "wan2.6-t2v",
                "display_name": "wan2.6-t2v",
                "note": "stable alternative",
            },
        ),
        "premium_models": (
            {"model_id": "ima-pro", "display_name": "Seedance 2.0"},
        ),
    },
    "image_to_video": {
        "recommended_models": (
            {
                "model_id": "ima-pro-fast",
                "display_name": "Seedance 2.0 Fast",
                "note": "no-subscription recommendation",
            },
            {
                "model_id": "wan2.6-i2v",
                "display_name": "wan2.6-i2v",
                "note": "stable alternative",
            },
        ),
        "premium_models": (
            {"model_id": "ima-pro", "display_name": "Seedance 2.0"},
        ),
    },
    "reference_image_to_video": {
        "recommended_models": (
            {
                "model_id": "kling-video-o1",
                "display_name": "kling-video-o1",
                "note": "recommended",
            },
            {
                "model_id": "ima-pro-fast",
                "display_name": "Seedance 2.0 Fast",
                "note": "second choice; no-subscription recommendation",
            },
        ),
        "premium_models": (
            {"model_id": "ima-pro", "display_name": "Seedance 2.0"},
        ),
    },
    "first_last_frame_to_video": {
        "recommended_models": (
            {
                "model_id": "kling-video-o1",
                "display_name": "kling-video-o1",
                "note": "recommended",
            },
            {
                "model_id": "ima-pro-fast",
                "display_name": "Seedance 2.0 Fast",
                "note": "second choice; no-subscription recommendation",
            },
        ),
        "premium_models": (
            {"model_id": "ima-pro", "display_name": "Seedance 2.0"},
        ),
    },
}


@dataclass(frozen=True)
class SuggestedModelChoice:
    model: dict | None
    is_catalog_recommended: bool


def get_task_type_recommendation_metadata(task_type: str) -> dict[str, tuple[dict[str, str], ...]]:
    return TASK_TYPE_RECOMMENDATIONS.get(
        task_type,
        {"recommended_models": (), "premium_models": ()},
    )


def catalog_model_lookup(models: list[dict]) -> dict[str, str]:
    available_models: dict[str, str] = {}
    for model in models:
        canonical_model_id = normalize_model_id(model.get("model_id")) or model.get("model_id")
        if not canonical_model_id or canonical_model_id in available_models:
            continue
        available_models[canonical_model_id] = to_user_facing_model_name(
            model.get("name"),
            canonical_model_id,
        )
    return available_models


def format_recommendation_entry(recommendation: dict[str, str], available_models: dict[str, str]) -> str:
    model_id = recommendation["model_id"]
    display_name = recommendation["display_name"]
    note = recommendation.get("note")
    parts = []
    if model_id != display_name:
        parts.append(f"{display_name} ({model_id})")
    else:
        parts.append(display_name)

    catalog_display_name = available_models.get(model_id)
    if catalog_display_name and catalog_display_name not in {display_name, model_id}:
        parts.append(f"catalog label: {catalog_display_name}")

    if note:
        parts.append(note)

    return " - ".join(parts)


def build_recommendation_lines(task_type: str, models: list[dict]) -> list[str]:
    metadata = get_task_type_recommendation_metadata(task_type)
    available_models = catalog_model_lookup(models)
    lines = [f"Recommended for {task_type}:"]

    if metadata["recommended_models"]:
        for recommendation in metadata["recommended_models"]:
            lines.append(f"- {format_recommendation_entry(recommendation, available_models)}")
    elif available_models:
        for model_id, display_name in list(available_models.items())[:3]:
            if display_name and display_name != model_id:
                lines.append(f"- {display_name} ({model_id})")
            else:
                lines.append(f"- {model_id}")
    else:
        lines.append("- No catalog models were returned for this task type.")

    for premium_model in metadata["premium_models"]:
        premium_model_id = premium_model["model_id"]
        premium_display_name = premium_model["display_name"]
        lines.append(
            f"Premium note: {premium_display_name} (--model-id {premium_model_id}) "
            "requires an active subscription plan."
        )

    return lines


def resolve_suggested_model(task_type: str, models: list[dict]) -> SuggestedModelChoice:
    metadata = get_task_type_recommendation_metadata(task_type)
    models_by_id: dict[str, dict] = {}
    for model in models:
        canonical_model_id = normalize_model_id(model.get("model_id")) or model.get("model_id")
        if canonical_model_id and canonical_model_id not in models_by_id:
            models_by_id[canonical_model_id] = model

    for recommendation in metadata["recommended_models"]:
        preferred = models_by_id.get(recommendation["model_id"])
        if preferred:
            return SuggestedModelChoice(model=preferred, is_catalog_recommended=True)

    return SuggestedModelChoice(model=models[0] if models else None, is_catalog_recommended=False)


def pick_recommended_model(task_type: str, models: list[dict]) -> dict | None:
    return resolve_suggested_model(task_type, models).model


path: scripts/ima_runtime/shared/__init__.py
content: """Shared runtime helpers for ima-video-ai."""


path: scripts/ima_runtime/shared/types.py
content: from __future__ import annotations

from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Literal, Mapping

MediaKind = Literal["video"]
MediaType = Literal["image", "video", "audio"]
MediaRole = Literal[
    "image_input",
    "first_frame",
    "last_frame",
    "reference_image",
    "reference_video",
    "reference_audio",
]


def _freeze_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]:
    return MappingProxyType(dict(value))


@dataclass(frozen=True)
class MediaAsset:
    source: str
    media_type: MediaType
    role: MediaRole


@dataclass(frozen=True)
class PreparedMediaAsset:
    source: str
    media_type: MediaType
    role: MediaRole
    url: str
    width: int = 0
    height: int = 0
    duration: float = 0.0
    fps: float = 0.0
    cover_url: str = ""
    size_bytes: int = 0


@dataclass(frozen=True)
class MediaPayloadBundle:
    input_images: tuple[str, ...] = ()
    src_image: tuple[Mapping[str, Any], ...] = ()
    src_video: tuple[Mapping[str, Any], ...] = ()
    src_audio: tuple[Mapping[str, Any], ...] = ()


@dataclass(frozen=True)
class GatewayRequest:
    prompt: str
    media_targets: tuple[MediaKind, ...]
    input_images: tuple[str, ...] = ()
    media_assets: tuple[MediaAsset, ...] = ()
    intent_hints: Mapping[str, Any] = field(default_factory=dict)
    extra_params: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        object.__setattr__(self, "intent_hints", _freeze_mapping(self.intent_hints))
        object.__setattr__(self, "extra_params", _freeze_mapping(self.extra_params))


@dataclass(frozen=True)
class RouteDecision:
    capability: MediaKind
    reason: str


@dataclass(frozen=True)
class ClarificationRequest:
    reason: str
    question: str
    options: tuple[str, ...] = ()


@dataclass(frozen=True)
class WorkflowStepDraft:
    step_id: str
    capability: MediaKind
    goal: str
    depends_on: tuple[str, ...] = ()


@dataclass(frozen=True)
class WorkflowPlanDraft:
    summary: str
    steps: tuple[WorkflowStepDraft, ...]


@dataclass(frozen=True)
class TaskSpec:
    capability: MediaKind
    task_type: str
    prompt: str
    input_images: tuple[str, ...] = ()
    media_assets: tuple[MediaAsset, ...] = ()
    extra_params: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        object.__setattr__(self, "extra_params", _freeze_mapping(self.extra_params))


@dataclass(frozen=True)
class ModelCandidate:
    name: str
    model_id: str
    version_id: str
    metadata: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        object.__setattr__(self, "metadata", _freeze_mapping(self.metadata))


@dataclass(frozen=True)
class ModelBinding:
    candidate: ModelCandidate
    attribute_id: int
    credit: int
    resolved_params: Mapping[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        object.__setattr__(self, "resolved_params", _freeze_mapping(self.resolved_params))


@dataclass(frozen=True)
class ExecutionResult:
    task_id: str
    url: str
    cover_url: str = ""
    model_id: str = ""
    model_name: str = ""
    credit: int = 0


path: scripts/ima_runtime/shared/prefs.py
content: from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path

from ima_runtime.shared.catalog import normalize_model_id
from ima_runtime.shared.config import PREFS_PATH


def load_prefs(*, prefs_path: Path | None = None) -> dict:
    path = Path(prefs_path or PREFS_PATH)
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def save_pref(user_id: str, task_type: str, model_params: dict, *, prefs_path: Path | None = None) -> None:
    path = Path(prefs_path or PREFS_PATH)
    path.parent.mkdir(parents=True, exist_ok=True)
    loaded_prefs = load_prefs(prefs_path=path)
    users = loaded_prefs.get("users") if isinstance(loaded_prefs, dict) else None
    prefs = {"users": users if isinstance(users, dict) else {}}
    canonical_model_id = normalize_model_id(model_params.get("model_id")) or model_params.get("model_id")
    prefs["users"].setdefault(user_id, {})[task_type] = {
        "model_id": canonical_model_id,
        "model_name": model_params["model_name"],
        "credit": model_params["credit"],
        "last_used": datetime.now(timezone.utc).isoformat(),
    }
    path.write_text(json.dumps(prefs, ensure_ascii=False, indent=2), encoding="utf-8")


def get_preferred_model_id(user_id: str, task_type: str, *, prefs_path: Path | None = None) -> str | None:
    prefs = load_prefs(prefs_path=prefs_path)
    users = prefs.get("users") if isinstance(prefs, dict) else None
    if not isinstance(users, dict):
        return None
    user_prefs = users.get(user_id)
    if not isinstance(user_prefs, dict):
        return None
    entry = user_prefs.get(task_type)
    if not isinstance(entry, dict):
        return None
    return normalize_model_id(entry.get("model_id"))


path: scripts/ima_runtime/shared/seedance_capabilities.py
content: from __future__ import annotations

SEEDANCE_MODEL_IDS = {"ima-pro", "ima-pro-fast"}

SEEDANCE_MEDIA_CAPABILITIES = {
    "ima-pro": {
        "image_to_video": {"compliance_required": True, "supported_media": {"image"}},
        "first_last_frame_to_video": {"compliance_required": True, "supported_media": {"image"}},
        "reference_image_to_video": {
            "compliance_required": True,
            "supported_media": {"image", "video", "audio"},
            "catalog_form_fields": {"audio", "duration", "aspect_ratio", "resolution"},
        },
    },
    "ima-pro-fast": {
        "image_to_video": {"compliance_required": True, "supported_media": {"image"}},
        "first_last_frame_to_video": {"compliance_required": True, "supported_media": {"image"}},
        "reference_image_to_video": {
            "compliance_required": True,
            "supported_media": {"image", "video", "audio"},
            "catalog_form_fields": {"audio", "duration", "aspect_ratio", "resolution"},
        },
    },
}


def is_seedance_model(model_id: str | None) -> bool:
    return bool(model_id in SEEDANCE_MODEL_IDS)


def requires_seedance_compliance(task_type: str, model_id: str | None) -> bool:
    if not is_seedance_model(model_id):
        return False
    capability = SEEDANCE_MEDIA_CAPABILITIES.get(model_id, {}).get(task_type, {})
    return bool(capability.get("compliance_required"))


def supported_seedance_media(task_type: str, model_id: str | None) -> set[str]:
    if not is_seedance_model(model_id):
        return set()
    capability = SEEDANCE_MEDIA_CAPABILITIES.get(model_id, {}).get(task_type, {})
    return set(capability.get("supported_media", set()))


path: scripts/ima_runtime/shared/inputs.py
content: from __future__ import annotations

import logging
import mimetypes
import os
from pathlib import Path

from ima_runtime.shared.client import get_upload_token, upload_to_oss
from ima_runtime.shared.config import DEFAULT_IM_BASE_URL
from ima_runtime.shared.media_utils import assert_public_https_url, detect_media_type
from ima_runtime.shared.types import MediaAsset


logger = logging.getLogger("ima_skills")


def flatten_input_images_args(raw_groups) -> list[str]:
    flattened: list[str] = []
    for group in raw_groups or []:
        if isinstance(group, list):
            flattened.extend([str(v) for v in group if str(v).strip()])
        elif group is not None and str(group).strip():
            flattened.append(str(group))
    return flattened


def flatten_media_args(raw_groups) -> list[str]:
    return flatten_input_images_args(raw_groups)


def build_cli_media_assets(
    task_type: str,
    *,
    input_images,
    reference_images,
    reference_videos,
    reference_audios,
) -> tuple[MediaAsset, ...]:
    input_sources = flatten_media_args(input_images)
    reference_image_sources = flatten_media_args(reference_images)
    reference_video_sources = flatten_media_args(reference_videos)
    reference_audio_sources = flatten_media_args(reference_audios)
    assets: list[MediaAsset] = []

    if task_type == "text_to_video":
        if input_sources or reference_image_sources or reference_video_sources or reference_audio_sources:
            raise ValueError("text_to_video does not accept media inputs")
        return ()

    if task_type == "image_to_video":
        if reference_image_sources or reference_video_sources or reference_audio_sources:
            raise ValueError("image_to_video does not accept reference media flags")
        if len(input_sources) != 1:
            raise ValueError("image_to_video requires exactly 1 input image")
        return (MediaAsset(source=input_sources[0], media_type="image", role="image_input"),)

    if task_type == "first_last_frame_to_video":
        if reference_image_sources or reference_video_sources or reference_audio_sources:
            raise ValueError("first_last_frame_to_video does not accept reference media flags")
        if len(input_sources) != 2:
            raise ValueError("first_last_frame_to_video requires exactly 2 input images")
        return (
            MediaAsset(source=input_sources[0], media_type="image", role="first_frame"),
            MediaAsset(source=input_sources[1], media_type="image", role="last_frame"),
        )

    if task_type == "reference_image_to_video":
        legacy_reference_sources = input_sources + reference_image_sources
        for source in legacy_reference_sources:
            media_type = detect_media_type(source)
            if media_type == "video":
                assets.append(MediaAsset(source=source, media_type="video", role="reference_video"))
            elif media_type == "audio":
                assets.append(MediaAsset(source=source, media_type="audio", role="reference_audio"))
            else:
                assets.append(MediaAsset(source=source, media_type="image", role="reference_image"))
        assets.extend(MediaAsset(source=source, media_type="video", role="reference_video") for source in reference_video_sources)
        assets.extend(MediaAsset(source=source, media_type="audio", role="reference_audio") for source in reference_audio_sources)
        if not assets:
            raise ValueError("reference_image_to_video requires at least 1 reference input")
        return tuple(assets)

    return tuple(assets)


def validate_and_filter_inputs(task_type: str, input_images: list[str]) -> list[str]:
    if task_type == "text_to_video":
        return []
    if task_type == "image_to_video" and len(input_images) != 1:
        raise ValueError("image_to_video requires exactly 1 input image")
    if task_type == "first_last_frame_to_video" and len(input_images) != 2:
        raise ValueError("first_last_frame_to_video requires exactly 2 input images")
    if task_type == "reference_image_to_video" and len(input_images) < 1:
        raise ValueError("reference_image_to_video requires at least 1 input image")
    return input_images


def validate_task_inputs(task_type: str, input_images: list[str], media_assets: tuple[MediaAsset, ...] = ()) -> list[str]:
    if not media_assets:
        return validate_and_filter_inputs(task_type, input_images)

    if task_type == "text_to_video":
        if media_assets:
            raise ValueError("text_to_video does not accept media inputs")
        return []
    if task_type == "image_to_video":
        if len(media_assets) != 1 or media_assets[0].media_type != "image":
            raise ValueError("image_to_video requires exactly 1 input image")
        return [media_assets[0].source]
    if task_type == "first_last_frame_to_video":
        if len(media_assets) != 2 or any(asset.media_type != "image" for asset in media_assets):
            raise ValueError("first_last_frame_to_video requires exactly 2 input images")
        return [asset.source for asset in media_assets]
    if task_type == "reference_image_to_video":
        if not media_assets:
            raise ValueError("reference_image_to_video requires at least 1 reference input")
        return [asset.source for asset in media_assets if asset.media_type == "image"]
    return input_images


def prepare_media_url(
    source: str | bytes,
    api_key: str,
    *,
    media_type: str = "image",
    im_base_url: str = DEFAULT_IM_BASE_URL,
) -> str:
    """
    Convert any supported media source to a public HTTPS CDN URL.

    - If source is already HTTPS URL → return as-is
    - If source is local file/bytes → upload to OSS first
    """
    if isinstance(source, str) and source.startswith("https://"):
        assert_public_https_url(source)
        logger.info(f"Using URL directly: {source[:50]}...")
        return source
    if isinstance(source, str) and source.startswith("http://"):
        raise RuntimeError(f"Only HTTPS remote media URLs are supported: {source}")

    if not api_key:
        raise RuntimeError(f"Local {media_type} upload requires IMA API key (--api-key)")

    if isinstance(source, str):
        if not os.path.isfile(source):
            raise RuntimeError(f"{media_type.capitalize()} file not found: {source}")

        default_ext = {"image": "jpeg", "video": "mp4", "audio": "mp3"}.get(media_type, "bin")
        ext = Path(source).suffix.lstrip(".").lower() or default_ext
        with open(source, "rb") as f:
            media_bytes = f.read()
        default_type = {
            "image": "image/jpeg",
            "video": "video/mp4",
            "audio": "audio/mpeg",
        }.get(media_type, "application/octet-stream")
        content_type = mimetypes.guess_type(source)[0] or default_type
        logger.info(f"Read local file: {source} ({len(media_bytes)} bytes)")
    else:
        media_bytes = source
        ext = {"image": "jpeg", "video": "mp4", "audio": "mp3"}.get(media_type, "bin")
        content_type = {
            "image": "image/jpeg",
            "video": "video/mp4",
            "audio": "audio/mpeg",
        }.get(media_type, "application/octet-stream")
        logger.info(f"Using raw bytes ({len(media_bytes)} bytes)")

    token_data = get_upload_token(api_key, ext, content_type, im_base_url)
    ful = token_data.get("ful")
    fdl = token_data.get("fdl")

    if not ful or not fdl:
        raise RuntimeError("Upload token missing 'ful' or 'fdl' field")

    upload_to_oss(media_bytes, content_type, ful)
    logger.info(f"{media_type.capitalize()} uploaded: {fdl[:50]}...")
    return fdl


def prepare_image_url(source: str | bytes, api_key: str,
                      im_base_url: str = DEFAULT_IM_BASE_URL) -> str:
    return prepare_media_url(source, api_key, media_type="image", im_base_url=im_base_url)


path: scripts/ima_runtime/shared/media_payloads.py
content: from __future__ import annotations

import mimetypes
import os
from pathlib import Path

from ima_runtime.shared.inputs import prepare_media_url
from ima_runtime.shared.media_utils import (
    detect_media_type,
    download_remote_media_to_temp,
    extract_video_cover_frame,
    get_audio_duration,
    get_image_dimensions,
    get_video_metadata,
    is_remote_url,
)
from ima_runtime.shared.reference_validation import AUDIO_MAX_BYTES, IMAGE_MAX_BYTES, REQUEST_MAX_BYTES, VIDEO_MAX_BYTES
from ima_runtime.shared.seedance_capabilities import is_seedance_model
from ima_runtime.shared.types import MediaAsset, MediaPayloadBundle, PreparedMediaAsset


REMOTE_DOWNLOAD_CAPS = {
    "image": IMAGE_MAX_BYTES,
    "video": VIDEO_MAX_BYTES,
    "audio": AUDIO_MAX_BYTES,
}


def infer_supported_media_types(task_type: str, model_info: dict) -> set[str]:
    if task_type in {"image_to_video", "first_last_frame_to_video"}:
        return {"image"}
    if task_type != "reference_image_to_video":
        return set()

    accept_types = model_info.get("files_accept_types") or []
    supported: set[str] = set()
    for item in accept_types:
        value = str(item).lower()
        if value.startswith("image/"):
            supported.add("image")
        elif value.startswith("video/"):
            supported.add("video")
        elif value.startswith("audio/"):
            supported.add("audio")
    return supported or {"image"}


def model_supports_media(task_type: str, model_info: dict, media_assets: tuple[MediaAsset, ...]) -> bool:
    supported = infer_supported_media_types(task_type, model_info)
    return all(asset.media_type in supported for asset in media_assets)


def filter_models_for_media_support(task_type: str, models: list[dict], media_assets: tuple[MediaAsset, ...]) -> list[dict]:
    if not media_assets:
        return models
    return [model for model in models if model_supports_media(task_type, model, media_assets)]


def validate_model_media_support(task_type: str, model_info: dict, media_assets: tuple[MediaAsset, ...]) -> None:
    if task_type in {"image_to_video", "first_last_frame_to_video"}:
        if any(asset.media_type != "image" for asset in media_assets):
            raise ValueError(f"{task_type} only supports image inputs.")
        return

    if task_type != "reference_image_to_video":
        return

    supported = infer_supported_media_types(task_type, model_info)
    unsupported = [asset.media_type for asset in media_assets if asset.media_type not in supported]
    if unsupported:
        model_id = model_info.get("model_id") or "<unknown-model>"
        raise ValueError(
            f"{model_id} does not support reference media types in reference_image_to_video: "
            f"{', '.join(sorted(set(unsupported)))}."
        )


def _download_and_probe_remote(source: str, media_type: str) -> tuple[str, int]:
    suffix = Path(source).suffix or {
        "image": ".jpg",
        "video": ".mp4",
        "audio": ".mp3",
    }.get(media_type, "")
    temp_path = download_remote_media_to_temp(
        source,
        suffix=suffix,
        max_bytes=REMOTE_DOWNLOAD_CAPS.get(media_type, REQUEST_MAX_BYTES),
    )
    size_bytes = os.path.getsize(temp_path)
    return temp_path, size_bytes


def _extract_metadata_for_path(path: str, media_type: str) -> dict:
    if media_type == "image":
        width, height = get_image_dimensions(path)
        return {"width": width, "height": height}
    if media_type == "video":
        return get_video_metadata(path)
    if media_type == "audio":
        return {"duration": get_audio_duration(path)}
    return {}


def _build_video_cover_url(path: str, api_key: str) -> str:
    try:
        cover_path = extract_video_cover_frame(path)
    except Exception:
        return ""
    try:
        return prepare_media_url(cover_path, api_key, media_type="image")
    finally:
        if os.path.exists(cover_path):
            os.unlink(cover_path)


def prepare_media_assets(media_assets: tuple[MediaAsset, ...], api_key: str) -> tuple[PreparedMediaAsset, ...]:
    prepared: list[PreparedMediaAsset] = []
    for asset in media_assets:
        temp_path = None
        try:
            if is_remote_url(asset.source):
                if asset.source.startswith("http://"):
                    raise RuntimeError(f"Only HTTPS remote media URLs are supported: {asset.source}")
                temp_path, size_bytes = _download_and_probe_remote(asset.source, asset.media_type)
                metadata = _extract_metadata_for_path(temp_path, asset.media_type)
                url = asset.source
                cover_url = _build_video_cover_url(temp_path, api_key) if asset.media_type == "video" else ""
            else:
                if not os.path.isfile(asset.source):
                    raise RuntimeError(f"{asset.media_type.capitalize()} file not found: {asset.source}")
                size_bytes = os.path.getsize(asset.source)
                metadata = _extract_metadata_for_path(asset.source, asset.media_type)
                url = prepare_media_url(asset.source, api_key, media_type=asset.media_type)
                cover_url = _build_video_cover_url(asset.source, api_key) if asset.media_type == "video" else ""

            prepared.append(
                PreparedMediaAsset(
                    source=asset.source,
                    media_type=asset.media_type,
                    role=asset.role,
                    url=url,
                    width=int(metadata.get("width", 0) or 0),
                    height=int(metadata.get("height", 0) or 0),
                    duration=float(metadata.get("duration", 0.0) or 0.0),
                    fps=float(metadata.get("fps", 0.0) or 0.0),
                    cover_url=cover_url,
                    size_bytes=size_bytes,
                )
            )
        finally:
            if temp_path and os.path.exists(temp_path):
                os.unlink(temp_path)
    return tuple(prepared)


def build_media_payload_bundle(task_type: str, model_id: str, prepared_assets: tuple[PreparedMediaAsset, ...]) -> MediaPayloadBundle:
    input_urls = tuple(asset.url for asset in prepared_assets)
    if task_type != "reference_image_to_video" or not is_seedance_model(model_id):
        return MediaPayloadBundle(input_images=input_urls)

    src_image = tuple(
        {
            "url": asset.url,
            "width": asset.width,
            "height": asset.height,
        }
        for asset in prepared_assets
        if asset.media_type == "image"
    )
    src_video = tuple(
        {
            "url": asset.url,
            "duration": int(asset.duration) if asset.duration else 0,
            "width": asset.width,
            "height": asset.height,
            "cover": asset.cover_url,
        }
        for asset in prepared_assets
        if asset.media_type == "video"
    )
    src_audio = tuple(
        {
            "url": asset.url,
            "duration": int(asset.duration) if asset.duration else 0,
        }
        for asset in prepared_assets
        if asset.media_type == "audio"
    )
    return MediaPayloadBundle(
        input_images=input_urls,
        src_image=src_image,
        src_video=src_video,
        src_audio=src_audio,
    )


path: scripts/ima_runtime/shared/errors.py
content: from __future__ import annotations

import math
import re

import requests

from ima_runtime.shared.catalog import canonicalize_model_id
from ima_runtime.shared.config import VIDEO_RECORDS_URL


def normalize_model_id(model_id: str | None) -> str | None:
    """Normalize known model aliases; return original model_id when no alias applies."""
    return canonicalize_model_id(model_id)


def to_user_facing_model_name(model_name: str | None, model_id: str | None) -> str:
    """Return user-facing product branding for known canonical model IDs."""
    canonical = normalize_model_id(model_id)
    if canonical == "ima-pro":
        return "Seedance 2.0"
    if canonical == "ima-pro-fast":
        return "Seedance 2.0 Fast"
    return model_name or "IMA Model"


def extract_error_info(exception: Exception) -> dict:
    """
    Extract error code and message from exception.

    Handles:
    - RuntimeError from create_task with code in message
    - requests.HTTPError (500, 400, etc.)
    - TimeoutError from poll_task

    Returns: {"code": int|str, "message": str, "type": str}
    """
    error_str = str(exception)

    # Check for HTTP status codes (500, 400, etc.)
    if isinstance(exception, requests.HTTPError):
        status_code = exception.response.status_code
        try:
            response_data = exception.response.json()
            api_code = response_data.get("code")
            api_msg = response_data.get("message", "")
            return {
                "code": api_code if api_code else status_code,
                "message": api_msg or error_str,
                "type": f"http_{status_code}",
                "raw_response": response_data
            }
        except:
            return {
                "code": status_code,
                "message": error_str,
                "type": f"http_{status_code}"
            }

    # Check for API error codes in RuntimeError message (6009, 6010, etc.)
    code_match = re.search(r'code[=:]?\s*(\d+)', error_str, re.IGNORECASE)
    if code_match:
        code = int(code_match.group(1))
        return {
            "code": code,
            "message": error_str,
            "type": f"api_{code}"
        }

    # Timeout error
    if isinstance(exception, TimeoutError):
        return {
            "code": "timeout",
            "message": error_str,
            "type": "timeout"
        }

    # Generic error
    return {
        "code": "unknown",
        "message": error_str,
        "type": "unknown"
    }


def _normalize_compare_value(value) -> str:
    if isinstance(value, bool):
        return str(value).lower()
    return str(value).strip().upper()


def _parse_min_pixels(text: str) -> int | None:
    match = re.search(
        r"(?:at\s+least\s+(\d+)\s+pixels|pixels?\s+should\s+be\s+at\s+least\s+(\d+))",
        text,
        re.IGNORECASE,
    )
    if not match:
        return None
    return int(match.group(1) or match.group(2))


def _parse_size_dims(value) -> tuple[int, int] | None:
    if not isinstance(value, str):
        return None
    match = re.search(r"(\d{2,5})\s*[xX×]\s*(\d{2,5})", value)
    if not match:
        return None
    return int(match.group(1)), int(match.group(2))


def _format_rule_attributes(rule: dict, max_items: int = 4) -> str:
    attrs = rule.get("attributes") or {}
    parts = [f"{k}={v}" for k, v in attrs.items() if not (k == "default" and v == "enabled")]
    if not parts:
        return "<default rule>"
    return ", ".join(parts[:max_items])


def _best_rule_mismatch(credit_rules: list, merged_params: dict) -> dict | None:
    if not credit_rules:
        return None
    best = None
    normalized_params = {
        str(k).strip().lower(): _normalize_compare_value(v)
        for k, v in merged_params.items()
    }
    for rule in credit_rules:
        attrs = rule.get("attributes") or {}
        if not attrs:
            continue
        missing: list[str] = []
        conflicts: list[tuple[str, str, str]] = []
        matched = 0
        for key, expected in attrs.items():
            if key == "default" and expected == "enabled":
                continue
            k = str(key).strip().lower()
            expected_norm = _normalize_compare_value(expected)
            actual_norm = normalized_params.get(k)
            if actual_norm is None:
                missing.append(str(key))
            elif actual_norm == expected_norm:
                matched += 1
            else:
                actual_raw = merged_params.get(key, merged_params.get(k, ""))
                conflicts.append((str(key), str(actual_raw), str(expected)))
        score = matched * 3 - len(missing) * 2 - len(conflicts) * 3
        candidate = {
            "rule": rule,
            "missing": missing,
            "conflicts": conflicts,
            "score": score,
        }
        if best is None or candidate["score"] > best["score"]:
            best = candidate
    return best


def build_contextual_diagnosis(error_info: dict,
                               task_type: str,
                               model_params: dict,
                               current_params: dict | None,
                               input_images: list[str] | None,
                               credit_rules: list | None) -> dict:
    code = error_info.get("code")
    code_str = str(code).strip() if code is not None else ""
    raw_message = str(error_info.get("message") or "")
    msg_lower = raw_message.lower()
    merged_params = dict(model_params.get("form_params") or {})
    merged_params.update(current_params or {})
    media_inputs = input_images or []
    model_name = model_params.get("model_name") or "unknown_model"
    model_id = model_params.get("model_id") or "unknown_model_id"

    diagnosis = {
        "code": code,
        "confidence": "medium",
        "headline": "Model task failed with current configuration",
        "reasoning": [],
        "actions": [],
        "model_name": model_name,
        "model_id": model_id,
        "task_type": task_type,
    }

    input_required = {
        "image_to_video",
        "first_last_frame_to_video",
        "reference_image_to_video",
    }
    if task_type in input_required and not media_inputs:
        diagnosis["confidence"] = "high"
        diagnosis["headline"] = "Missing required reference media for this task type"
        diagnosis["reasoning"].append(f"{task_type} requires input media, but input_images is empty.")
        diagnosis["actions"].append("Provide at least one URL/path via --input-images.")
        if task_type == "first_last_frame_to_video":
            diagnosis["actions"].append("Provide at least 2 frames: first and last.")
        return diagnosis

    if task_type == "first_last_frame_to_video" and 0 < len(media_inputs) < 2:
        diagnosis["confidence"] = "high"
        diagnosis["headline"] = "Insufficient frames for first_last_frame_to_video"
        diagnosis["actions"].append("Pass two frame URLs in --input-images.")
        return diagnosis

    api_key_error_patterns = (
        r"\bunauthorized\b",
        r"\binvalid\s+api(?:\s|_|-)key\b",
        r"\bmissing\s+api(?:\s|_|-)key\b",
        r"\bapi(?:\s|_|-)key\s+is\s+required\b",
        r"\bapi(?:\s|_|-)key\s+required\b",
        r"\brequires?\s+an?\s+api(?:\s|_|-)key\b",
    )
    if code_str == "401" or any(re.search(pattern, msg_lower) for pattern in api_key_error_patterns):
        diagnosis["confidence"] = "high"
        diagnosis["headline"] = "API key is missing, invalid, or unauthorized"
        diagnosis["actions"].append("Create or regenerate your API key: https://www.imaclaw.ai/imaclaw/apikey")
        diagnosis["actions"].append("Retry with the new key in --api-key.")
        return diagnosis

    if code in (4014, "4014") or code_str == "4014" or "requires a subscription" in msg_lower:
        diagnosis["confidence"] = "high"
        diagnosis["headline"] = "Current model requires an active subscription plan"
        diagnosis["actions"].append("Activate or upgrade your plan: https://www.imaclaw.ai/imaclaw/subscription")
        diagnosis["actions"].append("Then retry the same request.")
        return diagnosis

    if code in (4008, "4008") or code_str == "4008" or "insufficient points" in msg_lower:
        diagnosis["confidence"] = "high"
        diagnosis["headline"] = "Account points are not enough for this video request"
        diagnosis["actions"].append("Top up credits: https://www.imaclaw.ai/imaclaw/subscription")
        diagnosis["actions"].append("Or switch to a lower-cost model profile.")
        return diagnosis

    min_pixels = _parse_min_pixels(raw_message)
    dims = _parse_size_dims(str(merged_params.get("size") or "")) or _parse_size_dims(raw_message)
    if min_pixels is not None and dims is not None:
        requested_pixels = dims[0] * dims[1]
        if requested_pixels < min_pixels:
            diagnosis["confidence"] = "high"
            diagnosis["headline"] = "Output size is below this model's minimum pixel requirement"
            diagnosis["reasoning"].append(
                f"Requested size {dims[0]}x{dims[1]} ({requested_pixels} px) is below required {min_pixels} px."
            )
            target = int(math.ceil(math.sqrt(min_pixels)))
            diagnosis["actions"].append(f"Increase --size to at least around {target}x{target}.")
            diagnosis["actions"].append("Retry with the same model.")
            return diagnosis

    credit_rules = credit_rules or []
    rule_mismatch = _best_rule_mismatch(credit_rules, merged_params)
    if (
        code in (6009, 6010)
        or "invalid product attribute" in msg_lower
        or "no matching" in msg_lower
        or "attribute" in msg_lower
    ):
        diagnosis["confidence"] = "high" if code in (6009, 6010) else "medium"
        diagnosis["headline"] = "Current parameter combination does not fit this model rule set"
        if rule_mismatch:
            if rule_mismatch["missing"]:
                diagnosis["reasoning"].append(
                    "Missing parameters for best-matching rule: "
                    + ", ".join(rule_mismatch["missing"][:4])
                )
            if rule_mismatch["conflicts"]:
                compact = ", ".join(
                    f"{k}={got} (expected {expected})"
                    for k, got, expected in rule_mismatch["conflicts"][:3]
                )
                diagnosis["reasoning"].append(f"Conflicting values: {compact}")
            diagnosis["actions"].append(
                "Use a rule-compatible profile: " + _format_rule_attributes(rule_mismatch["rule"])
            )
        diagnosis["actions"].append("Remove custom --extra-params and retry with defaults.")
        return diagnosis

    if code == "timeout" or "timed out" in msg_lower:
        diagnosis["headline"] = "Task exceeded polling timeout for current video settings"
        diagnosis["actions"].append("Retry with lower resolution/duration.")
        diagnosis["actions"].append(f"Check your creation record: {VIDEO_RECORDS_URL}")
        return diagnosis

    if code == 500 or "internal server error" in msg_lower:
        diagnosis["headline"] = "Backend rejected current parameter complexity"
        for key in ("resolution", "duration", "mode", "quality"):
            if key in merged_params:
                fallback = get_param_degradation_strategy(key, str(merged_params[key]))
                if fallback:
                    diagnosis["actions"].append(f"Try {key}={fallback[0]} (current {merged_params[key]}).")
                    break
        diagnosis["actions"].append("Retry after simplifying parameters.")
        return diagnosis

    diagnosis["reasoning"].append(
        f"Model context: {to_user_facing_model_name(model_name, model_id)}, "
        f"task={task_type}, media_count={len(media_inputs)}."
    )
    diagnosis["actions"].append("Retry with defaults (remove --extra-params).")
    diagnosis["actions"].append("Use --list-models to verify supported settings.")
    return diagnosis


def format_user_failure_message(diagnosis: dict,
                                attempts_used: int,
                                max_attempts: int) -> str:
    display_model = to_user_facing_model_name(
        diagnosis.get("model_name"),
        diagnosis.get("model_id"),
    )
    lines = [
        f"Task failed after {attempts_used}/{max_attempts} attempt(s).",
        (
            f"Model: {display_model} | "
            f"Task: {diagnosis.get('task_type')}"
        ),
        f"Likely cause ({diagnosis.get('confidence', 'medium')} confidence): {diagnosis.get('headline')}",
    ]
    reasoning = diagnosis.get("reasoning") or []
    if reasoning:
        lines.append("Why this diagnosis:")
        for item in reasoning[:3]:
            lines.append(f"- {item}")
    actions = diagnosis.get("actions") or []
    if actions:
        lines.append("What to do next:")
        for i, action in enumerate(actions[:4], 1):
            lines.append(f"{i}. {action}")
    code = diagnosis.get("code")
    if code not in (None, "", "unknown"):
        lines.append(f"Reference code: {code}")
    lines.append("Technical details were recorded in local logs.")
    return "\n".join(lines)


def format_preflight_failure_message(operation_name: str, diagnosis: dict) -> str:
    lines = [
        f"Unable to load {operation_name}.",
        f"Likely cause ({diagnosis.get('confidence', 'medium')} confidence): {diagnosis.get('headline')}",
    ]
    actions = diagnosis.get("actions") or []
    if actions:
        lines.append("What to do next:")
        for i, action in enumerate(actions[:4], 1):
            lines.append(f"{i}. {action}")
    return "\n".join(lines)


def get_param_degradation_strategy(param_key: str, current_value: str) -> list:
    """
    Get degradation sequence for a parameter when error occurs.

    Returns list of fallback values to try, from high-quality to low-quality.
    Empty list means no degradation available.
    """
    # Resolution degradation (1080p → 720p → 480p) for video
    if param_key.lower() == "resolution":
        res_map = {
            "1080p": ["720p", "480p"],
            "720p": ["480p"],
            "480p": []
        }
        return res_map.get(current_value.lower(), [])

    # Duration degradation (10s → 5s) for video
    if param_key.lower() == "duration":
        dur_map = {
            "10s": ["5s"],
            "5s": []
        }
        return dur_map.get(current_value.lower(), [])

    # Mode degradation for video models
    if param_key.lower() == "mode":
        mode_map = {
            "professional": ["standard"],
            "standard": []
        }
        return mode_map.get(current_value.lower(), [])

    # Quality degradation
    if param_key.lower() == "quality":
        quality_map = {
            "高清": ["标清"],
            "high": ["standard", "low"],
            "standard": ["low"],
            "low": []
        }
        return quality_map.get(current_value.lower(), [])

    return []


path: scripts/ima_runtime/capabilities/__init__.py
content: """Capability packages for ima-video-ai."""


path: scripts/ima_runtime/capabilities/video/params.py
content: from __future__ import annotations

from ima_runtime.shared.types import ModelBinding, TaskSpec


def build_video_model_params(binding: ModelBinding) -> dict:
    resolved_params = dict(binding.resolved_params)
    if "form_params" not in resolved_params:
        raise RuntimeError("Video model binding missing required resolved param: form_params")

    return {
        "attribute_id": binding.attribute_id,
        "credit": binding.credit,
        "model_id": binding.candidate.model_id,
        "model_id_raw": resolved_params.get("model_id_raw", binding.candidate.model_id),
        "model_name": binding.candidate.name,
        "model_version": binding.candidate.version_id,
        "form_params": dict(resolved_params["form_params"]),
        "rule_attributes": dict(resolved_params.get("rule_attributes", {})),
        "all_credit_rules": list(resolved_params.get("all_credit_rules", [])),
        "virtual_fields": list(resolved_params.get("virtual_fields", [])),
    }


def normalize_video_binding(spec: TaskSpec, model_params: dict) -> tuple[dict, dict]:
    extra = dict(spec.extra_params)
    dropped: dict = {}
    if spec.task_type == "text_to_video":
        extra.pop("src_img_url", None)
    return extra, dropped


path: scripts/ima_runtime/capabilities/video/models.py
content: from __future__ import annotations

from ima_runtime.shared.catalog import canonicalize_model_id, extract_model_params, find_model_version
from ima_runtime.shared.types import ModelBinding, ModelCandidate


def normalize_model_id(model_id: str | None) -> str | None:
    return canonicalize_model_id(model_id)


def build_video_model_binding(product_tree: list, model_id: str, version_id: str | None = None) -> ModelBinding:
    normalized_model_id = normalize_model_id(model_id) or model_id
    node = find_model_version(product_tree, normalized_model_id, version_id)
    if node is None:
        raise RuntimeError(
            f"Video model not found: model_id={normalized_model_id}, version_id={version_id}"
        )
    params = extract_model_params(node)
    return ModelBinding(
        candidate=ModelCandidate(
            name=params["model_name"],
            model_id=params["model_id"],
            version_id=params["model_version"],
        ),
        attribute_id=params["attribute_id"],
        credit=params["credit"],
        resolved_params=params,
    )


path: scripts/ima_runtime/capabilities/video/__init__.py
content: from .executor import execute_video_task
from .models import build_video_model_binding, normalize_model_id
from .routes import build_video_task_spec

__all__ = ["build_video_model_binding", "build_video_task_spec", "execute_video_task", "normalize_model_id"]


path: capabilities/video/CAPABILITY.md
content: # Video Capability

## Responsibility

This capability owns all shipped media generation in this repo:

- `text_to_video`
- `image_to_video`
- `first_last_frame_to_video`
- `reference_image_to_video`

## Owning Code

- `scripts/ima_runtime/capabilities/video/routes.py`
- `scripts/ima_runtime/capabilities/video/models.py`
- `scripts/ima_runtime/capabilities/video/params.py`
- `scripts/ima_runtime/capabilities/video/executor.py`

It depends on shared catalog, client, input, config, and error modules, but it is the only capability package in the current repo.

## Read Order

1. `references/scenarios.md`
2. `references/routing.md`
3. `references/models.md`
4. `references/params.md`
5. `references/parameter-tuning.md`

## Boundary

- capability code decides video task type, model binding, video-only param shaping, and execution
- shared code owns product-list fetch, create/poll HTTP calls, input upload, prefs, and error translation
- non-video features are out of scope for this repo


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)