diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 4a487a2..1f9497d 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -162,6 +162,9 @@ CACHES = { CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1") CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2") +# 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。 +# 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。 +CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true" CELERY_TASK_ACKS_LATE = True CELERY_TASK_REJECT_ON_WORKER_LOST = True CELERY_WORKER_PREFETCH_MULTIPLIER = 1 @@ -229,6 +232,9 @@ PROVIDER_API_VERSIONS = { "yunqi": env("YUNQI_API_VERSION", "2025-04-01-preview"), } +# 自由创作视频:团队在途任务并发上限(视频长时高价,限并发同时压 web 慢调用敞口) +FREE_VIDEO_MAX_CONCURRENT = int(env("FREE_VIDEO_MAX_CONCURRENT", "3")) + # 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有 ASSETS_API = { "access_key": env("ASSETS_API_ACCESS_KEY", ""), diff --git a/core/backend/apps/ai/catalog.py b/core/backend/apps/ai/catalog.py index f5f2341..b6b4dc7 100644 --- a/core/backend/apps/ai/catalog.py +++ b/core/backend/apps/ai/catalog.py @@ -66,9 +66,54 @@ VOLCANO_MODELS = [ "audio": "optional", "modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"], "durations": list(range(4, 16)), - "resolutions": ["480p", "720p"], + "resolutions": ["480p", "720p", "1080p", "4k"], "watermark": False, "source": "video-flow/data/vendor/volcengine.ts", + # 自由创作按 token 计费:元/百万tokens,按 分辨率档 × 是否含视频参考 取价, + # 无精确分辨率键回落 default。数字来源火山 Seedance 2.0 官方价目。 + "pricing": { + "unit": "cny_per_million_tokens", + "default": {"no_ref_video": 46, "with_ref_video": 28}, + "1080p": {"no_ref_video": 51, "with_ref_video": 31}, + "4k": {"no_ref_video": 26, "with_ref_video": 16}, + }, + }, + }, + { + "display_name": "Seedance-2.0-Fast", + "name": "doubao-seedance-2-0-fast-260128", + "capability": "video", + "endpoint": "contents/generations/tasks", + "metadata": { + "audio": "optional", + "modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"], + "durations": list(range(4, 16)), + # 1080p/4k 仅标准档支持(火山限制),提交侧校验拒绝 + "resolutions": ["480p", "720p"], + "watermark": False, + "source": "jimeng-clone/backend/utils/airdrama_client.py", + "pricing": { + "unit": "cny_per_million_tokens", + "default": {"no_ref_video": 37, "with_ref_video": 22}, + }, + }, + }, + { + "display_name": "Seedance-2.0-Mini", + "name": "doubao-seedance-2-0-mini-260615", + "capability": "video", + "endpoint": "contents/generations/tasks", + "metadata": { + "audio": "optional", + "modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"], + "durations": list(range(4, 16)), + "resolutions": ["480p", "720p"], + "watermark": False, + "source": "jimeng-clone/backend/utils/airdrama_client.py", + "pricing": { + "unit": "cny_per_million_tokens", + "default": {"no_ref_video": 23, "with_ref_video": 14}, + }, }, }, { diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py new file mode 100644 index 0000000..c55b191 --- /dev/null +++ b/core/backend/apps/ai/free_video.py @@ -0,0 +1,728 @@ +"""自由创作·独立视频生成(不绑 project)。 + +移植自 jimeng-clone(apps/generation/views.py 的 video_generate_view / tasks.py 轮询), +嫁接 AirShelf 底座:AITask 状态机 + CreditAccount 三段式计费(reserve/charge/release)+ +TOS 存储 + VolcanoArkProvider。 + +链路: + submit_free_video → 校验/估价/并发闸 → AITask(RESERVED) → 调火山(SUBMITTED) → 派兜底轮询 + finalize_free_video → 查火山;终态幂等化(POSTPROCESSING 认领防双 poll 双扣) + 成功: 下载→TOS→Asset(FREE_CREATE)+首帧封面 → 按真实 tokens 结算 + 失败: 错误码映射中文 + 退费 + web poll 端点与 worker 兜底任务共用 finalize;本地无 worker 也能全程收尾。 +""" +import logging +import re +import uuid +from datetime import timedelta +from decimal import Decimal, ROUND_HALF_UP +from io import BytesIO + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.utils import timezone + +from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup +from apps.assets.storage import TosStorage +from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit + +from .models import AITask, ModelConfig +from .providers.volcano import VolcanoArkProvider +from .video_errors import map_video_error, parse_provider_error +from .video_pricing import ( + RESERVE_BUFFER, + estimate_video_cost, + get_resolution, + tokens_to_cost, +) + +logger = logging.getLogger(__name__) + +FREE_VIDEO_MODELS = { + "doubao-seedance-2-0-260128", + "doubao-seedance-2-0-fast-260128", + "doubao-seedance-2-0-mini-260615", +} +HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制) +RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} +RESOLUTIONS = {"480p", "720p", "1080p", "4k"} +MODES = {"universal", "keyframe"} +IN_FLIGHT_STATUSES = ( + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, +) + +_ORPHAN_MATERIAL_MENTION_RE = re.compile(r"@(?:图片|视频|音频|素材)[^\s,。!?、;:,.!?;:))]*") + + +def find_orphan_material_mention(prompt: str, references: list) -> str | None: + """prompt 里 @了素材类占位但 references 为空 → 返回该 mention(供 400 提示)。""" + if not prompt or references: + return None + match = _ORPHAN_MATERIAL_MENTION_RE.search(prompt) + return match.group(0) if match else None + + +def _format_prompt_for_ark(prompt: str, label_placeholders: list[tuple[str, str]]) -> str: + """@label → 「图片N/视频N/音频N」。火山只认「类型+序号」指代(官方 FAQ Q3), + 文件名/asset id 会按位置概率对齐 → 人物颠倒。调用方保证按 label 长度降序, + 防「碧」先于「碧碧」被替换的子串吞噬;用 str.replace 防 label 含正则元字符崩溃。 + 用户原文保留在 request_payload 供「再次生成」回填 mention chip。""" + result = prompt + for label, placeholder in label_placeholders: + if not label: + continue + result = result.replace(f"@{label}", placeholder) + return result + + +def _normalize_remote_asset_id(remote_id: str) -> str: + # 火山返回 "Asset-xxx",引用时须小写前缀 "asset-xxx" + if remote_id.startswith("Asset-"): + return "asset-" + remote_id[6:] + return remote_id + + +def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool: + """本地 processing 的素材实时查火山刷新。返回是否 active。best-effort。""" + try: + from apps.assets import assets_client + + result = assets_client.get_asset(free_asset.remote_asset_id) + if result and result.get("Status") == "Active": + free_asset.status = FreeAsset.Status.ACTIVE + free_asset.url = result.get("Url", free_asset.url) or free_asset.url + free_asset.save(update_fields=["status", "url", "updated_at"]) + return True + except Exception: # noqa: BLE001 — 刷新失败按未就绪处理 + logger.warning("free asset %s refresh failed", free_asset.id, exc_info=True) + return False + + +def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict: + """references → 火山 content_items + api_prompt(@label 已替换)。 + + 移植 jimeng views.py:369-567:URL 去重、blob: 拦截、素材库引用(FreeAsset → asset://)、 + 素材组展开、图≤9/视频≤3/音频≤3 校验、@label→「图片N」映射(不变量:任意时刻 + image_n/video_n/audio_n == content_items 里该类型已 push 的个数)。 + 校验失败抛 ValueError(用户友好中文),由 view 转 400。 + """ + content_items: list[dict] = [] + snapshots: list[dict] = [] + seen_urls: set[str] = set() + group_cache: dict[str, list[tuple[str, str, float]]] = {} + label_to_placeholder: dict[str, str] = {} + image_n = video_n = audio_n = 0 + video_duration_total = 0.0 # 输入参考视频总时长(token 公式的输入项 + ≤15s 校验) + + def _placeholder_for(asset_type: str) -> str: + if asset_type == "Video": + return f"视频{video_n}" + if asset_type == "Audio": + return f"音频{audio_n}" + return f"图片{image_n}" + + def _push(kind: str, url: str, role: str, duration: float = 0.0) -> str: + """push 一个 content_item 并递增 counter,返回素材类型(Image/Video/Audio)。""" + nonlocal image_n, video_n, audio_n, video_duration_total + if kind == "video": + video_n += 1 + video_duration_total += duration or 0.0 + item: dict = {"type": "video_url", "video_url": {"url": url}} + if role: + item["role"] = role + content_items.append(item) + return "Video" + if kind == "audio": + audio_n += 1 + item = {"type": "audio_url", "audio_url": {"url": url}} + if role: + item["role"] = role + content_items.append(item) + return "Audio" + image_n += 1 + item = {"type": "image_url", "image_url": {"url": url}} + if role: + item["role"] = role + content_items.append(item) + return "Image" + + def _resolve_group_assets(group: FreeAssetGroup) -> list[tuple[str, str, float]]: + resolved: list[tuple[str, str, float]] = [] + for fa in group.assets.exclude(remote_asset_id="").order_by("created_at"): + if fa.status == FreeAsset.Status.PROCESSING and not _refresh_processing_free_asset(fa): + continue # 未就绪的跳过 + if fa.status != FreeAsset.Status.ACTIVE: + continue + resolved.append( + (f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}", fa.asset_type, fa.duration or 0.0) + ) + return resolved + + for ref in references or []: + url = str(ref.get("url") or "") + ref_type = str(ref.get("type") or "image") + role = str(ref.get("role") or "") + label = str(ref.get("label") or "") + source = str(ref.get("source") or "upload") + duration = float(ref.get("duration") or 0) + + dedupe_key = url or f"{source}:{ref.get('asset_id') or ref.get('group_id')}" + if dedupe_key in seen_urls: + continue + seen_urls.add(dedupe_key) + + if url.startswith("blob:"): + raise ValueError(f"素材「{label or '未命名'}」上传失败,请删除后重新添加") + + snap = {"url": url, "type": ref_type, "role": role, "label": label, "source": source} + if ref.get("thumb_url"): + snap["thumb_url"] = ref["thumb_url"] + if duration: + snap["duration"] = duration + if ref.get("asset_id"): + snap["asset_id"] = str(ref["asset_id"]) + if ref.get("group_id"): + snap["group_id"] = str(ref["group_id"]) + snapshots.append(snap) + + # 素材库单素材:FreeAsset → asset://{remote_id} + if source == "library" and ref.get("asset_id"): + fa = FreeAsset.objects.filter( + id=ref["asset_id"], group__team=team, group__is_deleted=False + ).first() + if fa is None: + raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除") + if fa.status == FreeAsset.Status.PROCESSING and not _refresh_processing_free_asset(fa): + raise ValueError(f"素材「{label or fa.name}」尚在处理中,请稍后重试") + if fa.status != FreeAsset.Status.ACTIVE or not fa.remote_asset_id: + raise ValueError(f"素材「{label or fa.name}」尚未就绪,请稍后重试") + resolved_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}" + kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image") + asset_type = _push(kind, resolved_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), fa.duration or 0.0) + if label and label not in label_to_placeholder: + label_to_placeholder[label] = _placeholder_for(asset_type) + continue + + # 素材组引用:展开组内全部 active 素材(一个 label 对应 N 素材,语义变化 → 不登记 label) + if source == "library_group" and ref.get("group_id"): + gid = str(ref["group_id"]) + if gid not in group_cache: + group = FreeAssetGroup.objects.filter(id=gid, team=team, is_deleted=False).first() + group_cache[gid] = _resolve_group_assets(group) if group else [] + asset_list = group_cache[gid] + if not asset_list: + raise ValueError(f"素材「{label or '未命名'}」尚未就绪,请在素材库中确认状态为「可用」后重试") + for asset_url, asset_type, dur in asset_list: + kind = {"Video": "video", "Audio": "audio"}.get(asset_type, "image") + _push(kind, asset_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), dur) + continue + + # 直传素材(已上传 TOS 的直链) + if ref_type == "image": + # 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame + effective_role = "reference_image" if mode == "universal" else (role or "first_frame") + asset_type = _push("image", url, effective_role) + elif ref_type == "video": + asset_type = _push("video", url, role or "reference_video", duration) + elif ref_type == "audio": + asset_type = _push("audio", url, role or "reference_audio", duration) + else: + logger.warning("unknown ref_type=%s url=%s label=%s, skipped", ref_type, url, label) + continue + + if label and label not in label_to_placeholder: + label_to_placeholder[label] = _placeholder_for(asset_type) + + if image_n > 9: + raise ValueError(f"参考图片最多 9 张(含素材库引用,同一素材按 1 张计算),当前 {image_n} 张,请减少后重试") + if video_n > 3: + raise ValueError(f"参考视频最多 3 条,当前 {video_n} 条,请减少后重试") + if audio_n > 3: + raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试") + if audio_n > 0 and image_n + video_n == 0: + raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频") + if video_duration_total > 15: + raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试") + + # @label 替换:按 label 长度降序,防子串吞噬 + ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True) + api_prompt = _format_prompt_for_ark(prompt, ordered) + + return { + "content_items": content_items, + "api_prompt": api_prompt, + "snapshots": snapshots, + "image_n": image_n, + "video_n": video_n, + "audio_n": audio_n, + "video_duration_total": video_duration_total, + } + + +def _reap_stale_free_video_tasks(*, team) -> None: + """僵尸回收(趁每次新提交顺手做,无需定时任务): + · RESERVED 超 10 分钟:没提交到火山就死(worker 崩溃/进程重启)→ 标失败退费; + · SUBMITTED/POLLING 超 2 小时:轮询链早已断且无人认领(正常出片 5-10 分钟)→ 标失败退费; + · POSTPROCESSING 超 30 分钟:转存/结算中途崩溃 → 标失败退费(火山可能已出片,平台承担该笔成本)。""" + now = timezone.now() + buckets = [ + ([AITask.Status.RESERVED], now - timedelta(minutes=10), "任务未在预期时间内提交(自动回收)"), + ([AITask.Status.SUBMITTED, AITask.Status.POLLING], now - timedelta(hours=2), "生成超时(自动回收)"), + ([AITask.Status.POSTPROCESSING], now - timedelta(minutes=30), "视频结果处理超时(自动回收)"), + ] + for statuses, cutoff, reason in buckets: + stale = AITask.objects.filter( + team=team, + project__isnull=True, + task_type=AITask.Type.FREE_VIDEO, + status__in=statuses, + updated_at__lt=cutoff, + ) + for task in stale: + try: + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status not in statuses: + continue + locked.status = AITask.Status.FAILED + locked.error_message = reason + locked.completed_at = timezone.now() + locked.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + try: + reservation = locked.credit_reservation + except ObjectDoesNotExist: + reservation = None + if reservation is not None: + release_credit(reservation=reservation, reason=reason) + except Exception: # noqa: BLE001 — 单个回收失败不应阻断新提交 + logger.warning("reap stale free video task %s failed", task.id, exc_info=True) + continue + + +def submit_free_video(*, team, user, params: dict) -> AITask: + """提交一条自由创作视频。校验/估价/预留在前(失败不留半套),火山调用在事务外。 + 创建失败不抛:返回 FAILED 任务(带友好中文错误),前端渲染失败卡。校验类错误抛 ValueError → 400。""" + prompt = str(params.get("prompt") or "").strip() + mode = str(params.get("mode") or "universal") + model_name = str(params.get("model") or HIGH_RES_MODEL) + aspect_ratio = str(params.get("aspect_ratio") or "16:9") + resolution = str(params.get("resolution") or "720p") + generate_audio = bool(params.get("generate_audio", True)) + search_mode = str(params.get("search_mode") or "off") + references = params.get("references") or [] + try: + duration = int(params.get("duration") or 5) + except (TypeError, ValueError): + raise ValueError("时长参数无效") + try: + seed = int(params.get("seed") if params.get("seed") is not None else -1) + except (TypeError, ValueError): + seed = -1 + + if not prompt: + raise ValueError("提示词不能为空") + if mode not in MODES: + raise ValueError("生成模式无效") + if model_name not in FREE_VIDEO_MODELS: + raise ValueError("模型无效") + if aspect_ratio not in RATIOS: + raise ValueError("画面比例无效") + if resolution not in RESOLUTIONS: + raise ValueError("分辨率无效") + if not 4 <= duration <= 15: + raise ValueError("视频时长需在 4-15 秒之间") + if resolution in ("1080p", "4k") and model_name != HIGH_RES_MODEL: + raise ValueError(f"{resolution} 仅标准档模型支持,请切换模型或降低分辨率") + get_resolution(aspect_ratio, resolution) # 组合合法性 fail loud + + orphan = find_orphan_material_mention(prompt, references) + if orphan: + raise ValueError(f"「{orphan}」对应的内容为空,请补充素材或删除该引用") + + if mode == "keyframe": + roles = [str(r.get("role") or "") for r in references] + if any(str(r.get("type") or "image") != "image" for r in references): + raise ValueError("首尾帧模式仅支持图片素材") + if "first_frame" not in roles: + raise ValueError("首尾帧模式需要提供首帧图片") + if len(references) > 2: + raise ValueError("首尾帧模式最多提供首帧和尾帧各一张图片") + + model_config = ( + ModelConfig.objects.select_related("provider") + .filter(name=model_name, capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE) + .first() + ) + if model_config is None: + raise ValueError("视频模型未配置,请联系管理员") + + _reap_stale_free_video_tasks(team=team) + + # 团队并发闸(移植 jimeng Layer2.6):视频是长时高价任务,必须限并发 + max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3)) + in_flight = AITask.objects.filter( + team=team, task_type=AITask.Type.FREE_VIDEO, status__in=IN_FLIGHT_STATUSES + ).count() + if in_flight >= max_concurrent: + raise ValueError(f"当前有 {in_flight} 个视频任务进行中(上限 {max_concurrent}),请等待完成后再提交") + + built = build_content_items(team=team, prompt=prompt, mode=mode, references=references) + + tokens, cost = estimate_video_cost( + model_config, + aspect_ratio=aspect_ratio, + resolution=resolution, + duration=duration, + references=built["snapshots"], + ) + reserve_amount = (cost * RESERVE_BUFFER).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + request_payload = { + "feature": "free_video", + "mode": mode, + "model": model_name, + "endpoint": model_config.endpoint, + "prompt": prompt, + "api_prompt": built["api_prompt"], + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "duration": duration, + "seed": seed, + "generate_audio": generate_audio, + "search_mode": search_mode, + "estimated_tokens": tokens, + "references": built["snapshots"], + } + + # 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套 + with transaction.atomic(): + task = AITask.objects.create( + team=team, + created_by=user, + project=None, + task_type=AITask.Type.FREE_VIDEO, + status=AITask.Status.CREATED, + model_config=model_config, + idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}", + request_payload=request_payload, + estimated_cost=cost, + ) + try: + reserve_credit(team=team, user=user, task=task, amount=reserve_amount) + except ValueError as exc: + if "insufficient credit" in str(exc): + raise ValueError("团队余额不足,请充值后重试") from exc + raise + task.status = AITask.Status.RESERVED + task.save(update_fields=["status", "updated_at"]) + + # 火山调用在事务外(不持锁调外网) + try: + from .services import build_provider + + provider = build_provider(model_config) + response = provider.create_video_task( + model=model_config.name, + endpoint=model_config.endpoint, + prompt=built["api_prompt"], + ratio=aspect_ratio, + duration=duration, + resolution=resolution, + generate_audio=generate_audio, + content_items=built["content_items"], + seed=seed if seed != -1 else None, + search_mode=search_mode, + ) + task.provider_task_id = str(response.get("id") or response.get("task_id") or "") + task.response_payload = response + task.status = AITask.Status.SUBMITTED + task.submitted_at = timezone.now() + task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"]) + except Exception as exc: # noqa: BLE001 — 创建失败:标失败退费,返回失败卡(不向上抛) + code, raw_message = parse_provider_error(exc) + friendly = map_video_error(code, raw_message) + task.status = AITask.Status.FAILED + task.error_code = (code or "CreateTaskError")[:64] + task.error_message = friendly + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=task.credit_reservation, reason=friendly) + _notify_failure(task, raw=f"[{code}] {raw_message}" if code else raw_message) + logger.warning("free video create failed: %s", exc) + return task + + # worker 兜底轮询(自重排);派发失败仅 log,前端主动 poll 仍能收尾 + try: + from .tasks import poll_free_video_task + + poll_free_video_task.apply_async(args=[str(task.id), 0], countdown=30) + except Exception: # noqa: BLE001 + logger.error("poll_free_video_task enqueue failed; relying on client polling", exc_info=True) + + return task + + +def _notify_failure(task: AITask, *, raw: str) -> None: + from .services import notify_generation_failure + + notify_generation_failure( + task=task, + project=None, + recipient=task.created_by, + stage_label="自由创作视频", + raw=raw, + hint=task.error_message, + ) + + +def _store_free_video_media(*, task: AITask, media: str) -> Asset: + """下载火山结果 → 转存 TOS(火山原始 URL 仅 7 天有效)→ 建 Asset(FREE_CREATE,自动入库) + + ffmpeg 抽首帧封面挂同 Asset 非主文件。""" + fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) + if "video" not in content_type: + content_type = "video/mp4" + # 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛 + # "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。 + video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b"" + asset_id = uuid.uuid4() + object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4" + stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) + prompt = (task.request_payload or {}).get("prompt") or "" + asset = Asset.objects.create( + id=asset_id, + team=task.team, + created_by=task.created_by, + name=(prompt[:50] or "自由创作视频"), + asset_type=Asset.Type.VIDEO, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.FREE_CREATE, + origin_task=task, + metadata={"feature": "free_video"}, + ) + AssetFile.objects.create( + asset=asset, + object_key=stored.object_key, + bucket=stored.bucket, + content_type=stored.content_type, + size_bytes=stored.size_bytes, + is_primary=True, + ) + # 首帧封面(best-effort):任务流/资产库缩略图 + try: + if video_bytes: + import subprocess + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory(prefix="airshelf-fc-poster-") as tmp: + tmp_dir = Path(tmp) + video_path = tmp_dir / "in.mp4" + poster_path = tmp_dir / "poster.jpg" + video_path.write_bytes(video_bytes) + proc = subprocess.run( + ["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)], + capture_output=True, + timeout=60, + ) + if proc.returncode == 0 and poster_path.exists() and poster_path.stat().st_size: + poster_key = f"teams/{task.team_id}/free-create/{asset_id}-poster.jpg" + poster_stored = TosStorage().upload_fileobj( + fileobj=BytesIO(poster_path.read_bytes()), object_key=poster_key, content_type="image/jpeg" + ) + AssetFile.objects.create( + asset=asset, + object_key=poster_stored.object_key, + bucket=poster_stored.bucket, + content_type=poster_stored.content_type, + size_bytes=poster_stored.size_bytes, + is_primary=False, + ) + except Exception: # noqa: BLE001 — 封面仅用于展示,失败不阻断 + logger.warning("free video poster extract failed for task %s", task.id, exc_info=True) + return asset + + +def finalize_free_video(*, task: AITask) -> AITask: + """单次轮询 + 幂等终态化。web poll 端点与 worker 兜底共用。 + + 防双 poll 双扣:succeeded 时先持锁把任务从 SUBMITTED/POLLING「认领」成 POSTPROCESSING, + 并发的另一路看到 POSTPROCESSING 直接返回;认领者独占 下载→TOS→建资产→结算 全程。 + 认领后崩溃由 _reap_stale_free_video_tasks(30 分钟)兜底退费。""" + if task.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING): + return task + if not task.provider_task_id: + return task + + from .services import build_provider + + provider = build_provider(task.model_config) + response = provider.poll_video_task( + endpoint=task.model_config.endpoint, provider_task_id=task.provider_task_id + ) + remote_status = str(response.get("status") or "") + + if remote_status in {"queued", "running", "processing", "submitted"}: + # 仍在生成:只在首次进入 POLLING 时落一次库(不逐次回写完整 response,省写带宽) + if task.status != AITask.Status.POLLING: + task.status = AITask.Status.POLLING + task.save(update_fields=["status", "updated_at"]) + return task + + if remote_status in {"failed", "expired", "cancelled"}: + err = response.get("error") or {} + code = str(err.get("code") or "") + raw_message = str(err.get("message") or "video generation failed") + friendly = map_video_error(code, raw_message) + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING): + return locked + locked.status = AITask.Status.FAILED + locked.error_code = code[:64] + locked.error_message = friendly + locked.response_payload = response + locked.completed_at = timezone.now() + locked.save( + update_fields=["status", "error_code", "error_message", "response_payload", "completed_at", "updated_at"] + ) + release_credit(reservation=locked.credit_reservation, reason=friendly) + _notify_failure(locked, raw=f"[{code}] {raw_message}") + return locked + + # succeeded —— 认领 POSTPROCESSING(并发 finalize 只有一路进入慢活) + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=task.id) + if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING): + return locked + locked.status = AITask.Status.POSTPROCESSING + locked.save(update_fields=["status", "updated_at"]) + + payload = dict(locked.request_payload or {}) + try: + media = provider.extract_first_media_url(response) + try: + _store_free_video_media(task=locked, media=media) + except Exception: # noqa: BLE001 — TOS 转存失败:兜底记火山临时 URL(约 24h 内可看),不吞成功 + logger.exception("free video TOS store failed for task %s, falling back to raw url", locked.id) + payload["fallback_video_url"] = media + payload["fallback_note"] = "结果转存失败,当前链接约 24 小时后失效" + + usage = response.get("usage") or {} + try: + total_tokens = int(usage.get("total_tokens") or 0) + except (TypeError, ValueError): + total_tokens = 0 + with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or []) + resolution = payload.get("resolution") or "720p" + if total_tokens > 0: + actual = tokens_to_cost( + locked.model_config, total_tokens, with_video_ref=with_video_ref, resolution=resolution + ) + payload["actual_tokens"] = total_tokens + else: + actual = locked.estimated_cost + seed_out = response.get("seed") + if seed_out is not None: + payload["seed_used"] = seed_out + + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=locked.id) + if locked.status != AITask.Status.POSTPROCESSING: + return locked + reservation = locked.credit_reservation + if actual > reservation.amount: + # ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 RESERVE_BUFFER) + logger.warning( + "free video task %s actual cost %s exceeds reserved %s, clamped", + locked.id, actual, reservation.amount, + ) + actual = reservation.amount + locked.status = AITask.Status.SUCCEEDED + locked.actual_cost = actual + locked.request_payload = payload + locked.response_payload = response + locked.completed_at = timezone.now() + locked.save( + update_fields=["status", "actual_cost", "request_payload", "response_payload", "completed_at", "updated_at"] + ) + charge_reserved_credit(reservation=reservation, actual_amount=actual) + return locked + except Exception as exc: # noqa: BLE001 — 后处理失败:标失败退费(release 幂等,已扣则不动) + logger.exception("free video finalize failed for task %s", locked.id) + with transaction.atomic(): + locked = AITask.objects.select_for_update().get(id=locked.id) + if locked.status != AITask.Status.POSTPROCESSING: + return locked + locked.status = AITask.Status.FAILED + locked.error_code = "PostprocessError" + locked.error_message = "视频结果处理失败,请重试" + locked.completed_at = timezone.now() + locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200]) + _notify_failure(locked, raw=str(exc)) + return locked + + +def serialize_free_video_task(task: AITask) -> dict: + """任务 → 前端契约。视频/封面直链从 generated_assets 取(TOS 公读),转存失败回落火山临时 URL。""" + payload = task.request_payload or {} + video_url = "" + thumbnail_url = "" + try: + storage = TosStorage() if settings.TOS.get("endpoint") else None + except Exception: # noqa: BLE001 + storage = None + + def _file_url(f) -> str: + if f.preview_url: + return f.preview_url + if storage and f.object_key: + try: + return storage.public_url(object_key=f.object_key, bucket=f.bucket or None) + except Exception: # noqa: BLE001 + return "" + return "" + + for asset in task.generated_assets.all(): + if asset.is_deleted: + continue + for f in asset.files.all(): + url = _file_url(f) + if not url: + continue + if f.is_primary and not video_url: + video_url = url + elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url: + thumbnail_url = url + if not video_url: + video_url = payload.get("fallback_video_url") or "" + + return { + "id": str(task.id), + "status": task.status, + "mode": payload.get("mode") or "universal", + "model": payload.get("model") or "", + "prompt": payload.get("prompt") or "", + "aspect_ratio": payload.get("aspect_ratio") or "16:9", + "resolution": payload.get("resolution") or "720p", + "duration": payload.get("duration") or 5, + "seed": payload.get("seed", -1), + "seed_used": payload.get("seed_used"), + "generate_audio": payload.get("generate_audio", True), + "references": payload.get("references") or [], + "estimated_tokens": payload.get("estimated_tokens") or 0, + "actual_tokens": payload.get("actual_tokens") or 0, + "estimated_cost": str(task.estimated_cost), + "actual_cost": str(task.actual_cost), + "error_message": task.error_message or "", + "fallback_note": payload.get("fallback_note") or "", + "is_favorited": task.is_favorited, + "video_url": video_url, + "thumbnail_url": thumbnail_url, + "created_at": task.created_at.isoformat() if task.created_at else None, + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + } diff --git a/core/backend/apps/ai/media_probe.py b/core/backend/apps/ai/media_probe.py new file mode 100644 index 0000000..8c640e4 --- /dev/null +++ b/core/backend/apps/ai/media_probe.py @@ -0,0 +1,46 @@ +"""自由创作·上传素材探测(移植自 jimeng-clone utils/media_utils.py)。 + +ffprobe 取视频/音频时长、ffmpeg 抽视频首帧缩略图。生产镜像已带 ffmpeg +(_generate_video_poster 在用)。全部 best-effort:探测失败返回 None,由调用方决定拒绝或放行。 +""" +import subprocess +import tempfile +from pathlib import Path + + +def probe_duration(file_path: str) -> float | None: + """ffprobe 取媒体时长(秒)。失败返回 None。""" + try: + proc = subprocess.run( + [ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(file_path), + ], + capture_output=True, + timeout=30, + ) + if proc.returncode != 0: + return None + return float(proc.stdout.decode().strip()) + except Exception: # noqa: BLE001 + return None + + +def extract_video_poster(file_path: str) -> bytes | None: + """ffmpeg 抽视频首帧 jpg 字节。失败返回 None。""" + try: + with tempfile.TemporaryDirectory(prefix="airshelf-freeprobe-") as tmp: + poster_path = Path(tmp) / "poster.jpg" + proc = subprocess.run( + ["ffmpeg", "-y", "-ss", "0", "-i", str(file_path), "-frames:v", "1", "-q:v", "3", str(poster_path)], + capture_output=True, + timeout=60, + ) + if proc.returncode != 0 or not poster_path.exists(): + return None + data = poster_path.read_bytes() + return data or None + except Exception: # noqa: BLE001 + return None diff --git a/core/backend/apps/ai/migrations/0022_aitask_is_deleted_aitask_is_favorited_and_more.py b/core/backend/apps/ai/migrations/0022_aitask_is_deleted_aitask_is_favorited_and_more.py new file mode 100644 index 0000000..f1adb45 --- /dev/null +++ b/core/backend/apps/ai/migrations/0022_aitask_is_deleted_aitask_is_favorited_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.15 on 2026-07-02 07:23 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0007_team_monthly_credit_limit"), + ("ai", "0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx"), + ("projects", "0006_migrate_storyboard_to_shots"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="aitask", + name="is_deleted", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="aitask", + name="is_favorited", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="aitask", + name="task_type", + field=models.CharField( + choices=[ + ("script_generation", "Script Generation"), + ("script_optimization", "Script Optimization"), + ("entity_extraction", "Entity Extraction"), + ("product_image", "Product Image"), + ("person_image", "Person Image"), + ("scene_image", "Scene Image"), + ("storyboard", "Storyboard"), + ("video_segment", "Video Segment"), + ("voiceover", "Voiceover"), + ("export", "Export"), + ("free_video", "Free Video"), + ], + max_length=48, + ), + ), + migrations.AddIndex( + model_name="aitask", + index=models.Index( + fields=["team", "task_type", "-created_at"], + name="ai_aitask_team_id_6a4627_idx", + ), + ), + ] diff --git a/core/backend/apps/ai/migrations/0023_seed_seedance_free_video_models.py b/core/backend/apps/ai/migrations/0023_seed_seedance_free_video_models.py new file mode 100644 index 0000000..cddf5a4 --- /dev/null +++ b/core/backend/apps/ai/migrations/0023_seed_seedance_free_video_models.py @@ -0,0 +1,119 @@ +"""Seed 自由创作视频三档模型(Seedance 2.0 标准/Fast/Mini)+ token 定价表。 + +- 定价存 ModelConfig.metadata["pricing"](元/百万tokens,按 分辨率档 × 是否含视频参考 取价), + 与 apps/ai/catalog.py 保持一致;数字来源火山 Seedance 2.0 官方价目。 +- 标准档已存在(catalog bootstrap 种的),只 merge pricing/resolutions 进 metadata,不覆盖其它键; + fast/mini 新建,is_default=False 且 created_at 晚于现有视频模型 → + get_default_model(VIDEO) 仍取原默认,pipeline 视频链路零回归。 +- 幂等:可重复 apply。 +""" +from django.db import migrations + +STANDARD_PRICING = { + "unit": "cny_per_million_tokens", + "default": {"no_ref_video": 46, "with_ref_video": 28}, + "1080p": {"no_ref_video": 51, "with_ref_video": 31}, + "4k": {"no_ref_video": 26, "with_ref_video": 16}, +} + +NEW_MODELS = [ + # name, display_name, pricing + ( + "doubao-seedance-2-0-fast-260128", + "Seedance-2.0-Fast", + {"unit": "cny_per_million_tokens", "default": {"no_ref_video": 37, "with_ref_video": 22}}, + ), + ( + "doubao-seedance-2-0-mini-260615", + "Seedance-2.0-Mini", + {"unit": "cny_per_million_tokens", "default": {"no_ref_video": 23, "with_ref_video": 14}}, + ), +] + +COMMON_METADATA = { + "audio": "optional", + "modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"], + "durations": list(range(4, 16)), + "resolutions": ["480p", "720p"], + "watermark": False, + "source": "jimeng-clone/backend/utils/airdrama_client.py", +} + + +def seed(apps, schema_editor): + ModelProvider = apps.get_model("ai", "ModelProvider") + ModelConfig = apps.get_model("ai", "ModelConfig") + + provider, _ = ModelProvider.objects.get_or_create( + name="volcengine", + defaults={ + "display_name": "火山引擎(豆包)", + "status": "active", + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + }, + ) + + # 标准档:merge pricing + 扩分辨率,不动 status/is_default/unit_price/其它 metadata + standard = ModelConfig.objects.filter( + provider=provider, name="doubao-seedance-2-0-260128", capability="video" + ).first() + if standard: + meta = dict(standard.metadata or {}) + meta["pricing"] = STANDARD_PRICING + meta["resolutions"] = ["480p", "720p", "1080p", "4k"] + standard.metadata = meta + standard.save(update_fields=["metadata"]) + else: + meta = dict(COMMON_METADATA) + meta["pricing"] = STANDARD_PRICING + meta["resolutions"] = ["480p", "720p", "1080p", "4k"] + ModelConfig.objects.create( + provider=provider, + name="doubao-seedance-2-0-260128", + capability="video", + display_name="Seedance-2.0", + endpoint="contents/generations/tasks", + status="active", + metadata=meta, + ) + + for name, display, pricing in NEW_MODELS: + meta = dict(COMMON_METADATA) + meta["pricing"] = pricing + obj, created = ModelConfig.objects.get_or_create( + provider=provider, + name=name, + capability="video", + defaults={ + "display_name": display, + "endpoint": "contents/generations/tasks", + "status": "active", + "metadata": meta, + }, + ) + if not created: + existing = dict(obj.metadata or {}) + existing.setdefault("resolutions", meta["resolutions"]) + existing.setdefault("durations", meta["durations"]) + existing["pricing"] = pricing + obj.metadata = existing + if obj.status != "active": + obj.status = "active" + obj.save(update_fields=["metadata", "status"]) + else: + obj.save(update_fields=["metadata"]) + + +def unseed(apps, schema_editor): + # 保守反向:只停用本迁移新建的两档,不删数据、不动标准档 metadata + ModelConfig = apps.get_model("ai", "ModelConfig") + ModelConfig.objects.filter( + provider__name="volcengine", + capability="video", + name__in=[n for n, _, _ in NEW_MODELS], + ).update(status="disabled") + + +class Migration(migrations.Migration): + dependencies = [("ai", "0022_aitask_is_deleted_aitask_is_favorited_and_more")] + operations = [migrations.RunPython(seed, unseed)] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 2ac436d..292cf9c 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -97,6 +97,8 @@ class AITask(TeamOwnedModel): VIDEO_SEGMENT = "video_segment", "Video Segment" VOICEOVER = "voiceover", "Voiceover" EXPORT = "export", "Export" + # 自由创作(不绑 project 的独立视频生成,universal 全能参考 / keyframe 首尾帧) + FREE_VIDEO = "free_video", "Free Video" class Status(models.TextChoices): CREATED = "created", "Created" @@ -140,6 +142,9 @@ class AITask(TeamOwnedModel): # YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊 # 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。 read_at = models.DateTimeField(null=True, blank=True) + # 自由创作任务流的收藏 / 软删(其它任务类型恒 False,无行为影响) + is_favorited = models.BooleanField(default=False) + is_deleted = models.BooleanField(default=False) class Meta: indexes = [ @@ -152,6 +157,8 @@ class AITask(TeamOwnedModel): models.Index(fields=["conversation", "created_at"]), # YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标) models.Index(fields=["team", "read_at"]), + # 自由创作任务流:按团队 + 类型倒序分页 + models.Index(fields=["team", "task_type", "-created_at"]), ] def __str__(self) -> str: diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py index fd9b23e..037a341 100644 --- a/core/backend/apps/ai/providers/volcano.py +++ b/core/backend/apps/ai/providers/volcano.py @@ -192,12 +192,19 @@ class VolcanoArkProvider: resolution: str = "720p", reference_images: list[str] | None = None, generate_audio: bool = True, + content_items: list[dict[str, Any]] | None = None, + seed: int | None = None, + search_mode: str = "off", ) -> dict[str, Any]: if not self.api_key: raise ValueError("VOLCANO_ARK_API_KEY is not configured") content: list[dict[str, Any]] = [{"type": "text", "text": prompt}] - for image_url in reference_images or []: - content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"}) + if content_items is not None: + # 自由创作:调用方整段接管参考素材(混合 image/video/audio + role first_frame/last_frame 等) + content.extend(content_items) + else: + for image_url in reference_images or []: + content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"}) body = { "model": model, "content": content, @@ -208,6 +215,10 @@ class VolcanoArkProvider: # Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。 "generate_audio": generate_audio, } + if seed is not None and seed != -1: + body["seed"] = seed + if search_mode == "smart": + body["tools"] = [{"type": "web_search"}] response = requests.post( f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, @@ -236,9 +247,24 @@ class VolcanoArkProvider: return item["url"] if item.get("b64_json"): return item["b64_json"] - content = data.get("content") or {} - if content.get("video_url"): - return content["video_url"] + # 视频任务响应的 content 有两种形态:dict {video_url: "..."} 或 + # list [{type:"video_url", video_url:{url:"..."}}](Seedance 2.0 多模态响应)。 + content = data.get("content") + if isinstance(content, dict): + video_url = content.get("video_url") + if isinstance(video_url, str) and video_url: + return video_url + if isinstance(video_url, dict) and video_url.get("url"): + return video_url["url"] + if isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + video_url = item.get("video_url") + if isinstance(video_url, str) and video_url: + return video_url + if isinstance(video_url, dict) and video_url.get("url"): + return video_url["url"] raise ValueError("Volcano response does not contain media url") @staticmethod diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index a4906fd..e51add4 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -698,6 +698,9 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str, suffix = ".jpg" elif "webp" in content_type: suffix = ".webp" + # 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 抛 + # "I/O operation on closed file" 被下面的 except 吞掉 → 视频封面一直静默抽不出来(自由创作联调实测)。 + raw_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b"" asset_id = uuid.uuid4() object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}" stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) @@ -721,11 +724,7 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str, ) # 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图 if "video" in content_type: - try: - video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b"" - except Exception: # noqa: BLE001 - video_bytes = b"" - poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id) + poster = _generate_video_poster(video_bytes=raw_bytes, team=team, project=project, asset_id=asset_id) if poster: AssetFile.objects.create( asset=asset, diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py index 07cef5a..8fb3fd7 100644 --- a/core/backend/apps/ai/tasks.py +++ b/core/backend/apps/ai/tasks.py @@ -50,3 +50,30 @@ def generate_triview_task(self, task_id: str) -> str: run_triview_task(task_id=task_id) return task_id + +@app.task(bind=True, max_retries=0) +def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str: + """自由创作视频·worker 兜底轮询:每 30s 一次自重排(不依赖 celery beat), + 上限 60 次(≈30 分钟,足够 Seedance 5-10 分钟出片)。finalize 幂等(POSTPROCESSING 认领), + 与前端主动 poll 并存不双扣。轮询本身出错不重试(max_retries=0),下一次自重排继续。""" + from apps.ai.free_video import finalize_free_video + from apps.ai.models import AITask + + task = AITask.objects.select_related("model_config", "model_config__provider", "team").filter(id=task_id).first() + if task is None: + return task_id + try: + task = finalize_free_video(task=task) + except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动等)不终结任务,等下一轮 + import logging + + logging.getLogger(__name__).warning("poll_free_video_task %s attempt %s failed", task_id, attempt, exc_info=True) + # eager(本地联调/单测)下 apply_async 会内联立即执行,自重排=同步死循环 → 跳过,收尾交给前端主动 poll + from django.conf import settings as dj_settings + + if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False): + return task_id + if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < 60: + poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30) + return task_id + diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py new file mode 100644 index 0000000..46fe4c6 --- /dev/null +++ b/core/backend/apps/ai/test_free_video.py @@ -0,0 +1,412 @@ +"""自由创作视频(free_video)单测:估价 / @替换 / 提交与退费 / 终态幂等 / 并发闸 / 僵尸回收 / API。 + +运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_free_video --settings=airshelf.settings.test +provider 全程 mock(不触网);模型行由迁移 0023 种子提供。 +""" +from decimal import Decimal +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from rest_framework.test import APIClient + +from apps.accounts.models import Team, TeamMember, User +from apps.ai.free_video import ( + build_content_items, + finalize_free_video, + find_orphan_material_mention, + submit_free_video, +) +from apps.ai.models import AITask, ModelConfig +from apps.ai.video_pricing import ( + RESERVE_BUFFER, + calculate_cost, + estimate_tokens, + estimate_video_cost, + get_resolution, + get_token_price, +) +from apps.billing.models import CreditAccount, CreditLedger, CreditReservation + +STANDARD = "doubao-seedance-2-0-260128" +FAST = "doubao-seedance-2-0-fast-260128" +MINI = "doubao-seedance-2-0-mini-260615" + + +def _model(name=STANDARD) -> ModelConfig: + return ModelConfig.objects.get(name=name, capability="video") + + +class VideoPricingTests(TestCase): + """token 公式与分档取价(与 jimeng billing.py / QuotaConfig 数字对齐)。""" + + def test_resolution_map_spot_checks(self): + self.assertEqual(get_resolution("16:9", "720p"), (1280, 720)) + self.assertEqual(get_resolution("9:16", "480p"), (496, 864)) + self.assertEqual(get_resolution("21:9", "4k"), (4398, 1886)) + with self.assertRaises(KeyError): + get_resolution("2:3", "720p") + + def test_estimate_tokens_formula(self): + # (1280×720×24×5)/1024 = 108000 + self.assertEqual(estimate_tokens(1280, 720, 5), 108000) + # 输入参考视频时长计入 + self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=3), estimate_tokens(1280, 720, 8)) + + def test_token_price_tiers(self): + std = _model(STANDARD) + self.assertEqual(get_token_price(std, False, "720p"), Decimal("46")) + self.assertEqual(get_token_price(std, True, "720p"), Decimal("28")) + self.assertEqual(get_token_price(std, False, "1080p"), Decimal("51")) + self.assertEqual(get_token_price(std, True, "4k"), Decimal("16")) + self.assertEqual(get_token_price(_model(FAST), False, "480p"), Decimal("37")) + self.assertEqual(get_token_price(_model(MINI), True, "720p"), Decimal("14")) + + def test_fast_1080p_fails_loud(self): + # fast/mini 没有 1080p/4k 档价:绝不静默按 default 计费 + with self.assertRaises(ValueError): + get_token_price(_model(FAST), False, "1080p") + with self.assertRaises(ValueError): + get_token_price(_model(MINI), True, "4k") + + def test_estimate_video_cost_with_video_reference(self): + std = _model(STANDARD) + refs = [{"type": "video", "duration": 3.0}] + tokens, cost = estimate_video_cost(std, aspect_ratio="16:9", resolution="720p", duration=5, references=refs) + self.assertEqual(tokens, estimate_tokens(1280, 720, 5, input_video_duration=3.0)) + self.assertEqual(cost, calculate_cost(tokens, Decimal("28"))) # 含视频参考 → with_ref_video 价 + + +class BuildContentItemsTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="fvowner", password="p") + self.team = Team.objects.create(name="FV", owner=self.user) + + def test_label_replacement_length_desc_no_substring_swallow(self): + refs = [ + {"url": "http://x/a.png", "type": "image", "label": "碧"}, + {"url": "http://x/b.png", "type": "image", "label": "碧碧"}, + ] + built = build_content_items(team=self.team, prompt="@碧碧 拥抱 @碧", mode="universal", references=refs) + # 「碧碧」(图片2)必须先于「碧」(图片1)替换,否则被吞成「图片1碧」 + self.assertEqual(built["api_prompt"], "图片2 拥抱 图片1") + + def test_counters_match_content_items(self): + refs = [ + {"url": "http://x/1.png", "type": "image", "label": "图a"}, + {"url": "http://x/2.png", "type": "image", "label": "图b"}, + {"url": "http://x/v.mp4", "type": "video", "label": "视a", "duration": 3}, + ] + built = build_content_items(team=self.team, prompt="@图a @图b @视a", mode="universal", references=refs) + self.assertEqual(built["image_n"], 2) + self.assertEqual(built["video_n"], 1) + self.assertEqual(built["api_prompt"], "图片1 图片2 视频1") + roles = [i.get("role") for i in built["content_items"]] + self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"]) + self.assertEqual(built["video_duration_total"], 3.0) + + def test_keyframe_roles(self): + refs = [ + {"url": "http://x/f.png", "type": "image", "role": "first_frame"}, + {"url": "http://x/l.png", "type": "image", "role": "last_frame"}, + ] + built = build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs) + self.assertEqual([i["role"] for i in built["content_items"]], ["first_frame", "last_frame"]) + + def test_too_many_images_rejected(self): + refs = [{"url": f"http://x/{i}.png", "type": "image"} for i in range(10)] + with self.assertRaisesMessage(ValueError, "最多 9 张"): + build_content_items(team=self.team, prompt="p", mode="universal", references=refs) + + def test_audio_alone_rejected(self): + refs = [{"url": "http://x/a.mp3", "type": "audio", "duration": 5}] + with self.assertRaisesMessage(ValueError, "音频不能单独"): + build_content_items(team=self.team, prompt="p", mode="universal", references=refs) + + def test_blob_url_rejected(self): + refs = [{"url": "blob:http://x/abc", "type": "image", "label": "残"}] + with self.assertRaisesMessage(ValueError, "上传失败"): + build_content_items(team=self.team, prompt="p", mode="universal", references=refs) + + def test_orphan_mention(self): + self.assertIsNotNone(find_orphan_material_mention("让 @图片1 动起来", [])) + self.assertIsNone(find_orphan_material_mention("让 @图片1 动起来", [{"url": "u"}])) + self.assertIsNone(find_orphan_material_mention("普通提示词", [])) + + +def _ark_create_response(task_id="ark-1"): + return {"id": task_id, "status": "queued"} + + +class SubmitFreeVideoTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="fvsub", password="p") + self.team = Team.objects.create(name="FVS", owner=self.user) + CreditAccount.objects.create(team=self.team, balance="100.0000") + self.provider = MagicMock() + self.provider.create_video_task.return_value = _ark_create_response() + patch("apps.ai.services.build_provider", return_value=self.provider).start() + patch("apps.ai.tasks.poll_free_video_task.apply_async").start() # eager celery 会同步跑,隔离掉 + self.addCleanup(patch.stopall) + + def _params(self, **over): + base = { + "prompt": "一只猫在海边奔跑", + "mode": "universal", + "model": STANDARD, + "aspect_ratio": "16:9", + "resolution": "480p", + "duration": 4, + "references": [], + } + base.update(over) + return base + + def test_submit_success_reserves_with_buffer(self): + task = submit_free_video(team=self.team, user=self.user, params=self._params()) + self.assertEqual(task.status, AITask.Status.SUBMITTED) + self.assertEqual(task.provider_task_id, "ark-1") + tokens, cost = estimate_video_cost( + _model(STANDARD), aspect_ratio="16:9", resolution="480p", duration=4, references=[] + ) + self.assertEqual(task.estimated_cost, cost) + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(reservation.amount, (cost * RESERVE_BUFFER).quantize(Decimal("0.01"))) + # 提交参数按契约落 payload + self.assertEqual(task.request_payload["estimated_tokens"], tokens) + self.assertEqual(task.request_payload["feature"], "free_video") + + def test_insufficient_balance_leaves_nothing(self): + CreditAccount.objects.filter(team=self.team).update(balance="0.0100") + with self.assertRaisesMessage(ValueError, "余额不足"): + submit_free_video(team=self.team, user=self.user, params=self._params()) + # 不留半套:任务行随事务回滚 + self.assertEqual(AITask.objects.filter(team=self.team, task_type=AITask.Type.FREE_VIDEO).count(), 0) + + def test_provider_failure_marks_failed_and_releases(self): + self.provider.create_video_task.side_effect = RuntimeError( + "火山报错 [InputTextSensitiveContentDetected] text blocked" + ) + task = submit_free_video(team=self.team, user=self.user, params=self._params()) + self.assertEqual(task.status, AITask.Status.FAILED) + self.assertEqual(task.error_message, "提示词包含敏感内容,请修改后重试") + self.assertEqual(task.error_code, "InputTextSensitiveContentDetected") + account = CreditAccount.objects.get(team=self.team) + self.assertEqual(account.reserved_balance, Decimal("0")) + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(reservation.status, CreditReservation.Status.RELEASED) + + def test_fast_1080p_rejected(self): + with self.assertRaisesMessage(ValueError, "仅标准档"): + submit_free_video( + team=self.team, user=self.user, params=self._params(model=FAST, resolution="1080p") + ) + + def test_orphan_mention_rejected(self): + with self.assertRaisesMessage(ValueError, "对应的内容为空"): + submit_free_video(team=self.team, user=self.user, params=self._params(prompt="让 @图片1 动")) + + def test_keyframe_requires_first_frame(self): + with self.assertRaisesMessage(ValueError, "首帧"): + submit_free_video(team=self.team, user=self.user, params=self._params(mode="keyframe")) + + def test_concurrency_gate(self): + for _ in range(3): + submit_free_video(team=self.team, user=self.user, params=self._params()) + with self.assertRaisesMessage(ValueError, "上限"): + submit_free_video(team=self.team, user=self.user, params=self._params()) + + def test_reap_stale_reserved_refunds(self): + from datetime import timedelta + + from django.utils import timezone + + task = submit_free_video(team=self.team, user=self.user, params=self._params()) + # 人造僵尸:RESERVED 且 20 分钟没动静 + AITask.objects.filter(id=task.id).update( + status=AITask.Status.RESERVED, updated_at=timezone.now() - timedelta(minutes=20) + ) + submit_free_video(team=self.team, user=self.user, params=self._params()) + task.refresh_from_db() + self.assertEqual(task.status, AITask.Status.FAILED) + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(reservation.status, CreditReservation.Status.RELEASED) + + +class FinalizeFreeVideoTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="fvfin", password="p") + self.team = Team.objects.create(name="FVF", owner=self.user) + CreditAccount.objects.create(team=self.team, balance="100.0000") + self.provider = MagicMock() + self.provider.create_video_task.return_value = _ark_create_response() + self.provider.extract_first_media_url.return_value = "http://ark/video.mp4" + patch("apps.ai.services.build_provider", return_value=self.provider).start() + patch("apps.ai.tasks.poll_free_video_task.apply_async").start() + # 媒体落库(下载/TOS/建资产)单测里 mock 掉,聚焦状态机与账务 + self.store = patch("apps.ai.free_video._store_free_video_media").start() + self.addCleanup(patch.stopall) + self.task = submit_free_video( + team=self.team, + user=self.user, + params={ + "prompt": "海边", + "mode": "universal", + "model": STANDARD, + "aspect_ratio": "16:9", + "resolution": "480p", + "duration": 4, + "references": [], + }, + ) + + def test_running_sets_polling_once(self): + self.provider.poll_video_task.return_value = {"status": "running"} + task = finalize_free_video(task=self.task) + self.assertEqual(task.status, AITask.Status.POLLING) + + def test_failed_maps_error_and_releases(self): + self.provider.poll_video_task.return_value = { + "status": "failed", + "error": {"code": "OutputVideoSensitiveContentDetected", "message": "output blocked"}, + } + task = finalize_free_video(task=self.task) + self.assertEqual(task.status, AITask.Status.FAILED) + self.assertIn("已被系统拦截", task.error_message) + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(reservation.status, CreditReservation.Status.RELEASED) + + def test_succeeded_charges_by_actual_tokens(self): + # 真实 tokens 略低于预估:按真实结算,差额自动 RELEASE + self.provider.poll_video_task.return_value = { + "status": "succeeded", + "usage": {"total_tokens": 30000}, + "seed": 42, + } + task = finalize_free_video(task=self.task) + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + expected = calculate_cost(30000, Decimal("46")) + self.assertEqual(task.actual_cost, expected) + self.assertEqual(task.request_payload["seed_used"], 42) + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(reservation.status, CreditReservation.Status.CHARGED) + account = CreditAccount.objects.get(team=self.team) + self.assertEqual(account.balance, Decimal("100.0000") - expected) + self.assertEqual(account.reserved_balance, Decimal("0")) + self.store.assert_called_once() + + def test_succeeded_clamps_when_tokens_exceed_reserve(self): + # 真实 tokens 远超预估:clamp 到预留额,不抛错、不超扣 + self.provider.poll_video_task.return_value = { + "status": "succeeded", + "usage": {"total_tokens": 10_000_000}, + } + task = finalize_free_video(task=self.task) + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(task.actual_cost, reservation.amount) + self.assertEqual(reservation.status, CreditReservation.Status.CHARGED) + + def test_double_finalize_charges_once(self): + self.provider.poll_video_task.return_value = { + "status": "succeeded", + "usage": {"total_tokens": 30000}, + } + first = finalize_free_video(task=self.task) + self.task.refresh_from_db() + second = finalize_free_video(task=self.task) + self.assertEqual(first.status, AITask.Status.SUCCEEDED) + self.assertEqual(second.status, AITask.Status.SUCCEEDED) + charges = CreditLedger.objects.filter(task=self.task, ledger_type=CreditLedger.Type.CHARGE).count() + self.assertEqual(charges, 1) + self.store.assert_called_once() + + def test_store_failure_falls_back_to_raw_url_and_still_charges(self): + # TOS 转存失败:不吞成功——记火山临时 URL + 正常结算(jimeng 同行为) + self.store.side_effect = RuntimeError("tos down") + self.provider.poll_video_task.return_value = { + "status": "succeeded", + "usage": {"total_tokens": 30000}, + } + task = finalize_free_video(task=self.task) + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + self.assertEqual(task.request_payload["fallback_video_url"], "http://ark/video.mp4") + reservation = CreditReservation.objects.get(task=task) + self.assertEqual(reservation.status, CreditReservation.Status.CHARGED) + + +class FreeVideoApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="fvapi", password="p") + self.team = Team.objects.create(name="FVA", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active") + CreditAccount.objects.create(team=self.team, balance="100.0000") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.provider = MagicMock() + self.provider.create_video_task.return_value = _ark_create_response() + patch("apps.ai.services.build_provider", return_value=self.provider).start() + patch("apps.ai.tasks.poll_free_video_task.apply_async").start() + self.addCleanup(patch.stopall) + + def _submit(self): + return self.client.post( + "/api/ai/free-video/", + { + "prompt": "一只猫", + "mode": "universal", + "model": STANDARD, + "aspect_ratio": "16:9", + "resolution": "480p", + "duration": 4, + "references": [], + }, + format="json", + ) + + def test_submit_and_list(self): + resp = self._submit() + self.assertEqual(resp.status_code, 202) + body = resp.json()["task"] + self.assertEqual(body["status"], AITask.Status.SUBMITTED) + self.assertEqual(body["model"], STANDARD) + listing = self.client.get("/api/ai/free-video/").json() + self.assertEqual(listing["total"], 1) + self.assertEqual(listing["results"][0]["id"], body["id"]) + self.assertFalse(listing["has_more"]) + + def test_validation_error_returns_400(self): + resp = self.client.post("/api/ai/free-video/", {"prompt": ""}, format="json") + self.assertEqual(resp.status_code, 400) + + def test_favorite_toggle_and_delete_rules(self): + task_id = self._submit().json()["task"]["id"] + fav = self.client.post(f"/api/ai/free-video/{task_id}/favorite/") + self.assertTrue(fav.json()["is_favorited"]) + # 在途拒删 + resp = self.client.delete(f"/api/ai/free-video/{task_id}/") + self.assertEqual(resp.status_code, 400) + AITask.objects.filter(id=task_id).update(status=AITask.Status.SUCCEEDED) + resp = self.client.delete(f"/api/ai/free-video/{task_id}/") + self.assertEqual(resp.status_code, 204) + listing = self.client.get("/api/ai/free-video/").json() + self.assertEqual(listing["total"], 0) + + def test_team_isolation(self): + self._submit() + stranger = User.objects.create_user(username="stranger", password="p") + other_team = Team.objects.create(name="Other", owner=stranger) + TeamMember.objects.create(team=other_team, user=stranger, role="owner", status="active") + other = APIClient() + other.force_authenticate(stranger) + listing = other.get("/api/ai/free-video/").json() + self.assertEqual(listing["total"], 0) + + def test_poll_endpoint_finalizes(self): + task_id = self._submit().json()["task"]["id"] + self.provider.poll_video_task.return_value = { + "status": "failed", + "error": {"code": "InternalError", "message": "boom"}, + } + resp = self.client.post(f"/api/ai/free-video/{task_id}/poll/") + self.assertEqual(resp.json()["task"]["status"], AITask.Status.FAILED) + self.assertIn("服务异常", resp.json()["task"]["error_message"]) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index aecebfc..ece6451 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -276,7 +276,7 @@ class StandaloneImageReferenceTests(TestCase): image_edit 的 gpt-image-2——否则参考图形同虚设。回归保护「选火山+传参考图=完全不参考」。""" from apps.ai.models import ModelProvider - vp = ModelProvider.objects.create(name="volcengine", display_name="火山") + vp, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "火山"}) ModelConfig.objects.create(provider=vp, name="seedream-4", display_name="Seedream", capability=ModelConfig.Capability.IMAGE) self._patch_provider() ref = Asset.objects.create( diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py index 909b089..e56df55 100644 --- a/core/backend/apps/ai/urls.py +++ b/core/backend/apps/ai/urls.py @@ -1,7 +1,17 @@ from django.urls import path from rest_framework.routers import DefaultRouter -from .views import AITaskViewSet, GenerateImageView, ImageConversationViewSet, ModelConfigViewSet +from .views import ( + AITaskViewSet, + FreeVideoDetailView, + FreeVideoFavoriteView, + FreeVideoPollView, + FreeVideoUploadView, + FreeVideoView, + GenerateImageView, + ImageConversationViewSet, + ModelConfigViewSet, +) router = DefaultRouter() router.register("tasks", AITaskViewSet, basename="ai-task") @@ -10,4 +20,9 @@ router.register("image-conversations", ImageConversationViewSet, basename="image urlpatterns = [ path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"), + path("free-video/", FreeVideoView.as_view(), name="ai-free-video"), + path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"), + path("free-video//poll/", FreeVideoPollView.as_view(), name="ai-free-video-poll"), + path("free-video//favorite/", FreeVideoFavoriteView.as_view(), name="ai-free-video-favorite"), + path("free-video//", FreeVideoDetailView.as_view(), name="ai-free-video-detail"), ] + router.urls diff --git a/core/backend/apps/ai/video_errors.py b/core/backend/apps/ai/video_errors.py new file mode 100644 index 0000000..7ff0d65 --- /dev/null +++ b/core/backend/apps/ai/video_errors.py @@ -0,0 +1,75 @@ +"""自由创作视频·火山错误码 → 用户友好中文映射(移植自 jimeng-clone utils/airdrama_client.py)。 + +两级匹配:① error code 精确匹配 ② message 关键词兜底。 +仅供 free_video 链路使用,不动现有 friendly_generation_error(避免影响项目视频链路文案)。 +原始报错(code + message)必须同时落 task.error_code / 日志,不允许只留友好文案吞根因 +(故事板 moderation_blocked 事故教训)。 +""" +import re + +ERROR_MESSAGES = { + # 输入内容审核 — 人脸/敏感内容 + "InputImageSensitiveContentDetected.PrivacyInformation": "参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片", + "InputImageSensitiveContentDetected": "参考图片包含敏感内容,请更换图片后重试", + "InputVideoSensitiveContentDetected.PrivacyInformation": "参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频", + "InputVideoSensitiveContentDetected": "参考视频包含敏感内容,请更换视频后重试", + "InputTextSensitiveContentDetected": "提示词包含敏感内容,请修改后重试", + "InputAudioSensitiveContentDetected": "参考音频包含敏感内容,请更换音频后重试", + # 输出内容审核 + "OutputVideoSensitiveContentDetected": "生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试", + "OutputVideoSensitiveContentDetected.PolicyViolation": "生成的视频涉及版权限制内容(如知名IP、名人肖像等),已被系统拦截,请修改提示词后重试", + "OutputImageSensitiveContentDetected": "生成的图片包含敏感内容,已被系统拦截", + # 参数错误 + "InvalidParameter": "请求参数无效,请检查输入内容", + "InvalidImage": "图片格式或尺寸不符合要求,请检查后重试", + "InvalidVideo": "视频格式或尺寸不符合要求,请检查后重试", + "InvalidAudio": "音频格式不符合要求,请检查后重试", + "AudioDurationExceeded": "音频总时长超过15秒限制,请缩短音频后重试", + "AudioFormatNotSupported": "音频格式不支持,请使用 MP3 或 WAV 格式", + # 限流 + "RateLimitExceeded": "请求过于频繁,请稍后重试", + "ConcurrencyLimitExceeded": "当前生成任务过多,请稍后重试", + # 账户 + "InsufficientBalance": "平台账户余额不足,请联系管理员", + # 素材 + "AssetNotFound": "引用的素材不存在或已被删除,请检查素材库", + # 服务端 + "ServerOverloaded": "服务器繁忙,请稍后重试", + "InternalError": "视频生成服务异常,请稍后重试", + "Timeout": "生成超时,请重试", +} + +# 关键词匹配:message 包含这些关键词时映射为中文提示(code 未命中时兜底) +_MESSAGE_KEYWORDS = { + "face": "检测到真实人脸,请使用虚拟人像素材替代真人照片", + "privacy": "检测到真实人脸,请使用虚拟人像素材替代真人照片", + "sensitive": "内容包含敏感信息,请修改后重试", + "not found": "引用的素材不存在或已被删除,请检查素材库", + "not valid": "请求参数无效,请检查输入内容", + "audio duration": "音频总时长超过15秒限制,请缩短音频后重试", + "audio": "音频不符合要求(支持MP3/WAV,单条2-15秒,总时长≤15秒)", +} + +# provider 创建阶段抛 RuntimeError("火山报错 [code] message"),从中抽 code/message +_RUNTIME_ERROR_RE = re.compile(r"火山报错 \[([^\]]*)\]\s*(.*)", re.S) + + +def map_video_error(code: str, message: str) -> str: + """error code / message → 用户友好中文。永远返回非空文案。""" + friendly = ERROR_MESSAGES.get(code or "") + if not friendly: + msg_lower = (message or "").lower() + for keyword, hint in _MESSAGE_KEYWORDS.items(): + if keyword in msg_lower: + friendly = hint + break + return friendly or "生成失败,请重试" + + +def parse_provider_error(exc: Exception) -> tuple[str, str]: + """从 provider 抛出的异常抽 (code, message)。抽不出则 code 为空、message 取异常文本。""" + text = str(exc) + m = _RUNTIME_ERROR_RE.search(text) + if m: + return m.group(1).strip(), m.group(2).strip() + return "", text diff --git a/core/backend/apps/ai/video_pricing.py b/core/backend/apps/ai/video_pricing.py new file mode 100644 index 0000000..91e7391 --- /dev/null +++ b/core/backend/apps/ai/video_pricing.py @@ -0,0 +1,136 @@ +"""自由创作视频 token 计费(移植自 jimeng-clone backend/utils/billing.py)。 + +Token 预估公式(火山官方):(输入视频时长 + 输出时长) × 宽 × 高 × 帧率 / 1024 +单价:元/百万tokens,存 ModelConfig.metadata["pricing"],按 分辨率档 × 是否含视频参考 取价。 + +⚠️ 预估仅用于前端展示与额度预留;真实费用以火山返回 usage.total_tokens 结算。 +预留额外加 RESERVE_BUFFER(ledger 禁超预留扣费,真实 tokens 可能高于预估), +结算时 clamp 到预留额并对差额告警。 +""" +from decimal import Decimal, ROUND_HALF_UP + +# 分辨率 → 像素映射(火山 Seedance 2.0 API 文档) +RESOLUTION_MAP = { + # 720p + ("720p", "16:9"): (1280, 720), + ("720p", "9:16"): (720, 1280), + ("720p", "4:3"): (1112, 834), + ("720p", "1:1"): (960, 960), + ("720p", "3:4"): (834, 1112), + ("720p", "21:9"): (1470, 630), + # 480p + ("480p", "16:9"): (864, 496), + ("480p", "9:16"): (496, 864), + ("480p", "4:3"): (752, 560), + ("480p", "1:1"): (640, 640), + ("480p", "3:4"): (560, 752), + ("480p", "21:9"): (992, 432), + # 1080p(仅标准档) + ("1080p", "16:9"): (1920, 1080), + ("1080p", "9:16"): (1080, 1920), + ("1080p", "4:3"): (1664, 1248), + ("1080p", "1:1"): (1440, 1440), + ("1080p", "3:4"): (1248, 1664), + ("1080p", "21:9"): (2206, 946), + # 4k(仅标准档) + ("4k", "16:9"): (3840, 2160), + ("4k", "9:16"): (2160, 3840), + ("4k", "4:3"): (3326, 2494), + ("4k", "1:1"): (2880, 2880), + ("4k", "3:4"): (2494, 3326), + ("4k", "21:9"): (4398, 1886), +} + +DEFAULT_FPS = 24 + +# 预留 = 预估费用 × buffer。ledger 的 charge_reserved_credit 在 actual > reserved 时抛错, +# 而火山真实 tokens 有最低用量限制/输入视频真实时长偏差,可能略高于预估。 +RESERVE_BUFFER = Decimal("1.10") + + +def get_resolution(aspect_ratio: str, tier: str) -> tuple: + """(tier, aspect_ratio) → (width, height)。非法组合 KeyError fail loud,不静默降级。""" + key = (tier, aspect_ratio) + if key not in RESOLUTION_MAP: + raise KeyError( + f"不支持的分辨率组合: tier={tier!r}, aspect_ratio={aspect_ratio!r}. " + f"仅支持 480p/720p/1080p/4k × 16:9/9:16/4:3/1:1/3:4/21:9" + ) + return RESOLUTION_MAP[key] + + +def estimate_tokens( + width: int, + height: int, + duration: int, + fps: int = DEFAULT_FPS, + input_video_duration: float = 0, +) -> int: + total_duration = duration + (input_video_duration or 0) + return round(width * height * fps * total_duration / 1024) + + +def has_video_reference(references: list) -> bool: + return any((ref or {}).get("type") == "video" for ref in references or []) + + +def sum_video_duration(references: list) -> float: + """输入参考视频总时长(秒),计入 token 公式的输入时长项。""" + return sum( + float(ref.get("duration") or 0) + for ref in references or [] + if (ref or {}).get("type") == "video" + ) + + +def get_token_price(model_config, with_video_ref: bool, resolution: str) -> Decimal: + """从 ModelConfig.metadata["pricing"] 取单价(元/百万tokens)。 + + 先按 resolution 精确键,无则回落 "default";缺 pricing/缺键抛 ValueError fail loud + (1080p/4k 只有标准档配了键,fast/mini 在提交校验就被拒,不允许静默按 720p 计价)。 + """ + pricing = (model_config.metadata or {}).get("pricing") or {} + # 1080p/4k 有独立价:缺该档键 = 模型不支持该分辨率(fast/mini),fail loud, + # 绝不按 default(480p/720p)价静默计费——那是欺骗用户(jimeng _get_token_price 同原则)。 + if resolution in ("1080p", "4k") and resolution not in pricing: + raise ValueError(f"模型 {model_config.name} 不支持 {resolution}——提交校验应已拦截,不应进到计价") + tier = pricing.get(resolution) or pricing.get("default") + if not tier: + raise ValueError(f"模型 {model_config.name} 未配置 pricing(resolution={resolution})") + key = "with_ref_video" if with_video_ref else "no_ref_video" + price = tier.get(key) + if price is None: + raise ValueError(f"模型 {model_config.name} pricing 缺 {resolution}/{key} 档单价") + return Decimal(str(price)) + + +def calculate_cost(tokens: int, price: Decimal) -> Decimal: + """tokens × 单价(元/百万tokens),保留 2 位小数。""" + cost = Decimal(str(tokens)) * Decimal(str(price)) / Decimal("1000000") + return cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def estimate_video_cost( + model_config, + *, + aspect_ratio: str, + resolution: str, + duration: int, + references: list, +) -> tuple[int, Decimal]: + """返回 (预估 tokens, 预估费用元)。供提交预留与前端预估口径对齐。""" + width, height = get_resolution(aspect_ratio, resolution) + tokens = estimate_tokens( + width, + height, + duration, + input_video_duration=sum_video_duration(references), + ) + price = get_token_price(model_config, has_video_reference(references), resolution) + return tokens, calculate_cost(tokens, price) + + +def tokens_to_cost(model_config, tokens: int, *, with_video_ref: bool, resolution: str) -> Decimal: + """按真实 usage.total_tokens 计价(结算口径,与预估同一张价表)。""" + price = get_token_price(model_config, with_video_ref, resolution) + return calculate_cost(tokens, price) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 723f195..41beb22 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -2,6 +2,7 @@ from django.db.models import Count from django.utils import timezone from rest_framework import status from rest_framework.decorators import action +from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet @@ -308,6 +309,253 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): return Response({"conversation_id": str(conversation.id), "tasks": data}) +def _free_video_task_queryset(team): + return ( + AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False) + .select_related("model_config") + .prefetch_related("generated_assets", "generated_assets__files") + ) + + +class FreeVideoView(APIView): + """自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。 + + POST /api/ai/free-video/ 提交任务,秒回(火山 create 同步调、轮询交给 worker 兜底 + 前端主动 poll) + GET /api/ai/free-video/ 任务流分页(offset/page_size,新→旧) + """ + + def post(self, request): + require_worker() # 生成闸:无 worker 时任务提交火山后无人兜底轮询(额度冻结、结果丢失) + from .free_video import serialize_free_video_task, submit_free_video + + team = get_current_team(request.user) + try: + task = submit_free_video(team=team, user=request.user, params=request.data or {}) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + # 重取带 prefetch 的实例,序列化统一走同一条路 + task = _free_video_task_queryset(team).get(id=task.id) + return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_202_ACCEPTED) + + def get(self, request): + from .free_video import serialize_free_video_task + + team = get_current_team(request.user) + try: + offset = max(0, int(request.query_params.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + try: + page_size = min(50, max(1, int(request.query_params.get("page_size") or 20))) + except (TypeError, ValueError): + page_size = 20 + qs = _free_video_task_queryset(team).order_by("-created_at") + total = qs.count() + tasks = list(qs[offset : offset + page_size]) + return Response( + { + "results": [serialize_free_video_task(t) for t in tasks], + "total": total, + "has_more": offset + page_size < total, + } + ) + + +class FreeVideoPollView(APIView): + """POST /api/ai/free-video//poll/ —— web 进程内单次轮询+终态化(幂等)。 + 前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。""" + + def post(self, request, task_id): + from .free_video import finalize_free_video, serialize_free_video_task + + team = get_current_team(request.user) + task = _free_video_task_queryset(team).filter(id=task_id).first() + if task is None: + return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND) + if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING): + try: + task = finalize_free_video(task=task) + except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动)不终结任务,返回现状继续轮 + import logging + + logging.getLogger(__name__).warning("free video poll failed for %s", task_id, exc_info=True) + # 终态后重取(finalize 里可能新建了资产) + task = _free_video_task_queryset(team).get(id=task.id) + return Response({"task": serialize_free_video_task(task)}) + + +class FreeVideoFavoriteView(APIView): + """POST /api/ai/free-video//favorite/ —— 收藏开关。""" + + def post(self, request, task_id): + team = get_current_team(request.user) + task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first() + if task is None: + return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND) + task.is_favorited = not task.is_favorited + task.save(update_fields=["is_favorited", "updated_at"]) + return Response({"is_favorited": task.is_favorited}) + + +class FreeVideoDetailView(APIView): + """DELETE /api/ai/free-video// —— 软删(在途任务拒删,等终态)。""" + + def delete(self, request, task_id): + team = get_current_team(request.user) + task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first() + if task is None: + return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND) + if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING): + return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST) + task.is_deleted = True + task.save(update_fields=["is_deleted", "updated_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# 上传参考素材的格式/尺寸限制(与 jimeng inputBar 校验对齐;后端兜底,前端也拦) +_FREE_REF_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} +_FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"} +_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"} +_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024 +_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024 +_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024 + + +class FreeVideoUploadView(APIView): + """POST /api/ai/free-video/upload/ —— 参考素材上传(图/视频/音频)。 + + 校验(图 300-6000px、比例(0.4,2.5)、≤30MB;视频 mp4/mov ≤50MB、2-15s;音频 mp3/wav ≤15MB、2-15s) + → TOS → Asset(source=UPLOAD, in_library=False) → {asset_id,url,type,duration,thumb_url}。 + 视频顺带 ffmpeg 抽首帧缩略图。""" + + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + import tempfile + import uuid as _uuid + from io import BytesIO + from pathlib import Path + + from apps.assets.models import Asset, AssetFile + from apps.assets.storage import TosStorage + + from .media_probe import extract_video_poster, probe_duration + + upload = request.FILES.get("file") + if upload is None: + return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST) + team = get_current_team(request.user) + content_type = (upload.content_type or "").lower() + size = upload.size or 0 + + if content_type in _FREE_REF_IMAGE_TYPES: + kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type] + if size > _FREE_REF_IMAGE_MAX: + return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST) + elif content_type in _FREE_REF_VIDEO_TYPES: + kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov" + if size > _FREE_REF_VIDEO_MAX: + return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST) + elif content_type in _FREE_REF_AUDIO_TYPES: + kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav" + if size > _FREE_REF_AUDIO_MAX: + return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + {"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw = upload.read() + width = height = None + duration = None + poster_bytes = None + + if kind == "image": + try: + from PIL import Image + + with Image.open(BytesIO(raw)) as im: + width, height = im.size + except Exception: # noqa: BLE001 + return Response({"detail": "图片解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) + if not (300 <= width <= 6000 and 300 <= height <= 6000): + return Response({"detail": "图片边长需在 300-6000 像素之间"}, status=status.HTTP_400_BAD_REQUEST) + ratio = width / height + if not (0.4 <= ratio <= 2.5): + return Response({"detail": "图片宽高比需在 0.4-2.5 之间"}, status=status.HTTP_400_BAD_REQUEST) + else: + with tempfile.TemporaryDirectory(prefix="airshelf-fc-upload-") as tmp: + tmp_path = Path(tmp) / f"in{suffix}" + tmp_path.write_bytes(raw) + duration = probe_duration(str(tmp_path)) + if duration is None: + return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) + if not (2 <= duration <= 15): + label = "视频" if kind == "video" else "音频" + return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST) + if kind == "video": + poster_bytes = extract_video_poster(str(tmp_path)) + + asset_id = _uuid.uuid4() + storage = TosStorage() + object_key = f"teams/{team.id}/free-create/uploads/{asset_id}{suffix}" + stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type) + name = (upload.name or f"素材{suffix}")[:255] + asset = Asset.objects.create( + id=asset_id, + team=team, + created_by=request.user, + name=name, + asset_type=asset_type, + source=Asset.Source.UPLOAD, + category=Asset.Category.UPLOAD, + in_library=False, # 仅作生成参考,不进资产库列表 + metadata={"feature": "free_video_reference"}, + ) + AssetFile.objects.create( + asset=asset, + object_key=stored.object_key, + bucket=stored.bucket, + content_type=stored.content_type, + size_bytes=stored.size_bytes, + width=width, + height=height, + duration_ms=int(duration * 1000) if duration else None, + is_primary=True, + ) + url = storage.public_url(object_key=stored.object_key) + thumb_url = "" + if poster_bytes: + poster_key = f"teams/{team.id}/free-create/uploads/{asset_id}-poster.jpg" + poster_stored = storage.upload_fileobj( + fileobj=BytesIO(poster_bytes), object_key=poster_key, content_type="image/jpeg" + ) + AssetFile.objects.create( + asset=asset, + object_key=poster_stored.object_key, + bucket=poster_stored.bucket, + content_type=poster_stored.content_type, + size_bytes=poster_stored.size_bytes, + is_primary=False, + ) + thumb_url = storage.public_url(object_key=poster_key) + + return Response( + { + "asset_id": str(asset.id), + "url": url, + "type": kind, + "name": name, + "duration": duration, + "width": width, + "height": height, + "thumb_url": thumb_url or (url if kind == "image" else ""), + }, + status=status.HTTP_201_CREATED, + ) + + class ModelConfigViewSet(ReadOnlyModelViewSet): # 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致 # (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建) diff --git a/core/backend/apps/assets/assets_client.py b/core/backend/apps/assets/assets_client.py index 77528d9..d9d445c 100644 --- a/core/backend/apps/assets/assets_client.py +++ b/core/backend/apps/assets/assets_client.py @@ -59,7 +59,11 @@ def _get_service(): Credentials(ak, sk, SERVICE, REGION), 10, 30, ) - actions = ["CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset"] + actions = [ + "CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset", + # 自由创作素材库(组/素材全生命周期) + "GetAssetGroup", "UpdateAssetGroup", "UpdateAsset", "DeleteAssetGroup", + ] api_info = {a: ApiInfo("POST", "/", {"Action": a, "Version": API_VERSION}, {}, {}) for a in actions} return Service(service_info, api_info) @@ -117,3 +121,49 @@ def list_asset_groups(page: int = 1, page_size: int = 20, name: str | None = Non {"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()}, ) return result.get("Items", []), result.get("TotalCount", 0) + + +def list_assets(group_ids: list | None = None, status: str | None = None, + name: str | None = None, page: int = 1, page_size: int = 20) -> tuple: + """列组内素材。返回 (items, total_count)。""" + filter_dict: dict = {"GroupType": "AIGC"} + if group_ids: + filter_dict["GroupIds"] = group_ids + if status: + filter_dict["Statuses"] = [status] + if name: + filter_dict["Name"] = name + result = _do_request( + "ListAssets", + {"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()}, + ) + return result.get("Items", []), result.get("TotalCount", 0) + + +def get_asset_group(group_id: str) -> dict: + return _do_request("GetAssetGroup", {"Id": group_id, "ProjectName": _project()}) + + +def update_asset_group(group_id: str, name: str | None = None, description: str | None = None) -> None: + body: dict = {"Id": group_id, "ProjectName": _project()} + if name is not None: + body["Name"] = name + if description is not None: + body["Description"] = description + _do_request("UpdateAssetGroup", body) + + +def update_asset(asset_id: str, name: str | None = None) -> None: + body: dict = {"Id": asset_id, "ProjectName": _project()} + if name is not None: + body["Name"] = name + _do_request("UpdateAsset", body) + + +def delete_asset(asset_id: str) -> None: + _do_request("DeleteAsset", {"Id": asset_id, "ProjectName": _project()}) + + +def delete_asset_group(group_id: str) -> None: + """删组(远程级联删组内素材)。""" + _do_request("DeleteAssetGroup", {"Id": group_id, "ProjectName": _project()}) diff --git a/core/backend/apps/assets/free_library.py b/core/backend/apps/assets/free_library.py new file mode 100644 index 0000000..0627a48 --- /dev/null +++ b/core/backend/apps/assets/free_library.py @@ -0,0 +1,296 @@ +"""自由创作·人物素材库端点(FreeAssetGroup / FreeAsset ←→ 火山 Assets API)。 + +移植自 jimeng-clone 素材库:建组=火山 CreateAssetGroup+本地记录;传素材=TOS 上传→CreateAsset(URL) +→FreeAsset(processing);状态轮询=GetAsset 刷新 active/failed;删除=远程 NotFound 幂等继续清本地。 +生成时以 asset://{remote_asset_id} 引用(见 apps/ai/free_video.build_content_items)。 +""" +import logging +import tempfile +import uuid +from io import BytesIO +from pathlib import Path + +from rest_framework import status +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.api import get_current_team + +from . import assets_client +from .assets_client import AssetsAPIError +from .models import FreeAsset, FreeAssetGroup +from .storage import TosStorage + +logger = logging.getLogger(__name__) + +_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} +_VIDEO_TYPES = {"video/mp4": ".mp4", "video/quicktime": ".mov"} +_AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav"} +_IMAGE_MAX = 30 * 1024 * 1024 +_VIDEO_MAX = 50 * 1024 * 1024 +_AUDIO_MAX = 15 * 1024 * 1024 + + +def _serialize_asset(fa: FreeAsset) -> dict: + return { + "id": str(fa.id), + "name": fa.name, + "url": fa.url, + "type": fa.asset_type.lower(), + "thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""), + "duration": fa.duration, + "status": fa.status, + "error_message": fa.error_message, + "created_at": fa.created_at.isoformat(), + } + + +def _serialize_group(group: FreeAssetGroup, asset_count: int | None = None) -> dict: + return { + "id": str(group.id), + "name": group.name, + "description": group.description, + "thumbnail_url": group.thumbnail_url, + "asset_count": asset_count if asset_count is not None else group.assets.count(), + "created_at": group.created_at.isoformat(), + } + + +def _assets_api_unavailable() -> Response: + return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + +class FreeAssetGroupListView(APIView): + """GET 列素材组 / POST 建组。""" + + def get(self, request): + team = get_current_team(request.user) + groups = FreeAssetGroup.objects.filter(team=team, is_deleted=False).order_by("-created_at") + counts = {str(g.id): g.assets.count() for g in groups} + return Response({"results": [_serialize_group(g, counts[str(g.id)]) for g in groups]}) + + def post(self, request): + if not assets_client.is_enabled(): + return _assets_api_unavailable() + team = get_current_team(request.user) + name = str(request.data.get("name") or "").strip() + description = str(request.data.get("description") or "").strip() + if not name: + return Response({"detail": "素材组名称不能为空"}, status=status.HTTP_400_BAD_REQUEST) + try: + remote_group_id = assets_client.create_asset_group(name, description) + except AssetsAPIError as exc: + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + group = FreeAssetGroup.objects.create( + team=team, created_by=request.user, name=name, description=description, remote_group_id=remote_group_id + ) + return Response({"group": _serialize_group(group, 0)}, status=status.HTTP_201_CREATED) + + +class FreeAssetGroupDetailView(APIView): + """GET 组详情+素材列表 / PATCH 改名 / DELETE 删组(远程级联,NotFound 幂等清本地)。""" + + def _get_group(self, request, group_id) -> FreeAssetGroup | None: + team = get_current_team(request.user) + return FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first() + + def get(self, request, group_id): + group = self._get_group(request, group_id) + if group is None: + return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND) + assets = list(group.assets.order_by("-created_at")) + return Response({"group": _serialize_group(group, len(assets)), "assets": [_serialize_asset(a) for a in assets]}) + + def patch(self, request, group_id): + group = self._get_group(request, group_id) + if group is None: + return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND) + name = request.data.get("name") + description = request.data.get("description") + update_fields = ["updated_at"] + if name is not None and str(name).strip(): + group.name = str(name).strip() + update_fields.append("name") + if description is not None: + group.description = str(description) + update_fields.append("description") + if len(update_fields) == 1: + return Response({"group": _serialize_group(group)}) + try: + assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description) + except AssetsAPIError as exc: + if exc.code != "NotFound": + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + group.save(update_fields=update_fields) + return Response({"group": _serialize_group(group)}) + + def delete(self, request, group_id): + group = self._get_group(request, group_id) + if group is None: + return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND) + try: + assets_client.delete_asset_group(group.remote_group_id) + except AssetsAPIError as exc: + # 远程已不存在 → 幂等继续清本地;其它错误如实反馈 + if exc.code != "NotFound": + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + group.is_deleted = True + group.save(update_fields=["is_deleted", "updated_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class FreeAssetUploadView(APIView): + """POST /free-groups//assets/ —— 上传素材进组:TOS → 火山 CreateAsset → FreeAsset(processing)。""" + + parser_classes = [MultiPartParser, FormParser] + + def post(self, request, group_id): + if not assets_client.is_enabled(): + return _assets_api_unavailable() + team = get_current_team(request.user) + group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first() + if group is None: + return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND) + upload = request.FILES.get("file") + if upload is None: + return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST) + + content_type = (upload.content_type or "").lower() + size = upload.size or 0 + if content_type in _IMAGE_TYPES: + kind, suffix, asset_type = "image", _IMAGE_TYPES[content_type], FreeAsset.Type.IMAGE + if size > _IMAGE_MAX: + return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST) + elif content_type in _VIDEO_TYPES: + kind, suffix, asset_type = "video", _VIDEO_TYPES[content_type], FreeAsset.Type.VIDEO + if size > _VIDEO_MAX: + return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST) + elif content_type in _AUDIO_TYPES: + kind, suffix, asset_type = "audio", _AUDIO_TYPES[content_type], FreeAsset.Type.AUDIO + if size > _AUDIO_MAX: + return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + {"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw = upload.read() + duration = None + thumb_url = "" + storage = TosStorage() + file_id = uuid.uuid4() + + if kind != "image": + from apps.ai.media_probe import extract_video_poster, probe_duration + + with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp: + tmp_path = Path(tmp) / f"in{suffix}" + tmp_path.write_bytes(raw) + duration = probe_duration(str(tmp_path)) + if duration is None: + return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST) + if not (2 <= duration <= 15): + label = "视频" if kind == "video" else "音频" + return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST) + if kind == "video": + poster = extract_video_poster(str(tmp_path)) + if poster: + poster_key = f"teams/{team.id}/free-create/library/{file_id}-poster.jpg" + storage.upload_fileobj(fileobj=BytesIO(poster), object_key=poster_key, content_type="image/jpeg") + thumb_url = storage.public_url(object_key=poster_key) + + object_key = f"teams/{team.id}/free-create/library/{file_id}{suffix}" + stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type) + url = storage.public_url(object_key=stored.object_key) + + name = str(request.data.get("name") or "").strip() or (upload.name or f"素材{suffix}") + name = name[:128] + try: + remote_asset_id = assets_client.create_asset( + group.remote_group_id, url, name=name, asset_type=asset_type + ) + except AssetsAPIError as exc: + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + + fa = FreeAsset.objects.create( + group=group, + remote_asset_id=remote_asset_id, + name=name, + url=url, + asset_type=asset_type, + thumbnail_url=thumb_url or (url if kind == "image" else ""), + duration=duration, + status=FreeAsset.Status.PROCESSING, + ) + if not group.thumbnail_url and fa.thumbnail_url: + group.thumbnail_url = fa.thumbnail_url + group.save(update_fields=["thumbnail_url", "updated_at"]) + return Response({"asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED) + + +class FreeAssetDetailView(APIView): + """PATCH 改名 / DELETE 删素材(远程 NotFound 幂等清本地)。""" + + def _get_asset(self, request, asset_id) -> FreeAsset | None: + team = get_current_team(request.user) + return FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).select_related("group").first() + + def patch(self, request, asset_id): + fa = self._get_asset(request, asset_id) + if fa is None: + return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND) + name = str(request.data.get("name") or "").strip() + if not name: + return Response({"detail": "素材名称不能为空"}, status=status.HTTP_400_BAD_REQUEST) + try: + assets_client.update_asset(fa.remote_asset_id, name=name) + except AssetsAPIError as exc: + if exc.code != "NotFound": + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + fa.name = name[:128] + fa.save(update_fields=["name", "updated_at"]) + return Response({"asset": _serialize_asset(fa)}) + + def delete(self, request, asset_id): + fa = self._get_asset(request, asset_id) + if fa is None: + return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND) + try: + assets_client.delete_asset(fa.remote_asset_id) + except AssetsAPIError as exc: + if exc.code != "NotFound": + return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST) + fa.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class FreeAssetPollView(APIView): + """POST /free-assets//poll/ —— 查火山刷新审核状态(processing → active/failed)。""" + + def post(self, request, asset_id): + team = get_current_team(request.user) + fa = FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).first() + if fa is None: + return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND) + if fa.status == FreeAsset.Status.PROCESSING: + try: + result = assets_client.get_asset(fa.remote_asset_id) + remote_status = str(result.get("Status") or "") + if remote_status == "Active": + fa.status = FreeAsset.Status.ACTIVE + fa.url = result.get("Url") or fa.url + fa.save(update_fields=["status", "url", "updated_at"]) + elif remote_status == "Failed": + fa.status = FreeAsset.Status.FAILED + fa.error_message = str(result.get("ErrorMessage") or "素材审核未通过") + fa.save(update_fields=["status", "error_message", "updated_at"]) + except AssetsAPIError as exc: + if exc.code == "NotFound": + fa.status = FreeAsset.Status.FAILED + fa.error_message = "素材在远程已不存在" + fa.save(update_fields=["status", "error_message", "updated_at"]) + else: + logger.warning("free asset %s poll failed: %s", asset_id, exc) + return Response({"asset": _serialize_asset(fa)}) diff --git a/core/backend/apps/assets/migrations/0009_freeassetgroup_freeasset_and_more.py b/core/backend/apps/assets/migrations/0009_freeassetgroup_freeasset_and_more.py new file mode 100644 index 0000000..598d109 --- /dev/null +++ b/core/backend/apps/assets/migrations/0009_freeassetgroup_freeasset_and_more.py @@ -0,0 +1,135 @@ +# Generated by Django 5.1.15 on 2026-07-02 07:23 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0007_team_monthly_credit_limit"), + ("assets", "0008_backfill_workbench_in_library"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FreeAssetGroup", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=128)), + ("description", models.TextField(blank=True)), + ("remote_group_id", models.CharField(max_length=128)), + ("thumbnail_url", models.URLField(blank=True)), + ("is_deleted", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_%(class)s_set", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="accounts.team", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="FreeAsset", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("remote_asset_id", models.CharField(max_length=128)), + ("name", models.CharField(blank=True, max_length=128)), + ("url", models.URLField(blank=True, max_length=1024)), + ( + "asset_type", + models.CharField( + choices=[ + ("Image", "Image"), + ("Video", "Video"), + ("Audio", "Audio"), + ], + default="Image", + max_length=16, + ), + ), + ("thumbnail_url", models.URLField(blank=True, max_length=1024)), + ("duration", models.FloatField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[ + ("processing", "Processing"), + ("active", "Active"), + ("failed", "Failed"), + ], + default="processing", + max_length=16, + ), + ), + ("error_message", models.TextField(blank=True)), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="assets.freeassetgroup", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="freeassetgroup", + index=models.Index( + fields=["team", "-created_at"], name="assets_free_team_id_c0cbb2_idx" + ), + ), + migrations.AddIndex( + model_name="freeasset", + index=models.Index( + fields=["group", "-created_at"], name="assets_free_group_i_b2a9ec_idx" + ), + ), + migrations.AddIndex( + model_name="freeasset", + index=models.Index( + fields=["remote_asset_id"], name="assets_free_remote__49909d_idx" + ), + ), + ] diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py index ef72264..c3d47bd 100644 --- a/core/backend/apps/assets/models.py +++ b/core/backend/apps/assets/models.py @@ -178,3 +178,61 @@ class AssetUsage(TimeStampedModel): usage_type = models.CharField(max_length=64) context = models.JSONField(default=dict, blank=True) + +class FreeAssetGroup(TeamOwnedModel): + """自由创作·人物素材组(一角色一组)。 + + 本质是「火山 Assets API 引用登记表」:组内素材登记到火山素材库拿 remote_asset_id, + 生成时以 asset://{remote_asset_id} 引用(免重复上传 + 火山侧预审)。 + 与 AssetReviewGroup(一团队一审核组)职责不同,与资产库 Asset(TOS 文件资产)也不同,独立建表。 + """ + + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + remote_group_id = models.CharField(max_length=128) + thumbnail_url = models.URLField(blank=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + indexes = [ + models.Index(fields=["team", "-created_at"]), + ] + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"free-group:{self.name}" + + +class FreeAsset(TimeStampedModel): + """自由创作·素材组内单素材(火山 Assets API 登记项)。""" + + class Type(models.TextChoices): + IMAGE = "Image", "Image" + VIDEO = "Video", "Video" + AUDIO = "Audio", "Audio" + + class Status(models.TextChoices): + PROCESSING = "processing", "Processing" # 火山侧处理/审核中 + ACTIVE = "active", "Active" # 可用,可 asset:// 引用 + FAILED = "failed", "Failed" # 审核/处理失败 + + group = models.ForeignKey(FreeAssetGroup, on_delete=models.CASCADE, related_name="assets") + remote_asset_id = models.CharField(max_length=128) + name = models.CharField(max_length=128, blank=True) + url = models.URLField(max_length=1024, blank=True) # TOS 公读直链(火山从这里拉源文件) + asset_type = models.CharField(max_length=16, choices=Type.choices, default=Type.IMAGE) + thumbnail_url = models.URLField(max_length=1024, blank=True) + duration = models.FloatField(null=True, blank=True) # 视频/音频时长(秒) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PROCESSING) + error_message = models.TextField(blank=True) + + class Meta: + indexes = [ + models.Index(fields=["group", "-created_at"]), + models.Index(fields=["remote_asset_id"]), + ] + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"free-asset:{self.name or self.remote_asset_id}" + diff --git a/core/backend/apps/assets/urls.py b/core/backend/apps/assets/urls.py index 78cdd43..d6b0e66 100644 --- a/core/backend/apps/assets/urls.py +++ b/core/backend/apps/assets/urls.py @@ -1,6 +1,13 @@ from django.urls import path from rest_framework.routers import DefaultRouter +from .free_library import ( + FreeAssetDetailView, + FreeAssetGroupDetailView, + FreeAssetGroupListView, + FreeAssetPollView, + FreeAssetUploadView, +) from .views import AssetUploadView, AssetViewSet router = DefaultRouter() @@ -8,4 +15,10 @@ router.register("", AssetViewSet, basename="asset") urlpatterns = [ path("upload/", AssetUploadView.as_view(), name="asset-upload"), + # 自由创作·人物素材库(火山 Assets API 引用登记)。必须在 router 通配之前注册。 + path("free-groups/", FreeAssetGroupListView.as_view(), name="free-asset-group-list"), + path("free-groups//", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"), + path("free-groups//assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"), + path("free-assets//", FreeAssetDetailView.as_view(), name="free-asset-detail"), + path("free-assets//poll/", FreeAssetPollView.as_view(), name="free-asset-poll"), ] + router.urls diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index e1bd3e4..24cb449 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -15,7 +15,7 @@ class CreditLedgerTests(TestCase): self.team = Team.objects.create(name="Billing Team", owner=self.user) TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000")) - self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano") + self.provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "Volcano"}) self.model = ModelConfig.objects.create( provider=self.provider, name="doubao-seed-2-0-pro-260215", @@ -110,7 +110,7 @@ class MemberLimitTests(TestCase): self.team = Team.objects.create(name="Limit Team", owner=self.user) self.member = TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000")) - self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano") + self.provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "Volcano"}) self.model = ModelConfig.objects.create( provider=self.provider, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE, ) diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index 172b329..2cb3891 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -82,7 +82,7 @@ class ProductMaterialsTests(TestCase): mk("product_image", "正面主图", {"product_id": pid}) mk("model_tryon", "上身图1", {"product_id": pid}) # 项目包:场景 + 视频素材(走 origin_task→project) - prov = ModelProvider.objects.create(name="volcengine", display_name="V", base_url="https://x") + prov, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V", "base_url": "https://x"}) mc = ModelConfig.objects.create(provider=prov, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE) task = AITask.objects.create(team=self.team, project=self.proj, task_type=AITask.Type.SCENE_IMAGE, status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="pm-1") mk("scene", "场景1", task=task) diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 37f4fab..0809e9c 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -32,10 +32,13 @@ class ProjectApiTests(TestCase): TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) CreditAccount.objects.create(team=self.team, balance="100.0000") self.product = Product.objects.create(team=self.team, created_by=self.user, title="Test Product") - self.provider = ModelProvider.objects.create( + # 迁移 0023(自由创作视频种子)已在测试库预建 volcengine provider,这里 get_or_create 避免撞唯一键 + self.provider, _ = ModelProvider.objects.get_or_create( name="volcengine", - display_name="Volcano", - base_url="https://ark.cn-beijing.volces.com/api/v3", + defaults={ + "display_name": "Volcano", + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + }, ) self.model = ModelConfig.objects.create( provider=self.provider, diff --git a/core/bug/image.png b/core/bug/image.png new file mode 100644 index 0000000..0291017 Binary files /dev/null and b/core/bug/image.png differ diff --git a/core/bug/电商项目测试清单 (11).xlsx b/core/bug/电商项目测试清单 (11).xlsx new file mode 100644 index 0000000..b6d1604 Binary files /dev/null and b/core/bug/电商项目测试清单 (11).xlsx differ diff --git a/core/bug/电商项目测试清单 (12).xlsx b/core/bug/电商项目测试清单 (12).xlsx new file mode 100644 index 0000000..4e986d2 Binary files /dev/null and b/core/bug/电商项目测试清单 (12).xlsx differ diff --git a/core/bug/电商项目测试清单.xlsx b/core/bug/电商项目测试清单.xlsx index 37e6323..7df6281 100644 Binary files a/core/bug/电商项目测试清单.xlsx and b/core/bug/电商项目测试清单.xlsx differ diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 49e6046..11984e8 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { AssetFactoryPage, AuthScreen, Dashboard, + FreeCreatePage, ImageWorkbenchPage, LibraryPage, MessagesPage, @@ -878,6 +879,8 @@ export function App() { ); case "assetFactory": return ; + case "freeCreate": + return setNotice({ type, text })} />; case "imageOptimize": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index cad9516..1443499 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -17,6 +17,11 @@ import type { AuthPayload, BillingSummary, BillingTrend, + FreeAssetGroup, + FreeAssetItem, + FreeVideoRef, + FreeVideoTask, + FreeVideoUploadResult, Ledger, LoginSession, Invitation, @@ -660,6 +665,67 @@ export const api = { generateImageStatus(ids: string[]) { return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`); }, + // —— 自由创作·视频生成 —— + // 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。 + submitFreeVideo(payload: { + prompt: string; + mode: "universal" | "keyframe"; + model?: string; + aspect_ratio?: string; + resolution?: string; + duration?: number; + seed?: number; + generate_audio?: boolean; + references?: FreeVideoRef[]; + }) { + return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) }); + }, + freeVideoTasks(offset = 0, pageSize = 20) { + return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>( + `/api/ai/free-video/?offset=${offset}&page_size=${pageSize}` + ); + }, + // web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾 + pollFreeVideo(id: string) { + return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" }); + }, + toggleFreeVideoFavorite(id: string) { + return request<{ is_favorited: boolean }>(`/api/ai/free-video/${id}/favorite/`, { method: "POST" }); + }, + deleteFreeVideo(id: string) { + return request(`/api/ai/free-video/${id}/`, { method: "DELETE" }); + }, + uploadFreeVideoRef(formData: FormData) { + return request("/api/ai/free-video/upload/", { method: "POST", body: formData }); + }, + // 自由创作·人物素材库(火山 Assets API asset:// 引用登记) + freeAssetGroups() { + return request<{ results: FreeAssetGroup[] }>("/api/assets/free-groups/"); + }, + createFreeAssetGroup(payload: { name: string; description?: string }) { + return request<{ group: FreeAssetGroup }>("/api/assets/free-groups/", { method: "POST", body: JSON.stringify(payload) }); + }, + freeAssetGroup(id: string) { + return request<{ group: FreeAssetGroup; assets: FreeAssetItem[] }>(`/api/assets/free-groups/${id}/`); + }, + updateFreeAssetGroup(id: string, payload: { name?: string; description?: string }) { + return request<{ group: FreeAssetGroup }>(`/api/assets/free-groups/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + deleteFreeAssetGroup(id: string) { + return request(`/api/assets/free-groups/${id}/`, { method: "DELETE" }); + }, + uploadFreeAsset(groupId: string, formData: FormData) { + return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData }); + }, + renameFreeAsset(id: string, name: string) { + return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) }); + }, + deleteFreeAsset(id: string) { + return request(`/api/assets/free-assets/${id}/`, { method: "DELETE" }); + }, + pollFreeAsset(id: string) { + return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" }); + }, recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) { return request("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) }); }, diff --git a/core/frontend/src/components/IconKitSvg.tsx b/core/frontend/src/components/IconKitSvg.tsx index 70cfce8..eed387f 100644 --- a/core/frontend/src/components/IconKitSvg.tsx +++ b/core/frontend/src/components/IconKitSvg.tsx @@ -27,7 +27,8 @@ const iconPaths: Record = { server: '', gauge: '', sliders: '', - logOut: '' + logOut: '', + film: '' }; const iconAliases: Record = { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index c1c9af1..1b87a1c 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -17,6 +17,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" }, { id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" }, { id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" }, + { id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" }, { id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, { id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, @@ -197,6 +198,7 @@ const NAV: NavDef[] = [ { id: "models", page: "models", label: "模特库", icon: "model" }, { id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" }, { id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" }, + { id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" }, { id: "library", page: "library", label: "资产库", icon: "library" }, { id: "team", page: "team", label: "团队", icon: "users" }, { id: "account", page: "account", label: "消费", icon: "creditCard" }, @@ -219,6 +221,7 @@ const PAGE_TO_NAV: Partial> = { modelPhotoDemoA: "assetFactory", modelPhotoDemoB: "assetFactory", platformCover: "assetFactory", + freeCreate: "freeCreate", library: "library", team: "team", account: "account", diff --git a/core/frontend/src/components/free-create/asset-library-modal.tsx b/core/frontend/src/components/free-create/asset-library-modal.tsx new file mode 100644 index 0000000..2bc5f78 --- /dev/null +++ b/core/frontend/src/components/free-create/asset-library-modal.tsx @@ -0,0 +1,289 @@ +// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除; +// 选中 active 素材注入输入条(source=library,生成时后端换 asset:// 引用)。 +// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。 +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react"; +import { api } from "../../api"; +import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types"; +import { useBodyScrollLock } from "../overlays"; + +const STATUS_PILL: Record = { + processing: { cls: "pill-info", label: "审核中" }, + active: { cls: "pill-ok", label: "可用" }, + failed: { cls: "pill-err", label: "未通过" } +}; + +export function AssetLibraryModal({ open, onClose, onPick, notify }: { + open: boolean; + onClose: () => void; + onPick: (ref: FreeVideoRef) => void; + notify: (type: "success" | "error" | "info", text: string) => void; +}) { + const [groups, setGroups] = useState([]); + const [activeGroup, setActiveGroup] = useState(null); + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + useBodyScrollLock(open); + + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const data = await api.freeAssetGroups(); + setGroups(data.results); + } catch (error) { + notify("error", error instanceof Error ? error.message : "素材组加载失败"); + } finally { + setLoading(false); + } + }, [notify]); + + const loadGroupDetail = useCallback(async (group: FreeAssetGroup) => { + setLoading(true); + try { + const data = await api.freeAssetGroup(group.id); + setActiveGroup(data.group); + setAssets(data.assets); + } catch (error) { + notify("error", error instanceof Error ? error.message : "素材加载失败"); + } finally { + setLoading(false); + } + }, [notify]); + + useEffect(() => { + if (!open) return; + setActiveGroup(null); + setAssets([]); + void loadGroups(); + }, [open, loadGroups]); + + // processing 素材每 8s 轮询刷新 + useEffect(() => { + if (!open || !activeGroup) return; + const pending = assets.filter((a) => a.status === "processing"); + if (pending.length === 0) return; + const timer = window.setInterval(() => { + pending.forEach((item) => { + void api.pollFreeAsset(item.id).then((data) => { + setAssets((prev) => prev.map((a) => (a.id === data.asset.id ? data.asset : a))); + }).catch(() => undefined); + }); + }, 8000); + return () => window.clearInterval(timer); + }, [open, activeGroup, assets]); + + if (!open) return null; + + const createGroup = async () => { + const name = newName.trim(); + if (!name) return; + try { + const data = await api.createFreeAssetGroup({ name }); + setGroups((prev) => [data.group, ...prev]); + setCreating(false); + setNewName(""); + notify("success", `素材组「${name}」已创建`); + } catch (error) { + notify("error", error instanceof Error ? error.message : "创建失败"); + } + }; + + const uploadAsset = async (file: File) => { + if (!activeGroup) return; + setUploading(true); + try { + const form = new FormData(); + form.append("file", file); + const data = await api.uploadFreeAsset(activeGroup.id, form); + setAssets((prev) => [data.asset, ...prev]); + notify("success", "素材已上传,审核中"); + } catch (error) { + notify("error", error instanceof Error ? error.message : "上传失败"); + } finally { + setUploading(false); + } + }; + + const renameAsset = async (item: FreeAssetItem) => { + const name = window.prompt("素材名称(用于 @ 引用)", item.name); + if (!name || name.trim() === item.name) return; + try { + const data = await api.renameFreeAsset(item.id, name.trim()); + setAssets((prev) => prev.map((a) => (a.id === item.id ? data.asset : a))); + } catch (error) { + notify("error", error instanceof Error ? error.message : "重命名失败"); + } + }; + + const removeAsset = async (item: FreeAssetItem) => { + if (!window.confirm(`删除素材「${item.name}」?`)) return; + try { + await api.deleteFreeAsset(item.id); + setAssets((prev) => prev.filter((a) => a.id !== item.id)); + } catch (error) { + notify("error", error instanceof Error ? error.message : "删除失败"); + } + }; + + const renameGroup = async (group: FreeAssetGroup) => { + const name = window.prompt("素材组名称", group.name); + if (!name || name.trim() === group.name) return; + try { + const data = await api.updateFreeAssetGroup(group.id, { name: name.trim() }); + setGroups((prev) => prev.map((g) => (g.id === group.id ? data.group : g))); + if (activeGroup?.id === group.id) setActiveGroup(data.group); + } catch (error) { + notify("error", error instanceof Error ? error.message : "重命名失败"); + } + }; + + const removeGroup = async (group: FreeAssetGroup) => { + if (!window.confirm(`删除素材组「${group.name}」及其全部素材?此操作不可恢复。`)) return; + try { + await api.deleteFreeAssetGroup(group.id); + setGroups((prev) => prev.filter((g) => g.id !== group.id)); + if (activeGroup?.id === group.id) { setActiveGroup(null); setAssets([]); } + notify("success", "素材组已删除"); + } catch (error) { + notify("error", error instanceof Error ? error.message : "删除失败"); + } + }; + + const pick = (item: FreeAssetItem) => { + if (item.status !== "active") { + notify("info", item.status === "processing" ? "素材还在审核中,请稍候" : "素材未通过审核,无法引用"); + return; + } + onPick({ + url: item.url, + type: item.type, + label: item.name, + thumb_url: item.thumb_url, + duration: item.duration || undefined, + asset_id: item.id, + source: "library" + }); + onClose(); + }; + + return createPortal( +
+
event.stopPropagation()}> + ++ +
+
+
+ {activeGroup ? ( + + ) : "人物素材库"} + // 火山素材登记 · @ 引用生成 +
+ +
+
+ {!activeGroup ? ( + <> +
+ {creating ? ( +
+ setNewName(event.target.value)} + onKeyDown={(event) => { if (event.key === "Enter") void createGroup(); if (event.key === "Escape") setCreating(false); }} + /> + + +
+ ) : ( + + )} +
+ {groups.length === 0 && !loading ? ( +
+ +

还没有素材组

+

// 一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截

+
+ ) : ( +
+ {groups.map((group) => ( +
void loadGroupDetail(group)} onKeyDown={(event) => { if (event.key === "Enter") void loadGroupDetail(group); }}> +
+ {group.thumbnail_url ? {group.name} : } +
+
{group.name}
+
// {group.asset_count} 个素材
+
+ + +
+
+ ))} +
+ )} + + ) : ( + <> +
+ { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void uploadAsset(file); + }} + /> + + // 审核通过(可用)后才能被生成引用 +
+ {assets.length === 0 && !loading ? ( +
+ +

组内还没有素材

+

// 上传该角色的图片/视频/音频

+
+ ) : ( +
+ {assets.map((item) => { + const pill = STATUS_PILL[item.status]; + return ( +
pick(item)} onKeyDown={(event) => { if (event.key === "Enter") pick(item); }}> +
+ {item.thumb_url ? {item.name} : {item.type === "audio" ? "♪" : item.type.toUpperCase()}} + {item.duration ? {item.duration}s : null} +
+
{item.name}
+ {pill.label} + {item.status === "failed" && item.error_message &&
// {item.error_message}
} +
+ + +
+
+ ); + })} +
+ )} + + )} +
+
+
, + document.body + ); +} diff --git a/core/frontend/src/components/free-create/constants.ts b/core/frontend/src/components/free-create/constants.ts new file mode 100644 index 0000000..e5a2cc3 --- /dev/null +++ b/core/frontend/src/components/free-create/constants.ts @@ -0,0 +1,154 @@ +// 自由创作·常量与预估计价(与后端 apps/ai/video_pricing.py 同一张表/同一公式,口径必须一致) +import type { FreeVideoRef, ModelConfig } from "../../types"; + +export type FreeMode = "universal" | "keyframe"; + +// 本地输入条里的参考素材(在 FreeVideoRef 之上带上传中间态) +export type LocalRef = FreeVideoRef & { + key: string; // 本地唯一键(删除/替换用) + uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交) +}; + +export const FC_MODELS = [ + { name: "doubao-seedance-2-0-260128", label: "Seedance 2.0", desc: "标准档 · 支持 1080P / 4K" }, + { name: "doubao-seedance-2-0-fast-260128", label: "Seedance 2.0 Fast", desc: "更快出片 · 480P / 720P" }, + { name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" } +] as const; +export const FC_STANDARD_MODEL = FC_MODELS[0].name; + +export const FC_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const; +export const FC_RESOLUTIONS = ["480p", "720p", "1080p", "4k"] as const; +export const FC_DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const; + +export const MODE_LABELS: Record = { universal: "全能参考", keyframe: "首尾帧" }; + +// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐) +export const IMAGE_MAX_BYTES = 30 * 1024 * 1024; +export const VIDEO_MAX_BYTES = 50 * 1024 * 1024; +export const AUDIO_MAX_BYTES = 15 * 1024 * 1024; +export const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]; +export const VIDEO_TYPES = ["video/mp4", "video/quicktime"]; +export const AUDIO_TYPES = ["audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"]; +export const MAX_IMAGES = 9; +export const MAX_VIDEOS = 3; +export const MAX_AUDIOS = 3; +export const MAX_VIDEO_TOTAL_SECONDS = 15; + +// 任务在途状态(继续轮询);终态 = succeeded / failed / cancelled / compensating +export const IN_FLIGHT_STATUSES = ["created", "reserved", "submitted", "polling", "postprocessing"]; +export function isInFlight(status: string) { + return IN_FLIGHT_STATUSES.includes(status); +} + +export const STATUS_LABELS: Record = { + created: "排队中", + reserved: "排队中", + submitted: "生成中", + polling: "生成中", + postprocessing: "处理中", + succeeded: "已完成", + failed: "失败", + cancelled: "已取消", + compensating: "补偿中" +}; + +// 分辨率像素表(火山 Seedance 2.0 文档,键 = `${resolution}:${ratio}`) +const RESOLUTION_MAP: Record = { + "720p:16:9": [1280, 720], "720p:9:16": [720, 1280], "720p:4:3": [1112, 834], + "720p:1:1": [960, 960], "720p:3:4": [834, 1112], "720p:21:9": [1470, 630], + "480p:16:9": [864, 496], "480p:9:16": [496, 864], "480p:4:3": [752, 560], + "480p:1:1": [640, 640], "480p:3:4": [560, 752], "480p:21:9": [992, 432], + "1080p:16:9": [1920, 1080], "1080p:9:16": [1080, 1920], "1080p:4:3": [1664, 1248], + "1080p:1:1": [1440, 1440], "1080p:3:4": [1248, 1664], "1080p:21:9": [2206, 946], + "4k:16:9": [3840, 2160], "4k:9:16": [2160, 3840], "4k:4:3": [3326, 2494], + "4k:1:1": [2880, 2880], "4k:3:4": [2494, 3326], "4k:21:9": [4398, 1886] +}; + +// 火山官方公式:(输入视频时长 + 输出时长) × 宽 × 高 × 24fps / 1024 +export function estimateTokens(ratio: string, resolution: string, duration: number, inputVideoSeconds = 0): number { + const size = RESOLUTION_MAP[`${resolution}:${ratio}`]; + if (!size) return 0; + const [w, h] = size; + return Math.round((w * h * 24 * (duration + inputVideoSeconds)) / 1024); +} + +type PricingTier = { no_ref_video?: number; with_ref_video?: number }; +type PricingTable = { default?: PricingTier } & Record; + +// 从 ModelConfig.metadata.pricing 取单价(元/百万tokens):resolution 精确键 → 回落 default +export function tokenPrice(config: ModelConfig | undefined, resolution: string, hasVideoRef: boolean): number { + const pricing = (config?.metadata?.pricing || {}) as PricingTable; + const tier = (pricing[resolution] as PricingTier | undefined) || pricing.default; + if (!tier) return 0; + return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0; +} + +export function estimateCost( + config: ModelConfig | undefined, + params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] } +): { tokens: number; cost: number } { + const inputVideoSeconds = params.refs + .filter((r) => r.type === "video") + .reduce((sum, r) => sum + (r.duration || 0), 0); + const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds); + const hasVideoRef = params.refs.some((r) => r.type === "video"); + const price = tokenPrice(config, params.resolution, hasVideoRef); + return { tokens, cost: Math.round(((tokens * price) / 1e6) * 100) / 100 }; +} + +export function modelLabel(name: string): string { + return FC_MODELS.find((m) => m.name === name)?.label || name; +} + +// —— 上传前的本地校验(后端仍会兜底) —— +export type FileCheck = { ok: true; type: "image" | "video" | "audio"; duration?: number } | { ok: false; error: string }; + +function probeImage(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + const { naturalWidth: w, naturalHeight: h } = img; + if (w < 300 || w > 6000 || h < 300 || h > 6000) resolve({ ok: false, error: "图片边长需在 300-6000 像素之间" }); + else if (w / h < 0.4 || w / h > 2.5) resolve({ ok: false, error: "图片宽高比需在 0.4-2.5 之间" }); + else resolve({ ok: true, type: "image" }); + }; + img.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "图片解析失败,请更换文件" }); }; + img.src = url; + }); +} + +function probeMedia(file: File, kind: "video" | "audio"): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const el = document.createElement(kind); + el.preload = "metadata"; + el.onloadedmetadata = () => { + URL.revokeObjectURL(url); + const duration = el.duration; + if (!isFinite(duration)) resolve({ ok: true, type: kind }); + else if (duration < 2 || duration > 15) resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` }); + else resolve({ ok: true, type: kind, duration: Math.round(duration * 10) / 10 }); + }; + el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); }; + el.src = url; + }); +} + +export async function checkRefFile(file: File): Promise { + const type = (file.type || "").toLowerCase(); + if (IMAGE_TYPES.includes(type)) { + if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" }; + return probeImage(file); + } + if (VIDEO_TYPES.includes(type)) { + if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" }; + return probeMedia(file, "video"); + } + if (AUDIO_TYPES.includes(type)) { + if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" }; + return probeMedia(file, "audio"); + } + return { ok: false, error: "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)" }; +} diff --git a/core/frontend/src/components/free-create/generation-card.tsx b/core/frontend/src/components/free-create/generation-card.tsx new file mode 100644 index 0000000..083bae6 --- /dev/null +++ b/core/frontend/src/components/free-create/generation-card.tsx @@ -0,0 +1,93 @@ +// 自由创作·任务卡:生成中(shimmer+平滑进度) / 失败(中文错误+重试) / 完成(悬停播放+操作)。 +import { useRef } from "react"; +import { Download, Heart, RotateCcw, Trash2 } from "lucide-react"; +import type { FreeVideoTask } from "../../types"; +import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants"; + +function ratioStyle(ratio: string) { + const [w, h] = ratio.split(":").map(Number); + return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" }; +} + +export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: { + task: FreeVideoTask; + progress: number; + onOpen: () => void; + onRetry: () => void; + onToggleFavorite: () => void; + onDelete: () => void; + onDownload: () => void; +}) { + const videoRef = useRef(null); + const inFlight = isInFlight(task.status); + const failed = task.status === "failed" || task.status === "cancelled"; + const done = task.status === "succeeded" && !!task.video_url; + + return ( +
+
{ if (event.key === "Enter") onOpen(); } : undefined} + onMouseEnter={() => { if (done) void videoRef.current?.play().catch(() => undefined); }} + onMouseLeave={() => { videoRef.current?.pause(); if (videoRef.current) videoRef.current.currentTime = 0; }} + > + {inFlight && ( +
+ +
+ {STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}% +
+ )} + {failed && ( +
+ 失败 +

{task.error_message || "生成失败,请重试"}

+ +
+ )} + {done && ( + <> +
+
+
{task.prompt}
+
+ // {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s + {task.status === "succeeded" && ` · ¥${Number(task.actual_cost || 0).toFixed(2)}`} +
+
+
+ ); +} diff --git a/core/frontend/src/components/free-create/input-bar.tsx b/core/frontend/src/components/free-create/input-bar.tsx new file mode 100644 index 0000000..54ac5a5 --- /dev/null +++ b/core/frontend/src/components/free-create/input-bar.tsx @@ -0,0 +1,173 @@ +// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。 +// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。 +import { useRef, useState, type RefObject } from "react"; +import { IconKitSvg } from "../IconKitSvg"; +import type { ModelConfig } from "../../types"; +import { MODE_LABELS, type FreeMode, type LocalRef } from "./constants"; +import { PromptInput, type PromptInputHandle } from "./prompt-input"; +import { FreeToolbar } from "./toolbar"; + +const ACCEPT_UNIVERSAL = "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav"; +const ACCEPT_IMAGE = "image/jpeg,image/png,image/webp"; + +function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void }) { + return ( +
+ {item.type === "image" ? ( + {item.label + ) : item.type === "video" ? ( + item.thumb_url ? {item.label : MP4 + ) : ( + + )} + {item.type !== "image" && item.duration ? {item.duration}s : null} + {item.uploading && } + {item.label ? @{item.label} : null} + +
+ ); +} + +function KeyframeSlot({ role, item, onPick, onRemove }: { + role: "first_frame" | "last_frame"; + item: LocalRef | undefined; + onPick: () => void; + onRemove: () => void; +}) { + const label = role === "first_frame" ? "首帧" : "尾帧(可选)"; + if (!item) { + return ( + + ); + } + return ( +
+ {label} + {item.uploading && } + {role === "first_frame" ? "首" : "尾"} + +
+ ); +} + +export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: { + mode: FreeMode; + model: string; + ratio: string; + resolution: string; + duration: number; + seed: number; + refs: LocalRef[]; + videoConfigs: ModelConfig[]; + submitting: boolean; + promptRef: RefObject; + /** 用户选择/拖入文件;keyframe 模式下带目标 role */ + onFiles: (files: File[], role?: "first_frame" | "last_frame") => void; + onRemoveRef: (key: string) => void; + onModeChange: (mode: FreeMode) => void; + onModelChange: (model: string) => void; + onRatioChange: (ratio: string) => void; + onResolutionChange: (resolution: string) => void; + onDurationChange: (duration: number) => void; + onSeedChange: (seed: number) => void; + onOpenLibrary: () => void; + onClear: () => void; + onSend: () => void; +}) { + const fileRef = useRef(null); + const pendingRoleRef = useRef<"first_frame" | "last_frame" | undefined>(undefined); + const [dragOver, setDragOver] = useState(false); + const [hasPrompt, setHasPrompt] = useState(false); + + const pickFiles = (role?: "first_frame" | "last_frame") => { + pendingRoleRef.current = role; + if (fileRef.current) { + fileRef.current.accept = mode === "keyframe" ? ACCEPT_IMAGE : ACCEPT_UNIVERSAL; + fileRef.current.multiple = mode === "universal"; + fileRef.current.click(); + } + }; + + const firstFrame = refs.find((r) => r.role === "first_frame"); + const lastFrame = refs.find((r) => r.role === "last_frame"); + + return ( +
{ event.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(event) => { + event.preventDefault(); + setDragOver(false); + const files = Array.from(event.dataTransfer.files || []); + if (files.length) onFiles(files, mode === "keyframe" ? (firstFrame ? "last_frame" : "first_frame") : undefined); + }} + > + { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (files.length) onFiles(files, pendingRoleRef.current); + pendingRoleRef.current = undefined; + }} + /> +
+ {mode === "universal" ? ( +
+ + + {refs.map((item) => ( + onRemoveRef(item.key)} /> + ))} +
+ ) : ( +
+ pickFiles("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} /> + + pickFiles("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} /> +
+ )} + +
+ + {dragOver &&
松开上传到「{MODE_LABELS[mode]}」
} +
+ ); +} diff --git a/core/frontend/src/components/free-create/prompt-input.tsx b/core/frontend/src/components/free-create/prompt-input.tsx new file mode 100644 index 0000000..e2f904c --- /dev/null +++ b/core/frontend/src/components/free-create/prompt-input.tsx @@ -0,0 +1,349 @@ +// 自由创作·提示词输入(contenteditable @mention 编辑器) +// 移植 jimeng-clone PromptInput 的核心机制:输入 @ 弹素材候选、插入不可编辑 mention chip、 +// 删素材自动清孤儿 chip、序列化时 chip → "@label" 纯文本(「图片N」替换在后端做)。 +// IME 注意:中文输入法 composition 期间不触发 @ 菜单/快捷键。 +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { FreeVideoRef } from "../../types"; +import type { LocalRef } from "./constants"; + +export type PromptInputHandle = { + /** 序列化为纯文本(chip → @label) */ + getText(): string; + clear(): void; + /** 再次生成回填:纯文本里的 @label 命中 refs 则还原成带缩略图的 chip */ + setContent(text: string, refs: FreeVideoRef[]): void; + /** 在光标处(未聚焦则在末尾)插入一个 mention chip */ + insertMention(ref: { label: string; thumb?: string }): void; + /** 删除素材后清理孤儿 chip */ + pruneMentions(validLabels: string[]): void; + focus(): void; +}; + +type Props = { + refs: LocalRef[]; + placeholder?: string; + disabled?: boolean; + onSubmit: () => void; + onOpenLibrary: () => void; + onTextChange?: (hasText: boolean) => void; +}; + +function chipHtml(label: string, thumb?: string): HTMLSpanElement { + const chip = document.createElement("span"); + chip.className = "fc-mention"; + chip.setAttribute("data-fc-mention", "1"); + chip.setAttribute("data-label", label); + chip.setAttribute("contenteditable", "false"); + if (thumb) { + const img = document.createElement("img"); + img.src = thumb; + img.alt = ""; + chip.appendChild(img); + } + chip.appendChild(document.createTextNode(`@${label}`)); + return chip; +} + +function serializeNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) return node.textContent || ""; + if (node.nodeType !== Node.ELEMENT_NODE) return ""; + const el = node as HTMLElement; + if (el.hasAttribute("data-fc-mention")) return `@${el.getAttribute("data-label") || ""}`; + if (el.tagName === "BR") return "\n"; + const inner = Array.from(el.childNodes).map(serializeNode).join(""); + // contenteditable 换行会包 div:块级元素前补换行(首个除外,由调用方 trim) + if (el.tagName === "DIV" || el.tagName === "P") return `\n${inner}`; + return inner; +} + +export const PromptInput = forwardRef(function PromptInput( + { refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onOpenLibrary, onTextChange }, + handleRef +) { + const editorRef = useRef(null); + const composingRef = useRef(false); + const savedRangeRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [menuQuery, setMenuQuery] = useState(""); + const [menuIndex, setMenuIndex] = useState(0); + const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); + + const labeledRefs = refs.filter((r) => (r.label || "").trim()); + const candidates = labeledRefs.filter((r) => !menuQuery || (r.label || "").toLowerCase().includes(menuQuery.toLowerCase())); + + const emitChange = () => { + const text = editorRef.current ? Array.from(editorRef.current.childNodes).map(serializeNode).join("").trim() : ""; + onTextChange?.(text.length > 0); + }; + + const saveRange = () => { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) { + savedRangeRef.current = sel.getRangeAt(0).cloneRange(); + } + }; + + const restoreRange = (): Range | null => { + const editor = editorRef.current; + if (!editor) return null; + const sel = window.getSelection(); + if (!sel) return null; + let range = savedRangeRef.current; + if (!range || !editor.contains(range.startContainer)) { + range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + } + sel.removeAllRanges(); + sel.addRange(range); + return range; + }; + + /** 找到光标前最近的 "@query" 触发串(同一文本节点内、不含空白),返回可删除的 Range */ + const findTrigger = (): { range: Range; query: string } | null => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return null; + const range = sel.getRangeAt(0); + if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) return null; + const textNode = range.startContainer as Text; + const upto = (textNode.textContent || "").slice(0, range.startOffset); + const at = upto.lastIndexOf("@"); + if (at === -1) return null; + const query = upto.slice(at + 1); + if (/[\s]/.test(query)) return null; + const del = document.createRange(); + del.setStart(textNode, at); + del.setEnd(textNode, range.startOffset); + return { range: del, query }; + }; + + const closeMenu = () => { setMenuOpen(false); setMenuQuery(""); setMenuIndex(0); }; + + const openMenuAtCaret = () => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + const rect = sel.getRangeAt(0).getBoundingClientRect(); + const editorRect = editorRef.current?.getBoundingClientRect(); + const left = rect.left || editorRect?.left || 0; + const top = rect.top || editorRect?.top || 0; + setMenuPos({ left, top }); + setMenuIndex(0); + setMenuOpen(true); + }; + + const insertChipAtTrigger = (label: string, thumb?: string) => { + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + const trigger = findTrigger(); + const sel = window.getSelection(); + const chip = chipHtml(label, thumb); + const space = document.createTextNode(" "); + if (trigger) { + trigger.range.deleteContents(); + trigger.range.insertNode(space); + trigger.range.insertNode(chip); + } else if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) { + const range = sel.getRangeAt(0); + range.deleteContents(); + range.insertNode(space); + range.insertNode(chip); + } else { + editor.appendChild(chip); + editor.appendChild(space); + } + // 光标移到空格后 + const after = document.createRange(); + after.setStartAfter(space); + after.collapse(true); + sel?.removeAllRanges(); + sel?.addRange(after); + saveRange(); + closeMenu(); + emitChange(); + }; + + useImperativeHandle(handleRef, () => ({ + getText() { + const editor = editorRef.current; + if (!editor) return ""; + return Array.from(editor.childNodes).map(serializeNode).join("").replace(/ /g, " ").trim(); + }, + clear() { + if (editorRef.current) editorRef.current.innerHTML = ""; + savedRangeRef.current = null; + emitChange(); + }, + setContent(text, contentRefs) { + const editor = editorRef.current; + if (!editor) return; + editor.innerHTML = ""; + // 按 label 长度降序切分,防「碧」吞「碧碧」(与后端替换同原则) + const labeled = contentRefs.filter((r) => (r.label || "").trim()).sort((a, b) => (b.label || "").length - (a.label || "").length); + let rest = text; + const parts: (string | FreeVideoRef)[] = []; + while (rest.length > 0) { + let hitIdx = -1; + let hitRef: FreeVideoRef | null = null; + for (const r of labeled) { + const idx = rest.indexOf(`@${r.label}`); + if (idx !== -1 && (hitIdx === -1 || idx < hitIdx)) { hitIdx = idx; hitRef = r; } + } + if (hitIdx === -1 || !hitRef) { parts.push(rest); break; } + if (hitIdx > 0) parts.push(rest.slice(0, hitIdx)); + parts.push(hitRef); + rest = rest.slice(hitIdx + `@${hitRef.label}`.length); + } + for (const part of parts) { + if (typeof part === "string") editor.appendChild(document.createTextNode(part)); + else editor.appendChild(chipHtml(part.label || "", part.thumb_url || (part.type === "image" ? part.url : ""))); + } + emitChange(); + }, + insertMention(ref) { + insertChipAtTrigger(ref.label, ref.thumb); + }, + pruneMentions(validLabels) { + const editor = editorRef.current; + if (!editor) return; + editor.querySelectorAll("[data-fc-mention]").forEach((chip) => { + if (!validLabels.includes(chip.getAttribute("data-label") || "")) chip.remove(); + }); + emitChange(); + }, + focus() { editorRef.current?.focus(); } + })); + + // 素材集合变化 → 清孤儿 chip(删素材后 prompt 里的引用即时消失) + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + const valid = labeledRefs.map((r) => r.label || ""); + let changed = false; + editor.querySelectorAll("[data-fc-mention]").forEach((chip) => { + if (!valid.includes(chip.getAttribute("data-label") || "")) { chip.remove(); changed = true; } + }); + if (changed) emitChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refs.map((r) => r.label).join("")]); + + // 菜单开着时点外部关闭 + useEffect(() => { + if (!menuOpen) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Element; + if (target.closest?.(".fc-mention-menu") || editorRef.current?.contains(target as Node)) return; + closeMenu(); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [menuOpen]); + + const menuItems: { key: string; label: string; thumb?: string; kind: "ref" | "library" }[] = [ + ...candidates.map((r) => ({ + key: r.key, + label: r.label || "", + thumb: r.thumb_url || (r.type === "image" ? r.url : ""), + kind: "ref" as const + })), + { key: "__library__", label: "从素材库选择…", kind: "library" as const } + ]; + + const pickMenuItem = (item: (typeof menuItems)[number]) => { + if (item.kind === "library") { + // 先删掉触发串,再开素材库(选中后由页面 insertMention 插 chip) + const trigger = findTrigger(); + trigger?.range.deleteContents(); + closeMenu(); + onOpenLibrary(); + return; + } + insertChipAtTrigger(item.label, item.thumb); + }; + + return ( +
+
{ + saveRange(); + emitChange(); + if (menuOpen) { + const trigger = findTrigger(); + if (!trigger) closeMenu(); + else setMenuQuery(trigger.query); + } + }} + onKeyUp={saveRange} + onMouseUp={saveRange} + onBlur={saveRange} + onCompositionStart={() => { composingRef.current = true; }} + onCompositionEnd={() => { composingRef.current = false; }} + onPaste={(event) => { + // 只贴纯文本,防外部富文本污染编辑器结构 + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + document.execCommand("insertText", false, text); + }} + onKeyDown={(event) => { + if (composingRef.current) return; + if (menuOpen) { + if (event.key === "ArrowDown") { event.preventDefault(); setMenuIndex((i) => Math.min(i + 1, menuItems.length - 1)); return; } + if (event.key === "ArrowUp") { event.preventDefault(); setMenuIndex((i) => Math.max(i - 1, 0)); return; } + if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); if (menuItems[menuIndex]) pickMenuItem(menuItems[menuIndex]); return; } + if (event.key === "Escape") { event.preventDefault(); closeMenu(); return; } + } + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + event.preventDefault(); + onSubmit(); + return; + } + if (event.key === "@" || (event.key === "2" && event.shiftKey)) { + // 等字符落进 DOM 后再定位菜单 + window.setTimeout(() => { + const trigger = findTrigger(); + if (trigger) { setMenuQuery(trigger.query); openMenuAtCaret(); } + }, 0); + } + }} + /> + {menuOpen && menuPos && createPortal( +
+
+
// 引用素材
+ {menuItems.map((item, i) => ( + + ))} +
+
, + document.body + )} +
+ ); +}); diff --git a/core/frontend/src/components/free-create/toolbar.tsx b/core/frontend/src/components/free-create/toolbar.tsx new file mode 100644 index 0000000..f525ba8 --- /dev/null +++ b/core/frontend/src/components/free-create/toolbar.tsx @@ -0,0 +1,177 @@ +// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。 +// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。 +import { useEffect, useRef, useState } from "react"; +import type { ModelConfig } from "../../types"; +import { + FC_DURATIONS, + FC_MODELS, + FC_RATIOS, + FC_RESOLUTIONS, + FC_STANDARD_MODEL, + MODE_LABELS, + estimateCost, + type FreeMode, + type LocalRef +} from "./constants"; + +type MenuItem = { value: string; label: string; desc?: string; disabled?: boolean; hint?: string }; + +function FcDropdown({ label, display, items, onSelect, disabled }: { + label: string; + display: string; + items: MenuItem[]; + onSelect: (value: string) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + useEffect(() => { + if (!open) return; + const onDown = (event: MouseEvent) => { + if (!wrapRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; + }, [open]); + return ( +
+ + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: { + mode: FreeMode; + model: string; + ratio: string; + resolution: string; + duration: number; + seed: number; + refs: LocalRef[]; + videoConfigs: ModelConfig[]; + hasPrompt: boolean; + submitting: boolean; + onModeChange: (mode: FreeMode) => void; + onModelChange: (model: string) => void; + onRatioChange: (ratio: string) => void; + onResolutionChange: (resolution: string) => void; + onDurationChange: (duration: number) => void; + onSeedChange: (seed: number) => void; + onClear: () => void; + onSend: () => void; +}) { + const isStandard = model === FC_STANDARD_MODEL; + const config = videoConfigs.find((c) => c.name === model); + const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs }); + const [seedOpen, setSeedOpen] = useState(false); + const seedRef = useRef(null); + useEffect(() => { + if (!seedOpen) return; + const onDown = (event: MouseEvent) => { if (!seedRef.current?.contains(event.target as Node)) setSeedOpen(false); }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [seedOpen]); + + const uploading = refs.some((r) => r.uploading); + const canSend = hasPrompt && !submitting && !uploading; + + return ( +
+
+ m.name === model)?.label || model} + items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))} + onSelect={(value) => { + onModelChange(value); + // 非标准档不支持 1080P/4K:自动回落 720P(与 jimeng 行为一致) + if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p"); + }} + /> + onModeChange(value as FreeMode)} + /> + ({ value: r, label: r }))} onSelect={onRatioChange} /> + ({ + value: r, + label: r.toUpperCase(), + disabled: (r === "1080p" || r === "4k") && !isStandard, + hint: "仅 Seedance 2.0 标准档支持" + }))} + onSelect={onResolutionChange} + /> + ({ value: String(d), label: `${d}s` }))} + onSelect={(value) => onDurationChange(Number(value))} + /> +
+ + {seedOpen && ( +
+
+ { + const v = parseInt(event.target.value, 10); + onSeedChange(Number.isNaN(v) ? -1 : v); + }} + /> + +
+
// -1 = 随机;相同种子可复现相似结果
+
+ )} +
+
+
+ + ≈ {tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)} + + + +
+
+ ); +} diff --git a/core/frontend/src/components/free-create/video-detail-modal.tsx b/core/frontend/src/components/free-create/video-detail-modal.tsx new file mode 100644 index 0000000..eaad144 --- /dev/null +++ b/core/frontend/src/components/free-create/video-detail-modal.tsx @@ -0,0 +1,161 @@ +// 自由创作·视频详情弹窗:自研全屏播放器(播放/暂停、seek、音量、全屏)+ 上下切换 + +// 下载 / 再次生成(回填输入条) / 收藏 / 删除。ESC / 点遮罩关闭。 +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ChevronLeft, ChevronRight, Download, Heart, Maximize, Pause, Play, RotateCcw, Trash2, Volume2, VolumeX, X } from "lucide-react"; +import type { FreeVideoTask } from "../../types"; +import { useBodyScrollLock } from "../overlays"; +import { MODE_LABELS, modelLabel } from "./constants"; + +function fmt(seconds: number): string { + if (!isFinite(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${String(s).padStart(2, "0")}`; +} + +export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClose, onDownload, onToggleFavorite, onReuse, onDelete }: { + task: FreeVideoTask; + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; + onClose: () => void; + onDownload: () => void; + onToggleFavorite: () => void; + onReuse: () => void; + onDelete: () => void; +}) { + const videoRef = useRef(null); + const stageRef = useRef(null); + const [playing, setPlaying] = useState(true); + const [muted, setMuted] = useState(false); + const [volume, setVolume] = useState(1); + const [current, setCurrent] = useState(0); + const [total, setTotal] = useState(0); + + useBodyScrollLock(true); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + else if (event.key === "ArrowLeft" && hasPrev) onPrev(); + else if (event.key === "ArrowRight" && hasNext) onNext(); + else if (event.key === " ") { event.preventDefault(); togglePlay(); } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasPrev, hasNext, onPrev, onNext, onClose]); + + // 切换任务时重置播放状态 + useEffect(() => { + setPlaying(true); + setCurrent(0); + setTotal(0); + }, [task.id]); + + const togglePlay = () => { + const video = videoRef.current; + if (!video) return; + if (video.paused) { void video.play().catch(() => undefined); } else { video.pause(); } + }; + + const [w, h] = task.aspect_ratio.split(":").map(Number); + + return createPortal( +
+ + {hasPrev && ( + + )} + {hasNext && ( + + )} +
event.stopPropagation()}> +
+
+
+ + {fmt(current)} / {fmt(total || task.duration)} + { + const t = Number(event.target.value); + if (videoRef.current) videoRef.current.currentTime = t; + setCurrent(t); + }} + /> + + { + const v = Number(event.target.value); + if (videoRef.current) { videoRef.current.volume = v; videoRef.current.muted = v === 0; } + }} + /> + +
+
+
{task.prompt}
+
+ // {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.aspect_ratio} · {task.resolution.toUpperCase()} · {task.duration}s + {task.seed_used != null && ` · seed ${task.seed_used}`} + {` · ¥${Number(task.actual_cost || 0).toFixed(2)}`} +
+ {task.fallback_note &&
// {task.fallback_note}
} +
+ + + + +
+
+
+
, + document.body + ); +} diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css new file mode 100644 index 0000000..8f939c6 --- /dev/null +++ b/core/frontend/src/free-create-page.css @@ -0,0 +1,568 @@ +/* 自由创作页(free-create)· 私有样式,全部 .fc- 前缀。 + 颜色/圆角/间距全部走 design-restraint token(§8 Don't List:禁裸 hex / >12px 圆角 / 灰阴影)。 */ + +.fc-page { display: flex; flex-direction: column; min-height: calc(100vh - 64px - 120px); } + +/* —— 任务流 —— */ +.fc-feed-wrap { flex: 1; padding-bottom: 24px; } +.fc-feed { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 14px; + align-items: start; +} +.fc-loading, .fc-sentinel { + padding: 28px 0; + text-align: center; + font-size: 11px; + letter-spacing: 0.04em; + color: var(--black-alpha-48); +} +.fc-empty { margin: 48px auto; } + +/* —— 任务卡 —— */ +.fc-card { + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + overflow: hidden; + display: flex; + flex-direction: column; + transition: border-color 0.2s; +} +.fc-card.done { cursor: pointer; } +.fc-card.done:hover { border-color: var(--black-alpha-48); } +.fc-card-media { + position: relative; + background: var(--background-base); + max-height: 420px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; } +.fc-card-generating { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: linear-gradient(110deg, var(--black-alpha-4) 30%, var(--black-alpha-7) 50%, var(--black-alpha-4) 70%); + background-size: 200% 100%; + animation: fc-shimmer 1.6s linear infinite; + font-size: 11px; + color: var(--black-alpha-56); +} +@keyframes fc-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.fc-progress { + width: 60%; + height: 4px; + border-radius: 2px; + background: var(--black-alpha-12); + overflow: hidden; +} +.fc-progress span { display: block; height: 100%; border-radius: 2px; background: var(--heat); transition: width 0.6s ease; } +.fc-card-failed { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px; + text-align: center; +} +.fc-card-failed p { font-size: 12px; line-height: 1.65; color: var(--black-alpha-64); max-width: 90%; margin: 0; } +.fc-card-hover { + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 6px; + opacity: 0; + transition: opacity 0.2s; +} +.fc-card:hover .fc-card-hover { opacity: 1; } +.fc-hover-btn { + width: 30px; + height: 30px; + border-radius: var(--r-md); + border: 1px solid var(--border-faint); + background: var(--surface); + color: var(--black-alpha-56); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} +.fc-hover-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); } +.fc-hover-btn.fav { color: var(--heat); } +.fc-hover-btn.danger:hover { color: var(--accent-crimson); } +.fc-card-meta { padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; } +.fc-card-prompt { + font-size: 13px; + color: var(--accent-black); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fc-card-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); } + +/* —— 底部输入条 —— */ +.fc-inputbar { + position: sticky; + bottom: 16px; + z-index: 5; + background: var(--surface-raised); + border: 1px solid var(--border-muted); + border-radius: var(--r-md); + padding: 14px 16px 12px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: var(--shadow-floating); +} +.fc-inputbar.drag { border-color: var(--heat-40); } +.fc-drop-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--heat-12); + border-radius: inherit; + color: var(--heat); + font-size: 12px; + pointer-events: none; +} +.fc-input-top { display: flex; gap: 12px; align-items: flex-start; } + +/* 参考素材条(universal) */ +.fc-refs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 300px; } +.fc-add { + width: 56px; + height: 56px; + border-radius: var(--r-md); + border: 1px dashed var(--black-alpha-24); + background: var(--background-base); + color: var(--black-alpha-56); + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + cursor: pointer; + font-size: 12px; + transition: border-color 0.2s, color 0.2s; +} +.fc-add:hover { border-color: var(--heat-40); color: var(--heat); } +.fc-ref { + position: relative; + width: 56px; + height: 56px; + border-radius: var(--r-md); + border: 1px solid var(--border-faint); + overflow: hidden; + background: var(--background-base); + display: flex; + align-items: center; + justify-content: center; +} +.fc-ref img { width: 100%; height: 100%; object-fit: cover; } +.fc-ref.uploading img { opacity: 0.4; } +.fc-ref .spinner { position: absolute; } +.fc-ref-kind { font-size: 11px; color: var(--black-alpha-48); } +.fc-ref-dur { + position: absolute; + right: 3px; + bottom: 3px; + font-size: 8.5px; + padding: 1px 4px; + border-radius: var(--r-sm); + background: var(--black-alpha-56); + color: var(--surface); +} +.fc-ref-label { + position: absolute; + left: 0; + right: 0; + bottom: 0; + font-size: 9px; + padding: 1px 4px; + background: var(--black-alpha-56); + color: var(--surface); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fc-ref-x { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + border-radius: 999px; + border: none; + background: var(--black-alpha-56); + color: var(--surface); + font-size: 11px; + line-height: 1; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; +} +.fc-ref:hover .fc-ref-x, .fc-kf-slot:hover .fc-ref-x { display: inline-flex; } + +/* 首尾帧(keyframe) */ +.fc-keyframes { display: flex; align-items: center; gap: 8px; } +.fc-kf-slot { + position: relative; + width: 88px; + height: 66px; + border-radius: var(--r-md); + border: 1px dashed var(--black-alpha-24); + background: var(--background-base); + color: var(--black-alpha-56); + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + font-size: 11px; + overflow: hidden; + transition: border-color 0.2s, color 0.2s; +} +.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); } +.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); cursor: default; } +.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.fc-kf-slot .spinner { position: absolute; } +.fc-kf-tag { + position: absolute; + left: 3px; + top: 3px; + font-size: 8.5px; + padding: 1px 4px; + border-radius: var(--r-sm); + background: var(--black-alpha-56); + color: var(--surface); +} +.fc-kf-arrow { color: var(--black-alpha-48); font-size: 13px; } + +/* 提示词编辑器(contenteditable) */ +.fc-prompt-wrap { flex: 1; min-width: 0; } +.fc-prompt { + min-height: 64px; + max-height: 160px; + overflow-y: auto; + font-size: 13px; + line-height: 1.65; + color: var(--accent-black); + outline: none; + padding: 6px 2px; + word-break: break-word; +} +.fc-prompt:empty::before { content: attr(data-placeholder); color: var(--black-alpha-48); pointer-events: none; } +.fc-mention { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + margin: 0 1px; + border-radius: var(--r-sm); + background: var(--heat-12); + border: 1px solid var(--heat-20); + color: var(--heat); + font-size: 12px; + vertical-align: middle; + user-select: none; + white-space: nowrap; +} +.fc-mention img { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; } + +/* @ 候选菜单(fixed,朝上弹) */ +.fc-mention-menu { position: fixed; z-index: 300; transform: translateY(-100%); } +.fc-mention-menu-inner { + min-width: 220px; + max-width: 280px; + max-height: 260px; + overflow-y: auto; + background: var(--surface-raised); + border: 1px solid var(--border-muted); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); + padding: 6px; +} +.fc-mention-menu-head { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); padding: 4px 8px; } +.fc-mention-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--r-sm); + background: transparent; + cursor: pointer; + font-size: 12.5px; + color: var(--accent-black); + text-align: left; +} +.fc-mention-item.active { background: var(--black-alpha-4); } +.fc-mention-item img { width: 24px; height: 24px; border-radius: var(--r-sm); object-fit: cover; } +.fc-mention-ph { width: 24px; height: 24px; border-radius: var(--r-sm); background: var(--black-alpha-7); } +.fc-mention-item .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fc-mention-item .lbl.lib { color: var(--black-alpha-56); } + +/* —— 工具栏 —— */ +.fc-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border-top: 1px solid var(--border-faint); padding-top: 10px; } +.fc-toolbar-l { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.fc-toolbar-r { margin-left: auto; display: flex; align-items: center; gap: 8px; } +.fc-estimate { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); font-variant-numeric: tabular-nums; } + +.fc-dd { position: relative; } +.fc-dd-btn { + height: 28px; + padding: 0 10px; + border-radius: var(--r-md); + border: 1px solid var(--border-faint); + background: var(--surface); + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--accent-black); + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.fc-dd-btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); } +.fc-dd-btn:disabled { color: var(--black-alpha-32); cursor: not-allowed; } +.fc-dd-btn svg { color: var(--black-alpha-48); transition: transform 0.2s; } +.fc-dd.open .fc-dd-btn svg { transform: rotate(180deg); } +.fc-dd-lbl { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); } +.fc-dd-val { font-weight: 500; } +.fc-dd-menu { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 60; + min-width: 180px; + background: var(--surface-raised); + border: 1px solid var(--border-muted); + border-radius: var(--r-md); + box-shadow: var(--shadow-floating); + padding: 6px; +} +.fc-dd-item { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 7px 10px; + border: none; + border-radius: var(--r-sm); + background: transparent; + cursor: pointer; + text-align: left; +} +.fc-dd-item:hover { background: var(--black-alpha-4); } +.fc-dd-item.selected { background: var(--heat-12); } +.fc-dd-item.selected .ti { color: var(--heat); } +.fc-dd-item.disabled { cursor: not-allowed; } +.fc-dd-item.disabled .ti, .fc-dd-item.disabled .de { color: var(--black-alpha-32); } +.fc-dd-item .ti { font-size: 12.5px; font-weight: 500; color: var(--accent-black); } +.fc-dd-item .de { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); } +.fc-seed-menu { min-width: 220px; padding: 10px; } +.fc-seed-row { display: flex; gap: 8px; align-items: center; } +.fc-seed-row .input { height: 30px; flex: 1; } +.fc-seed-hint { margin-top: 6px; font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); } +.fc-fav-on { color: var(--heat); } + +/* —— 全屏播放器 —— */ +.fc-player-bg { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(21, 20, 15, 0.72); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: 40px 72px; +} +.fc-player-x { + position: absolute; + top: 20px; + right: 24px; + width: 36px; + height: 36px; + border-radius: var(--r-md); + border: none; + background: var(--black-alpha-24); + color: var(--surface); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.fc-player-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + border-radius: 999px; + border: none; + background: var(--black-alpha-24); + color: var(--surface); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + z-index: 2; +} +.fc-player-nav.prev { left: 20px; } +.fc-player-nav.next { right: 20px; } +.fc-player-nav:hover, .fc-player-x:hover { background: var(--black-alpha-48); } +.fc-player { + width: min(920px, 100%); + max-height: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} +.fc-player-stage { + max-height: 62vh; + margin: 0 auto; + max-width: 100%; + background: #000; /* 视频画布留黑,行业惯例,非界面色 */ + border-radius: var(--r-md); + overflow: hidden; + cursor: pointer; +} +.fc-player-stage video { width: 100%; height: 100%; object-fit: contain; display: block; } +.fc-player-controls { + display: flex; + align-items: center; + gap: 10px; + background: var(--surface-raised); + border-radius: var(--r-md); + padding: 8px 12px; +} +.fc-ctl { + width: 30px; + height: 30px; + border: none; + border-radius: var(--r-md); + background: transparent; + color: var(--black-alpha-56); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.fc-ctl:hover { background: var(--black-alpha-4); color: var(--accent-black); } +.fc-time { font-size: 11px; color: var(--black-alpha-56); font-variant-numeric: tabular-nums; white-space: nowrap; } +.fc-seek { flex: 1; accent-color: var(--heat); } +.fc-vol { width: 72px; accent-color: var(--heat); } +.fc-player-info { + background: var(--surface-raised); + border-radius: var(--r-md); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.fc-player-prompt { font-size: 13px; line-height: 1.65; color: var(--accent-black); max-height: 72px; overflow-y: auto; } +.fc-player-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); } +.fc-player-warn { font-size: 11px; color: var(--accent-honey); } +.fc-player-actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; } + +/* —— 素材库弹窗 —— */ +.fc-lib-modal { width: min(720px, 92vw); } +.fc-lib-body { max-height: 60vh; overflow-y: auto; } +.fc-lib-back { + display: inline-flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + font-size: inherit; + font-weight: inherit; + color: inherit; + cursor: pointer; + padding: 0; +} +.fc-lib-back:hover { color: var(--heat); } +.fc-lib-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.fc-lib-hint { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); } +.fc-lib-create { display: flex; gap: 8px; align-items: center; flex: 1; } +.fc-lib-create .input { height: 30px; flex: 1; max-width: 260px; } +.fc-lib-groups, .fc-lib-assets { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 12px; +} +.fc-lib-group, .fc-lib-asset { + position: relative; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.fc-lib-group:hover, .fc-lib-asset.pickable:hover { background: var(--surface); border-color: var(--heat-40); } +.fc-lib-asset:not(.pickable) { cursor: default; } +.fc-lib-thumb { + position: relative; + aspect-ratio: 1; + border-radius: var(--r-sm); + background: var(--black-alpha-4); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + color: var(--black-alpha-48); +} +.fc-lib-thumb img { width: 100%; height: 100%; object-fit: cover; } +.fc-lib-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); } +.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; } +.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; } +.fc-lib-ops button { + width: 22px; + height: 22px; + border: none; + border-radius: var(--r-sm); + background: var(--black-alpha-56); + color: var(--surface); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.fc-lib-ops button:hover { background: var(--accent-black); } + +/* 移动端:输入条参数换行、任务流单列 */ +@media (max-width: 720px) { + .fc-feed { grid-template-columns: 1fr; } + .fc-input-top { flex-direction: column; } + .fc-refs { max-width: none; } + .fc-player-bg { padding: 16px; } + .fc-player-nav.prev { left: 6px; } + .fc-player-nav.next { right: 6px; } +} diff --git a/core/frontend/src/main.tsx b/core/frontend/src/main.tsx index 3f550b0..90da611 100644 --- a/core/frontend/src/main.tsx +++ b/core/frontend/src/main.tsx @@ -12,6 +12,7 @@ import "./library-page.css"; import "./messages-page.css"; import "./settings-page.css"; import "./ai-tools-page.css"; +import "./free-create-page.css"; import "./product-create-page.css"; import "./project-wizard-page.css"; import "./admin-page.css"; diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx new file mode 100644 index 0000000..d318f6f --- /dev/null +++ b/core/frontend/src/routes/free-create.tsx @@ -0,0 +1,549 @@ +// 自由创作 · AI 视频生成(移植自 jimeng-clone,嫁接 AirShelf 底座) +// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端 +// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。 +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Users } from "lucide-react"; +import { api, ApiError } from "../api"; +import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types"; +import { + FC_MODELS, + FC_STANDARD_MODEL, + MAX_AUDIOS, + MAX_IMAGES, + MAX_VIDEOS, + MAX_VIDEO_TOTAL_SECONDS, + checkRefFile, + isInFlight, + type FreeMode, + type LocalRef +} from "../components/free-create/constants"; +import { FreeInputBar } from "../components/free-create/input-bar"; +import { GenerationCard } from "../components/free-create/generation-card"; +import { VideoDetailModal } from "../components/free-create/video-detail-modal"; +import { AssetLibraryModal } from "../components/free-create/asset-library-modal"; +import type { PromptInputHandle } from "../components/free-create/prompt-input"; +import { ConfirmModal } from "../components/overlays"; + +const PAGE_SIZE = 20; +const PROGRESS_KEY = "fc-progress"; + +function loadProgress(): Record { + try { + return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record; + } catch { + return {}; + } +} + +// 渐进轮询间隔:前 1 分钟 10s,1-3 分钟 30s,之后 60s(视频 5-10 分钟出片,别打爆后端) +function pollDelay(count: number): number { + if (count < 6) return 10000; + if (count < 10) return 30000; + return 60000; +} + +let refKeySeq = 0; +const nextRefKey = () => `ref-${Date.now()}-${refKeySeq++}`; + +export function FreeCreatePage({ modelConfigs, onNotify }: { + modelConfigs: ModelConfig[]; + onNotify: (type: "success" | "error" | "info", text: string) => void; +}) { + // App 每次渲染会重建 onNotify 箭头函数;用 ref 稳定身份,否则依赖它的 effect(首屏拉取/轮询) + // 会随 App 任意 setState(如 30s 未读轮询)反复重跑 → 任务流无谓全量刷新(实测踩坑)。 + const onNotifyRef = useRef(onNotify); + onNotifyRef.current = onNotify; + const notify = useCallback((type: "success" | "error" | "info", text: string) => onNotifyRef.current(type, text), []); + const videoConfigs = useMemo( + () => modelConfigs.filter((c) => c.capability === "video" && FC_MODELS.some((m) => m.name === c.name)), + [modelConfigs] + ); + + // —— 任务流 —— + const [tasks, setTasks] = useState([]); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const loadingMoreRef = useRef(false); + const sentinelRef = useRef(null); + + // —— 输入条 —— + const [mode, setMode] = useState("universal"); + const [model, setModel] = useState(FC_STANDARD_MODEL); + const [ratio, setRatio] = useState("16:9"); + const [resolution, setResolution] = useState("720p"); + const [duration, setDuration] = useState(5); + const [seed, setSeed] = useState(-1); + const [refs, setRefs] = useState([]); + const [submitting, setSubmitting] = useState(false); + const promptRef = useRef(null); + + // —— 弹窗 —— + const [detailId, setDetailId] = useState(null); + const [libraryOpen, setLibraryOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + // —— 轮询/进度 —— + const pollTimersRef = useRef(new Map()); + const pollCountsRef = useRef(new Map()); + const [progress, setProgress] = useState>(() => loadProgress()); + const tasksRef = useRef([]); + tasksRef.current = tasks; + + const patchTask = useCallback((task: FreeVideoTask) => { + setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t))); + }, []); + + const stopPolling = useCallback((id: string) => { + const timer = pollTimersRef.current.get(id); + if (timer) window.clearTimeout(timer); + pollTimersRef.current.delete(id); + pollCountsRef.current.delete(id); + }, []); + + const schedulePoll = useCallback((id: string) => { + if (pollTimersRef.current.has(id)) return; + const tick = async () => { + pollTimersRef.current.delete(id); + const current = tasksRef.current.find((t) => t.id === id); + if (!current || !isInFlight(current.status)) { stopPolling(id); return; } + const count = (pollCountsRef.current.get(id) || 0) + 1; + pollCountsRef.current.set(id, count); + try { + const data = await api.pollFreeVideo(id); + patchTask(data.task); + if (isInFlight(data.task.status)) { + pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count))); + } else { + stopPolling(id); + setProgress((prev) => { const next = { ...prev }; delete next[id]; sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); return next; }); + if (data.task.status === "succeeded") notify("success", "视频生成完成"); + else if (data.task.status === "failed") notify("error", data.task.error_message || "视频生成失败"); + } + } catch { + // 单次轮询失败(网络抖动)不终结,下一轮继续 + pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count))); + } + }; + pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(pollCountsRef.current.get(id) || 0))); + }, [patchTask, stopPolling, notify]); + + // 平滑进度动画:每 2s 给在途任务 +0.6~1.6%,封顶 95,sessionStorage 持久(刷新可续) + useEffect(() => { + const timer = window.setInterval(() => { + const inflight = tasksRef.current.filter((t) => isInFlight(t.status)); + if (inflight.length === 0) return; + setProgress((prev) => { + const next = { ...prev }; + for (const t of inflight) { + next[t.id] = Math.min(95, (next[t.id] || 3) + 0.6 + Math.random()); + } + sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); + return next; + }); + }, 2000); + return () => window.clearInterval(timer); + }, []); + + // 首屏拉取 + 在途任务恢复轮询(后端持久恢复,换浏览器也不丢) + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const data = await api.freeVideoTasks(0, PAGE_SIZE); + if (cancelled) return; + setTasks(data.results); + setTotal(data.total); + setHasMore(data.has_more); + data.results.filter((t) => isInFlight(t.status)).forEach((t) => schedulePoll(t.id)); + } catch (error) { + if (!cancelled) notify("error", error instanceof Error ? error.message : "任务列表加载失败"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + const timers = pollTimersRef.current; + return () => { + cancelled = true; + timers.forEach((timer) => window.clearTimeout(timer)); + timers.clear(); + }; + }, [schedulePoll, notify]); + + // 滚动加载更多(旧任务) + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !hasMore) return; + const observer = new IntersectionObserver((entries) => { + if (!entries[0].isIntersecting || loadingMoreRef.current) return; + loadingMoreRef.current = true; + void api.freeVideoTasks(tasksRef.current.filter((t) => !t.id.startsWith("local-")).length, PAGE_SIZE) + .then((data) => { + setTasks((prev) => { + const seen = new Set(prev.map((t) => t.id)); + return [...prev, ...data.results.filter((t) => !seen.has(t.id))]; + }); + setTotal(data.total); + setHasMore(data.has_more); + }) + .catch(() => undefined) + .finally(() => { loadingMoreRef.current = false; }); + }, { rootMargin: "200px" }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore]); + + // —— 上传 —— + const addFiles = useCallback(async (files: File[], role?: "first_frame" | "last_frame") => { + for (const file of files) { + const check = await checkRefFile(file); + if (!check.ok) { notify("error", check.error); continue; } + if (mode === "keyframe") { + if (check.type !== "image") { notify("error", "首尾帧模式仅支持图片素材"); continue; } + } else { + const counts = { image: 0, video: 0, audio: 0 }; + let videoSeconds = 0; + for (const r of refs) { + counts[r.type] += 1; + if (r.type === "video") videoSeconds += r.duration || 0; + } + if (check.type === "image" && counts.image >= MAX_IMAGES) { notify("error", `参考图片最多 ${MAX_IMAGES} 张`); continue; } + if (check.type === "video" && counts.video >= MAX_VIDEOS) { notify("error", `参考视频最多 ${MAX_VIDEOS} 条`); continue; } + if (check.type === "audio" && counts.audio >= MAX_AUDIOS) { notify("error", `参考音频最多 ${MAX_AUDIOS} 条`); continue; } + if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS) { + notify("error", `参考视频总时长不能超过 ${MAX_VIDEO_TOTAL_SECONDS} 秒`); + continue; + } + } + const key = nextRefKey(); + const blobUrl = URL.createObjectURL(file); + const baseLabel = (file.name.replace(/\.[^.]+$/, "") || "素材").slice(0, 24); + let label = baseLabel; + let n = 2; + // 生成唯一 label(重名素材 @ 引用会歧义) + // eslint-disable-next-line no-loop-func + while (refs.some((r) => r.label === label)) label = `${baseLabel}${n++}`; + const local: LocalRef = { + key, + url: blobUrl, + type: check.type, + role: mode === "keyframe" ? role || "first_frame" : undefined, + label: mode === "keyframe" ? undefined : label, + duration: check.duration, + source: "upload", + uploading: true + }; + setRefs((prev) => { + // keyframe:同 role 只留一张(替换) + const cleaned = mode === "keyframe" ? prev.filter((r) => r.role !== local.role) : prev; + return [...cleaned, local]; + }); + const form = new FormData(); + form.append("file", file); + void api.uploadFreeVideoRef(form) + .then((data) => { + URL.revokeObjectURL(blobUrl); + setRefs((prev) => prev.map((r) => (r.key === key ? { + ...r, + uploading: false, + url: data.url, + thumb_url: data.thumb_url || (data.type === "image" ? data.url : ""), + duration: data.duration || r.duration, + asset_id: data.asset_id + } : r))); + }) + .catch((error) => { + URL.revokeObjectURL(blobUrl); + setRefs((prev) => prev.filter((r) => r.key !== key)); + notify("error", error instanceof Error ? error.message : "素材上传失败"); + }); + } + }, [mode, refs, notify]); + + const removeRef = useCallback((key: string) => { + setRefs((prev) => prev.filter((r) => r.key !== key)); + }, []); + + // 素材集合变化 → 编辑器清孤儿 chip + useEffect(() => { + promptRef.current?.pruneMentions(refs.map((r) => r.label || "").filter(Boolean)); + }, [refs]); + + // —— 提交 —— + const toPayloadRefs = (items: LocalRef[]): FreeVideoRef[] => + items.map(({ key: _key, uploading: _uploading, ...rest }) => rest); + + const doSubmit = useCallback(async (payload: { + prompt: string; + mode: FreeMode; + model: string; + aspect_ratio: string; + resolution: string; + duration: number; + seed: number; + references: FreeVideoRef[]; + }) => { + const localId = `local-${Date.now()}`; + const placeholder: FreeVideoTask = { + id: localId, + status: "submitted", + mode: payload.mode, + model: payload.model, + prompt: payload.prompt, + aspect_ratio: payload.aspect_ratio, + resolution: payload.resolution, + duration: payload.duration, + seed: payload.seed, + generate_audio: true, + references: payload.references, + estimated_tokens: 0, + actual_tokens: 0, + estimated_cost: "0", + actual_cost: "0", + error_message: "", + is_favorited: false, + video_url: "", + thumbnail_url: "", + created_at: new Date().toISOString(), + completed_at: null + }; + setTasks((prev) => [placeholder, ...prev]); + setSubmitting(true); + try { + const data = await api.submitFreeVideo(payload); + setTasks((prev) => prev.map((t) => (t.id === localId ? data.task : t))); + setTotal((prev) => prev + 1); + // 进度动画平移到真实任务 id + setProgress((prev) => { + const next = { ...prev, [data.task.id]: prev[localId] || 3 }; + delete next[localId]; + sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); + return next; + }); + if (isInFlight(data.task.status)) schedulePoll(data.task.id); + else if (data.task.status === "failed") notify("error", data.task.error_message || "创建任务失败"); + return true; + } catch (error) { + setTasks((prev) => prev.filter((t) => t.id !== localId)); + notify("error", error instanceof ApiError ? error.message : "提交失败,请重试"); + return false; + } finally { + setSubmitting(false); + } + }, [schedulePoll, notify]); + + const handleSend = useCallback(async () => { + const prompt = promptRef.current?.getText() || ""; + if (!prompt.trim()) { notify("error", "请先输入提示词"); return; } + if (refs.some((r) => r.uploading)) { notify("info", "素材上传中,请稍候"); return; } + if (mode === "keyframe" && !refs.some((r) => r.role === "first_frame")) { + notify("error", "首尾帧模式需要提供首帧图片"); + return; + } + if (refs.length === 0 && /@(图片|视频|音频|素材)/.test(prompt)) { + notify("error", "提示词里 @ 引用的素材为空,请补充素材或删除该引用"); + return; + } + const audioCount = refs.filter((r) => r.type === "audio").length; + if (audioCount > 0 && refs.length === audioCount) { + notify("error", "音频不能单独作为参考素材,请同时提供图片或视频"); + return; + } + const ok = await doSubmit({ + prompt: prompt.trim(), + mode, + model, + aspect_ratio: ratio, + resolution, + duration, + seed, + references: toPayloadRefs(refs) + }); + if (ok) { + promptRef.current?.clear(); + setRefs([]); + } + }, [refs, mode, model, ratio, resolution, duration, seed, doSubmit, notify]); + + const handleRetry = useCallback((task: FreeVideoTask) => { + void doSubmit({ + prompt: task.prompt, + mode: task.mode, + model: task.model, + aspect_ratio: task.aspect_ratio, + resolution: task.resolution, + duration: task.duration, + seed: task.seed, + references: task.references + }); + }, [doSubmit]); + + // 再次生成:参数 + 素材 + 提示词(含 mention chip)全部回填输入条 + const handleReuse = useCallback((task: FreeVideoTask) => { + setDetailId(null); + setMode(task.mode); + setModel(task.model); + setRatio(task.aspect_ratio); + setResolution(task.resolution); + setDuration(task.duration); + setSeed(task.seed ?? -1); + setRefs(task.references.map((r) => ({ ...r, key: nextRefKey() }))); + window.setTimeout(() => promptRef.current?.setContent(task.prompt, task.references), 0); + notify("info", "已回填参数,可修改后重新生成"); + }, [notify]); + + const handleFavorite = useCallback((task: FreeVideoTask) => { + void api.toggleFreeVideoFavorite(task.id) + .then((data) => patchTask({ ...task, is_favorited: data.is_favorited })) + .catch((error) => notify("error", error instanceof Error ? error.message : "操作失败")); + }, [patchTask, notify]); + + const handleDownload = useCallback(async (task: FreeVideoTask) => { + if (!task.video_url) return; + try { + const response = await fetch(task.video_url); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `airshelf-free-${task.id.slice(0, 8)}.mp4`; + a.click(); + URL.revokeObjectURL(url); + } catch { + window.open(task.video_url, "_blank"); + } + }, []); + + const confirmDelete = useCallback(async () => { + const target = deleteTarget; + if (!target) return; + try { + await api.deleteFreeVideo(target.id); + stopPolling(target.id); + setTasks((prev) => prev.filter((t) => t.id !== target.id)); + setTotal((prev) => Math.max(0, prev - 1)); + if (detailId === target.id) setDetailId(null); + notify("success", "已删除"); + } catch (error) { + notify("error", error instanceof Error ? error.message : "删除失败"); + } finally { + setDeleteTarget(null); + } + }, [deleteTarget, detailId, stopPolling, notify]); + + // 素材库选中 → 注入输入条 + 插 mention chip + const handleLibraryPick = useCallback((ref: FreeVideoRef) => { + if (mode === "keyframe") { notify("info", "首尾帧模式请直接上传图片"); return; } + let label = ref.label || "素材"; + let n = 2; + while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`; + setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]); + window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0); + }, [mode, refs, notify]); + + const clearInput = useCallback(() => { + promptRef.current?.clear(); + setRefs([]); + }, []); + + // 详情弹窗的上一条/下一条(只在已完成的任务间切换) + const doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url); + const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null; + const detailIndex = detailTask ? doneTasks.findIndex((t) => t.id === detailTask.id) : -1; + + return ( +
+
+
+

自由创作

+
+ // {total} 个视频 · AI 视频生成 · 全能参考 / 首尾帧 +
+
+
+ +
+
+ +
+ {loading ? ( +
// 加载中…
+ ) : tasks.length === 0 ? ( +
+ +

还没有作品

+

// 在下方输入提示词,生成你的第一条视频

+
+ ) : ( +
+ {tasks.map((task) => ( + setDetailId(task.id)} + onRetry={() => handleRetry(task)} + onToggleFavorite={() => handleFavorite(task)} + onDelete={() => setDeleteTarget(task)} + onDownload={() => void handleDownload(task)} + /> + ))} +
+ )} + {hasMore &&
// 下滑加载更多
} +
+ + void addFiles(files, role)} + onRemoveRef={removeRef} + onModeChange={(next) => { setMode(next); setRefs([]); if (next === "keyframe") { setRatio("16:9"); } }} + onModelChange={setModel} + onRatioChange={setRatio} + onResolutionChange={setResolution} + onDurationChange={setDuration} + onSeedChange={setSeed} + onOpenLibrary={() => setLibraryOpen(true)} + onClear={clearInput} + onSend={() => void handleSend()} + /> + + {detailTask && ( + 0} + hasNext={detailIndex >= 0 && detailIndex < doneTasks.length - 1} + onPrev={() => { if (detailIndex > 0) setDetailId(doneTasks[detailIndex - 1].id); }} + onNext={() => { if (detailIndex < doneTasks.length - 1) setDetailId(doneTasks[detailIndex + 1].id); }} + onClose={() => setDetailId(null)} + onDownload={() => void handleDownload(detailTask)} + onToggleFavorite={() => handleFavorite(detailTask)} + onReuse={() => handleReuse(detailTask)} + onDelete={() => setDeleteTarget(detailTask)} + /> + )} + + setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} /> + + setDeleteTarget(null)} + onConfirm={() => void confirmDelete()} + /> +
+ ); +} diff --git a/core/frontend/src/routes/index.ts b/core/frontend/src/routes/index.ts index b9854df..c72f9de 100644 --- a/core/frontend/src/routes/index.ts +++ b/core/frontend/src/routes/index.ts @@ -10,4 +10,5 @@ export { AccountPage } from "./account"; export { TeamPage } from "./team"; export { MessagesPage } from "./messages"; export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools"; +export { FreeCreatePage } from "./free-create"; export { SettingsPage } from "./settings"; diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 9b15540..a83c16b 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -27,6 +27,7 @@ export type Page = | "team" | "messages" | "assetFactory" + | "freeCreate" | "imageOptimize" | "modelPhoto" | "modelPhotoDemoA" @@ -92,6 +93,7 @@ export const routeLabels: Record = { team: "团队", messages: "消息", assetFactory: "图片工具", + freeCreate: "自由创作", imageOptimize: "图片创作", modelPhoto: "模特上身图", modelPhotoDemoA: "模特图方案 A", @@ -151,6 +153,7 @@ export function resolveRoute(): ResolvedRoute { if (path === "/team") return { page: "team", authMode: "login", hash }; if (path === "/messages") return { page: "messages", authMode: "login", hash }; if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash }; + if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash }; if (path === "/image-optimize") return { page: "imageOptimize", authMode: "login", hash }; if (path === "/model-photo") return { page: "modelPhoto", authMode: "login", hash }; if (path === "/model-photo/demo-a") return { page: "modelPhotoDemoA", authMode: "login", hash }; @@ -190,6 +193,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { return "/messages"; case "assetFactory": return "/asset-factory"; + case "freeCreate": + return "/free-create"; case "imageOptimize": return "/image-optimize"; case "modelPhoto": diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index a799dfb..16579e1 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -534,6 +534,80 @@ export type ModelConfig = { capability: string; status: string; unit_price?: string; // 单位价(每张图/每次调用),前端据此算「预估扣费」与后端实扣一致(PMC#20) + // 模型元数据:自由创作视频模型带 pricing(元/百万tokens 分档价表)/resolutions/durations,前端预估消耗读它 + metadata?: Record; +}; + +// —— 自由创作·视频生成 —— +export type FreeVideoRef = { + url: string; + type: "image" | "video" | "audio"; + role?: string; // universal: reference_image/video/audio;keyframe: first_frame/last_frame + label?: string; // @mention 引用名 + thumb_url?: string; + duration?: number; // 视频/音频时长(秒) + asset_id?: string; // 直传素材的 Asset id / 素材库 FreeAsset id + source?: "upload" | "library" | "library_group"; + group_id?: string; +}; + +export type FreeVideoTask = { + id: string; + status: string; // AITask 状态机原样透传:created/reserved/submitted/polling/postprocessing/succeeded/failed/cancelled + mode: "universal" | "keyframe"; + model: string; + prompt: string; + aspect_ratio: string; + resolution: string; + duration: number; + seed: number; + seed_used?: number | null; + generate_audio: boolean; + references: FreeVideoRef[]; + estimated_tokens: number; + actual_tokens: number; + estimated_cost: string; + actual_cost: string; + error_message: string; + fallback_note?: string; + is_favorited: boolean; + video_url: string; + thumbnail_url: string; + created_at: string | null; + completed_at: string | null; +}; + +export type FreeVideoUploadResult = { + asset_id: string; + url: string; + type: "image" | "video" | "audio"; + name: string; + duration: number | null; + width: number | null; + height: number | null; + thumb_url: string; +}; + +// 自由创作·人物素材库(火山 Assets API 引用登记) +export type FreeAssetItem = { + id: string; + name: string; + url: string; + type: "image" | "video" | "audio"; + thumb_url: string; + duration: number | null; + status: "processing" | "active" | "failed"; + error_message: string; + created_at: string; +}; + +export type FreeAssetGroup = { + id: string; + name: string; + description: string; + thumbnail_url: string; + asset_count: number; + created_at: string; }; export type AITask = {