Files
xinjian0101-continuity-dire…/production_core.py
xinjian0101 4289f7f828 feat: upload complete v0.8.0 ComfyUI plugin and redesign dashboard
Publish the complete Continuity Director plugin with 14 functional nodes, a bilingual production dashboard, examples, tests, package metadata, help documentation, and CI. Remove obsolete encoded bootstrap artifacts and source-reconstruction automation.
2026-06-19 15:01:57 +08:00

95 lines
5.2 KiB
Python

"""Production planning, take ranking, and quality-gate helpers."""
from __future__ import annotations
import copy
from typing import Any
from .continuity_core import ContinuityError, digest, normalize_id, parse_json
DEFAULT_WEIGHTS = {"identity": 0.35, "continuity": 0.25, "technical": 0.20, "motion": 0.10, "prompt": 0.10}
DEFAULT_THRESHOLDS = {"identity": 0.78, "continuity": 0.72, "technical": 0.70}
def _clamp(value: Any, minimum: float = 0.0, maximum: float = 1.0) -> float:
try:
number = float(value)
except (TypeError, ValueError):
number = 0.0
return max(minimum, min(maximum, number))
def expand_storyboard(storyboard: Any, manifest: dict[str, Any] | None, base_seed: int, takes_per_shot: int) -> dict[str, Any]:
source = parse_json(storyboard, default=[], expected=(list, dict))
shots = source.get("shots", []) if isinstance(source, dict) else source
if not isinstance(shots, list):
raise ContinuityError("Storyboard 'shots' must be a list")
takes_per_shot = max(1, min(16, int(takes_per_shot)))
entries: list[dict[str, Any]] = []
for shot_index, raw in enumerate(shots):
if isinstance(raw, str):
raw = {"prompt": raw}
if not isinstance(raw, dict):
raise ContinuityError(f"Shot {shot_index + 1} must be an object or string")
shot_id = normalize_id(raw.get("id") or raw.get("shot_id") or f"shot-{shot_index + 1:03d}")
for take_index in range(takes_per_shot):
seed = int(raw.get("seed", base_seed + shot_index * 1000 + take_index))
entries.append({"shot_id": shot_id, "take_id": f"{shot_id}-take-{take_index + 1:02d}", "take_index": take_index + 1, "seed": seed, "prompt": str(raw.get("prompt", "")), "negative_prompt": str(raw.get("negative_prompt", "")), "scene_id": normalize_id(raw.get("scene_id", "scene-default")), "character_ids": list(raw.get("character_ids", [])), "camera": copy.deepcopy(raw.get("camera", {})), "duration_seconds": float(raw.get("duration_seconds", 3.0)), "references": copy.deepcopy(raw.get("references", [])), "depends_on": list(raw.get("depends_on", []))})
chain = {"schema": "continuity-director/shot-chain@1.0", "manifest_hash": (manifest or {}).get("hash", ""), "shot_count": len(shots), "take_count": len(entries), "takes": entries}
chain["hash"] = digest(chain)
return chain
def reference_handoff(previous: Any, current: Any, strategy: str = "last_to_first") -> dict[str, Any]:
previous_data = parse_json(previous, default={}, expected=dict)
current_data = parse_json(current, default={}, expected=dict)
strategy = strategy if strategy in {"last_to_first", "shared_anchor", "manual"} else "last_to_first"
if strategy == "last_to_first":
selected = previous_data.get("last_frame") or previous_data.get("frame") or previous_data.get("asset")
elif strategy == "shared_anchor":
selected = current_data.get("anchor") or previous_data.get("anchor")
else:
selected = current_data.get("manual")
result = {"schema": "continuity-director/reference-handoff@1.0", "strategy": strategy, "selected_reference": selected, "previous": previous_data, "current": current_data}
result["hash"] = digest(result)
return result
def quality_gate(metrics: Any, thresholds: Any | None = None, mode: str = "all") -> dict[str, Any]:
metric_data = parse_json(metrics, default={}, expected=dict)
threshold_data = dict(DEFAULT_THRESHOLDS)
threshold_data.update(parse_json(thresholds, default={}, expected=dict))
checks = []
for name, minimum in threshold_data.items():
score = _clamp(metric_data.get(name, 0.0))
minimum = _clamp(minimum)
checks.append({"metric": name, "score": score, "minimum": minimum, "passed": score >= minimum})
passed = True if not checks else (any(item["passed"] for item in checks) if mode == "any" else all(item["passed"] for item in checks))
return {"schema": "continuity-director/quality-gate@1.0", "mode": mode, "passed": passed, "checks": checks, "failed_metrics": [item["metric"] for item in checks if not item["passed"]]}
def score_take(metrics: dict[str, Any], weights: dict[str, Any] | None = None) -> float:
applied = dict(DEFAULT_WEIGHTS)
if weights:
applied.update(weights)
positive = {key: max(0.0, float(value)) for key, value in applied.items()}
total_weight = sum(positive.values()) or 1.0
return sum(_clamp(metrics.get(key, 0.0)) * weight for key, weight in positive.items()) / total_weight
def rank_takes(takes: Any, weights: Any | None = None) -> list[dict[str, Any]]:
take_list = parse_json(takes, default=[], expected=list)
weight_data = parse_json(weights, default={}, expected=dict)
ranked: list[dict[str, Any]] = []
for index, take in enumerate(take_list):
if not isinstance(take, dict):
raise ContinuityError(f"Take {index + 1} must be an object")
item = copy.deepcopy(take)
item["take_id"] = str(item.get("take_id") or item.get("id") or f"take-{index + 1:03d}")
item["score"] = round(score_take(item.get("metrics", item), weight_data), 6)
ranked.append(item)
ranked.sort(key=lambda item: (-item["score"], item["take_id"]))
for rank, item in enumerate(ranked, start=1):
item["rank"] = rank
return ranked