[feat] 添加镜头语义建议和动作拍点支持,扩展相关模型和逻辑以增强镜头生成的连贯性与准确性

This commit is contained in:
extreme committed 2026-04-17 17:19:39 +08:00
1 parent c0b88ccaf9
commit 5f863b662e
50 files changed
+3522 -105

No files matched your search

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.task_manager import DeliveryMode, SqlAlchemyTaskStore, TaskManager
from app.dependencies import get_db
from app.models.task_links import GenerationTaskLink
from app.schemas.studio.shots import ShotVideoPromptPackRead
from app.services.film.generated_video import build_run_args, preview_prompt_and_images
from app.services.studio.shot_status import mark_shot_generating
from app.tasks.execute_task import enqueue_task_execution
@@ -21,6 +22,7 @@ router = APIRouter()
class VideoPromptPreviewResponse(BaseModel):
prompt: str = Field(..., description="最终用于视频生成的提示词")
images: list[str] = Field(default_factory=list, description="关联参考图 file_id 列表")
pack: ShotVideoPromptPackRead | None = Field(None, description="视频提示词预览上下文包")
@@ -35,14 +37,14 @@ async def preview_video_generation_prompt(
db: AsyncSession = Depends(get_db),
) -> ApiResponse[VideoPromptPreviewResponse]:
"""预览视频生成的提示词与自动关联参考图。"""
prompt, images, _shot_detail = await preview_prompt_and_images(
prompt, images, pack = await preview_prompt_and_images(
db,
shot_id=body.shot_id,
reference_mode=body.reference_mode,
prompt=body.prompt,
images=body.images,
)
return success_response(VideoPromptPreviewResponse(prompt=prompt, images=images))
return success_response(VideoPromptPreviewResponse(prompt=prompt, images=images, pack=pack))
@router.post(
@@ -78,6 +78,7 @@ from app.services.studio import (
sync_shot_extracted_candidates_from_draft,
sync_shot_extracted_dialogue_candidates_from_draft,
)
from app.services.studio.shot_semantic_defaults import apply_shot_semantic_defaults_from_draft
from app.api.v1.routes.film.common import AsyncTaskCreateRead
logger = logging.getLogger(__name__)
@@ -999,6 +1000,11 @@ async def extract_script(
chapter_id=request.chapter_id,
draft=cached,
)
await apply_shot_semantic_defaults_from_draft(
db,
chapter_id=request.chapter_id,
draft=cached,
)
await db.commit()
return success_response(data=cached, meta={"from_cache": True})
@@ -1020,6 +1026,11 @@ async def extract_script(
chapter_id=request.chapter_id,
draft=result,
)
await apply_shot_semantic_defaults_from_draft(
db,
chapter_id=request.chapter_id,
draft=result,
)
await db.commit()
return success_response(data=result, meta={"from_cache": False})
except Exception as e:
@@ -40,6 +40,7 @@ from app.services.studio.generation.frame import (
build_frame_submission_payload as _build_frame_submission_payload_service,
derive_frame_preview as _derive_frame_preview_service,
)
from app.services.film.shot_frame_prompt_tasks import build_run_args as _build_shot_frame_prompt_run_args_service
from app.services.studio.generation.frame.derive_preview import (
to_rendered_shot_frame_prompt_read as _to_rendered_shot_frame_prompt_read_service,
)
@@ -135,6 +136,38 @@ class RenderedPromptResponse(BaseModel):
)
async def _load_frame_render_guidance(
*,
db: AsyncSession,
shot_id: str,
frame_type: ShotFrameType,
) -> dict[str, str]:
"""读取最终图片提示词需要保留的高优先级镜头约束。"""
try:
run_args = await _build_shot_frame_prompt_run_args_service(
db,
shot_id=shot_id,
frame_type=frame_type.value if hasattr(frame_type, "value") else str(frame_type),
)
except HTTPException:
return {
"director_command_summary": "",
"continuity_guidance": "",
"frame_specific_guidance": "",
"composition_anchor": "",
"screen_direction_guidance": "",
}
input_dict = dict(run_args.get("input") or {})
return {
"director_command_summary": str(input_dict.get("director_command_summary") or "").strip(),
"continuity_guidance": str(input_dict.get("continuity_guidance") or "").strip(),
"frame_specific_guidance": str(input_dict.get("frame_specific_guidance") or "").strip(),
"composition_anchor": str(input_dict.get("composition_anchor") or "").strip(),
"screen_direction_guidance": str(input_dict.get("screen_direction_guidance") or "").strip(),
}
@router.post(
"/actors/{actor_id}/image-tasks",
@@ -345,10 +378,20 @@ async def create_shot_frame_image_generation_task(
shot_detail = await db.get(ShotDetail, shot_id)
if shot_detail is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ShotDetail not found")
render_guidance = await _load_frame_render_guidance(
db=db,
shot_id=shot_id,
frame_type=body.frame_type,
)
base = _build_frame_base_draft_service(
shot_id=shot_id,
frame_type=body.frame_type,
prompt=prompt,
director_command_summary=render_guidance["director_command_summary"],
continuity_guidance=render_guidance["continuity_guidance"],
frame_specific_guidance=render_guidance["frame_specific_guidance"],
composition_anchor=render_guidance["composition_anchor"],
screen_direction_guidance=render_guidance["screen_direction_guidance"],
)
context = _build_frame_context_service(
shot_id=shot_id,
@@ -414,17 +457,26 @@ async def render_shot_frame_prompt(
body: ShotFramePromptRenderRequest,
db: AsyncSession = Depends(get_db),
) -> ApiResponse[RenderedShotFramePromptRead]:
_ = shot_id, db
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="prompt is required for shot frame render",
)
render_guidance = await _load_frame_render_guidance(
db=db,
shot_id=shot_id,
frame_type=body.frame_type,
)
base = _build_frame_base_draft_service(
shot_id=shot_id,
frame_type=body.frame_type,
prompt=prompt,
director_command_summary=render_guidance["director_command_summary"],
continuity_guidance=render_guidance["continuity_guidance"],
frame_specific_guidance=render_guidance["frame_specific_guidance"],
composition_anchor=render_guidance["composition_anchor"],
screen_direction_guidance=render_guidance["screen_direction_guidance"],
)
context = _build_frame_context_service(
shot_id=shot_id,
@@ -17,8 +17,9 @@ _SCRIPT_EXTRACTOR_SYSTEM_PROMPT = """\
- chapter_id(必填)
- characters: [{name, description, costume_name?, prop_names[], tags[]}]
- scenes/props/costumes: [{name, description, tags[], prompt_template_id?, view_count}]
- shots: [{index, title, script_excerpt, scene_name?, character_names[], prop_names[], costume_names[], dialogue_lines[], actions[]}]
- shots: [{index, title, script_excerpt, scene_name?, character_names[], prop_names[], costume_names[], dialogue_lines[], actions[], semantic_suggestion?}]
- dialogue_lines: [{index, text, line_mode, speaker_name?, target_name?}]
- semantic_suggestion: {camera_shot?, angle?, movement?, duration?, action_beats[], notes?}
强约束:
- 同名实体在输出中只出现一次(全局去重);shots 中引用必须使用同一名称
@@ -34,6 +35,13 @@ _SCRIPT_EXTRACTOR_SYSTEM_PROMPT = """\
- 输出 shots 之前,必须做“全集校验”并补齐缺失:所有 shots[*] 中出现的 character_names/prop_names/costume_names/scene_name 的名字集合,必须都能在对应全局列表(characters/props/costumes/scenes)的 name 中找到;如果有缺失,必须在全局列表中补齐对应条目(描述可最小化,但 name 必须完全一致),禁止用别名替换来绕过。
- 角色名/场景名必须原样保留字符细节:包括全角/半角括号、空格、标点,不要自动做任何规范化或替换(例如不能把「女子(群)」改成「女子(群)」或「女子 (群)」)。
- 严格区分:shots[*].title 是“镜头标题”(一句话描述该镜头画面/动作),不要拿它当作 scenes 的 scene 名;shots[*].scene_name 才是场景名称,必须来自 scenes 全局列表的 name。
- 除实体与对白外,还必须尽量补充镜头语言默认建议:`camera_shot` / `angle` / `movement` / `duration`。
- `camera_shot` 只能输出:ECU / CU / MCU / MS / MLS / LS / ELS。
- `angle` 只能输出:EYE_LEVEL / HIGH_ANGLE / LOW_ANGLE / BIRD_EYE / DUTCH / OVER_SHOULDER。
- `movement` 只能输出:STATIC / PAN / TILT / DOLLY_IN / DOLLY_OUT / TRACK / CRANE / HANDHELD / STEADICAM / ZOOM_IN / ZOOM_OUT。
- `duration` 必须输出正整数秒数;若无法判断,可省略。
- `action_beats` 需要输出 2-4 条按镜头内部时间顺序排列的动作拍点;每一条只描述一个主要动作或状态变化,不要写成长段文学描述。
- `semantic_suggestion` 是“镜头默认语义建议”,不是最终生成提示词,不要输出提示词式修饰文本。
输入:
- project_id
@@ -80,4 +88,3 @@ class ElementExtractorAgent(AgentBase[StudioScriptExtractionDraft]):
if k not in data or not isinstance(data[k], list):
data[k] = []
return data
@@ -18,6 +18,10 @@ def _prepare_shot_frame_input(input_dict: dict[str, Any]) -> dict[str, Any]:
out["mood_tags"] = ", ".join(str(t) for t in out["mood_tags"])
else:
out.setdefault("mood_tags", "")
if "action_beats" in out and isinstance(out["action_beats"], list):
out["action_beats"] = ";".join(str(item) for item in out["action_beats"] if str(item).strip())
else:
out.setdefault("action_beats", "")
for key in (
"visual_style",
"style",
@@ -32,11 +36,26 @@ def _prepare_shot_frame_input(input_dict: dict[str, Any]) -> dict[str, Any]:
"duration",
"scene_id",
"dialog_summary",
"action_beats",
"action_beat_phases",
"selected_action_beat_phase",
"selected_action_beat_text",
"character_context",
"scene_context",
"prop_context",
"costume_context",
"subject_priority",
"previous_shot_title",
"previous_shot_script_excerpt",
"previous_shot_end_state",
"next_shot_title",
"next_shot_script_excerpt",
"next_shot_start_goal",
"continuity_guidance",
"composition_anchor",
"screen_direction_guidance",
"frame_specific_guidance",
"director_command_summary",
"retry_guidance",
):
if key not in out or out[key] is None:
@@ -64,16 +83,31 @@ _SHOT_FRAME_INPUT_VARS = [
"duration",
"scene_id",
"dialog_summary",
"action_beats",
"action_beat_phases",
"selected_action_beat_phase",
"selected_action_beat_text",
"character_context",
"scene_context",
"prop_context",
"costume_context",
"subject_priority",
"previous_shot_title",
"previous_shot_script_excerpt",
"previous_shot_end_state",
"next_shot_title",
"next_shot_script_excerpt",
"next_shot_start_goal",
"continuity_guidance",
"composition_anchor",
"screen_direction_guidance",
"frame_specific_guidance",
"director_command_summary",
"retry_guidance",
]
_FRAME_FOCUS = {
"首帧": "优先描述镜头开始时最先看到的画面建立信息,强调开场定场、主体初始状态与进入情境的第一印象。",
"首帧": "优先描述镜头开始时最先看到的画面建立信息,强调开场定场、主体初始状态与进入情境的第一印象;只表现触发瞬间或最初反应,不要直接写成后续完成动作、最终姿态或情绪爆发完成态。",
"尾帧": "优先描述镜头结束时最终停留的画面状态,强调动作收束、人物结束姿态、视线落点或情绪余韵。",
"关键帧": "优先捕捉镜头中最具代表性、最有戏剧张力或信息密度最高的瞬间,不必平均描述整个过程。",
}
@@ -95,6 +129,15 @@ _SHOT_FRAME_TEMPLATE = """你是一名专业影视分镜提示词设计师,需
10. 主体优先级建议:{subject_priority}
11. 不要为了“写全信息”而平均罗列所有角色、道具、服装;优先突出主角色、主场景和主动作,其余元素仅在能强化当前画面时再进入提示词
12. 如果下面提供了“修正要求”,必须逐条满足后再输出最终结果
13. 若存在上一镜头信息,当前画面应尽量承接上一镜头的动作、空间方向、视线或情绪,不要像全新场景重新开局
14. 若存在下一镜头信息,当前画面应为下一镜头留下自然的动作或情绪收束,避免硬切
15. 尽量明确主体在画面中的相对位置、朝向与动作峰值,减少构图和轴线突变
16. 若提供了构图与空间锚点建议,应尽量落实到画面描述中,让主体位置、环境重心和空间方向更稳定
17. 若提供了朝向与视线建议,应保持人物左右关系、视线落点与反打轴线稳定,不要无故翻转
18. 若提供了当前帧专项建议,应优先满足该建议,确保首帧/关键帧/尾帧各自承担清晰职责
19. 若提供了导演指令摘要,应将其视为最高优先级的镜头执行约束,优先体现到最终画面描述中
20. 若当前为首帧,只能表现事件触发瞬间或最初反应,不要直接把后续完成动作、最终姿态或情绪爆发结果写进画面
21. 若当前为首帧且镜头存在连续动作链,应优先使用“刚开始 / 尚未完成 / 被打断”的未完成态表达,而不是结果态表达
## 镜头信息
剧本摘录:{script_excerpt}
@@ -108,12 +151,30 @@ _SHOT_FRAME_TEMPLATE = """你是一名专业影视分镜提示词设计师,需
视效:{vfx_type} - {vfx_note}
时长:{duration}秒
对白摘要:{dialog_summary}
动作拍点:{action_beats}
动作拍点阶段:{action_beat_phases}
当前帧优先消费阶段:{selected_action_beat_phase}
当前帧主拍点:{selected_action_beat_text}
## 已确认实体上下文
角色:{character_context}
场景:{scene_context}
道具:{prop_context}
服装:{costume_context}
## 相邻镜头承接信息
上一镜头标题:{previous_shot_title}
上一镜头剧本摘录:{previous_shot_script_excerpt}
上一镜头结尾状态:{previous_shot_end_state}
下一镜头标题:{next_shot_title}
下一镜头剧本摘录:{next_shot_script_excerpt}
下一镜头起始目标:{next_shot_start_goal}
连续性建议:{continuity_guidance}
构图与空间锚点:{composition_anchor}
朝向与视线建议:{screen_direction_guidance}
当前帧专项建议:{frame_specific_guidance}
导演指令摘要:{director_command_summary}
修正要求:{retry_guidance}
## 输出(仅 {frame_name} 基础提示词,JSON:{{"prompt": "..."}})
+6
View File
@@ -194,6 +194,12 @@ class ShotDetail(Base,TimestampMixin):
default="",
comment="分镜整体描述(用于提示词补充)",
)
action_beats: Mapped[list[str]] = mapped_column(
JSON,
nullable=False,
default=list,
comment="镜头动作拍点(按时间顺序排列,用于关键帧与视频生成)",
)
prompt_template_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("prompt_templates.id", ondelete="SET NULL"),
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.models.studio import CameraAngle, CameraMovement, CameraShotType
from app.schemas.skills.common import DialogueLine, DialogueLineMode, EvidenceSpan
from app.schemas.skills.film import Character, Location, Prop, Scene, ProjectCinematicBreakdown
@@ -320,6 +321,19 @@ class StudioShotDraftDialogueLine(BaseModel):
target_name: Optional[str] = Field(None, description="听者角色名称(可空)")
class ShotSemanticSuggestion(BaseModel):
"""镜头语义默认建议:用于准备阶段初始化镜头语言与动作拍点。"""
model_config = ConfigDict(extra="forbid")
camera_shot: Optional[CameraShotType] = Field(None, description="建议景别")
angle: Optional[CameraAngle] = Field(None, description="建议机位")
movement: Optional[CameraMovement] = Field(None, description="建议运镜")
duration: Optional[int] = Field(None, ge=1, description="建议时长(秒)")
action_beats: List[str] = Field(default_factory=list, description="按时间顺序排列的动作拍点")
notes: Optional[str] = Field(None, description="不确定项说明")
class StudioShotDraft(BaseModel):
"""镜头草稿:不含 shot_id,由导入 API 生成;引用实体用 name。"""
@@ -336,6 +350,10 @@ class StudioShotDraft(BaseModel):
dialogue_lines: List[StudioShotDraftDialogueLine] = Field(default_factory=list, description="对白列表")
actions: List[str] = Field(default_factory=list, description="动作/场景描述")
semantic_suggestion: Optional[ShotSemanticSuggestion] = Field(
None,
description="镜头语言默认建议与动作拍点候选",
)
class StudioScriptExtractionDraft(BaseModel):
@@ -483,4 +501,3 @@ __all__ = [
"TableData",
"OutputCompileResult",
]
@@ -26,11 +26,26 @@ class ShotFramePromptInput(BaseModel):
duration: Optional[int] = Field(None, description="时长(秒)")
scene_id: Optional[str] = Field(None, description="关联场景 ID")
dialog_summary: Optional[str] = Field(None, description="对白摘要")
action_beats: Optional[List[str]] = Field(None, description="按时间顺序排列的动作拍点")
action_beat_phases: Optional[str] = Field(None, description="动作拍点的阶段摘要,如 trigger / peak / aftermath")
selected_action_beat_phase: Optional[str] = Field(None, description="当前帧优先消费的动作阶段")
selected_action_beat_text: Optional[str] = Field(None, description="当前帧优先消费的动作拍点原文")
character_context: Optional[str] = Field(None, description="镜头已确认角色及描述")
scene_context: Optional[str] = Field(None, description="镜头已确认场景及描述")
prop_context: Optional[str] = Field(None, description="镜头已确认道具及描述")
costume_context: Optional[str] = Field(None, description="镜头已确认服装及描述")
subject_priority: Optional[str] = Field(None, description="主体优先级建议,说明本镜头应优先写谁、先写哪个场景")
previous_shot_title: Optional[str] = Field(None, description="上一镜头标题,用于连续性承接")
previous_shot_script_excerpt: Optional[str] = Field(None, description="上一镜头剧本摘录,用于连续性承接")
previous_shot_end_state: Optional[str] = Field(None, description="上一镜头结尾状态摘要")
next_shot_title: Optional[str] = Field(None, description="下一镜头标题,用于连续性承接")
next_shot_script_excerpt: Optional[str] = Field(None, description="下一镜头剧本摘录,用于连续性承接")
next_shot_start_goal: Optional[str] = Field(None, description="下一镜头起始目标摘要")
continuity_guidance: Optional[str] = Field(None, description="当前镜头与相邻镜头的承接建议")
composition_anchor: Optional[str] = Field(None, description="画面构图与空间锚点建议")
screen_direction_guidance: Optional[str] = Field(None, description="人物朝向、视线与左右轴线建议")
frame_specific_guidance: Optional[str] = Field(None, description="按首帧/关键帧/尾帧区分的专项生成建议")
director_command_summary: Optional[str] = Field(None, description="汇总后的导演指令摘要,高优先级约束")
retry_guidance: Optional[str] = Field(None, description="自动重试时附加的修正要求")
+33
View File
@@ -139,6 +139,7 @@ class ShotDetailBase(BaseModel):
has_bgm: bool = Field(False, description="是否包含 BGM")
vfx_type: VFXType = Field(VFXType.none, description="视效类型")
vfx_note: str = Field("", description="视效说明")
action_beats: list[str] = Field(default_factory=list, description="动作拍点(按时间顺序排列)")
first_frame_prompt: str = Field(
"",
description="镜头分镜首帧提示词",
@@ -170,6 +171,7 @@ class ShotDetailUpdate(BaseModel):
has_bgm: bool | None = None
vfx_type: VFXType | None = None
vfx_note: str | None = None
action_beats: list[str] | None = None
first_frame_prompt: str | None = None
last_frame_prompt: str | None = None
key_frame_prompt: str | None = None
@@ -319,11 +321,24 @@ class ShotFramePromptMappingRead(BaseModel):
file_id: str = Field(..., description="本次渲染与生成使用的文件 ID")
class FrameGuidanceDecisionRead(BaseModel):
"""分镜帧 guidance 的保留/压缩决策结果。"""
text: str = Field(..., description="guidance 原文")
category: str = Field(..., description="guidance 分类,如 summary / continuity / composition / screen")
reason_tag: str = Field("", description="简短原因标签,如 首帧保空间 / 关键帧保轴线")
reason: str = Field(..., description="该 guidance 被保留或压缩的原因说明")
class RenderedShotFramePromptRead(BaseModel):
"""关键帧最终生成提示词渲染结果。"""
base_prompt: str = Field(..., description="原始基础提示词(不含图片映射说明)")
rendered_prompt: str = Field(..., description="最终提交给模型的提示词(含图片映射说明)")
selected_guidance: list[str] = Field(default_factory=list, description="最终 prompt 实际保留的 guidance 列表")
dropped_guidance: list[str] = Field(default_factory=list, description="本次渲染中被压缩掉的 guidance 列表")
selected_guidance_details: list[FrameGuidanceDecisionRead] = Field(default_factory=list, description="最终保留 guidance 的决策详情")
dropped_guidance_details: list[FrameGuidanceDecisionRead] = Field(default_factory=list, description="被压缩 guidance 的决策详情")
images: list[str] = Field(default_factory=list, description="最终参考图 file_id 列表,顺序与 mappings 一致")
mappings: list[ShotFramePromptMappingRead] = Field(
default_factory=list,
@@ -382,6 +397,13 @@ class ShotPreparationLinkRequest(BaseModel):
linked_entity_id: str = Field(..., description="要关联的实体 ID")
class ActionBeatPhaseRead(BaseModel):
"""动作拍点的轻量阶段推断结果。"""
text: str = Field(..., description="动作拍点原文")
phase: Literal["trigger", "peak", "aftermath"] = Field(..., description="推断阶段:触发 / 峰值 / 收束")
class ShotPreparationStateRead(BaseModel):
"""分镜准备页聚合状态。"""
@@ -396,6 +418,11 @@ class ShotPreparationStateRead(BaseModel):
description="当前已保存的对白行",
)
pending_confirm_count: int = Field(..., description="当前仍待确认的总数量(资产 + 对白)")
basic_info_ready: bool = Field(..., description="标题与剧本摘录是否已补齐")
semantic_defaults_ready: bool = Field(..., description="镜头语言默认值是否已确认")
action_beats_ready: bool = Field(..., description="动作拍点是否已确认")
action_beats_count: int = Field(0, description="当前已确认动作拍点数量")
action_beat_phases: list[ActionBeatPhaseRead] = Field(default_factory=list, description="当前动作拍点的阶段推断结果")
ready_for_generation: bool = Field(..., description="当前镜头是否已完成准备,可进入后续生成")
@@ -441,6 +468,12 @@ class ShotVideoPromptPackRead(BaseModel):
title: str = Field("", description="镜头标题")
script_excerpt: str = Field("", description="剧本摘录")
action_beats: list[str] = Field(default_factory=list, description="动作/场景要点")
action_beat_phases: list[ActionBeatPhaseRead] = Field(default_factory=list, description="动作拍点的阶段推断结果")
previous_shot_summary: str = Field("", description="上一镜头摘要,用于提示词连续性约束")
next_shot_goal: str = Field("", description="下一镜头目标,用于提示词连续性约束")
continuity_guidance: str = Field("", description="当前镜头与相邻镜头的承接建议")
composition_anchor: str = Field("", description="当前镜头的构图与空间锚点建议")
screen_direction_guidance: str = Field("", description="当前镜头的人物朝向、视线与左右轴线建议")
dialogue_summary: str = Field("", description="对白摘要")
characters: list[ShotPromptAssetRef] = Field(default_factory=list, description="角色引用")
scene: ShotPromptAssetRef | None = Field(None, description="场景引用")
+6 -2
View File
@@ -80,7 +80,7 @@ async def preview_prompt_and_images(
reference_mode: str,
prompt: str | None,
images: list[str] | None = None,
) -> tuple[str, list[str], ShotDetail]:
) -> tuple[str, list[str], dict | None]:
shot_detail = await validate_shot_and_duration(db, shot_id)
base = build_video_base_draft(shot_id=shot_id, prompt=prompt)
context = await build_video_context(
@@ -92,7 +92,11 @@ async def preview_prompt_and_images(
submission = await build_video_submission_payload(db, base=base, context=context)
if not submission.prompt:
raise HTTPException(status_code=400, detail="prompt is required")
return submission.prompt, submission.images, shot_detail
prompt_preview_payload = submission.extra.get("prompt_preview")
if isinstance(prompt_preview_payload, dict):
pack = prompt_preview_payload.get("pack")
return submission.prompt, submission.images, pack if isinstance(pack, dict) else None
return submission.prompt, submission.images, None
async def resolve_default_video_model(db: AsyncSession) -> Model:
@@ -30,6 +30,7 @@ from app.models.studio import (
)
from app.services.llm.runtime import build_default_text_llm_sync
from app.services.common import entity_not_found, invalid_choice
from app.services.studio.action_beats import infer_action_beat_sequence, pick_action_beat_for_frame
from app.services.studio.shot_status import recompute_shot_status
from app.services.worker.async_task_support import cancel_if_requested_async
from app.services.worker.task_logging import log_task_event, log_task_failure
@@ -126,6 +127,432 @@ def _build_subject_priority(
return ";".join(parts) if parts else "优先根据镜头信息突出主角色和主场景,不必平均铺陈所有元素"
def _truncate_for_prompt(value: str | None, *, limit: int = 80) -> str:
text = _compact_text(value)
if len(text) <= limit:
return text
return f"{text[:limit].rstrip()}..."
def _summarize_neighbor_shot(shot: Shot | None) -> tuple[str, str, str]:
"""生成相邻镜头的标题、摘录和状态摘要,供连续性提示词使用。"""
if shot is None:
return "", "", ""
detail = getattr(shot, "detail", None)
scene_name = _compact_text(getattr(getattr(detail, "scene", None), "name", None))
camera_parts = [
_enum_value(getattr(detail, "camera_shot", None)),
_enum_value(getattr(detail, "angle", None)),
_enum_value(getattr(detail, "movement", None)),
]
camera_text = " / ".join(part for part in camera_parts if part)
description = _truncate_for_prompt(getattr(detail, "description", None), limit=60)
summary_parts = [
f"场景:{scene_name}" if scene_name else "",
f"镜头语言:{camera_text}" if camera_text else "",
f"画面状态:{description}" if description else "",
]
return (
_compact_text(getattr(shot, "title", None)),
_truncate_for_prompt(getattr(shot, "script_excerpt", None), limit=80),
";".join(part for part in summary_parts if part),
)
def _build_continuity_guidance(
*,
previous_shot: Shot | None,
current_shot: Shot,
next_shot: Shot | None,
) -> str:
"""基于相邻镜头关系生成简明连续性约束。"""
guidance: list[str] = []
current_detail = getattr(current_shot, "detail", None)
current_scene_id = str(getattr(current_detail, "scene_id", "") or "")
previous_detail = getattr(previous_shot, "detail", None) if previous_shot else None
next_detail = getattr(next_shot, "detail", None) if next_shot else None
previous_scene_id = str(getattr(previous_detail, "scene_id", "") or "")
next_scene_id = str(getattr(next_detail, "scene_id", "") or "")
if previous_shot is not None:
guidance.append("当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局")
if current_scene_id and previous_scene_id and current_scene_id == previous_scene_id:
guidance.append("上一镜头与当前镜头处于同一场景,优先保持空间轴线和主体朝向稳定")
if _enum_value(getattr(previous_detail, "movement", None)) and _enum_value(getattr(current_detail, "movement", None)):
guidance.append("若镜头语言未明显变化,应视为同一动作链条中的推进或延续")
if next_shot is not None:
guidance.append("当前镜头应形成自然收束,为下一镜头预留动作或情绪落点,避免硬切")
if current_scene_id and next_scene_id and current_scene_id == next_scene_id:
guidance.append("下一镜头与当前镜头处于同一场景,尽量保持视觉重心与空间关系可连续延展")
return ";".join(guidance)
def _build_composition_anchor(
*,
detail: ShotDetail,
previous_shot: Shot | None,
next_shot: Shot | None,
characters: list[Character],
scenes: list[Scene],
) -> str:
"""根据镜头语言和相邻镜头关系生成构图锚点建议。"""
anchors: list[str] = []
camera_shot = _enum_value(detail.camera_shot)
movement = _enum_value(detail.movement)
if camera_shot in {"ECU", "CU"}:
anchors.append("以主角色面部或关键动作作为画面重心,弱化环境干扰")
elif camera_shot in {"MS", "FS"}:
anchors.append("保持人物与环境同时可读,避免只剩情绪特写或只剩空场")
else:
anchors.append("优先建立空间关系,再突出主角色动作")
if movement in {"DOLLY_IN", "ZOOM_IN"}:
anchors.append("构图应体现向主体推进的视觉趋势,焦点逐步收束到主角色")
elif movement in {"DOLLY_OUT", "ZOOM_OUT"}:
anchors.append("构图应体现从主体向环境退开的趋势,保留更多空间信息")
elif movement == "STATIC":
anchors.append("保持构图稳定,不要无故改变主体在画面中的重心位置")
if scenes:
anchors.append(f"以场景 {scenes[0].name} 作为空间锚点,保证主体与环境关系清晰")
if characters:
anchors.append(f"优先锁定角色 {characters[0].name} 的朝向和视线,不要无故翻转左右关系")
if previous_shot is not None and str(getattr(getattr(previous_shot, 'detail', None), 'scene_id', '') or '') == str(detail.scene_id or ''):
anchors.append("与上一镜头同场景时,尽量延续同一空间轴线和主体朝向")
if next_shot is not None and str(getattr(getattr(next_shot, 'detail', None), 'scene_id', '') or '') == str(detail.scene_id or ''):
anchors.append("与下一镜头同场景时,为后续镜头保留稳定的视觉落点与空间方向")
return ";".join(anchors)
def _build_screen_direction_guidance(
*,
detail: ShotDetail,
previous_shot: Shot | None,
next_shot: Shot | None,
dialogue_summary: str,
character_names: list[str],
) -> str:
"""生成人物朝向、视线与左右轴线建议。"""
guidance: list[str] = []
angle = _enum_value(detail.angle)
if angle == "OVER_SHOULDER":
guidance.append("当前镜头为过肩视角,应保持前景肩部与被看对象的左右关系稳定")
elif angle == "EYE_LEVEL":
guidance.append("优先保持人物视线水平和对视方向稳定,避免无故翻转左右朝向")
else:
guidance.append("明确主体朝向和视线落点,避免人物突然改向或跳轴")
if dialogue_summary.strip():
guidance.append("存在对白时,优先保证说话者与受话者的视线关系连续")
if len(character_names) >= 2:
guidance.append(f"角色 {character_names[0]} 与 {character_names[1]} 的左右站位和对视方向应保持一致")
elif character_names:
guidance.append(f"角色 {character_names[0]} 的朝向与视线落点应在相邻镜头中保持延续")
current_scene_id = str(detail.scene_id or "")
previous_scene_id = str(getattr(getattr(previous_shot, "detail", None), "scene_id", "") or "")
next_scene_id = str(getattr(getattr(next_shot, "detail", None), "scene_id", "") or "")
if previous_shot is not None and current_scene_id and current_scene_id == previous_scene_id:
guidance.append("与上一镜头同场景时,不要无故翻转人物面向和左右轴线")
if next_shot is not None and current_scene_id and current_scene_id == next_scene_id:
guidance.append("与下一镜头同场景时,当前镜头结尾应保留可延续的视线方向")
return ";".join(guidance)
_SEQUENTIAL_REACTION_KEYWORDS = (
"听到",
"闻声",
"忽然",
"突然",
"下意识",
"立刻",
"随即",
"紧接着",
"随后",
"脱手",
"掉在地上",
"捂住耳朵",
"捂住",
"蹲下",
"跪下",
"跌坐",
"转身",
"回头",
)
def _has_sequential_reaction_chain(*values: str | None) -> bool:
"""判断文本是否包含明显的连续反应链,供首帧时间切片加权使用。"""
text = " ".join(_compact_text(value) for value in values if _compact_text(value))
if not text:
return False
keyword_hits = sum(1 for keyword in _SEQUENTIAL_REACTION_KEYWORDS if keyword in text)
punctuation_hits = text.count(",") + text.count("。") + text.count(";") + text.count("、")
return keyword_hits >= 2 or (keyword_hits >= 1 and punctuation_hits >= 2)
def _build_frame_specific_guidance(
*,
frame_type: str,
previous_shot: Shot | None,
next_shot: Shot | None,
detail: ShotDetail,
script_excerpt: str,
action_beats: list[str],
) -> str:
"""按首帧/关键帧/尾帧生成专项提示,拉开三类帧职责差异。"""
guidance: list[str] = []
if frame_type == "first":
guidance.append("首帧应优先建立空间、主体初始站位和第一眼视觉印象,不要直接跳到动作尾声")
guidance.append("首帧只表现事件触发瞬间或最初反应的起始状态,不要直接写成后续完成动作、最终姿态或情绪爆发结果")
guidance.append("若剧本存在连续反应链,优先写成动作刚开始、尚未完成或被打断的状态,例如手刚松脱、身体骤然僵住、人物尚未完全蹲下")
if _has_sequential_reaction_chain(script_excerpt, detail.description):
guidance.append("当前镜头存在明显连续反应链,首帧必须截取触发后最早的可见瞬间,禁止直接落到捂耳、蹲下、倒地或转身完成态")
if previous_shot is not None:
guidance.append("首帧要承接上一镜头结束状态,但仍应让观众迅速看清当前空间与主体起始状态")
if _enum_value(detail.camera_shot) in {"LS", "ELS", "MLS"}:
guidance.append("当前景别较大时,优先把环境、人物位置关系和进入方向交代清楚")
elif frame_type == "last":
guidance.append("尾帧应强调动作收束、情绪余韵或视线停留点,不要重新铺开新的动作起点")
if next_shot is not None:
guidance.append("尾帧应为下一镜头留下自然衔接的姿态、视线或情绪落点")
guidance.append("尾帧中的主体姿态应更稳定,便于后续镜头承接")
else:
guidance.append("关键帧应锁定镜头内最有戏剧张力或信息密度最高的瞬间,不要平均描述整个过程")
guidance.append("优先选择动作峰值、情绪爆点或构图最有代表性的瞬间")
if _enum_value(detail.movement) in {"DOLLY_IN", "ZOOM_IN", "TRACK"}:
guidance.append("若镜头存在推进或跟拍,关键帧应体现运动过程中最集中、最有压迫感的画面")
beat_item = pick_action_beat_for_frame(frame_type, action_beats)
if beat_item is not None:
phase_label = {
"trigger": "触发阶段",
"peak": "峰值阶段",
"aftermath": "收束阶段",
}.get(beat_item.phase, "当前阶段")
guidance.append(f"当前帧优先围绕动作拍点“{beat_item.text}”组织画面({phase_label}),不要越级跳到其他阶段")
return ";".join(guidance)
def _format_action_beat_phase_summary(action_beats: list[str]) -> str:
"""格式化动作拍点阶段摘要,供 agent 输入与调试预览复用。"""
sequence = infer_action_beat_sequence(action_beats)
phase_labels = {
"trigger": "触发",
"peak": "峰值",
"aftermath": "收束",
}
return ";".join(
f"{index + 1}. {phase_labels.get(item.phase, item.phase)} · {item.text}"
for index, item in enumerate(sequence)
)
def _same_scene(shot: Shot | None, current_scene_id: str) -> bool:
"""判断相邻镜头是否与当前镜头处于同一场景。"""
return bool(
shot is not None
and current_scene_id
and str(getattr(getattr(shot, "detail", None), "scene_id", "") or "") == current_scene_id
)
def _score_director_guidance_item(
*,
category: str,
text: str,
frame_type: str,
has_dialogue: bool,
character_count: int,
same_scene_with_previous: bool,
same_scene_with_next: bool,
movement: str,
) -> int:
"""为 guidance 句子打分,优先保留更能稳定镜头连续性的约束。"""
score = 0
if category == "frame":
score += 10
if frame_type == "first" and ("建立空间" in text or "起始状态" in text):
score += 5
if frame_type == "first" and ("触发瞬间" in text or "后续完成动作" in text or "尚未完成" in text):
score += 6
if frame_type == "first" and ("连续反应链" in text or "最早的可见瞬间" in text or "完成态" in text):
score += 8
if frame_type == "key" and ("动作峰值" in text or "戏剧张力" in text or "情绪爆点" in text):
score += 5
if frame_type == "last" and ("动作收束" in text or "情绪余韵" in text or "停留点" in text):
score += 5
elif category == "continuity":
score += 8
if "承接上一镜头" in text:
score += 3
if same_scene_with_previous:
score += 4
if "下一镜头" in text or "收束" in text:
score += 3
if same_scene_with_next:
score += 4
if "空间轴线" in text or "主体朝向稳定" in text or "视觉重心" in text:
score += 3
elif category == "composition":
score += 7
if frame_type == "first" and ("空间锚点" in text or "建立空间" in text):
score += 5
if frame_type == "key" and ("画面重心" in text or "推进" in text or "焦点" in text):
score += 4
if frame_type == "last" and ("视觉落点" in text or "空间方向" in text):
score += 4
if "锁定角色" in text or "重心位置" in text:
score += 2
elif category == "screen":
score += 6
if "不要无故翻转" in text or "跳轴" in text:
score += 5
if has_dialogue and ("视线关系连续" in text or "对视方向" in text):
score += 5
if character_count >= 2 and ("左右站位" in text or "对视方向" in text):
score += 4
if same_scene_with_previous or same_scene_with_next:
if "同场景" in text or "视线方向" in text or "左右轴线" in text:
score += 4
if frame_type == "last" and "视线方向" in text:
score += 2
if movement in {"DOLLY_IN", "ZOOM_IN", "TRACK"} and category == "composition" and "推进" in text:
score += 3
return score
def _build_director_must_categories(
*,
frame_type: str,
has_dialogue: bool,
character_count: int,
same_scene_with_previous: bool,
same_scene_with_next: bool,
movement: str,
) -> list[str]:
"""按镜头风险动态决定哪些 guidance 应提升为必须项。"""
if frame_type == "first":
must_categories = ["frame", "continuity", "composition"]
elif frame_type == "key":
must_categories = ["frame", "composition", "continuity"]
else:
must_categories = ["frame", "continuity", "screen"]
if movement in {"DOLLY_IN", "ZOOM_IN", "TRACK"} and "composition" in must_categories:
must_categories = ["composition" if item == "composition" else item for item in must_categories]
must_categories.insert(0, must_categories.pop(must_categories.index("composition")))
if has_dialogue or character_count >= 2 or same_scene_with_previous or same_scene_with_next:
if "screen" not in must_categories:
insert_at = 2 if frame_type == "key" else 1
must_categories.insert(min(insert_at, len(must_categories)), "screen")
elif frame_type == "last":
must_categories.insert(1, must_categories.pop(must_categories.index("screen")))
deduped: list[str] = []
for category in must_categories:
if category not in deduped:
deduped.append(category)
return deduped[:4]
def _build_director_command_summary(
*,
frame_type: str,
frame_specific_guidance: str,
continuity_guidance: str,
composition_anchor: str,
screen_direction_guidance: str,
has_dialogue: bool,
character_count: int,
same_scene_with_previous: bool,
same_scene_with_next: bool,
movement: str,
) -> str:
"""将多类 guidance 压缩成高优先级导演指令摘要。"""
seen: set[str] = set()
def _split_bucket(category: str, block: str) -> list[str]:
bucket: list[str] = []
for piece in str(block or "").split(";"):
text = piece.strip()
if not text or text in seen:
continue
seen.add(text)
bucket.append(text)
return sorted(
bucket,
key=lambda item: _score_director_guidance_item(
category=category,
text=item,
frame_type=frame_type,
has_dialogue=has_dialogue,
character_count=character_count,
same_scene_with_previous=same_scene_with_previous,
same_scene_with_next=same_scene_with_next,
movement=movement,
),
reverse=True,
)
buckets = {
"frame": _split_bucket("frame", frame_specific_guidance),
"continuity": _split_bucket("continuity", continuity_guidance),
"composition": _split_bucket("composition", composition_anchor),
"screen": _split_bucket("screen", screen_direction_guidance),
}
must_categories = _build_director_must_categories(
frame_type=frame_type,
has_dialogue=has_dialogue,
character_count=character_count,
same_scene_with_previous=same_scene_with_previous,
same_scene_with_next=same_scene_with_next,
movement=movement,
)
must_items: list[str] = []
prefer_items: list[str] = []
consumed_must_items: set[str] = set()
for category in must_categories:
bucket = buckets.get(category) or []
if bucket:
must_items.append(bucket[0])
consumed_must_items.add(bucket[0])
frame_bucket = buckets.get("frame") or []
if frame_type == "first" and len(frame_bucket) > 1:
primary_frame_item = frame_bucket[0]
if any(keyword in primary_frame_item for keyword in ("连续反应链", "触发瞬间", "尚未完成", "完成态")):
secondary_frame_item = frame_bucket[1]
if secondary_frame_item not in consumed_must_items:
must_items.append(secondary_frame_item)
consumed_must_items.add(secondary_frame_item)
for category in ("frame", "continuity", "composition", "screen"):
bucket = buckets.get(category) or []
if category in must_categories and bucket:
prefer_items.extend(item for item in bucket[1:] if item not in consumed_must_items)
continue
prefer_items.extend(item for item in bucket if item not in consumed_must_items)
if category not in must_categories and bucket:
head = bucket[0]
if head not in consumed_must_items:
prefer_items.insert(0, head)
items = [*(f"必须:{item}" for item in must_items[:4]), *(f"优先:{item}" for item in prefer_items[:4])]
if not items:
return ""
return ";".join(items[:8])
def _extract_context_names(context_text: str | None) -> list[str]:
names: list[str] = []
for raw_line in str(context_text or "").splitlines():
@@ -212,6 +639,19 @@ async def build_run_args(
raise HTTPException(status_code=404, detail=entity_not_found("ShotDetail"))
detail = shot.detail
neighbors_stmt = (
select(Shot)
.options(selectinload(Shot.detail).selectinload(ShotDetail.scene))
.where(
Shot.chapter_id == shot.chapter_id,
Shot.index.in_([shot.index - 1, shot.index + 1]),
)
)
neighbor_rows = (await db.execute(neighbors_stmt)).scalars().all()
previous_shot = next((item for item in neighbor_rows if item.index == shot.index - 1), None)
next_shot = next((item for item in neighbor_rows if item.index == shot.index + 1), None)
previous_title, previous_excerpt, previous_state = _summarize_neighbor_shot(previous_shot)
next_title, next_excerpt, next_goal = _summarize_neighbor_shot(next_shot)
dialog_summary = "\n".join(line.text for line in (detail.dialog_lines or []) if line.text)
project = getattr(getattr(shot, "chapter", None), "project", None)
visual_style = _enum_value(getattr(project, "visual_style", None))
@@ -243,6 +683,36 @@ async def build_run_args(
]
scenes = list(scenes_by_id.values())
continuity_guidance = _build_continuity_guidance(
previous_shot=previous_shot,
current_shot=shot,
next_shot=next_shot,
)
action_beats = [str(item).strip() for item in list(getattr(detail, "action_beats", []) or []) if str(item).strip()]
selected_action_beat = pick_action_beat_for_frame(normalized_frame_type, action_beats)
action_beat_phases = _format_action_beat_phase_summary(action_beats)
composition_anchor = _build_composition_anchor(
detail=detail,
previous_shot=previous_shot,
next_shot=next_shot,
characters=characters,
scenes=scenes,
)
screen_direction_guidance = _build_screen_direction_guidance(
detail=detail,
previous_shot=previous_shot,
next_shot=next_shot,
dialogue_summary=dialog_summary,
character_names=[character.name for character in characters],
)
frame_specific_guidance = _build_frame_specific_guidance(
frame_type=normalized_frame_type,
previous_shot=previous_shot,
next_shot=next_shot,
detail=detail,
script_excerpt=shot.script_excerpt or "",
action_beats=action_beats,
)
return {
"shot_id": shot_id,
"frame_type": normalized_frame_type,
@@ -263,6 +733,10 @@ async def build_run_args(
"duration": detail.duration,
"scene_id": detail.scene_id,
"dialog_summary": dialog_summary,
"action_beats": action_beats,
"action_beat_phases": action_beat_phases,
"selected_action_beat_phase": getattr(selected_action_beat, "phase", ""),
"selected_action_beat_text": getattr(selected_action_beat, "text", ""),
"character_context": _build_character_context(characters),
"scene_context": _build_named_asset_context(scenes),
"prop_context": _build_named_asset_context(props),
@@ -273,6 +747,28 @@ async def build_run_args(
props=props,
costumes=costumes,
),
"previous_shot_title": previous_title,
"previous_shot_script_excerpt": previous_excerpt,
"previous_shot_end_state": previous_state,
"next_shot_title": next_title,
"next_shot_script_excerpt": next_excerpt,
"next_shot_start_goal": next_goal,
"continuity_guidance": continuity_guidance,
"composition_anchor": composition_anchor,
"screen_direction_guidance": screen_direction_guidance,
"frame_specific_guidance": frame_specific_guidance,
"director_command_summary": _build_director_command_summary(
frame_type=normalized_frame_type,
frame_specific_guidance=frame_specific_guidance,
continuity_guidance=continuity_guidance,
composition_anchor=composition_anchor,
screen_direction_guidance=screen_direction_guidance,
has_dialogue=bool(dialog_summary.strip()),
character_count=len(characters),
same_scene_with_previous=_same_scene(previous_shot, str(detail.scene_id or "")),
same_scene_with_next=_same_scene(next_shot, str(detail.scene_id or "")),
movement=_enum_value(detail.movement),
),
},
}
+41 -58
View File
@@ -97,15 +97,33 @@ async def find_active_divide_task(
*,
chapter_id: str,
) -> GenerationTask | None:
return await _find_active_task(
db,
relation_type=CHAPTER_DIVISION_RELATION_TYPE,
relation_entity_id=chapter_id,
)
async def _find_active_task(
db: AsyncSession,
*,
relation_type: str,
relation_entity_id: str,
) -> GenerationTask | None:
"""按业务关联查询任意活动任务。
这里的目标只是“是否已有运行中的同类任务”,不需要挑出“最新”那一条。
因此直接从 relation link 侧过滤,再按活动状态做一次连接,并去掉排序,
避免在任务量大时触发 MySQL 的大排序与 sort buffer 压力。
"""
stmt = (
select(GenerationTask)
.join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id)
.where(
GenerationTaskLink.relation_type == CHAPTER_DIVISION_RELATION_TYPE,
GenerationTaskLink.relation_entity_id == chapter_id,
GenerationTaskLink.relation_type == relation_type,
GenerationTaskLink.relation_entity_id == relation_entity_id,
GenerationTask.status.in_(_ACTIVE_TASK_STATUSES),
)
.order_by(GenerationTask.updated_at.desc(), GenerationTask.id.desc())
.limit(1)
)
return (await db.execute(stmt)).scalars().first()
@@ -171,18 +189,11 @@ async def find_active_extract_task(
*,
chapter_id: str,
) -> GenerationTask | None:
stmt = (
select(GenerationTask)
.join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id)
.where(
GenerationTaskLink.relation_type == SCRIPT_EXTRACTION_RELATION_TYPE,
GenerationTaskLink.relation_entity_id == chapter_id,
GenerationTask.status.in_(_ACTIVE_TASK_STATUSES),
)
.order_by(GenerationTask.updated_at.desc(), GenerationTask.id.desc())
.limit(1)
return await _find_active_task(
db,
relation_type=SCRIPT_EXTRACTION_RELATION_TYPE,
relation_entity_id=chapter_id,
)
return (await db.execute(stmt)).scalars().first()
async def find_active_merge_task(
@@ -190,18 +201,11 @@ async def find_active_merge_task(
*,
relation_entity_id: str,
) -> GenerationTask | None:
stmt = (
select(GenerationTask)
.join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id)
.where(
GenerationTaskLink.relation_type == ENTITY_MERGE_RELATION_TYPE,
GenerationTaskLink.relation_entity_id == relation_entity_id,
GenerationTask.status.in_(_ACTIVE_TASK_STATUSES),
)
.order_by(GenerationTask.updated_at.desc(), GenerationTask.id.desc())
.limit(1)
return await _find_active_task(
db,
relation_type=ENTITY_MERGE_RELATION_TYPE,
relation_entity_id=relation_entity_id,
)
return (await db.execute(stmt)).scalars().first()
async def find_active_consistency_task(
@@ -209,18 +213,11 @@ async def find_active_consistency_task(
*,
relation_entity_id: str,
) -> GenerationTask | None:
stmt = (
select(GenerationTask)
.join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id)
.where(
GenerationTaskLink.relation_type == CONSISTENCY_CHECK_RELATION_TYPE,
GenerationTaskLink.relation_entity_id == relation_entity_id,
GenerationTask.status.in_(_ACTIVE_TASK_STATUSES),
)
.order_by(GenerationTask.updated_at.desc(), GenerationTask.id.desc())
.limit(1)
return await _find_active_task(
db,
relation_type=CONSISTENCY_CHECK_RELATION_TYPE,
relation_entity_id=relation_entity_id,
)
return (await db.execute(stmt)).scalars().first()
async def find_active_variant_task(
@@ -228,18 +225,11 @@ async def find_active_variant_task(
*,
relation_entity_id: str,
) -> GenerationTask | None:
stmt = (
select(GenerationTask)
.join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id)
.where(
GenerationTaskLink.relation_type == VARIANT_ANALYSIS_RELATION_TYPE,
GenerationTaskLink.relation_entity_id == relation_entity_id,
GenerationTask.status.in_(_ACTIVE_TASK_STATUSES),
)
.order_by(GenerationTask.updated_at.desc(), GenerationTask.id.desc())
.limit(1)
return await _find_active_task(
db,
relation_type=VARIANT_ANALYSIS_RELATION_TYPE,
relation_entity_id=relation_entity_id,
)
return (await db.execute(stmt)).scalars().first()
async def _find_active_analysis_task(
@@ -248,18 +238,11 @@ async def _find_active_analysis_task(
relation_type: str,
relation_entity_id: str,
) -> GenerationTask | None:
stmt = (
select(GenerationTask)
.join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id)
.where(
GenerationTaskLink.relation_type == relation_type,
GenerationTaskLink.relation_entity_id == relation_entity_id,
GenerationTask.status.in_(_ACTIVE_TASK_STATUSES),
)
.order_by(GenerationTask.updated_at.desc(), GenerationTask.id.desc())
.limit(1)
return await _find_active_task(
db,
relation_type=relation_type,
relation_entity_id=relation_entity_id,
)
return (await db.execute(stmt)).scalars().first()
def pick_merge_relation_entity_id(*, chapter_id: str | None, project_id: str | None) -> str:
@@ -39,6 +39,7 @@ from app.services.studio.shot_extracted_candidates import (
from app.services.studio.shot_extracted_dialogue_candidates import (
sync_from_extraction_draft_sync as sync_shot_extracted_dialogue_candidates_from_draft_sync,
)
from app.services.studio.shot_semantic_defaults import apply_shot_semantic_defaults_from_draft_sync
from app.services.worker.task_executor import (
AbstractLLMResultGenerator,
AbstractWorkerTaskExecutor,
@@ -310,8 +311,11 @@ def apply_extraction_result(
chapter_id: str,
draft: Any,
) -> None:
"""将提取草稿同步为候选与镜头语言默认值。"""
sync_shot_extracted_candidates_from_draft_sync(db, chapter_id=chapter_id, draft=draft)
sync_shot_extracted_dialogue_candidates_from_draft_sync(db, chapter_id=chapter_id, draft=draft)
apply_shot_semantic_defaults_from_draft_sync(db, chapter_id=chapter_id, draft=draft)
def run_divide_task_sync(task_id: str) -> None:
+147
View File
@@ -0,0 +1,147 @@
"""镜头动作拍点辅助服务。
该模块负责对 `ShotDetail.action_beats` 做轻量阶段推断,帮助关键帧与视频链
在不引入复杂结构化模型的前提下,更稳定地区分:
- trigger:触发 / 起始反应
- peak:动作峰值 / 戏剧张力最高阶段
- aftermath:结果态 / 收束阶段
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
ActionBeatPhase = Literal["trigger", "peak", "aftermath"]
_TRIGGER_KEYWORDS = (
"听到",
"看到",
"看见",
"发现",
"察觉",
"突然",
"骤然",
"刚",
"开始",
"初次",
"异响",
"咔嚓",
"响起",
"推门",
"转头",
"回头",
)
_AFTERMATH_KEYWORDS = (
"呼吸急促",
"喘",
"余韵",
"停留",
"停住",
"站定",
"蹲着",
"倒在",
"保持",
"恢复",
"看向",
"余波",
"收束",
)
_PEAK_KEYWORDS = (
"脱手",
"下坠",
"落地",
"捂住",
"捂耳",
"蹲下",
"扑",
"冲",
"举起",
"挥",
"对峙",
"爆发",
"僵住",
"下沉",
)
@dataclass(frozen=True, slots=True)
class ActionBeatPhaseItem:
"""单条动作拍点的阶段推断结果。"""
text: str
phase: ActionBeatPhase
def _count_hits(text: str, keywords: tuple[str, ...]) -> int:
"""统计一段文本中命中的阶段关键词数量。"""
return sum(1 for keyword in keywords if keyword in text)
def infer_action_beat_phase(*, text: str, index: int, total: int) -> ActionBeatPhase:
"""为单条动作拍点推断阶段。
规则优先级:
1. 明显的 aftermath 关键词
2. 明显的 trigger 关键词
3. 明显的 peak 关键词
4. 按位置兜底:
- 首条默认 trigger
- 尾条默认 aftermath(总数 >= 3)
- 其余默认 peak
"""
normalized = str(text or "").strip()
if not normalized:
return "peak"
aftermath_hits = _count_hits(normalized, _AFTERMATH_KEYWORDS)
trigger_hits = _count_hits(normalized, _TRIGGER_KEYWORDS)
peak_hits = _count_hits(normalized, _PEAK_KEYWORDS)
if aftermath_hits > 0 and aftermath_hits >= max(trigger_hits, peak_hits):
return "aftermath"
if trigger_hits > 0 and trigger_hits >= peak_hits:
return "trigger"
if peak_hits > 0:
return "peak"
if index == 0:
return "trigger"
if total >= 3 and index == total - 1:
return "aftermath"
return "peak"
def infer_action_beat_sequence(action_beats: list[str] | None) -> list[ActionBeatPhaseItem]:
"""将动作拍点列表转换为带阶段信息的序列。"""
beats = [str(item).strip() for item in list(action_beats or []) if str(item).strip()]
total = len(beats)
return [
ActionBeatPhaseItem(
text=text,
phase=infer_action_beat_phase(text=text, index=index, total=total),
)
for index, text in enumerate(beats)
]
def pick_action_beat_for_frame(frame_type: str, action_beats: list[str] | None) -> ActionBeatPhaseItem | None:
"""按帧类型挑选最适合作为当前帧主拍点的动作项。"""
sequence = infer_action_beat_sequence(action_beats)
if not sequence:
return None
if frame_type == "first":
return next((item for item in sequence if item.phase == "trigger"), sequence[0])
if frame_type == "last":
return next((item for item in sequence if item.phase == "aftermath"), sequence[-1])
peak_items = [item for item in sequence if item.phase == "peak"]
if peak_items:
return peak_items[-1]
return sequence[min(len(sequence) // 2, len(sequence) - 1)]
@@ -11,6 +11,11 @@ class FrameBaseDraft(GenerationBaseDraft):
shot_id: str
frame_type: ShotFrameType
prompt: str
director_command_summary: str = ""
continuity_guidance: str = ""
frame_specific_guidance: str = ""
composition_anchor: str = ""
screen_direction_guidance: str = ""
def build_frame_base_draft(
@@ -18,10 +23,20 @@ def build_frame_base_draft(
shot_id: str,
frame_type: ShotFrameType,
prompt: str,
director_command_summary: str = "",
continuity_guidance: str = "",
frame_specific_guidance: str = "",
composition_anchor: str = "",
screen_direction_guidance: str = "",
) -> FrameBaseDraft:
"""构造分镜帧基础草稿,并附带最终渲染阶段仍需保留的高优先级约束。"""
return FrameBaseDraft(
shot_id=shot_id,
frame_type=frame_type,
prompt=(prompt or "").strip(),
director_command_summary=(director_command_summary or "").strip(),
continuity_guidance=(continuity_guidance or "").strip(),
frame_specific_guidance=(frame_specific_guidance or "").strip(),
composition_anchor=(composition_anchor or "").strip(),
screen_direction_guidance=(screen_direction_guidance or "").strip(),
)
@@ -20,6 +20,10 @@ def build_frame_submission_payload(
"render_context": {
"base_prompt": derived.base_prompt,
"rendered_prompt": derived.rendered_prompt,
"selected_guidance": derived.selected_guidance,
"dropped_guidance": derived.dropped_guidance,
"selected_guidance_details": [item.model_dump() for item in derived.selected_guidance_details],
"dropped_guidance_details": [item.model_dump() for item in derived.dropped_guidance_details],
"images": derived.images,
"mappings": [mapping.model_dump() for mapping in derived.mappings],
}
@@ -1,6 +1,6 @@
from __future__ import annotations
from app.schemas.studio.shots import RenderedShotFramePromptRead, ShotFramePromptMappingRead
from app.schemas.studio.shots import FrameGuidanceDecisionRead, RenderedShotFramePromptRead, ShotFramePromptMappingRead
from app.services.studio.generation.frame.build_base import FrameBaseDraft
from app.services.studio.generation.frame.build_context import FrameGenerationContext
from app.services.studio.generation.shared.types import GenerationDerivedPreview
@@ -24,6 +24,265 @@ def replace_reference_names_in_prompt(
return text
def _score_frame_guidance_line(
*,
frame_type: str,
category: str,
text: str,
) -> int:
"""按帧类型和文本特征给图片 guidance 打分,控制最终 prompt 的收敛顺序。"""
score_by_category = {
"summary": 100,
"continuity": 80,
"frame": 70,
"screen": 65,
"composition": 60,
}
score = score_by_category.get(category, 0)
if frame_type == "first" and category == "frame":
score += 18
if frame_type == "first" and category == "composition":
score += 15
if frame_type == "key" and category == "frame":
score += 6
if frame_type == "last" and category == "frame":
score += 8
if frame_type in {"key", "last"} and category == "screen":
score += 10
if frame_type == "last" and category == "continuity":
score += 5
if category == "frame" and any(keyword in text for keyword in ("触发瞬间", "初始反应", "尚未完成", "动作峰值", "情绪余韵", "收束")):
score += 8
if category == "screen" and any(keyword in text for keyword in ("视线", "对视", "左右", "朝向", "跳轴")):
score += 10
if category == "composition" and any(keyword in text for keyword in ("空间", "重心", "环境", "锚点", "站位")):
score += 10
if category == "continuity" and any(keyword in text for keyword in ("上一镜头", "下一镜头", "承接", "收束")):
score += 5
return score
def _build_frame_guidance_reason(
*,
frame_type: str,
category: str,
selected: bool,
) -> str:
"""生成 guidance 被保留或压缩的可解释原因。"""
if selected:
if category == "frame":
if frame_type == "first":
return "当前是首帧,系统优先保留触发瞬间与未完成态约束,避免画面直接跳到后续完成动作。"
if frame_type == "key":
return "当前是关键帧,系统保留帧职责 guidance 来强化动作峰值或情绪爆点。"
if frame_type == "last":
return "当前是尾帧,系统保留帧职责 guidance 来强化动作收束与情绪落点。"
if frame_type == "first" and category == "composition":
return "当前是首帧,系统优先稳住空间建立与主体站位,所以保留构图锚点。"
if frame_type in {"key", "last"} and category == "screen":
return "当前镜头更看重视线与左右轴线稳定,因此优先保留朝向与视线 guidance。"
if category == "summary":
return "导演主指令始终属于最高优先级约束,因此会优先保留。"
if category == "continuity":
return "连续性 guidance 直接影响镜头承接稳定性,因此被保留。"
if category == "composition":
return "这条 guidance 对画面空间重心更关键,因此被保留。"
if category == "screen":
return "这条 guidance 对视线、朝向或轴线更关键,因此被保留。"
if category == "frame":
if frame_type == "first":
return "当前已有更高优先级的首帧约束进入最终 prompt,因此这条帧职责 guidance 被压缩。"
if frame_type == "key":
return "当前已有更高优先级的关键帧约束进入最终 prompt,因此这条帧职责 guidance 被压缩。"
if frame_type == "last":
return "当前已有更高优先级的尾帧约束进入最终 prompt,因此这条帧职责 guidance 被压缩。"
if frame_type == "first" and category == "screen":
return "当前是首帧,系统更优先保空间建立与站位关系,因此将朝向与视线 guidance 降为次级。"
if frame_type in {"key", "last"} and category == "composition":
return "当前镜头更优先保视线与左右轴线稳定,因此构图锚点 guidance 被压缩。"
if category == "summary":
return "当前已有更高分的导演主指令进入最终 prompt,这条摘要未继续保留。"
if category == "continuity":
return "当前已有更高优先级的镜头约束进入最终 prompt,因此这条连续性 guidance 被压缩。"
if category == "composition":
return "当前已有更高优先级的空间或朝向约束进入最终 prompt,因此这条构图 guidance 被压缩。"
if category == "screen":
return "当前已有更高优先级的空间或连续性约束进入最终 prompt,因此这条朝向/视线 guidance 被压缩。"
return "当前已有更高优先级 guidance 进入最终 prompt,因此该条目未被保留。"
def _build_frame_guidance_reason_tag(
*,
frame_type: str,
category: str,
selected: bool,
) -> str:
"""生成更适合前端快速阅读的短标签。"""
if category == "summary":
return "导演主指令"
if category == "frame":
if frame_type == "first":
return "首帧保时序" if selected else "首帧降时序"
if frame_type == "key":
return "关键帧保峰值" if selected else "关键帧降峰值"
if frame_type == "last":
return "尾帧保收束" if selected else "尾帧降收束"
if category == "continuity":
return "连续性优先" if selected else "连续性降级"
if frame_type == "first" and category == "composition":
return "首帧保空间" if selected else "首帧降构图"
if frame_type == "first" and category == "screen":
return "首帧降轴线"
if frame_type in {"key", "last"} and category == "screen":
return "关键帧保轴线" if frame_type == "key" and selected else ("尾帧保轴线" if selected else "轴线降级")
if frame_type in {"key", "last"} and category == "composition":
return "关键帧降构图" if frame_type == "key" else "尾帧降构图"
if category == "composition":
return "构图优先" if selected else "构图降级"
if category == "screen":
return "轴线优先" if selected else "轴线降级"
return "优先级调整"
def _collect_frame_guidance_lines(
*,
frame_type: str,
replaced_prompt: str,
director_command_summary: str,
continuity_guidance: str,
frame_specific_guidance: str,
composition_anchor: str,
screen_direction_guidance: str,
) -> tuple[list[str], list[str], list[FrameGuidanceDecisionRead], list[FrameGuidanceDecisionRead]]:
"""收集最终保留与被压缩掉的 guidance,供渲染与前端展示共用。"""
text = (replaced_prompt or "").strip()
candidates: list[tuple[int, int, str, str]] = []
normalized_summary = (director_command_summary or "").strip()
normalized_continuity = (continuity_guidance or "").strip()
normalized_frame = (frame_specific_guidance or "").strip()
normalized_composition = (composition_anchor or "").strip()
normalized_screen = (screen_direction_guidance or "").strip()
if normalized_summary and normalized_summary not in text:
candidates.append(
(
_score_frame_guidance_line(frame_type=frame_type, category="summary", text=normalized_summary),
0,
"summary",
f"高优先级导演指令:{normalized_summary}",
)
)
if (
normalized_continuity
and normalized_continuity not in text
and normalized_continuity not in normalized_summary
and "连续性要求:" not in text
):
candidates.append(
(
_score_frame_guidance_line(frame_type=frame_type, category="continuity", text=normalized_continuity),
1,
"continuity",
f"连续性要求:{normalized_continuity}",
)
)
if (
normalized_frame
and normalized_frame not in text
and normalized_frame not in normalized_summary
and "当前帧职责:" not in text
):
candidates.append(
(
_score_frame_guidance_line(frame_type=frame_type, category="frame", text=normalized_frame),
2,
"frame",
f"当前帧职责:{normalized_frame}",
)
)
if (
normalized_composition
and normalized_composition not in text
and "构图锚点:" not in text
):
candidates.append(
(
_score_frame_guidance_line(frame_type=frame_type, category="composition", text=normalized_composition),
4,
"composition",
f"构图锚点:{normalized_composition}",
)
)
if (
normalized_screen
and normalized_screen not in text
and "朝向与视线:" not in text
):
candidates.append(
(
_score_frame_guidance_line(frame_type=frame_type, category="screen", text=normalized_screen),
3,
"screen",
f"朝向与视线:{normalized_screen}",
)
)
ranked = sorted(candidates, key=lambda item: (-item[0], item[1]))
selected_rows = ranked[:3]
dropped_rows = ranked[3:]
selected = [line for _, _, _, line in selected_rows]
dropped = [line for _, _, _, line in dropped_rows]
selected_details = [
FrameGuidanceDecisionRead(
text=line,
category=category,
reason_tag=_build_frame_guidance_reason_tag(frame_type=frame_type, category=category, selected=True),
reason=_build_frame_guidance_reason(frame_type=frame_type, category=category, selected=True),
)
for _, _, category, line in selected_rows
]
dropped_details = [
FrameGuidanceDecisionRead(
text=line,
category=category,
reason_tag=_build_frame_guidance_reason_tag(frame_type=frame_type, category=category, selected=False),
reason=_build_frame_guidance_reason(frame_type=frame_type, category=category, selected=False),
)
for _, _, category, line in dropped_rows
]
return selected, dropped, selected_details, dropped_details
def enrich_frame_prompt_with_guidance(
*,
frame_type: str,
replaced_prompt: str,
director_command_summary: str,
continuity_guidance: str,
frame_specific_guidance: str,
composition_anchor: str,
screen_direction_guidance: str,
) -> str:
"""将高优先级导演约束补入最终图片提示词,避免只停留在调试展示。"""
text = (replaced_prompt or "").strip()
guidance_lines, _, _, _ = _collect_frame_guidance_lines(
frame_type=frame_type,
replaced_prompt=text,
director_command_summary=director_command_summary,
continuity_guidance=continuity_guidance,
frame_specific_guidance=frame_specific_guidance,
composition_anchor=composition_anchor,
screen_direction_guidance=screen_direction_guidance,
)
if not guidance_lines:
return text
return "\n".join([*guidance_lines, text]).strip()
def compose_shot_frame_rendered_prompt(
*,
replaced_prompt: str,
@@ -49,6 +308,10 @@ class FrameDerivedPreview(GenerationDerivedPreview):
frame_type: str
base_prompt: str
rendered_prompt: str
selected_guidance: list[str]
dropped_guidance: list[str]
selected_guidance_details: list[FrameGuidanceDecisionRead]
dropped_guidance_details: list[FrameGuidanceDecisionRead]
images: list[str]
mappings: list[ShotFramePromptMappingRead]
@@ -63,8 +326,26 @@ def derive_frame_preview(
base_prompt=normalized_base_prompt,
mappings=context.ordered_refs,
)
rendered_prompt = compose_shot_frame_rendered_prompt(
selected_guidance, dropped_guidance, selected_guidance_details, dropped_guidance_details = _collect_frame_guidance_lines(
frame_type=base.frame_type.value if hasattr(base.frame_type, "value") else str(base.frame_type),
replaced_prompt=replaced_prompt,
director_command_summary=base.director_command_summary,
continuity_guidance=base.continuity_guidance,
frame_specific_guidance=base.frame_specific_guidance,
composition_anchor=base.composition_anchor,
screen_direction_guidance=base.screen_direction_guidance,
)
enriched_prompt = enrich_frame_prompt_with_guidance(
frame_type=base.frame_type.value if hasattr(base.frame_type, "value") else str(base.frame_type),
replaced_prompt=replaced_prompt,
director_command_summary=base.director_command_summary,
continuity_guidance=base.continuity_guidance,
frame_specific_guidance=base.frame_specific_guidance,
composition_anchor=base.composition_anchor,
screen_direction_guidance=base.screen_direction_guidance,
)
rendered_prompt = compose_shot_frame_rendered_prompt(
replaced_prompt=enriched_prompt,
mappings=context.ordered_refs,
)
return FrameDerivedPreview(
@@ -72,6 +353,10 @@ def derive_frame_preview(
frame_type=base.frame_type.value if hasattr(base.frame_type, "value") else str(base.frame_type),
base_prompt=normalized_base_prompt,
rendered_prompt=rendered_prompt,
selected_guidance=selected_guidance,
dropped_guidance=dropped_guidance,
selected_guidance_details=selected_guidance_details,
dropped_guidance_details=dropped_guidance_details,
images=[mapping.file_id for mapping in context.ordered_refs],
mappings=context.ordered_refs,
)
@@ -84,7 +369,10 @@ def to_rendered_shot_frame_prompt_read(
return RenderedShotFramePromptRead(
base_prompt=derived.base_prompt,
rendered_prompt=derived.rendered_prompt,
selected_guidance=derived.selected_guidance,
dropped_guidance=derived.dropped_guidance,
selected_guidance_details=derived.selected_guidance_details,
dropped_guidance_details=derived.dropped_guidance_details,
images=derived.images,
mappings=derived.mappings,
)
@@ -10,6 +10,7 @@ from app.services.studio.shot_video_prompt_pack import (
_render_template,
_resolve_video_prompt_template,
build_shot_video_prompt_pack,
enrich_rendered_video_prompt,
)
@@ -34,10 +35,14 @@ async def derive_video_preview(
) -> VideoDerivedPreview:
pack = await build_shot_video_prompt_pack(db, shot_id=base.shot_id)
if base.prompt:
rendered_prompt = enrich_rendered_video_prompt(
rendered_prompt=base.prompt,
pack=pack,
)
return VideoDerivedPreview(
shot_id=base.shot_id,
reference_mode=context.reference_mode,
rendered_prompt=base.prompt,
rendered_prompt=rendered_prompt,
images=context.images,
pack=pack,
template_id=context.template_id,
@@ -55,6 +60,11 @@ async def derive_video_preview(
if not rendered_prompt:
warnings.append("视频提示词模板渲染结果为空,已使用系统默认拼装提示词")
rendered_prompt = _fallback_video_prompt(pack)
else:
rendered_prompt = enrich_rendered_video_prompt(
rendered_prompt=rendered_prompt,
pack=pack,
)
return VideoDerivedPreview(
shot_id=base.shot_id,
@@ -33,6 +33,7 @@ from app.models.studio import (
from app.services.common import entity_not_found
from app.services.studio.entity_thumbnails import resolve_thumbnail_infos
from app.schemas.skills.script_processing import (
ShotSemanticSuggestion,
StudioAssetDraft,
StudioCharacterDraft,
StudioScriptExtractionDraft,
@@ -364,6 +365,14 @@ async def build_script_extraction_draft_for_shot(db: AsyncSession, shot_id: str)
costume_names=costume_names_shot,
dialogue_lines=dialogue_lines,
actions=actions,
semantic_suggestion=ShotSemanticSuggestion(
camera_shot=getattr(detail, "camera_shot", None) if detail is not None else None,
angle=getattr(detail, "angle", None) if detail is not None else None,
movement=getattr(detail, "movement", None) if detail is not None else None,
duration=getattr(detail, "duration", None) if detail is not None else None,
action_beats=list(getattr(detail, "action_beats", []) or []) if detail is not None else [],
notes=None,
),
)
return StudioScriptExtractionDraft(
@@ -7,12 +7,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.studio.cast import ShotCharacterLinkCreate
from app.schemas.studio.shots import ProjectAssetLinkCreate
from app.schemas.studio.shots import (
ActionBeatPhaseRead,
ShotDialogLineRead,
ShotExtractedDialogueCandidateRead,
ShotPreparationLinkEntityType,
ShotPreparationStateRead,
)
from app.services.studio.action_beats import infer_action_beat_sequence
from app.services.studio.shot_assets import create_project_asset_link
from app.services.studio.shot_details import get as get_shot_detail
from app.services.studio.shot_character_links import list_by_shot as list_shot_character_links, upsert as upsert_shot_character_link
from app.services.studio.shot_assets_overview import get_shot_assets_overview
from app.services.studio.shot_dialogs import list_by_shot as list_saved_dialog_lines_by_shot
@@ -28,6 +31,7 @@ async def build_shot_preparation_state(
"""组装分镜准备页所需的统一聚合状态。"""
shot = await get_shot(db, shot_id=shot_id)
shot_read = await build_shot_read(db, shot=shot)
detail = await get_shot_detail(db, shot_id=shot_id)
assets_overview = await get_shot_assets_overview(db, shot_id=shot_id)
dialogue_candidates = [
ShotExtractedDialogueCandidateRead.model_validate(row)
@@ -41,13 +45,31 @@ async def build_shot_preparation_state(
int(assets_overview.summary.pending_count or 0)
+ int(shot_read.extraction.pending_dialogue_count or 0)
)
ready_for_generation = shot_read.status.value == "ready"
basic_info_ready = bool((shot_read.title or "").strip()) and bool((shot_read.script_excerpt or "").strip())
semantic_defaults_ready = bool(detail.camera_shot) and bool(detail.angle) and bool(detail.movement) and int(detail.duration or 0) > 0
action_beats_count = len([item for item in (detail.action_beats or []) if (item or "").strip()])
action_beats_ready = action_beats_count > 0
action_beat_phases = [
ActionBeatPhaseRead(text=item.text, phase=item.phase)
for item in infer_action_beat_sequence(detail.action_beats)
]
ready_for_generation = (
shot_read.status.value == "ready"
and basic_info_ready
and semantic_defaults_ready
and action_beats_ready
)
return ShotPreparationStateRead(
shot=shot_read,
assets_overview=assets_overview,
dialogue_candidates=dialogue_candidates,
saved_dialogue_lines=saved_dialogue_lines,
pending_confirm_count=pending_confirm_count,
basic_info_ready=basic_info_ready,
semantic_defaults_ready=semantic_defaults_ready,
action_beats_ready=action_beats_ready,
action_beats_count=action_beats_count,
action_beat_phases=action_beat_phases,
ready_for_generation=ready_for_generation,
)
@@ -0,0 +1,124 @@
"""将提取草稿中的镜头语言默认建议回写到 ShotDetail。"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.models.studio import CameraAngle, CameraMovement, CameraShotType, Shot, ShotDetail
def _get_value(obj: Any, key: str) -> Any:
"""兼容 dict / Pydantic model / 普通对象的字段读取。"""
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
def _coerce_enum(enum_cls: type, value: Any) -> Any:
"""将提取结果里的字符串安全转换为枚举;非法值直接忽略。"""
if value in (None, ""):
return None
if isinstance(value, enum_cls):
return value
try:
return enum_cls(value)
except Exception:
return None
def _coerce_duration(value: Any) -> int | None:
"""将提取结果里的时长安全转换为正整数秒数。"""
if value in (None, ""):
return None
try:
duration = int(value)
except Exception:
return None
return duration if duration > 0 else None
def _apply_detail_defaults_from_shot_draft(detail: ShotDetail, shot_draft: Any) -> None:
"""将单镜提取草稿中的默认镜头语言写入到现有 ShotDetail。"""
semantic = _get_value(shot_draft, "semantic_suggestion")
source = semantic or shot_draft
camera_shot = _coerce_enum(CameraShotType, _get_value(source, "camera_shot"))
angle = _coerce_enum(CameraAngle, _get_value(source, "angle"))
movement = _coerce_enum(CameraMovement, _get_value(source, "movement"))
duration = _coerce_duration(_get_value(source, "duration"))
action_beats = [
str(item).strip()
for item in list(_get_value(source, "action_beats") or [])
if str(item).strip()
]
if camera_shot is not None:
detail.camera_shot = camera_shot
if angle is not None:
detail.angle = angle
if movement is not None:
detail.movement = movement
if duration is not None:
detail.duration = duration
if action_beats:
detail.action_beats = action_beats
async def apply_shot_semantic_defaults_from_draft(
db: AsyncSession,
*,
chapter_id: str,
draft: Any,
) -> None:
"""按镜头序号匹配草稿,并将镜头语言默认建议回写到 ShotDetail。"""
shot_rows = (
await db.execute(
select(Shot.id, Shot.index).where(Shot.chapter_id == chapter_id)
)
).all()
shot_id_by_index = {int(row.index): str(row.id) for row in shot_rows}
for shot_draft in list(_get_value(draft, "shots") or []):
shot_index = _coerce_duration(_get_value(shot_draft, "index"))
if shot_index is None:
continue
shot_id = shot_id_by_index.get(shot_index)
if not shot_id:
continue
detail = await db.get(ShotDetail, shot_id)
if detail is None:
continue
_apply_detail_defaults_from_shot_draft(detail, shot_draft)
def apply_shot_semantic_defaults_from_draft_sync(
db: Session,
*,
chapter_id: str,
draft: Any,
) -> None:
"""同步任务执行器版本:将镜头语言默认建议回写到 ShotDetail。"""
shot_rows = db.execute(select(Shot.id, Shot.index).where(Shot.chapter_id == chapter_id)).all()
shot_id_by_index = {int(row.index): str(row.id) for row in shot_rows}
for shot_draft in list(_get_value(draft, "shots") or []):
shot_index = _coerce_duration(_get_value(shot_draft, "index"))
if shot_index is None:
continue
shot_id = shot_id_by_index.get(shot_index)
if not shot_id:
continue
detail = db.get(ShotDetail, shot_id)
if detail is None:
continue
_apply_detail_defaults_from_shot_draft(detail, shot_draft)
@@ -11,6 +11,7 @@
from __future__ import annotations
from typing import Any
import re
from fastapi import HTTPException, status
from langchain_core.prompts import PromptTemplate as LcPromptTemplate
@@ -20,11 +21,13 @@ from sqlalchemy.orm import selectinload
from app.models.studio import Chapter, PromptCategory, PromptTemplate, Shot, ShotDetail
from app.schemas.studio.shots import (
ActionBeatPhaseRead,
ShotPromptAssetRef,
ShotPromptCameraInfo,
ShotVideoPromptPackRead,
)
from app.services.common import entity_not_found
from app.services.studio.action_beats import infer_action_beat_sequence
from app.services.studio.shot_assets_overview import get_shot_assets_overview
@@ -64,6 +67,12 @@ def _pack_variables(pack: ShotVideoPromptPackRead) -> dict[str, Any]:
"title": pack.title,
"script_excerpt": pack.script_excerpt,
"action_beats": "\n".join(pack.action_beats),
"action_beats_text": ";".join(pack.action_beats),
"previous_shot_summary": pack.previous_shot_summary,
"next_shot_goal": pack.next_shot_goal,
"continuity_guidance": pack.continuity_guidance,
"composition_anchor": pack.composition_anchor,
"screen_direction_guidance": pack.screen_direction_guidance,
"dialogue_summary": pack.dialogue_summary,
"characters": pack.characters,
"character_names": "、".join(item.name for item in pack.characters),
@@ -91,12 +100,247 @@ def _render_template(content: str, variables: dict[str, Any]) -> str:
return template.format(**render_vars).strip()
def _build_guidance_suffix(pack: ShotVideoPromptPackRead) -> str:
"""生成一段稳定的镜头执行约束,供模板渲染结果补强使用。"""
lines: list[str] = []
if pack.action_beats:
lines.append(f"动作节拍:{';'.join(pack.action_beats)}")
if pack.previous_shot_summary:
lines.append(f"上一镜头承接:{pack.previous_shot_summary}")
if pack.next_shot_goal:
lines.append(f"下一镜头目标:{pack.next_shot_goal}")
if pack.continuity_guidance:
lines.append(f"连续性要求:{pack.continuity_guidance}")
if pack.composition_anchor:
lines.append(f"构图锚点:{pack.composition_anchor}")
if pack.screen_direction_guidance:
lines.append(f"朝向与视线:{pack.screen_direction_guidance}")
return "\n".join(lines).strip()
def enrich_rendered_video_prompt(
*,
rendered_prompt: str,
pack: ShotVideoPromptPackRead,
) -> str:
"""为模板渲染结果补强关键执行约束。
若模板未显式消费新的连续性/构图变量,则自动在末尾追加一段稳定约束,
避免这类信息只停留在 preview pack 中却没有真正进入最终 prompt。
"""
text = str(rendered_prompt or "").strip()
suffix = _build_guidance_suffix(pack)
if not suffix:
return text
normalized = text.replace(" ", "")
if any(
marker in normalized
for marker in (
"动作节拍:",
"上一镜头承接:",
"连续性要求:",
"构图锚点:",
"朝向与视线:",
)
):
return text
if not text:
return suffix
return f"{text}\n\n{suffix}".strip()
def _split_beats(*values: str) -> list[str]:
"""按常见中文断句规则拆分为动作节拍候选。"""
parts: list[str] = []
for value in values:
text = str(value or "").strip()
if not text:
continue
for piece in re.split(r"[。!?;\n]+", text):
item = piece.strip(",,、 ").strip()
if len(item) >= 4:
parts.append(item)
return parts
def _dedupe_keep_order(items: list[str], *, limit: int = 4) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for item in items:
normalized = item.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
if len(result) >= limit:
break
return result
def _build_action_beats(
*,
confirmed_action_beats: list[str] | None,
script_excerpt: str,
shot_description: str,
dialogue_summary: str,
) -> list[str]:
"""从剧本摘录、镜头描述与对白中提炼镜头内动作节拍。"""
confirmed = _dedupe_keep_order(list(confirmed_action_beats or []), limit=4)
if confirmed:
return confirmed
beat_candidates = _split_beats(shot_description, script_excerpt)
dialogue_candidates = [
f"对白推进:{item}"
for item in _split_beats(dialogue_summary)
if len(item) <= 24
]
return _dedupe_keep_order(beat_candidates + dialogue_candidates, limit=4)
def _build_neighbor_prompt_summary(shot: Shot | None) -> tuple[str, str]:
"""生成相邻镜头摘要与承接目标。"""
if shot is None:
return "", ""
detail = getattr(shot, "detail", None)
scene_name = str(getattr(getattr(detail, "scene", None), "name", "") or "").strip()
shot_description = str(getattr(detail, "description", "") or "").strip()
camera_parts = [
_enum_value(getattr(detail, "camera_shot", None)),
_enum_value(getattr(detail, "angle", None)),
_enum_value(getattr(detail, "movement", None)),
]
summary_parts = [
f"标题:{str(shot.title or '').strip()}" if str(shot.title or '').strip() else "",
f"场景:{scene_name}" if scene_name else "",
f"镜头语言:{' / '.join(part for part in camera_parts if part)}" if any(camera_parts) else "",
f"画面状态:{shot_description}" if shot_description else "",
f"剧本摘录:{str(shot.script_excerpt or '').strip()}" if str(shot.script_excerpt or '').strip() else "",
]
compact = ";".join(part for part in summary_parts if part)
goal_parts = [
f"标题:{str(shot.title or '').strip()}" if str(shot.title or '').strip() else "",
shot_description or str(shot.script_excerpt or "").strip(),
]
goal = ";".join(part for part in goal_parts if part)
return compact, goal
def _build_continuity_guidance(
*,
current_shot: Shot,
previous_shot: Shot | None,
next_shot: Shot | None,
) -> str:
"""构造视频提示词的连续性建议。"""
detail = getattr(current_shot, "detail", None)
current_scene_id = str(getattr(detail, "scene_id", "") or "")
tips: list[str] = []
if previous_shot is not None:
tips.append("承接上一镜头的动作、视线或情绪,不要像新场景重新开局")
previous_detail = getattr(previous_shot, "detail", None)
previous_scene_id = str(getattr(previous_detail, "scene_id", "") or "")
if current_scene_id and previous_scene_id and current_scene_id == previous_scene_id:
tips.append("与上一镜头同场景时保持空间轴线和人物朝向稳定")
if next_shot is not None:
tips.append("在本镜头结尾形成自然收束,为下一镜头留出动作或情绪落点")
next_detail = getattr(next_shot, "detail", None)
next_scene_id = str(getattr(next_detail, "scene_id", "") or "")
if current_scene_id and next_scene_id and current_scene_id == next_scene_id:
tips.append("与下一镜头同场景时保持视觉重心连续,避免突兀跳轴")
return ";".join(tips)
def _build_composition_anchor(
*,
shot: Shot,
previous_shot: Shot | None,
next_shot: Shot | None,
scene_name: str,
character_names: list[str],
) -> str:
"""为视频提示词构造稳定的画面构图与空间锚点建议。"""
detail = getattr(shot, "detail", None)
camera_shot = _enum_value(getattr(detail, "camera_shot", None))
movement = _enum_value(getattr(detail, "movement", None))
anchors: list[str] = []
if camera_shot in {"ECU", "CU"}:
anchors.append("以人物表情或关键动作细节作为画面重心,环境只保留必要轮廓")
elif camera_shot in {"MS", "FS"}:
anchors.append("保持人物与环境的相对位置可读,避免主体漂在空场中")
else:
anchors.append("先建立环境纵深和主体站位,再推进动作表现")
if movement in {"DOLLY_IN", "ZOOM_IN"}:
anchors.append("镜头重心应逐步向主体收束,形成推进感")
elif movement in {"DOLLY_OUT", "ZOOM_OUT"}:
anchors.append("镜头重心可从主体退向环境,强调空间扩展")
elif movement == "STATIC":
anchors.append("保持主体在画面中的位置稳定,避免重心突然跳变")
if scene_name:
anchors.append(f"以场景 {scene_name} 作为主要空间锚点")
if character_names:
anchors.append(f"锁定角色 {'、'.join(character_names[:2])} 的朝向与视线逻辑")
current_scene_id = str(getattr(detail, "scene_id", "") or "")
previous_scene_id = str(getattr(getattr(previous_shot, "detail", None), "scene_id", "") or "")
next_scene_id = str(getattr(getattr(next_shot, "detail", None), "scene_id", "") or "")
if previous_shot is not None and current_scene_id and current_scene_id == previous_scene_id:
anchors.append("与上一镜头同场景时,延续既有空间轴线和视觉方向")
if next_shot is not None and current_scene_id and current_scene_id == next_scene_id:
anchors.append("与下一镜头同场景时,保留可自然衔接的视觉落点")
return ";".join(anchors)
def _build_screen_direction_guidance(
*,
shot: Shot,
previous_shot: Shot | None,
next_shot: Shot | None,
dialogue_summary: str,
character_names: list[str],
) -> str:
"""构造视频镜头的人物朝向、视线与左右轴线建议。"""
detail = getattr(shot, "detail", None)
angle = _enum_value(getattr(detail, "angle", None))
guidance: list[str] = []
if angle == "OVER_SHOULDER":
guidance.append("保持过肩镜头的前景肩部位置稳定,不要无故交换左右关系")
elif angle == "EYE_LEVEL":
guidance.append("保持人物平视方向和视线落点连续,避免镜头间突然翻面")
else:
guidance.append("明确人物朝向与视线落点,避免跳轴和朝向突变")
if dialogue_summary.strip():
guidance.append("对白驱动镜头应优先保持说话者与受话者的对视逻辑")
if len(character_names) >= 2:
guidance.append(f"角色 {character_names[0]} 与 {character_names[1]} 的左右站位和对视方向应保持稳定")
elif character_names:
guidance.append(f"角色 {character_names[0]} 的视线方向应在相邻镜头中保持延续")
current_scene_id = str(getattr(detail, "scene_id", "") or "")
previous_scene_id = str(getattr(getattr(previous_shot, "detail", None), "scene_id", "") or "")
next_scene_id = str(getattr(getattr(next_shot, "detail", None), "scene_id", "") or "")
if previous_shot is not None and current_scene_id and current_scene_id == previous_scene_id:
guidance.append("与上一镜头同场景时,不要无故翻转人物左右面向")
if next_shot is not None and current_scene_id and current_scene_id == next_scene_id:
guidance.append("与下一镜头同场景时,为后续镜头保留可延续的视线方向")
return ";".join(guidance)
def _fallback_video_prompt(pack: ShotVideoPromptPackRead) -> str:
style_text = ",".join(x for x in [pack.visual_style, pack.style] if x)
camera_text = " / ".join(x for x in [pack.camera.camera_shot, pack.camera.angle, pack.camera.movement] if x)
parts = [
f"镜头标题:{pack.title}",
f"剧本摘录:{pack.script_excerpt}",
f"动作节拍:{';'.join(pack.action_beats)}" if pack.action_beats else "",
f"画面风格:{style_text}",
f"镜头语言:{camera_text}",
f"时长:{pack.camera.duration} 秒" if pack.camera.duration else "",
@@ -105,6 +349,11 @@ def _fallback_video_prompt(pack: ShotVideoPromptPackRead) -> str:
f"道具:{'、'.join(item.name for item in pack.props)}",
f"服装:{'、'.join(item.name for item in pack.costumes)}",
f"对白摘要:{pack.dialogue_summary}",
f"上一镜头:{pack.previous_shot_summary}" if pack.previous_shot_summary else "",
f"下一镜头目标:{pack.next_shot_goal}" if pack.next_shot_goal else "",
f"连续性要求:{pack.continuity_guidance}" if pack.continuity_guidance else "",
f"构图锚点:{pack.composition_anchor}" if pack.composition_anchor else "",
f"朝向与视线:{pack.screen_direction_guidance}" if pack.screen_direction_guidance else "",
f"氛围:{pack.atmosphere}",
f"负面约束:{pack.negative_prompt}",
]
@@ -153,6 +402,17 @@ async def build_shot_video_prompt_pack(
detail = shot.detail
project = getattr(getattr(shot, "chapter", None), "project", None)
overview = await get_shot_assets_overview(db, shot_id=shot_id)
neighbors_stmt = (
select(Shot)
.options(selectinload(Shot.detail).selectinload(ShotDetail.scene))
.where(
Shot.chapter_id == shot.chapter_id,
Shot.index.in_([shot.index - 1, shot.index + 1]),
)
)
neighbor_rows = (await db.execute(neighbors_stmt)).scalars().all()
previous_shot = next((item for item in neighbor_rows if item.index == shot.index - 1), None)
next_shot = next((item for item in neighbor_rows if item.index == shot.index + 1), None)
characters: list[ShotPromptAssetRef] = []
props: list[ShotPromptAssetRef] = []
@@ -177,12 +437,45 @@ async def build_shot_video_prompt_pack(
for line in sorted(dialog_lines, key=lambda x: (x.index, x.id))
if line.text
)
previous_shot_summary, _ = _build_neighbor_prompt_summary(previous_shot)
_, next_shot_goal = _build_neighbor_prompt_summary(next_shot)
action_beats = _build_action_beats(
confirmed_action_beats=list(getattr(detail, "action_beats", []) or []),
script_excerpt=shot.script_excerpt or "",
shot_description=str(getattr(detail, "description", "") or ""),
dialogue_summary=dialogue_summary,
)
return ShotVideoPromptPackRead(
shot_id=shot.id,
title=shot.title or "",
script_excerpt=shot.script_excerpt or "",
action_beats=[],
action_beats=action_beats,
action_beat_phases=[
ActionBeatPhaseRead(text=item.text, phase=item.phase)
for item in infer_action_beat_sequence(action_beats)
],
previous_shot_summary=previous_shot_summary,
next_shot_goal=next_shot_goal,
continuity_guidance=_build_continuity_guidance(
current_shot=shot,
previous_shot=previous_shot,
next_shot=next_shot,
),
composition_anchor=_build_composition_anchor(
shot=shot,
previous_shot=previous_shot,
next_shot=next_shot,
scene_name=scene.name if scene else "",
character_names=[item.name for item in characters],
),
screen_direction_guidance=_build_screen_direction_guidance(
shot=shot,
previous_shot=previous_shot,
next_shot=next_shot,
dialogue_summary=dialogue_summary,
character_names=[item.name for item in characters],
),
dialogue_summary=dialogue_summary,
characters=characters,
scene=scene,
+43
View File
@@ -0,0 +1,43 @@
SET @has_shot_details_action_beats = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'shot_details'
AND COLUMN_NAME = 'action_beats'
);
SET @add_shot_details_action_beats = IF(
@has_shot_details_action_beats = 0,
"ALTER TABLE shot_details ADD COLUMN action_beats JSON NULL COMMENT '镜头动作拍点(按时间顺序排列,用于关键帧与视频生成)'",
'SELECT 1'
);
PREPARE stmt_add_shot_details_action_beats FROM @add_shot_details_action_beats;
EXECUTE stmt_add_shot_details_action_beats;
DEALLOCATE PREPARE stmt_add_shot_details_action_beats;
SET @backfill_shot_details_action_beats = IF(
@has_shot_details_action_beats = 0,
"UPDATE shot_details SET action_beats = JSON_ARRAY() WHERE action_beats IS NULL",
'SELECT 1'
);
PREPARE stmt_backfill_shot_details_action_beats FROM @backfill_shot_details_action_beats;
EXECUTE stmt_backfill_shot_details_action_beats;
DEALLOCATE PREPARE stmt_backfill_shot_details_action_beats;
SET @normalize_shot_details_action_beats = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'shot_details'
AND COLUMN_NAME = 'action_beats'
AND IS_NULLABLE = 'YES'
);
SET @alter_shot_details_action_beats = IF(
@normalize_shot_details_action_beats > 0,
"ALTER TABLE shot_details MODIFY COLUMN action_beats JSON NOT NULL COMMENT '镜头动作拍点(按时间顺序排列,用于关键帧与视频生成)'",
'SELECT 1'
);
PREPARE stmt_alter_shot_details_action_beats FROM @alter_shot_details_action_beats;
EXECUTE stmt_alter_shot_details_action_beats;
DEALLOCATE PREPARE stmt_alter_shot_details_action_beats;
@@ -53,7 +53,31 @@ def test_preview_video_generation_prompt_returns_success_envelope(client: TestCl
db = _FakeDB()
async def _fake_preview(*_args, **_kwargs):
return "视频预览提示词", ["file-1", "file-2"], object()
return "视频预览提示词", ["file-1", "file-2"], {
"shot_id": "shot-1",
"title": "镜头一",
"script_excerpt": "主角转身看向门口。",
"action_beats": ["主角转身", "视线停在门口"],
"action_beat_phases": [
{"text": "主角转身", "phase": "trigger"},
{"text": "视线停在门口", "phase": "aftermath"},
],
"previous_shot_summary": "标题:镜头零;剧本摘录:主角推门进入走廊",
"next_shot_goal": "标题:镜头二;主角停住动作,保持警惕",
"continuity_guidance": "承接上一镜头动作,不要像全新场面重新开局",
"composition_anchor": "以走廊门口作为空间锚点",
"screen_direction_guidance": "保持主角朝向和视线落点连续",
"dialogue_summary": "",
"characters": [],
"scene": None,
"props": [],
"costumes": [],
"camera": {"camera_shot": "MS", "angle": "EYE_LEVEL", "movement": "STATIC", "duration": 4},
"atmosphere": "紧张",
"visual_style": "现实",
"style": "真人都市",
"negative_prompt": "",
}
monkeypatch.setattr(route, "preview_prompt_and_images", _fake_preview)
app.dependency_overrides[get_db] = _override_db(db)
@@ -77,6 +101,8 @@ def test_preview_video_generation_prompt_returns_success_envelope(client: TestCl
assert body["message"] == "success"
assert body["data"]["prompt"] == "视频预览提示词"
assert body["data"]["images"] == ["file-1", "file-2"]
assert body["data"]["pack"]["previous_shot_summary"].startswith("标题:镜头零")
assert body["data"]["pack"]["next_shot_goal"].startswith("标题:镜头二")
def test_preview_video_generation_prompt_not_found_returns_api_response(
+141 -6
View File
@@ -30,6 +30,9 @@ from app.services.film.generated_video import (
)
from app.bootstrap import bootstrap_all_registries
from app.services.llm import resolve_provider_key_from_name
from app.services.studio.generation.video.derive_preview import derive_video_preview
from app.services.studio.generation.video.build_base import VideoBaseDraft
from app.services.studio.generation.video.build_context import VideoGenerationContext
from app.services.studio import get_shot_video_readiness
@@ -50,7 +53,17 @@ async def _seed_shot_graph(db: AsyncSession) -> None:
visual_style=ProjectVisualStyle.live_action,
)
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
prev_shot = Shot(id="s0", chapter_id="c1", index=0, title="镜头零", script_excerpt="角色沿着墙边逼近门口。")
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一", script_excerpt="角色推门而入。")
next_shot = Shot(id="s2", chapter_id="c1", index=2, title="镜头二", script_excerpt="角色停下脚步,盯向走廊尽头。")
prev_detail = ShotDetail(
id="s0",
camera_shot=CameraShotType.ms,
angle=CameraAngle.eye_level,
movement=CameraMovement.dolly_in,
duration=4,
description="主角贴墙缓慢逼近门口,视线紧盯前方。",
)
detail = ShotDetail(
id="s1",
camera_shot=CameraShotType.ms,
@@ -59,11 +72,20 @@ async def _seed_shot_graph(db: AsyncSession) -> None:
duration=6,
follow_atmosphere=True,
vfx_type=VFXType.none,
description="角色推门后微微停顿,确认走廊内部情况,再向前迈出一步。",
first_frame_prompt="首帧提示词",
last_frame_prompt="尾帧提示词",
key_frame_prompt="关键帧提示词",
)
db.add_all([project, chapter, shot, detail])
next_detail = ShotDetail(
id="s2",
camera_shot=CameraShotType.cu,
angle=CameraAngle.eye_level,
movement=CameraMovement.static,
duration=3,
description="角色停住动作,盯向走廊尽头,情绪绷紧。",
)
db.add_all([project, chapter, prev_shot, shot, next_shot, prev_detail, detail, next_detail])
await db.commit()
@@ -114,7 +136,7 @@ async def test_preview_prompt_and_images_uses_auto_frame_ids() -> None:
)
await db.commit()
prompt, images, detail = await preview_prompt_and_images(
prompt, images, pack = await preview_prompt_and_images(
db,
shot_id="s1",
reference_mode="first_last",
@@ -123,8 +145,17 @@ async def test_preview_prompt_and_images_uses_auto_frame_ids() -> None:
assert "镜头标题:镜头一" in prompt
assert "剧本摘录:角色推门而入。" in prompt
assert "动作节拍:" in prompt
assert "上一镜头:" in prompt
assert "下一镜头目标:" in prompt
assert "构图锚点:" in prompt
assert "朝向与视线:" in prompt
assert images == ["f1", "f2"]
assert detail.duration == 6
assert pack is not None
assert pack["camera"]["duration"] == 6
assert pack["action_beats"]
assert "镜头零" in pack["previous_shot_summary"]
assert "镜头二" in pack["next_shot_goal"]
await engine.dispose()
@@ -133,7 +164,7 @@ async def test_preview_prompt_and_images_prefers_request_images_when_provided()
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
prompt, images, detail = await preview_prompt_and_images(
prompt, images, pack = await preview_prompt_and_images(
db,
shot_id="s1",
reference_mode="first_last",
@@ -141,9 +172,12 @@ async def test_preview_prompt_and_images_prefers_request_images_when_provided()
images=["manual-first", "manual-last"],
)
assert prompt == "自定义视频提示词"
assert "自定义视频提示词" in prompt
assert "动作节拍:" in prompt
assert "连续性要求:" in prompt
assert images == ["manual-first", "manual-last"]
assert detail.duration == 6
assert pack is not None
assert pack["camera"]["duration"] == 6
await engine.dispose()
@@ -215,8 +249,109 @@ async def test_build_run_args_uses_prompt_pack_when_prompt_missing(monkeypatch:
)
assert "镜头标题:镜头一" in run_args["input"]["prompt"]
assert "动作节拍:" in run_args["input"]["prompt"]
assert run_args["input"]["ratio"] == "16:9"
assert run_args["prompt_preview"]["shot_id"] == "s1"
assert run_args["prompt_preview"]["pack"]["action_beats"]
assert "镜头零" in run_args["prompt_preview"]["pack"]["previous_shot_summary"]
assert "镜头二" in run_args["prompt_preview"]["pack"]["next_shot_goal"]
assert run_args["prompt_preview"]["pack"]["composition_anchor"]
assert run_args["prompt_preview"]["pack"]["screen_direction_guidance"]
await engine.dispose()
@pytest.mark.asyncio
async def test_template_render_is_enriched_with_guidance_when_template_omits_it(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
async def _fake_template(*_args, **_kwargs):
return type("Template", (), {"id": "tpl-1", "name": "simple", "content": "镜头标题:{{ title }}"})()
monkeypatch.setattr(
"app.services.studio.generation.video.derive_preview._resolve_video_prompt_template",
_fake_template,
)
derived = await derive_video_preview(
db,
base=VideoBaseDraft(shot_id="s1", prompt=""),
context=VideoGenerationContext(
shot_id="s1",
reference_mode="text_only",
images=[],
template_id=None,
),
)
assert "镜头标题:镜头一" in derived.rendered_prompt
assert "动作节拍:" in derived.rendered_prompt
assert "连续性要求:" in derived.rendered_prompt
assert "构图锚点:" in derived.rendered_prompt
assert "朝向与视线:" in derived.rendered_prompt
await engine.dispose()
@pytest.mark.asyncio
async def test_manual_video_prompt_is_also_enriched_with_guidance() -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
derived = await derive_video_preview(
db,
base=VideoBaseDraft(shot_id="s1", prompt="手动视频提示词"),
context=VideoGenerationContext(
shot_id="s1",
reference_mode="text_only",
images=[],
template_id=None,
),
)
assert "手动视频提示词" in derived.rendered_prompt
assert "动作节拍:" in derived.rendered_prompt
assert "连续性要求:" in derived.rendered_prompt
assert "构图锚点:" in derived.rendered_prompt
assert "朝向与视线:" in derived.rendered_prompt
await engine.dispose()
@pytest.mark.asyncio
async def test_template_render_keeps_existing_guidance_without_duplicate_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
async def _fake_template(*_args, **_kwargs):
return type(
"Template",
(),
{
"id": "tpl-2",
"name": "guided",
"content": "镜头标题:{{ title }}\n连续性要求:{{ continuity_guidance }}",
},
)()
monkeypatch.setattr(
"app.services.studio.generation.video.derive_preview._resolve_video_prompt_template",
_fake_template,
)
derived = await derive_video_preview(
db,
base=VideoBaseDraft(shot_id="s1", prompt=""),
context=VideoGenerationContext(
shot_id="s1",
reference_mode="text_only",
images=[],
template_id=None,
),
)
assert derived.rendered_prompt.count("连续性要求:") == 1
await engine.dispose()
+86
View File
@@ -290,6 +290,11 @@ def test_derive_frame_preview_replaces_reference_names_with_stable_order() -> No
shot_id="shot-1",
frame_type=ShotFrameType.first,
prompt="张三在雨夜中逼近李四",
director_command_summary="必须:锁定主角视线方向;优先:保持同场景轴线稳定",
continuity_guidance="当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
frame_specific_guidance="首帧只表现受惊瞬间,人物身体骤然僵住,动作尚未完成",
composition_anchor="以门口灯光和主角站位作为画面重心,保持环境与人物同时可读",
screen_direction_guidance="保持张三与李四的左右站位和对视方向稳定,避免无故翻转朝向",
)
context = build_frame_context(
shot_id="shot-1",
@@ -305,4 +310,85 @@ def test_derive_frame_preview_replaces_reference_names_with_stable_order() -> No
assert preview.images == ["char-1-front", "char-2-front"]
assert preview.mappings[0].token == "图1"
assert preview.mappings[1].token == "图2"
assert preview.selected_guidance == [
"高优先级导演指令:必须:锁定主角视线方向;优先:保持同场景轴线稳定",
"当前帧职责:首帧只表现受惊瞬间,人物身体骤然僵住,动作尚未完成",
"连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
]
assert preview.dropped_guidance == [
"构图锚点:以门口灯光和主角站位作为画面重心,保持环境与人物同时可读",
"朝向与视线:保持张三与李四的左右站位和对视方向稳定,避免无故翻转朝向",
]
assert preview.selected_guidance_details[1].reason_tag == "首帧保时序"
assert preview.selected_guidance_details[1].reason == "当前是首帧,系统优先保留触发瞬间与未完成态约束,避免画面直接跳到后续完成动作。"
assert preview.dropped_guidance_details[0].reason_tag == "首帧降构图"
assert preview.dropped_guidance_details[1].reason_tag == "首帧降轴线"
assert "高优先级导演指令:必须:锁定主角视线方向;优先:保持同场景轴线稳定" in preview.rendered_prompt
assert "当前帧职责:首帧只表现受惊瞬间,人物身体骤然僵住,动作尚未完成" in preview.rendered_prompt
assert "连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局" in preview.rendered_prompt
assert "构图锚点:以门口灯光和主角站位作为画面重心,保持环境与人物同时可读" not in preview.rendered_prompt
assert "朝向与视线:保持张三与李四的左右站位和对视方向稳定,避免无故翻转朝向" not in preview.rendered_prompt
assert "图1在雨夜中逼近图2" in preview.rendered_prompt
def test_derive_frame_preview_prioritizes_composition_for_first_frame() -> None:
base = build_frame_base_draft(
shot_id="shot-2",
frame_type=ShotFrameType.first,
prompt="主角推门进入空旷大厅",
director_command_summary="必须:先建立空间",
continuity_guidance="当前镜头应承接上一镜头的动作与情绪",
frame_specific_guidance="首帧优先表现推门瞬间和人物尚未完全进入大厅的状态",
composition_anchor="以门框、人物站位和大厅纵深作为空间锚点,优先建立环境与人物关系",
screen_direction_guidance="保持人物朝向稳定",
)
context = build_frame_context(
shot_id="shot-2",
frame_type=ShotFrameType.first,
items=[],
)
preview = derive_frame_preview(base=base, context=context)
assert preview.selected_guidance[1] == "当前帧职责:首帧优先表现推门瞬间和人物尚未完全进入大厅的状态"
assert preview.dropped_guidance == [
"构图锚点:以门框、人物站位和大厅纵深作为空间锚点,优先建立环境与人物关系",
"朝向与视线:保持人物朝向稳定",
]
assert preview.selected_guidance_details[1].reason_tag == "首帧保时序"
assert preview.selected_guidance_details[1].reason == "当前是首帧,系统优先保留触发瞬间与未完成态约束,避免画面直接跳到后续完成动作。"
assert "当前帧职责:首帧优先表现推门瞬间和人物尚未完全进入大厅的状态" in preview.rendered_prompt
assert "构图锚点:以门框、人物站位和大厅纵深作为空间锚点,优先建立环境与人物关系" not in preview.rendered_prompt
assert "朝向与视线:保持人物朝向稳定" not in preview.rendered_prompt
def test_derive_frame_preview_prioritizes_screen_guidance_for_key_frame() -> None:
base = build_frame_base_draft(
shot_id="shot-3",
frame_type=ShotFrameType.key,
prompt="两人对峙,情绪到达顶点",
director_command_summary="必须:锁定对峙张力",
continuity_guidance="当前镜头应承接上一镜头的动作与情绪",
frame_specific_guidance="关键帧应锁定对峙动作的峰值瞬间",
composition_anchor="以走廊尽头和人物站位作为空间锚点,保持环境与人物同时可读",
screen_direction_guidance="保持两人的左右站位和对视方向稳定,避免跳轴",
)
context = build_frame_context(
shot_id="shot-3",
frame_type=ShotFrameType.key,
items=[],
)
preview = derive_frame_preview(base=base, context=context)
assert preview.selected_guidance[2] == "朝向与视线:保持两人的左右站位和对视方向稳定,避免跳轴"
assert preview.dropped_guidance == [
"当前帧职责:关键帧应锁定对峙动作的峰值瞬间",
"构图锚点:以走廊尽头和人物站位作为空间锚点,保持环境与人物同时可读",
]
assert preview.selected_guidance_details[2].reason_tag == "关键帧保轴线"
assert preview.selected_guidance_details[2].reason == "当前镜头更看重视线与左右轴线稳定,因此优先保留朝向与视线 guidance。"
assert preview.dropped_guidance_details[0].reason_tag == "关键帧降峰值"
assert "朝向与视线:保持两人的左右站位和对视方向稳定,避免跳轴" in preview.rendered_prompt
assert "构图锚点:以走廊尽头和人物站位作为空间锚点,保持环境与人物同时可读" not in preview.rendered_prompt
assert "当前帧职责:关键帧应锁定对峙动作的峰值瞬间" not in preview.rendered_prompt
@@ -125,6 +125,16 @@ def test_create_shot_frame_image_task_requires_prompt(client: TestClient) -> Non
def test_render_shot_frame_prompt_returns_success_envelope_when_prompt_given(client: TestClient, monkeypatch) -> None:
db = _DummyDB()
async def _fake_load_frame_render_guidance(**_kwargs):
return {
"director_command_summary": "必须:锁定主角视线方向",
"continuity_guidance": "当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
"frame_specific_guidance": "首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作",
"composition_anchor": "以温室门框和人物站位作为空间锚点,保持环境与人物同时可读",
"screen_direction_guidance": "保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
}
monkeypatch.setattr(route, "_load_frame_render_guidance", _fake_load_frame_render_guidance)
app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.post(
@@ -146,11 +156,29 @@ def test_render_shot_frame_prompt_returns_success_envelope_when_prompt_given(cli
assert body["code"] == 200
assert body["message"] == "success"
assert body["data"]["base_prompt"] == "生成一个紧张的首帧画面"
assert body["data"]["selected_guidance"] == [
"高优先级导演指令:必须:锁定主角视线方向",
"当前帧职责:首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作",
"连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
]
assert body["data"]["dropped_guidance"] == [
"构图锚点:以温室门框和人物站位作为空间锚点,保持环境与人物同时可读",
"朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
]
assert body["data"]["selected_guidance_details"][1]["reason_tag"] == "首帧保时序"
assert body["data"]["selected_guidance_details"][1]["reason"] == "当前是首帧,系统优先保留触发瞬间与未完成态约束,避免画面直接跳到后续完成动作。"
assert body["data"]["dropped_guidance_details"][0]["reason_tag"] == "首帧降构图"
assert body["data"]["dropped_guidance_details"][1]["reason_tag"] == "首帧降轴线"
assert body["data"]["images"] == ["file-1", "file-2"]
assert body["data"]["mappings"] == [
{"token": "图1", "type": "character", "id": "char-1", "name": "陆远", "file_id": "file-1"},
{"token": "图2", "type": "scene", "id": "scene-1", "name": "温室", "file_id": "file-2"},
]
assert "高优先级导演指令:必须:锁定主角视线方向" in body["data"]["rendered_prompt"]
assert "当前帧职责:首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作" in body["data"]["rendered_prompt"]
assert "连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局" in body["data"]["rendered_prompt"]
assert "构图锚点:以温室门框和人物站位作为空间锚点,保持环境与人物同时可读" not in body["data"]["rendered_prompt"]
assert "朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向" not in body["data"]["rendered_prompt"]
assert "图1: 陆远" in body["data"]["rendered_prompt"]
assert "图2: 温室" in body["data"]["rendered_prompt"]
assert "生成一个紧张的首帧画面" in body["data"]["rendered_prompt"]
@@ -186,8 +214,22 @@ def test_create_shot_frame_image_task_renders_prompt_before_submit(client: TestC
async def _fake_resolve_image_refs(*_args, **_kwargs):
return [{"image_url": "data:image/png;base64,abc"}]
async def _fake_load_frame_render_guidance(**_kwargs):
return {
"director_command_summary": "必须:锁定主角视线方向",
"continuity_guidance": "当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局",
"frame_specific_guidance": "首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作",
"composition_anchor": "以温室门框和人物站位作为空间锚点,保持环境与人物同时可读",
"screen_direction_guidance": "保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
}
async def _fake_create_image_task_and_link(*_args, **kwargs):
assert kwargs["prompt"].startswith("## 图片内容说明")
assert "高优先级导演指令:必须:锁定主角视线方向" in kwargs["prompt"]
assert "当前帧职责:首帧只表现惊响出现后的最初僵直反应,人物尚未完成捂耳和下蹲动作" in kwargs["prompt"]
assert "连续性要求:当前镜头应承接上一镜头的动作与情绪,不要像全新场面重新开局" in kwargs["prompt"]
assert "构图锚点:以温室门框和人物站位作为空间锚点,保持环境与人物同时可读" not in kwargs["prompt"]
assert "朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向" not in kwargs["prompt"]
assert "图1: 陆远" in kwargs["prompt"]
assert kwargs["images"] == [{"image_url": "data:image/png;base64,abc"}]
assert kwargs["target_ratio"] == "9:16"
@@ -195,9 +237,14 @@ def test_create_shot_frame_image_task_renders_prompt_before_submit(client: TestC
assert kwargs["purpose"] == "video_reference"
assert kwargs["render_context"]["images"] == ["file-1"]
assert kwargs["render_context"]["mappings"][0]["token"] == "图1"
assert kwargs["render_context"]["selected_guidance"][0] == "高优先级导演指令:必须:锁定主角视线方向"
assert kwargs["render_context"]["selected_guidance_details"][1]["reason_tag"] == "首帧保时序"
assert kwargs["render_context"]["dropped_guidance_details"][0]["reason_tag"] == "首帧降构图"
assert kwargs["render_context"]["dropped_guidance_details"][1]["reason_tag"] == "首帧降轴线"
return "task-1"
monkeypatch.setattr(route, "_resolve_reference_image_refs_by_file_ids_service", _fake_resolve_image_refs)
monkeypatch.setattr(route, "_load_frame_render_guidance", _fake_load_frame_render_guidance)
monkeypatch.setattr(route, "_create_image_task_and_link_service", _fake_create_image_task_and_link)
app.dependency_overrides[get_db] = _override_db(db)
try:
@@ -298,6 +345,24 @@ def test_run_image_generation_task_persists_render_context(monkeypatch) -> None:
"render_context": {
"images": ["file-1"],
"mappings": [{"token": "图1", "name": "陆远", "file_id": "file-1"}],
"selected_guidance": ["高优先级导演指令:必须:锁定主角视线方向"],
"dropped_guidance": ["朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向"],
"selected_guidance_details": [
{
"text": "高优先级导演指令:必须:锁定主角视线方向",
"category": "summary",
"reason_tag": "导演主指令",
"reason": "导演主指令始终属于最高优先级约束,因此会优先保留。",
}
],
"dropped_guidance_details": [
{
"text": "朝向与视线:保持陆远与环境入口的视线方向稳定,避免无故翻转朝向",
"category": "screen",
"reason_tag": "首帧降轴线",
"reason": "当前是首帧,系统更优先保空间建立与站位关系,因此将朝向与视线 guidance 降为次级。",
}
],
},
"input": {"prompt": "## 图片内容说明\n图1: 陆远", "model": "gpt-image-1"},
},
@@ -305,3 +370,5 @@ def test_run_image_generation_task_persists_render_context(monkeypatch) -> None:
)
assert calls["result_payload"]["render_context"]["images"] == ["file-1"]
assert calls["result_payload"]["render_context"]["selected_guidance_details"][0]["reason_tag"] == "导演主指令"
assert calls["result_payload"]["render_context"]["dropped_guidance_details"][0]["reason_tag"] == "首帧降轴线"
@@ -54,6 +54,7 @@ async def test_extract_script_uses_cache_by_default(monkeypatch):
monkeypatch.setattr(route, "ElementExtractorAgent", _FakeAgent)
monkeypatch.setattr(route, "sync_shot_extracted_candidates_from_draft", _async_noop)
monkeypatch.setattr(route, "sync_shot_extracted_dialogue_candidates_from_draft", _async_noop)
monkeypatch.setattr(route, "apply_shot_semantic_defaults_from_draft", _async_noop)
request = route.ScriptExtractRequest(
project_id="project-1",
@@ -90,6 +91,7 @@ async def test_extract_script_refresh_cache_forces_recompute(monkeypatch):
monkeypatch.setattr(route, "ElementExtractorAgent", _FakeAgent)
monkeypatch.setattr(route, "sync_shot_extracted_candidates_from_draft", _async_noop)
monkeypatch.setattr(route, "sync_shot_extracted_dialogue_candidates_from_draft", _async_noop)
monkeypatch.setattr(route, "apply_shot_semantic_defaults_from_draft", _async_noop)
request = route.ScriptExtractRequest(
project_id="project-1",
@@ -11,6 +11,7 @@ from app.dependencies import get_db
from app.main import app
from app.models.studio import ShotCandidateStatus, ShotCandidateType, ShotDialogueCandidateStatus, ShotStatus
from app.schemas.studio.shots import (
ActionBeatPhaseRead,
ShotAssetsOverviewRead,
ShotAssetsOverviewSummary,
ShotExtractionSummaryRead,
@@ -69,6 +70,15 @@ def _fake_preparation_state() -> ShotPreparationStateRead:
dialogue_candidates=[],
saved_dialogue_lines=[],
pending_confirm_count=0,
basic_info_ready=True,
semantic_defaults_ready=True,
action_beats_ready=True,
action_beats_count=3,
action_beat_phases=[
ActionBeatPhaseRead(text="听到异响骤然僵住", phase="trigger"),
ActionBeatPhaseRead(text="修枝剪脱手下坠", phase="peak"),
ActionBeatPhaseRead(text="蹲下后呼吸急促", phase="aftermath"),
],
ready_for_generation=True,
)
@@ -143,6 +153,9 @@ def test_update_shot_skip_extraction_returns_success_envelope(client: TestClient
assert body["code"] == 200
assert body["data"]["action"] == "skip_extraction"
assert body["data"]["state"]["shot"]["status"] == "ready"
assert body["data"]["state"]["action_beats_ready"] is True
assert body["data"]["state"]["action_beats_count"] == 3
assert body["data"]["state"]["action_beat_phases"][0]["phase"] == "trigger"
assert body["data"]["state"]["ready_for_generation"] is True
@@ -33,6 +33,7 @@ from app.services.film.shot_frame_prompt_tasks import (
normalize_frame_type,
relation_type_for_frame,
)
from app.services.studio.action_beats import infer_action_beat_sequence, pick_action_beat_for_frame
async def _build_session() -> tuple[AsyncSession, object]:
@@ -53,7 +54,9 @@ async def _seed_shot_graph(db: AsyncSession) -> None:
unify_style=True,
)
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
prev_shot = Shot(id="s0", chapter_id="c1", index=0, title="镜头零", script_excerpt="角色沿着墙边逼近门口。")
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一", script_excerpt="角色推门而入。")
next_shot = Shot(id="s2", chapter_id="c1", index=2, title="镜头二", script_excerpt="角色停下脚步,盯向走廊尽头。")
actor = Actor(
id="actor-1",
name="演员甲",
@@ -106,6 +109,24 @@ async def _seed_shot_graph(db: AsyncSession) -> None:
vfx_note="无",
description="狭长走廊里,主角谨慎前行并回头确认身后动静。",
)
prev_detail = ShotDetail(
id="s0",
camera_shot=CameraShotType.ms,
angle=CameraAngle.eye_level,
movement=CameraMovement.dolly_in,
scene_id="scene-1",
duration=4,
description="主角贴墙缓慢逼近门口,视线紧盯前方。",
)
next_detail = ShotDetail(
id="s2",
camera_shot=CameraShotType.cu,
angle=CameraAngle.eye_level,
movement=CameraMovement.static,
scene_id="scene-1",
duration=3,
description="主角停住动作,神情绷紧,视线落向走廊尽头。",
)
line = ShotDialogLine(
shot_detail_id="s1",
index=0,
@@ -121,13 +142,17 @@ async def _seed_shot_graph(db: AsyncSession) -> None:
[
project,
chapter,
prev_shot,
shot,
next_shot,
actor,
costume,
character,
scene,
prop,
prev_detail,
detail,
next_detail,
line,
shot_character_link,
scene_link,
@@ -138,6 +163,46 @@ async def _seed_shot_graph(db: AsyncSession) -> None:
await db.commit()
async def _seed_fear_reaction_shot_graph(db: AsyncSession) -> None:
project = Project(
id="fear-p1",
name="惊恐项目",
description="",
style=ProjectStyle.real_people_city,
visual_style=ProjectVisualStyle.live_action,
unify_style=True,
)
chapter = Chapter(id="fear-c1", project_id="fear-p1", index=1, title="温室")
shot = Shot(
id="fear-s1",
chapter_id="fear-c1",
index=1,
title="惊恐镜头",
script_excerpt="“咔嚓。”剪刀咬合的声音在安静的温室里显得格外刺耳。陆远浑身一僵,手里的修枝剪脱手掉在地上。他下意识地捂住耳朵,蹲下身,呼吸变得急促。",
)
detail = ShotDetail(
id="fear-s1",
camera_shot=CameraShotType.ms,
angle=CameraAngle.eye_level,
movement=CameraMovement.static,
duration=5,
action_beats=[
"听到剪刀咬合声,陆远骤然僵住",
"修枝剪脱手下坠",
"陆远抬手捂耳,身体下沉",
"蹲下后呼吸急促",
],
description="陆远在温室里修剪枝叶,忽然被刺耳剪刀声惊到,身体僵住,手中的修枝剪脱手下坠。",
atmosphere="压抑",
mood_tags=["惊恐"],
follow_atmosphere=True,
vfx_type=VFXType.none,
vfx_note="无",
)
db.add_all([project, chapter, shot, detail])
await db.commit()
def test_normalize_frame_type_and_relation_type() -> None:
assert normalize_frame_type(" First ") == "first"
assert relation_type_for_frame("first") == "shot_first_frame_prompt"
@@ -150,6 +215,25 @@ def test_normalize_frame_type_and_relation_type() -> None:
assert exc_info.value.status_code == 400
def test_action_beat_phase_inference_prefers_trigger_peak_and_aftermath() -> None:
beats = [
"听到剪刀咬合声,陆远骤然僵住",
"修枝剪脱手下坠",
"陆远抬手捂耳,身体下沉",
"蹲下后呼吸急促",
]
sequence = infer_action_beat_sequence(beats)
first_item = pick_action_beat_for_frame("first", beats)
key_item = pick_action_beat_for_frame("key", beats)
last_item = pick_action_beat_for_frame("last", beats)
assert [item.phase for item in sequence] == ["trigger", "peak", "peak", "aftermath"]
assert first_item is not None and first_item.text == "听到剪刀咬合声,陆远骤然僵住"
assert key_item is not None and key_item.text == "陆远抬手捂耳,身体下沉"
assert last_item is not None and last_item.text == "蹲下后呼吸急促"
@pytest.mark.asyncio
async def test_build_run_args_aggregates_dialog_and_project_style() -> None:
db, engine = await _build_session()
@@ -179,6 +263,46 @@ async def test_build_run_args_aggregates_dialog_and_project_style() -> None:
assert "优先以角色 主角 作为画面主体" in run_args["input"]["subject_priority"]
assert "优先建立场景 废弃走廊 的环境信息" in run_args["input"]["subject_priority"]
assert "道具 手电筒 仅在进入主动作或构图焦点时重点写入" in run_args["input"]["subject_priority"]
assert run_args["input"]["previous_shot_title"] == "镜头零"
assert "角色沿着墙边逼近门口" in run_args["input"]["previous_shot_script_excerpt"]
assert "场景:废弃走廊" in run_args["input"]["previous_shot_end_state"]
assert run_args["input"]["next_shot_title"] == "镜头二"
assert "角色停下脚步" in run_args["input"]["next_shot_script_excerpt"]
assert "画面状态:主角停住动作" in run_args["input"]["next_shot_start_goal"]
assert "承接上一镜头的动作与情绪" in run_args["input"]["continuity_guidance"]
assert "为下一镜头预留动作或情绪落点" in run_args["input"]["continuity_guidance"]
assert "保持人物与环境同时可读" in run_args["input"]["composition_anchor"]
assert "以场景 废弃走廊 作为空间锚点" in run_args["input"]["composition_anchor"]
assert "锁定角色 主角 的朝向和视线" in run_args["input"]["composition_anchor"]
assert "优先保持人物视线水平和对视方向稳定" in run_args["input"]["screen_direction_guidance"]
assert "存在对白时,优先保证说话者与受话者的视线关系连续" in run_args["input"]["screen_direction_guidance"]
assert "角色 主角 的朝向与视线落点应在相邻镜头中保持延续" in run_args["input"]["screen_direction_guidance"]
assert "首帧应优先建立空间、主体初始站位和第一眼视觉印象" in run_args["input"]["frame_specific_guidance"]
assert "首帧只表现事件触发瞬间或最初反应的起始状态" in run_args["input"]["frame_specific_guidance"]
assert "若剧本存在连续反应链,优先写成动作刚开始、尚未完成或被打断的状态" in run_args["input"]["frame_specific_guidance"]
assert "首帧要承接上一镜头结束状态" in run_args["input"]["frame_specific_guidance"]
assert "必须:若剧本存在连续反应链,优先写成动作刚开始、尚未完成或被打断的状态,例如手刚松脱、身体骤然僵住、人物尚未完全蹲下" in run_args["input"]["director_command_summary"]
assert "必须:与上一镜头同场景时,不要无故翻转人物面向和左右轴线" in run_args["input"]["director_command_summary"]
assert "必须:下一镜头与当前镜头处于同一场景,尽量保持视觉重心与空间关系可连续延展" in run_args["input"]["director_command_summary"]
assert "必须:以场景 废弃走廊 作为空间锚点,保证主体与环境关系清晰" in run_args["input"]["director_command_summary"]
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_provides_different_guidance_for_key_and_last_frames() -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
key_args = await build_run_args(db, shot_id="s1", frame_type="key")
last_args = await build_run_args(db, shot_id="s1", frame_type="last")
assert "关键帧应锁定镜头内最有戏剧张力或信息密度最高的瞬间" in key_args["input"]["frame_specific_guidance"]
assert "尾帧应强调动作收束、情绪余韵或视线停留点" in last_args["input"]["frame_specific_guidance"]
assert key_args["input"]["frame_specific_guidance"] != last_args["input"]["frame_specific_guidance"]
assert "必须:关键帧应锁定镜头内最有戏剧张力或信息密度最高的瞬间,不要平均描述整个过程" in key_args["input"]["director_command_summary"]
assert "必须:尾帧应强调动作收束、情绪余韵或视线停留点,不要重新铺开新的动作起点" in last_args["input"]["director_command_summary"]
assert "必须:与上一镜头同场景时,不要无故翻转人物面向和左右轴线" in last_args["input"]["director_command_summary"]
await engine.dispose()
@@ -204,3 +328,19 @@ async def test_build_run_args_requires_shot_detail() -> None:
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "ShotDetail not found"
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_strengthens_first_frame_for_sequential_reaction_chain() -> None:
db, engine = await _build_session()
async with db:
await _seed_fear_reaction_shot_graph(db)
run_args = await build_run_args(db, shot_id="fear-s1", frame_type="first")
assert "当前镜头存在明显连续反应链" in run_args["input"]["frame_specific_guidance"]
assert "禁止直接落到捂耳、蹲下、倒地或转身完成态" in run_args["input"]["frame_specific_guidance"]
assert "当前帧优先围绕动作拍点“听到剪刀咬合声,陆远骤然僵住”组织画面(触发阶段)" in run_args["input"]["frame_specific_guidance"]
assert "必须:若剧本存在连续反应链,优先写成动作刚开始、尚未完成或被打断的状态,例如手刚松脱、身体骤然僵住、人物尚未完全蹲下" in run_args["input"]["director_command_summary"]
assert "当前镜头存在明显连续反应链,首帧必须截取触发后最早的可见瞬间,禁止直接落到捂耳、蹲下、倒地或转身完成态" in run_args["input"]["director_command_summary"]
await engine.dispose()
@@ -0,0 +1,181 @@
from __future__ import annotations
import pytest
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.db import Base
from app.models.studio import (
CameraAngle,
CameraMovement,
CameraShotType,
Chapter,
Project,
ProjectStyle,
ProjectVisualStyle,
Shot,
ShotDetail,
)
from app.schemas.skills.script_processing import ShotSemanticSuggestion, StudioScriptExtractionDraft, StudioShotDraft
from app.services.script_processing_worker import apply_extraction_result
from app.services.studio.shot_extraction_draft import build_script_extraction_draft_for_shot
from app.services.studio.shot_semantic_defaults import apply_shot_semantic_defaults_from_draft
async def _seed_async_graph(db: AsyncSession) -> str:
"""构造最小项目/章节/镜头图,便于验证镜头语言默认值写回。"""
project = Project(
id="project-1",
name="项目一",
style=ProjectStyle.guoman,
visual_style=ProjectVisualStyle.anime,
)
chapter = Chapter(id="chapter-1", project_id=project.id, index=1, title="章节一")
shot = Shot(id="shot-1", chapter_id=chapter.id, index=1, title="镜头一", script_excerpt="原始摘录")
detail = ShotDetail(
id=shot.id,
camera_shot=CameraShotType.ms,
angle=CameraAngle.eye_level,
movement=CameraMovement.static,
duration=4,
)
db.add_all([project, chapter, shot, detail])
await db.flush()
return shot.id
def _seed_sync_graph(db: Session) -> str:
"""同步版本的最小项目/章节/镜头图。"""
project = Project(
id="project-1",
name="项目一",
style=ProjectStyle.guoman,
visual_style=ProjectVisualStyle.anime,
)
chapter = Chapter(id="chapter-1", project_id=project.id, index=1, title="章节一")
shot = Shot(id="shot-1", chapter_id=chapter.id, index=1, title="镜头一", script_excerpt="原始摘录")
detail = ShotDetail(
id=shot.id,
camera_shot=CameraShotType.ms,
angle=CameraAngle.eye_level,
movement=CameraMovement.static,
duration=4,
)
db.add_all([project, chapter, shot, detail])
db.flush()
return shot.id
def _build_semantic_draft() -> StudioScriptExtractionDraft:
"""构造带镜头语言默认建议的提取草稿。"""
return StudioScriptExtractionDraft(
project_id="project-1",
chapter_id="chapter-1",
script_text="测试文本",
characters=[],
scenes=[],
props=[],
costumes=[],
shots=[
StudioShotDraft(
index=1,
title="镜头一",
script_excerpt="摘录",
scene_name=None,
character_names=[],
prop_names=[],
costume_names=[],
dialogue_lines=[],
actions=["听到声音后僵住"],
semantic_suggestion=ShotSemanticSuggestion(
camera_shot=CameraShotType.cu,
angle=CameraAngle.low_angle,
movement=CameraMovement.track,
duration=6,
action_beats=["听到异响骤然僵住", "修枝剪脱手下坠"],
),
)
],
)
@pytest.mark.asyncio
async def test_apply_shot_semantic_defaults_from_draft_updates_shot_detail() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
import app.models.studio # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_local() as db:
shot_id = await _seed_async_graph(db)
draft = _build_semantic_draft()
await apply_shot_semantic_defaults_from_draft(db, chapter_id="chapter-1", draft=draft)
detail = await db.get(ShotDetail, shot_id)
assert detail is not None
assert detail.camera_shot == CameraShotType.cu
assert detail.angle == CameraAngle.low_angle
assert detail.movement == CameraMovement.track
assert detail.duration == 6
assert detail.action_beats == ["听到异响骤然僵住", "修枝剪脱手下坠"]
await engine.dispose()
@pytest.mark.asyncio
async def test_build_script_extraction_draft_for_shot_includes_semantic_suggestion() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
import app.models.studio # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_local() as db:
shot_id = await _seed_async_graph(db)
draft = await build_script_extraction_draft_for_shot(db, shot_id)
assert len(draft.shots) == 1
semantic = draft.shots[0].semantic_suggestion
assert semantic is not None
assert semantic.camera_shot == CameraShotType.ms
assert semantic.angle == CameraAngle.eye_level
assert semantic.movement == CameraMovement.static
assert semantic.duration == 4
assert semantic.action_beats == []
await engine.dispose()
def test_apply_extraction_result_sync_updates_shot_detail() -> None:
engine = create_engine("sqlite:///:memory:", future=True)
session_local = sessionmaker(engine, class_=Session, expire_on_commit=False)
import app.models.studio # noqa: F401
Base.metadata.create_all(engine)
with session_local() as db:
shot_id = _seed_sync_graph(db)
draft = _build_semantic_draft()
apply_extraction_result(db, chapter_id="chapter-1", draft=draft)
detail = db.get(ShotDetail, shot_id)
assert detail is not None
assert detail.camera_shot == CameraShotType.cu
assert detail.angle == CameraAngle.low_angle
assert detail.movement == CameraMovement.track
assert detail.duration == 6
assert detail.action_beats == ["听到异响骤然僵住", "修枝剪脱手下坠"]
engine.dispose()
+1 -1
View File
@@ -1 +1 @@
{"openapi":"3.1.0","info":{"title":"Jellyfish API","version":"0.1.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"V1 Health","operationId":"v1_health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}}}}},"/api/v1/film/tasks/video/preview-prompt":{"post":{"tags":["film"],"summary":"视频提示词预览","description":"预览视频生成的提示词与自动关联参考图。","operationId":"preview_video_generation_prompt_api_v1_film_tasks_video_preview_prompt_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VideoPromptPreviewResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/video":{"post":{"tags":["film"],"summary":"视频生成(任务版)","description":"创建视频生成任务并后台执行,结果通过 /tasks/{task_id}/result 获取。","operationId":"create_video_generation_task_api_v1_film_tasks_video_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/shot-frame-prompts":{"post":{"tags":["film"],"summary":"镜头分镜帧提示词生成(任务版)","operationId":"create_shot_frame_prompt_task_api_v1_film_tasks_shot_frame_prompts_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFramePromptRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks":{"get":{"tags":["film"],"summary":"全局任务列表(任务中心)","operationId":"list_tasks_api_v1_film_tasks_get","parameters":[{"name":"statuses","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/TaskStatus"}},{"type":"null"}],"description":"按任务状态过滤,可多选","title":"Statuses"},"description":"按任务状态过滤,可多选"},{"name":"task_kind","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 task_kind 过滤","title":"Task Kind"},"description":"按 task_kind 过滤"},{"name":"relation_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_type 过滤","title":"Relation Type"},"description":"按 relation_type 过滤"},{"name":"relation_entity_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_entity_id 过滤","title":"Relation Entity Id"},"description":"按 relation_entity_id 过滤"},{"name":"recent_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"description":"默认返回最近结束任务的时间窗口(秒)","default":300,"title":"Recent Seconds"},"description":"默认返回最近结束任务的时间窗口(秒)"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":20,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_TaskListItemRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/status":{"get":{"tags":["film"],"summary":"查询任务状态/进度(轮询)","operationId":"get_task_status_api_v1_film_tasks__task_id__status_get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskStatusRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidatioLine truncated
{"openapi":"3.1.0","info":{"title":"Jellyfish API","version":"0.1.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"V1 Health","operationId":"v1_health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}}}}},"/api/v1/film/tasks/video/preview-prompt":{"post":{"tags":["film"],"summary":"视频提示词预览","description":"预览视频生成的提示词与自动关联参考图。","operationId":"preview_video_generation_prompt_api_v1_film_tasks_video_preview_prompt_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VideoPromptPreviewResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/video":{"post":{"tags":["film"],"summary":"视频生成(任务版)","description":"创建视频生成任务并后台执行,结果通过 /tasks/{task_id}/result 获取。","operationId":"create_video_generation_task_api_v1_film_tasks_video_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/shot-frame-prompts":{"post":{"tags":["film"],"summary":"镜头分镜帧提示词生成(任务版)","operationId":"create_shot_frame_prompt_task_api_v1_film_tasks_shot_frame_prompts_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFramePromptRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks":{"get":{"tags":["film"],"summary":"全局任务列表(任务中心)","operationId":"list_tasks_api_v1_film_tasks_get","parameters":[{"name":"statuses","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/TaskStatus"}},{"type":"null"}],"description":"按任务状态过滤,可多选","title":"Statuses"},"description":"按任务状态过滤,可多选"},{"name":"task_kind","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 task_kind 过滤","title":"Task Kind"},"description":"按 task_kind 过滤"},{"name":"relation_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_type 过滤","title":"Relation Type"},"description":"按 relation_type 过滤"},{"name":"relation_entity_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_entity_id 过滤","title":"Relation Entity Id"},"description":"按 relation_entity_id 过滤"},{"name":"recent_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"description":"默认返回最近结束任务的时间窗口(秒)","default":300,"title":"Recent Seconds"},"description":"默认返回最近结束任务的时间窗口(秒)"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":20,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_TaskListItemRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/status":{"get":{"tags":["film"],"summary":"查询任务状态/进度(轮询)","operationId":"get_task_status_api_v1_film_tasks__task_id__status_get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskStatusRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidatioLine truncated
@@ -97,6 +97,7 @@ import type {
ShotRuntimeSummaryRead,
ProjectSceneLinkRead,
ShotStatus,
ShotVideoPromptPackRead,
} from '../../../services/generated'
import { listTaskLinksNormalized } from '../../../services/filmTaskLinks'
import { buildFileDownloadUrl, resolveAssetUrl } from '../assets/utils'
@@ -129,6 +130,10 @@ type ShotFramePromptQualityChecks = {
type FramePromptDerived = {
basePrompt: string
renderedPrompt: string
selectedGuidance: string[]
droppedGuidance: string[]
selectedGuidanceDetails: Array<{ text: string; category: string; reasonTag: string; reason: string }>
droppedGuidanceDetails: Array<{ text: string; category: string; reasonTag: string; reason: string }>
images: string[]
mappings: ShotFramePromptMappingRead[]
}
@@ -136,6 +141,7 @@ type VideoReferenceMode = 'first' | 'last' | 'key' | 'first_last' | 'first_last_
type VideoPromptDerived = {
prompt: string
images: string[]
pack: ShotVideoPromptPackRead | null
}
function normalizeFrameExclusiveTags(tags: string[]): string[] {
@@ -159,6 +165,77 @@ function readDebugContextText(
return typeof value === 'string' ? value.trim() : ''
}
function buildKeyframeGuidanceSummary(items: string[]): string[] {
const result: string[] = []
const seen = new Set<string>()
items
.flatMap((item) => item.split(';'))
.map((item) => item.trim())
.filter(Boolean)
.forEach((item) => {
if (seen.has(item)) return
seen.add(item)
result.push(item)
})
return result
}
function stripDirectiveLevelPrefix(item: string): string {
const text = String(item || '').trim()
if (text.startsWith('必须:')) return text.slice(3).trim()
if (text.startsWith('优先:')) return text.slice(3).trim()
return text
}
function parseDirectorCommandSummary(summary: string): Array<{
level: 'must' | 'prefer'
text: string
}> {
return String(summary || '')
.split(';')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
if (item.startsWith('必须:')) {
return { level: 'must' as const, text: item.slice(3).trim() }
}
if (item.startsWith('优先:')) {
return { level: 'prefer' as const, text: item.slice(3).trim() }
}
return { level: 'prefer' as const, text: item }
})
}
function buildGuidanceLevelSummary(
parsedDirectorCommands: Array<{ level: 'must' | 'prefer'; text: string }>,
guidanceSummary: string[],
): { must: number; prefer: number; normal: number } {
const must = parsedDirectorCommands.filter((item) => item.level === 'must').length
const prefer = parsedDirectorCommands.filter((item) => item.level === 'prefer').length
const parsedTexts = new Set(parsedDirectorCommands.map((item) => item.text.trim()).filter(Boolean))
const normal = guidanceSummary
.map((item) => stripDirectiveLevelPrefix(item))
.filter((item) => item && !parsedTexts.has(item)).length
return { must, prefer, normal }
}
function buildActionBeatPhaseTags(summary: string): Array<{ text: string; phaseLabel: string }> {
return String(summary || '')
.split(';')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const matched = item.match(/^\d+\.\s*(触发|峰值|收束)\s*·\s*(.+)$/)
if (!matched) {
return { phaseLabel: '阶段', text: item }
}
return {
phaseLabel: matched[1],
text: matched[2].trim(),
}
})
}
type InspectorMode = 'push' | 'overlay'
type ShotFilter = 'all' | 'pendingConfirm' | 'generating' | 'ready' | 'hidden' | 'problem'
@@ -2951,10 +3028,13 @@ function Inspector(props: {
const [keyframePromptPreviewFrameType, setKeyframePromptPreviewFrameType] = useState<PromptFrameType>('key')
const [keyframePromptDebugContext, setKeyframePromptDebugContext] = useState<ShotFramePromptDebugContext | null>(null)
const [keyframePromptDebugCollapsed, setKeyframePromptDebugCollapsed] = useState(true)
const [keyframeDirectiveCollapsed, setKeyframeDirectiveCollapsed] = useState(true)
const [keyframePromptDecisionCollapsed, setKeyframePromptDecisionCollapsed] = useState(true)
const [keyframePromptQualityChecks, setKeyframePromptQualityChecks] = useState<ShotFramePromptQualityChecks>(null)
const [videoPromptPreviewOpen, setVideoPromptPreviewOpen] = useState(false)
const [videoPromptPreviewLoading, setVideoPromptPreviewLoading] = useState(false)
const [videoPromptPreviewSubmitting, setVideoPromptPreviewSubmitting] = useState(false)
const [videoPromptContextCollapsed, setVideoPromptContextCollapsed] = useState(true)
const resolveVideoRatioForRequest = useCallback(() => {
const shotRatio = String(shotDetail?.override_video_ratio ?? '').trim()
const projectRatio = String(projectDefaultVideoRatio ?? '').trim()
@@ -2992,10 +3072,11 @@ function Inspector(props: {
ratio,
} as any,
})
const data = res.data as any
const data = (res as any)?.data ?? null
return {
prompt: typeof data?.prompt === 'string' ? data.prompt : '',
images: Array.isArray(data?.images) ? (data.images as string[]).filter(Boolean) : [],
pack: data?.pack ?? null,
}
},
submit: async ({ derived, context }) => {
@@ -3023,6 +3104,21 @@ function Inspector(props: {
const videoPromptPreviewDraft = videoPromptDraft.base.prompt
const videoPromptPreviewImages = videoPromptDraft.context.images
const videoReferenceMode = videoPromptDraft.context.referenceMode
const videoPromptPreviewPack = videoPromptDraft.derived?.pack ?? null
const videoActionBeatPhases = videoPromptPreviewPack?.action_beat_phases ?? []
const videoActionBeats = videoActionBeatPhases.length > 0
? videoActionBeatPhases.map((item) => ({
text: item.text,
phase: item.phase,
}))
: (videoPromptPreviewPack?.action_beats ?? []).map((text) => ({
text,
phase: null,
}))
const videoVisibleActionBeats = videoPromptContextCollapsed
? videoActionBeats.slice(0, 2)
: videoActionBeats
const hiddenVideoActionBeatCount = Math.max(0, videoActionBeats.length - videoVisibleActionBeats.length)
const [videoTaskPolling, setVideoTaskPolling] = useState(false)
const [videoTaskStatus, setVideoTaskStatus] = useState<string | null>(null)
const [videoTaskId, setVideoTaskId] = useState<string | null>(null)
@@ -3639,6 +3735,10 @@ function Inspector(props: {
return {
basePrompt: '',
renderedPrompt: '',
selectedGuidance: [],
droppedGuidance: [],
selectedGuidanceDetails: [],
droppedGuidanceDetails: [],
images: [],
mappings: [],
}
@@ -3672,6 +3772,32 @@ function Inspector(props: {
return {
basePrompt: typeof d?.base_prompt === 'string' ? d.base_prompt : basePrompt,
renderedPrompt: typeof d?.rendered_prompt === 'string' ? d.rendered_prompt : '',
selectedGuidance: Array.isArray(d?.selected_guidance)
? d.selected_guidance.map((item: unknown) => String(item ?? '').trim()).filter(Boolean)
: [],
droppedGuidance: Array.isArray(d?.dropped_guidance)
? d.dropped_guidance.map((item: unknown) => String(item ?? '').trim()).filter(Boolean)
: [],
selectedGuidanceDetails: Array.isArray(d?.selected_guidance_details)
? d.selected_guidance_details
.map((item: any) => ({
text: String(item?.text ?? '').trim(),
category: String(item?.category ?? '').trim(),
reasonTag: String(item?.reason_tag ?? '').trim(),
reason: String(item?.reason ?? '').trim(),
}))
.filter((item: { text: string }) => item.text)
: [],
droppedGuidanceDetails: Array.isArray(d?.dropped_guidance_details)
? d.dropped_guidance_details
.map((item: any) => ({
text: String(item?.text ?? '').trim(),
category: String(item?.category ?? '').trim(),
reasonTag: String(item?.reason_tag ?? '').trim(),
reason: String(item?.reason ?? '').trim(),
}))
.filter((item: { text: string }) => item.text)
: [],
images: Array.isArray(d?.images) ? (d.images as string[]).filter(Boolean) : [],
mappings: Array.isArray(d?.mappings) ? (d.mappings as ShotFramePromptMappingRead[]) : [],
}
@@ -3734,6 +3860,16 @@ function Inspector(props: {
})
const keyframePromptPreviewDraft = keyframePromptDraft.base.prompt
const keyframePromptRenderedDraft = keyframePromptDraft.derived?.renderedPrompt ?? ''
const keyframePromptSelectedGuidance = keyframePromptDraft.derived?.selectedGuidance ?? []
const keyframePromptDroppedGuidance = keyframePromptDraft.derived?.droppedGuidance ?? []
const keyframePromptSelectedGuidanceDetails = keyframePromptDraft.derived?.selectedGuidanceDetails ?? []
const keyframePromptDroppedGuidanceDetails = keyframePromptDraft.derived?.droppedGuidanceDetails ?? []
const keyframePromptVisibleSelectedGuidanceDetails = keyframePromptDecisionCollapsed
? keyframePromptSelectedGuidanceDetails.slice(0, 2)
: keyframePromptSelectedGuidanceDetails
const keyframePromptVisibleDroppedGuidanceDetails = keyframePromptDecisionCollapsed
? keyframePromptDroppedGuidanceDetails.slice(0, 1)
: keyframePromptDroppedGuidanceDetails
const keyframePromptRenderMappings = keyframePromptDraft.derived?.mappings ?? []
const keyframePromptPreviewRefFileIds = keyframePromptDraft.context.refFileIds
const keyframePromptRenderState = keyframePromptDraft.state
@@ -4120,6 +4256,7 @@ function Inspector(props: {
base: { prompt: '' },
context: nextContext,
})
setVideoPromptContextCollapsed(true)
setVideoPromptPreviewOpen(true)
setVideoPromptPreviewLoading(true)
try {
@@ -4291,6 +4428,8 @@ function Inspector(props: {
setKeyframePromptPreviewOpen(true)
setKeyframePromptPreviewFrameType(frameType)
setKeyframePromptDebugCollapsed(true)
setKeyframeDirectiveCollapsed(true)
setKeyframePromptDecisionCollapsed(true)
const basePrompt = getPromptFromDetailByType(frameType)
keyframePromptDraft.hydrate({
base: { frameType, prompt: basePrompt },
@@ -5496,6 +5635,30 @@ function Inspector(props: {
const debugCostumeContext = readDebugContextText(keyframePromptDebugContext, 'costume_context')
const debugShotDescription = readDebugContextText(keyframePromptDebugContext, 'shot_description')
const debugDialogSummary = readDebugContextText(keyframePromptDebugContext, 'dialog_summary')
const debugPreviousShotTitle = readDebugContextText(keyframePromptDebugContext, 'previous_shot_title')
const debugPreviousShotScriptExcerpt = readDebugContextText(keyframePromptDebugContext, 'previous_shot_script_excerpt')
const debugPreviousShotEndState = readDebugContextText(keyframePromptDebugContext, 'previous_shot_end_state')
const debugNextShotTitle = readDebugContextText(keyframePromptDebugContext, 'next_shot_title')
const debugNextShotScriptExcerpt = readDebugContextText(keyframePromptDebugContext, 'next_shot_script_excerpt')
const debugNextShotStartGoal = readDebugContextText(keyframePromptDebugContext, 'next_shot_start_goal')
const debugContinuityGuidance = readDebugContextText(keyframePromptDebugContext, 'continuity_guidance')
const debugCompositionAnchor = readDebugContextText(keyframePromptDebugContext, 'composition_anchor')
const debugScreenDirectionGuidance = readDebugContextText(keyframePromptDebugContext, 'screen_direction_guidance')
const debugFrameSpecificGuidance = readDebugContextText(keyframePromptDebugContext, 'frame_specific_guidance')
const debugDirectorCommandSummary = readDebugContextText(keyframePromptDebugContext, 'director_command_summary')
const debugActionBeatPhases = readDebugContextText(keyframePromptDebugContext, 'action_beat_phases')
const debugSelectedActionBeatPhase = readDebugContextText(keyframePromptDebugContext, 'selected_action_beat_phase')
const debugSelectedActionBeatText = readDebugContextText(keyframePromptDebugContext, 'selected_action_beat_text')
const actionBeatPhaseTags = buildActionBeatPhaseTags(debugActionBeatPhases)
const parsedDirectorCommandSummary = parseDirectorCommandSummary(debugDirectorCommandSummary)
const keyframeGuidanceSummary = buildKeyframeGuidanceSummary([
debugDirectorCommandSummary,
debugFrameSpecificGuidance,
debugContinuityGuidance,
debugCompositionAnchor,
debugScreenDirectionGuidance,
])
const guidanceLevelSummary = buildGuidanceLevelSummary(parsedDirectorCommandSummary, keyframeGuidanceSummary)
const debugUnifyStyle =
typeof keyframePromptDebugContext?.unify_style === 'boolean'
? (keyframePromptDebugContext.unify_style ? '是' : '否')
@@ -5509,6 +5672,19 @@ function Inspector(props: {
debugCostumeContext ||
debugShotDescription ||
debugDialogSummary ||
debugPreviousShotTitle ||
debugPreviousShotScriptExcerpt ||
debugPreviousShotEndState ||
debugNextShotTitle ||
debugNextShotScriptExcerpt ||
debugNextShotStartGoal ||
debugContinuityGuidance ||
debugCompositionAnchor ||
debugScreenDirectionGuidance ||
debugFrameSpecificGuidance ||
debugDirectorCommandSummary ||
debugActionBeatPhases ||
debugSelectedActionBeatText ||
debugUnifyStyle,
)
const hasPromptQualityChecks = keyframePromptQualityChecks !== null
@@ -5636,6 +5812,116 @@ function Inspector(props: {
placeholder="请输入基础提示词,例如人物动作、场景氛围、镜头视角等…"
disabled={keyframePromptActionLoading || shotRenderPromptLoading}
/>
{keyframeGuidanceSummary.length > 0 || debugDirectorCommandSummary ? (
<div className="mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-3 text-xs text-sky-800">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">基础提示词生成依据</div>
<div className="mt-1 text-sky-700">
这些导演约束主要用于生成上游基础提示词,默认先看摘要;只有少量高优先级规则会再进入最终图片提示词。
</div>
</div>
<Button size="small" type="text" onClick={() => setKeyframeDirectiveCollapsed((prev) => !prev)}>
{keyframeDirectiveCollapsed ? '展开细节' : '收起细节'}
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Tag color="red">{`必须 ${guidanceLevelSummary.must}`}</Tag>
<Tag color="blue">{`优先 ${guidanceLevelSummary.prefer}`}</Tag>
<Tag>{`普通 ${guidanceLevelSummary.normal}`}</Tag>
{actionBeatPhaseTags.length > 0 ? (
<Tag color="purple">{`动作阶段 ${actionBeatPhaseTags.length}`}</Tag>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{(keyframeDirectiveCollapsed
? (
parsedDirectorCommandSummary.length > 0
? parsedDirectorCommandSummary
.slice(0, 2)
.map((item) => `${item.level === 'must' ? '必须' : '优先'} · ${item.text}`)
: keyframeGuidanceSummary.slice(0, 2)
)
: keyframeGuidanceSummary
).map((item) => (
<Tooltip key={item} title={item}>
<Tag color="blue" className="max-w-[240px] overflow-hidden">
<span className="inline-block max-w-[200px] truncate align-bottom">{item}</span>
</Tag>
</Tooltip>
))}
</div>
{actionBeatPhaseTags.length > 0 ? (
<div className="mt-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-xs text-slate-700">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-slate-800">当前帧消费的动作阶段</div>
{debugSelectedActionBeatPhase && debugSelectedActionBeatText ? (
<Tag color="purple">
{`${debugSelectedActionBeatPhase} · ${debugSelectedActionBeatText}`}
</Tag>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{actionBeatPhaseTags.map((item, index) => (
<Tooltip key={`${item.phaseLabel}:${index}:${item.text}`} title={`${item.phaseLabel} · ${item.text}`}>
<Tag
color={
item.phaseLabel === '触发'
? 'gold'
: item.phaseLabel === '峰值'
? 'blue'
: 'green'
}
className="max-w-[240px] overflow-hidden"
>
<span className="inline-block max-w-[200px] truncate align-bottom">
{item.phaseLabel} · {item.text}
</span>
</Tag>
</Tooltip>
))}
</div>
</div>
) : null}
{!keyframeDirectiveCollapsed ? (
<div className="mt-3 grid gap-3 md:grid-cols-2">
{debugDirectorCommandSummary ? (
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-3 text-xs text-indigo-800">
<div className="font-medium">高优先级导演指令</div>
<div className="mt-2 flex flex-wrap gap-2">
{parsedDirectorCommandSummary.map((item, index) => (
<Tooltip key={`${item.level}:${index}:${item.text}`} title={`${item.level === 'must' ? '必须' : '优先'} · ${item.text}`}>
<Tag
color={item.level === 'must' ? 'red' : 'blue'}
className="max-w-[240px] overflow-hidden"
>
<span className="inline-block max-w-[200px] truncate align-bottom">
{item.level === 'must' ? '必须' : '优先'} · {item.text}
</span>
</Tag>
</Tooltip>
))}
</div>
</div>
) : null}
{keyframeGuidanceSummary.length > 0 ? (
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-3 text-xs text-blue-800">
<div className="font-medium">补充 Guidance</div>
<div className="mt-2 flex flex-wrap gap-2">
{keyframeGuidanceSummary.map((item) => (
<Tooltip key={item} title={item}>
<Tag color="blue" className="max-w-[220px] overflow-hidden">
<span className="inline-block max-w-[180px] truncate align-bottom">{item}</span>
</Tag>
</Tooltip>
))}
</div>
</div>
) : null}
</div>
) : null}
</div>
) : null}
{hasPromptDebugContext ? (
<div className="mt-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
<div className="flex items-center justify-between gap-3">
@@ -5647,13 +5933,13 @@ function Inspector(props: {
type="text"
onClick={() => setKeyframePromptDebugCollapsed((prev) => !prev)}
>
{keyframePromptDebugCollapsed ? '展开' : '收起'}
{keyframePromptDebugCollapsed ? '展开细节' : '收起细节'}
</Button>
</Space>
</div>
{keyframePromptDebugCollapsed ? (
<div className="mt-2 text-slate-500">
调试信息默认收起,展开后可查看最近一次 AI 生成使用的项目风格、镜头描述与实体上下文。
调试信息默认收起,展开后可查看最近一次 AI 生成使用的项目风格、镜头描述、连续性约束与实体上下文。
</div>
) : (
<div className="mt-2 grid gap-2 md:grid-cols-2">
@@ -5679,6 +5965,84 @@ function Inspector(props: {
<div className="mt-1 whitespace-pre-wrap text-slate-700">{debugDialogSummary}</div>
</div>
) : null}
{debugActionBeatPhases ? (
<div className="md:col-span-2">
<div className="text-slate-500">动作拍点阶段</div>
<div className="mt-1 flex flex-wrap gap-2">
{actionBeatPhaseTags.map((item, index) => (
<Tag
key={`debug-action-phase:${index}:${item.text}`}
color={
item.phaseLabel === '触发'
? 'gold'
: item.phaseLabel === '峰值'
? 'blue'
: 'green'
}
>
{`${item.phaseLabel} · ${item.text}`}
</Tag>
))}
</div>
{debugSelectedActionBeatPhase || debugSelectedActionBeatText ? (
<div className="mt-2 text-slate-700">
{`当前帧优先消费:${[debugSelectedActionBeatPhase, debugSelectedActionBeatText].filter(Boolean).join(' · ')}`}
</div>
) : null}
</div>
) : null}
{debugPreviousShotTitle || debugPreviousShotScriptExcerpt || debugPreviousShotEndState ? (
<div className="md:col-span-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
<div className="text-slate-500">上一镜头承接</div>
<div className="mt-1 space-y-1 text-slate-700">
{debugPreviousShotTitle ? <div>标题:{debugPreviousShotTitle}</div> : null}
{debugPreviousShotScriptExcerpt ? (
<div className="whitespace-pre-wrap">摘录:{debugPreviousShotScriptExcerpt}</div>
) : null}
{debugPreviousShotEndState ? (
<div className="whitespace-pre-wrap">结尾状态:{debugPreviousShotEndState}</div>
) : null}
</div>
</div>
) : null}
{debugNextShotTitle || debugNextShotScriptExcerpt || debugNextShotStartGoal ? (
<div className="md:col-span-2 rounded-lg border border-slate-200 bg-white px-3 py-3">
<div className="text-slate-500">下一镜头衔接</div>
<div className="mt-1 space-y-1 text-slate-700">
{debugNextShotTitle ? <div>标题:{debugNextShotTitle}</div> : null}
{debugNextShotScriptExcerpt ? (
<div className="whitespace-pre-wrap">摘录:{debugNextShotScriptExcerpt}</div>
) : null}
{debugNextShotStartGoal ? (
<div className="whitespace-pre-wrap">起始目标:{debugNextShotStartGoal}</div>
) : null}
</div>
</div>
) : null}
{debugContinuityGuidance ? (
<div className="md:col-span-2">
<div className="text-slate-500">连续性建议</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">{debugContinuityGuidance}</div>
</div>
) : null}
{debugCompositionAnchor ? (
<div className="md:col-span-2">
<div className="text-slate-500">构图与空间锚点</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">{debugCompositionAnchor}</div>
</div>
) : null}
{debugScreenDirectionGuidance ? (
<div className="md:col-span-2">
<div className="text-slate-500">朝向与视线建议</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">{debugScreenDirectionGuidance}</div>
</div>
) : null}
{debugFrameSpecificGuidance ? (
<div className="md:col-span-2">
<div className="text-slate-500">当前帧专项建议</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">{debugFrameSpecificGuidance}</div>
</div>
) : null}
{debugCharacterContext ? (
<div className="md:col-span-2">
<div className="text-slate-500">角色上下文</div>
@@ -5778,6 +6142,100 @@ function Inspector(props: {
))}
</div>
) : null}
{keyframePromptSelectedGuidance.length > 0 || keyframePromptDroppedGuidance.length > 0 ? (
<div className="mb-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-xs text-slate-700">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-slate-900">最终图片提示词收敛结果</div>
<div className="mt-1 text-slate-500">
系统会从上游导演约束里挑出最关键的少量规则,补进最终图片提示词。
</div>
</div>
<Space size="small" wrap>
<Tag color="green">{`保留 ${keyframePromptSelectedGuidance.length}`}</Tag>
<Tag color="gold">{`压缩 ${keyframePromptDroppedGuidance.length}`}</Tag>
{(keyframePromptSelectedGuidance.length > 2 || keyframePromptDroppedGuidance.length > 0) ? (
<Button
size="small"
type="text"
onClick={() => setKeyframePromptDecisionCollapsed((prev) => !prev)}
>
{keyframePromptDecisionCollapsed ? '查看取舍' : '收起取舍'}
</Button>
) : null}
</Space>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 text-xs text-emerald-800">
<div className="font-medium">实际保留的 Guidance</div>
{keyframePromptSelectedGuidance.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{keyframePromptVisibleSelectedGuidanceDetails.map((item) => (
<Tooltip
key={`selected:${item.text}`}
title={(
<div className="max-w-[320px] text-xs leading-5">
{item.reasonTag ? (
<Tag color="green" className="mb-1">
{item.reasonTag}
</Tag>
) : null}
<div>{item.text}</div>
{item.reason ? <div className="mt-1 text-slate-500">{item.reason}</div> : null}
</div>
)}
>
<Tag color="green" className="max-w-[240px] overflow-hidden">
<span className="inline-block max-w-[200px] truncate align-bottom">{item.text}</span>
</Tag>
</Tooltip>
))}
{keyframePromptDecisionCollapsed && keyframePromptSelectedGuidanceDetails.length > 2 ? (
<Tag>{`+${keyframePromptSelectedGuidanceDetails.length - 2} 条`}</Tag>
) : null}
</div>
) : (
<div className="mt-2 text-emerald-700">当前没有额外 guidance 被保留。</div>
)}
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-800">
<div className="font-medium">已压缩的 Guidance</div>
{keyframePromptDroppedGuidance.length > 0 ? (
keyframePromptDecisionCollapsed ? (
<div className="mt-2 text-amber-700">
当前有 {keyframePromptDroppedGuidance.length} 条 guidance 被压缩,展开后可查看具体取舍原因。
</div>
) : (
<div className="mt-2 flex flex-wrap gap-2">
{keyframePromptVisibleDroppedGuidanceDetails.map((item) => (
<Tooltip
key={`dropped:${item.text}`}
title={(
<div className="max-w-[320px] text-xs leading-5">
{item.reasonTag ? (
<Tag color="gold" className="mb-1">
{item.reasonTag}
</Tag>
) : null}
<div>{item.text}</div>
{item.reason ? <div className="mt-1 text-slate-500">{item.reason}</div> : null}
</div>
)}
>
<Tag color="gold" className="max-w-[240px] overflow-hidden">
<span className="inline-block max-w-[200px] truncate align-bottom">{item.text}</span>
</Tag>
</Tooltip>
))}
</div>
)
) : (
<div className="mt-2 text-amber-700">当前没有 guidance 被压缩。</div>
)}
</div>
</div>
</div>
) : null}
{hasBasePrompt ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm leading-6 text-slate-800 whitespace-pre-wrap min-h-[220px]">
{keyframePromptRenderedDraft || '系统正在根据当前内容准备最终提示词…'}
@@ -5821,6 +6279,102 @@ function Inspector(props: {
</div>
) : (
<div className="space-y-3">
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-slate-700">镜头连续性上下文</div>
<div className="mt-1 text-[11px] leading-5 text-slate-500">
这些上下文会参与视频模板渲染和最终提示词补强,默认先展示摘要,需要时再展开细节。
</div>
</div>
<Button
type="link"
size="small"
className="px-0"
onClick={() => setVideoPromptContextCollapsed((prev) => !prev)}
>
{videoPromptContextCollapsed ? '展开细节' : '收起细节'}
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Tag color="blue">{`动作节拍 ${videoActionBeats.length}`}</Tag>
{videoPromptPreviewPack?.previous_shot_summary ? (
<Tag color="purple">上一镜头</Tag>
) : null}
{videoPromptPreviewPack?.next_shot_goal ? (
<Tag color="cyan">下一镜头</Tag>
) : null}
{videoPromptPreviewPack?.continuity_guidance ? (
<Tag color="gold">连续性</Tag>
) : null}
</div>
<div className="mt-3 space-y-3">
<div>
<div className="text-slate-500">动作节拍</div>
{videoVisibleActionBeats.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-2">
{videoVisibleActionBeats.map((item, index) => (
<Tag
key={`${index}:${item.phase ?? 'raw'}:${item.text}`}
color={
item.phase === 'trigger'
? 'gold'
: item.phase === 'peak'
? 'blue'
: item.phase === 'aftermath'
? 'green'
: 'default'
}
>
{item.phase
? `${item.phase === 'trigger' ? '触发' : item.phase === 'peak' ? '峰值' : '收束'} · ${item.text}`
: item.text}
</Tag>
))}
{videoPromptContextCollapsed && hiddenVideoActionBeatCount > 0 ? (
<Tag>{`+${hiddenVideoActionBeatCount}`}</Tag>
) : null}
</div>
) : (
<div className="mt-1 text-gray-400">暂无动作节拍</div>
)}
</div>
{!videoPromptContextCollapsed ? (
<>
<div>
<div className="text-slate-500">上一镜头摘要</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">
{videoPromptPreviewPack?.previous_shot_summary || '无'}
</div>
</div>
<div>
<div className="text-slate-500">下一镜头目标</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">
{videoPromptPreviewPack?.next_shot_goal || '无'}
</div>
</div>
<div>
<div className="text-slate-500">连续性建议</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">
{videoPromptPreviewPack?.continuity_guidance || '无'}
</div>
</div>
<div>
<div className="text-slate-500">构图与空间锚点</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">
{videoPromptPreviewPack?.composition_anchor || '无'}
</div>
</div>
<div>
<div className="text-slate-500">朝向与视线建议</div>
<div className="mt-1 whitespace-pre-wrap text-slate-700">
{videoPromptPreviewPack?.screen_direction_guidance || '无'}
</div>
</div>
</>
) : null}
</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-2">关联图片(参考图)</div>
{videoPromptPreviewImages.length === 0 ? (
@@ -5,6 +5,7 @@ import type {
EntityNameExistenceItem,
ShotAssetOverviewItem,
ShotAssetsOverviewRead,
ShotDetailRead,
ShotDialogLineRead,
ShotDialogLineUpdate,
ShotExtractionSummaryRead,
@@ -17,6 +18,7 @@ import {
StudioChaptersService,
StudioEntitiesService,
StudioProjectsService,
StudioShotDetailsService,
StudioShotDialogLinesService,
StudioShotsService,
} from '../../../services/generated'
@@ -195,7 +197,9 @@ export function ChapterShotEditPage() {
const [scriptExcerpt, setScriptExcerpt] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [semanticSaving, setSemanticSaving] = useState(false)
const [preparationState, setPreparationState] = useState<ShotPreparationStateRead | null>(null)
const [shotDetail, setShotDetail] = useState<ShotDetailRead | null>(null)
const [shotAssetsOverview, setShotAssetsOverview] = useState<ShotAssetsOverviewRead | null>(null)
const preparationStateRequestSeqRef = useRef(0)
const [extractingAssets, setExtractingAssets] = useState(false)
@@ -328,7 +332,7 @@ export function ChapterShotEditPage() {
setLoading(true)
setDialogLoading(true)
try {
const [projectRes, chRes, listRes, preparationRes] = await Promise.all([
const [projectRes, chRes, listRes, preparationRes, detailRes] = await Promise.all([
StudioProjectsService.getProjectApiV1StudioProjectsProjectIdGet({ projectId }),
StudioChaptersService.getChapterApiV1StudioChaptersChapterIdGet({ chapterId }),
StudioShotsService.listShotsApiV1StudioShotsGet({
@@ -339,6 +343,7 @@ export function ChapterShotEditPage() {
isDesc: false,
}),
StudioShotsService.getShotPreparationStateApiApiV1StudioShotsShotIdPreparationStateGet({ shotId }),
StudioShotDetailsService.getShotDetailApiV1StudioShotDetailsShotIdGet({ shotId }),
])
const nextVisualStyle = projectRes.data?.visual_style
const nextStyle = projectRes.data?.style
@@ -355,6 +360,7 @@ export function ChapterShotEditPage() {
const items = listRes.data?.items ?? []
const preparationState = preparationRes.data ?? null
const detail = detailRes.data ?? null
const s = preparationState?.shot ?? null
if (!s) {
@@ -369,6 +375,7 @@ export function ChapterShotEditPage() {
}
setPreparationState(preparationState)
setShotDetail(detail)
setShot(s)
setTitle(s.title ?? '')
setScriptExcerpt(s.script_excerpt ?? '')
@@ -719,26 +726,63 @@ export function ChapterShotEditPage() {
return
}
setSaving(true)
setSemanticSaving(true)
try {
const res = await StudioShotsService.updateShotApiV1StudioShotsShotIdPatch({
shotId: shot.id,
requestBody: {
title: title.trim(),
script_excerpt: scriptExcerpt.trim() ? scriptExcerpt.trim() : null,
},
})
const next = res.data
const [shotRes, detailRes] = await Promise.all([
StudioShotsService.updateShotApiV1StudioShotsShotIdPatch({
shotId: shot.id,
requestBody: {
title: title.trim(),
script_excerpt: scriptExcerpt.trim() ? scriptExcerpt.trim() : null,
},
}),
shotDetail
? StudioShotDetailsService.updateShotDetailApiV1StudioShotDetailsShotIdPatch({
shotId: shot.id,
requestBody: {
camera_shot: shotDetail.camera_shot,
angle: shotDetail.angle,
movement: shotDetail.movement,
duration: shotDetail.duration ?? 4,
action_beats: shotDetail.action_beats ?? [],
},
})
: Promise.resolve({ data: null } as any),
])
const next = shotRes.data
const nextDetail = detailRes.data ?? null
if (nextDetail) {
setShotDetail(nextDetail)
}
if (next) {
setShot(next)
setShots((prev) => prev.map((x) => (x.id === next.id ? next : x)))
message.success('已保存')
message.success('已保存基础信息与镜头语言')
}
} catch {
message.error('保存失败')
} finally {
setSaving(false)
setSemanticSaving(false)
}
}, [scriptExcerpt, shot, title])
}, [scriptExcerpt, shot, shotDetail, title])
const updateShotSemantic = useCallback((patch: {
camera_shot?: ShotDetailRead['camera_shot']
angle?: ShotDetailRead['angle']
movement?: ShotDetailRead['movement']
duration?: number
action_beats?: Array<string>
}) => {
setShotDetail((prev) => {
if (!prev) return prev
return {
...prev,
...patch,
}
})
}, [])
const updateSkipExtraction = useCallback(
async (skip: boolean) => {
@@ -1146,16 +1190,21 @@ export function ChapterShotEditPage() {
return <Navigate to="/projects" replace />
}
const hasTitleAndExcerpt = !!title.trim() && !!scriptExcerpt.trim()
const hasTitleAndExcerpt = preparationState?.basic_info_ready ?? (!!title.trim() && !!scriptExcerpt.trim())
const hasSemanticDefaults = preparationState?.semantic_defaults_ready
?? (!!shotDetail?.camera_shot && !!shotDetail?.angle && !!shotDetail?.movement && (shotDetail?.duration ?? 0) > 0)
const actionBeatsCount = preparationState?.action_beats_count
?? (shotDetail?.action_beats ?? []).filter((item) => item.trim().length > 0).length
const actionBeatsReady = preparationState?.action_beats_ready ?? (actionBeatsCount > 0)
const linkedAssetCount = shotAssetsOverview?.summary.linked_count ?? 0
const pendingAssetCount = shotAssetsOverview?.summary.pending_count ?? 0
const pendingConfirmCount = preparationState?.pending_confirm_count ?? (pendingAssetCount + extractedDialogLines.length)
const assetsReady = !!shotAssetsOverview && pendingAssetCount === 0
const dialogsReady = extractedDialogLines.length === 0
const statusReady = preparationState?.ready_for_generation ?? (shot?.status === 'ready')
const basicInfoReady = hasTitleAndExcerpt
const basicInfoReady = hasTitleAndExcerpt && hasSemanticDefaults
const confirmReady = pendingConfirmCount === 0
const currentShotActionable = shot ? isActionablePreparationShot(shot) || !basicInfoReady : false
const currentShotActionable = shot ? isActionablePreparationShot(shot) || !basicInfoReady || !actionBeatsReady : false
const extractionSummary = getShotExtractionSummary(shot)
const extractionStateMeta = getExtractionStateMeta(shot, pendingConfirmCount)
const goToStudio = () => navigate(getChapterStudioPath(projectId, chapterId), {
@@ -1164,14 +1213,24 @@ export function ChapterShotEditPage() {
const nextStepTitle = statusReady ? '下一步:进入分镜工作室继续生成' : '下一步:先完成镜头准备,再进入工作室'
const nextStepDescription = statusReady
? '当前镜头的信息提取确认已经完成,接下来更适合去分镜工作室继续关键帧、参考图、视频提示词和视频生成。'
: '当前镜头仍有提取候选或对白待确认。先在这里完成准备,准备完成后再进入分镜工作室继续生成。'
: actionBeatsReady
? '当前镜头仍有提取候选、对白或镜头基础信息待确认。先在这里完成准备,准备完成后再进入分镜工作室继续生成。'
: '当前镜头的动作拍点还没有确认。建议先补齐动作序列,再进入工作室继续关键帧和视频生成。'
const checklistItems = [
{
key: 'script',
label: '标题与摘录',
tone: hasTitleAndExcerpt ? 'success' : 'warning',
text: hasTitleAndExcerpt ? '已保存基础信息' : '请先补标题和剧本摘录',
label: '标题、摘录与镜头语言',
tone: basicInfoReady ? 'success' : 'warning',
text: basicInfoReady ? '已确认基础信息与镜头语言' : '请先补齐标题、剧本摘录和镜头语言',
},
{
key: 'action_beats',
label: '动作拍点',
tone: actionBeatsReady ? 'success' : 'warning',
text: actionBeatsReady
? `已确认 ${actionBeatsCount} 条动作拍点`
: '请先确认当前镜头的动作变化序列',
},
{
key: 'assets',
@@ -1266,8 +1325,18 @@ export function ChapterShotEditPage() {
title={title}
scriptExcerpt={scriptExcerpt}
saving={saving}
semanticSaving={semanticSaving}
semantic={{
camera_shot: shotDetail?.camera_shot ?? undefined,
angle: shotDetail?.angle ?? undefined,
movement: shotDetail?.movement ?? undefined,
duration: shotDetail?.duration ?? 4,
action_beats: shotDetail?.action_beats ?? [],
}}
actionBeatPhases={preparationState?.action_beat_phases ?? []}
onTitleChange={setTitle}
onScriptExcerptChange={setScriptExcerpt}
onSemanticChange={updateShotSemantic}
onSave={() => void saveShot()}
/>
),
@@ -1,12 +1,58 @@
import { Button, Input } from 'antd'
import { Button, Input, InputNumber, Select, Tag } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import type { ActionBeatPhaseRead, CameraAngle, CameraMovement, CameraShotType } from '../../../../services/generated'
const CAMERA_SHOT_OPTIONS: Array<{ value: CameraShotType; label: string }> = [
{ value: 'ECU', label: '大特写' },
{ value: 'CU', label: '特写' },
{ value: 'MCU', label: '中近景' },
{ value: 'MS', label: '中景' },
{ value: 'MLS', label: '中远景' },
{ value: 'LS', label: '远景' },
{ value: 'ELS', label: '大远景' },
]
const CAMERA_ANGLE_OPTIONS: Array<{ value: CameraAngle; label: string }> = [
{ value: 'EYE_LEVEL', label: '平视' },
{ value: 'HIGH_ANGLE', label: '高角度' },
{ value: 'LOW_ANGLE', label: '低角度' },
{ value: 'BIRD_EYE', label: '鸟瞰' },
{ value: 'DUTCH', label: '荷兰式' },
{ value: 'OVER_SHOULDER', label: '过肩' },
]
const CAMERA_MOVEMENT_OPTIONS: Array<{ value: CameraMovement; label: string }> = [
{ value: 'STATIC', label: '固定镜头' },
{ value: 'PAN', label: '平移' },
{ value: 'TILT', label: '俯仰' },
{ value: 'DOLLY_IN', label: '推近' },
{ value: 'DOLLY_OUT', label: '拉远' },
{ value: 'TRACK', label: '跟拍' },
{ value: 'CRANE', label: '摇臂' },
{ value: 'HANDHELD', label: '手持' },
{ value: 'STEADICAM', label: '稳定器' },
{ value: 'ZOOM_IN', label: '变焦推近' },
{ value: 'ZOOM_OUT', label: '变焦拉远' },
]
type ShotSemanticDraft = {
camera_shot?: CameraShotType
angle?: CameraAngle
movement?: CameraMovement
duration?: number
action_beats?: Array<string>
}
type ChapterShotBasicInfoSectionProps = {
title: string
scriptExcerpt: string
saving: boolean
semanticSaving: boolean
semantic: ShotSemanticDraft
actionBeatPhases?: Array<ActionBeatPhaseRead>
onTitleChange: (value: string) => void
onScriptExcerptChange: (value: string) => void
onSemanticChange: (patch: ShotSemanticDraft) => void
onSave: () => void
}
@@ -14,15 +60,27 @@ export function ChapterShotBasicInfoSection({
title,
scriptExcerpt,
saving,
semanticSaving,
semantic,
actionBeatPhases,
onTitleChange,
onScriptExcerptChange,
onSemanticChange,
onSave,
}: ChapterShotBasicInfoSectionProps) {
const phaseByText = new Map((actionBeatPhases ?? []).map((item) => [item.text.trim(), item.phase]))
const phaseMeta = (phase?: ActionBeatPhaseRead['phase']) => {
if (phase === 'trigger') return { label: '触发', color: 'gold' as const }
if (phase === 'peak') return { label: '峰值', color: 'blue' as const }
if (phase === 'aftermath') return { label: '收束', color: 'green' as const }
return null
}
return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm space-y-3">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900">镜头基础信息</div>
<div className="text-[11px] text-slate-500 mt-1">先确认标题和内容,再继续处理系统提取结果。</div>
<div className="text-[11px] text-slate-500 mt-1">先确认标题、摘录和镜头语言默认值,再继续处理系统提取结果。</div>
</div>
<div className="space-y-3">
@@ -55,6 +113,117 @@ export function ChapterShotBasicInfoSection({
placeholder="剧本摘录"
/>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-slate-800">镜头语言默认值</div>
<div className="mt-1 text-[11px] text-slate-500">这里确认的是镜头语义真值,后续工作室可以微调,但仍然写回同一份分镜详情。</div>
</div>
<Button type="primary" size="small" loading={semanticSaving} onClick={onSave}>
保存
</Button>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<div className="mb-1 text-xs text-gray-600">景别</div>
<Select
value={semantic.camera_shot ?? undefined}
onChange={(value) => onSemanticChange({ camera_shot: value })}
options={CAMERA_SHOT_OPTIONS}
placeholder="选择景别"
className="w-full"
/>
</div>
<div>
<div className="mb-1 text-xs text-gray-600">机位</div>
<Select
value={semantic.angle ?? undefined}
onChange={(value) => onSemanticChange({ angle: value })}
options={CAMERA_ANGLE_OPTIONS}
placeholder="选择机位"
className="w-full"
/>
</div>
<div>
<div className="mb-1 text-xs text-gray-600">运镜</div>
<Select
value={semantic.movement ?? undefined}
onChange={(value) => onSemanticChange({ movement: value })}
options={CAMERA_MOVEMENT_OPTIONS}
placeholder="选择运镜"
className="w-full"
/>
</div>
<div>
<div className="mb-1 text-xs text-gray-600">时长(秒)</div>
<InputNumber
min={1}
max={30}
value={semantic.duration ?? 4}
onChange={(value) => onSemanticChange({ duration: Math.max(1, Math.round(Number(value) || 1)) })}
className="w-full"
/>
</div>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-slate-800">动作拍点</div>
<div className="mt-1 text-[11px] text-slate-500">按镜头内部时间顺序保留 2-4 条即可,后续关键帧和视频会优先消费这里的已确认版本。</div>
</div>
<Button
size="small"
onClick={() => onSemanticChange({ action_beats: [...(semantic.action_beats ?? []), ''] })}
>
新增一条
</Button>
</div>
<div className="space-y-2">
{(semantic.action_beats ?? []).length > 0 ? (semantic.action_beats ?? []).map((item, index) => (
<div key={`action-beat-${index}`} className="flex items-start gap-2">
<div className="mt-2 w-5 shrink-0 text-xs text-slate-500">{index + 1}.</div>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
{phaseMeta(phaseByText.get(item.trim())) ? (
<Tag color={phaseMeta(phaseByText.get(item.trim()))?.color} className="m-0">
{phaseMeta(phaseByText.get(item.trim()))?.label}
</Tag>
) : null}
</div>
<Input.TextArea
value={item}
onChange={(e) => {
const next = [...(semantic.action_beats ?? [])]
next[index] = e.target.value
onSemanticChange({ action_beats: next })
}}
autoSize={{ minRows: 1, maxRows: 3 }}
placeholder="例如:听到异响后骤然僵住"
/>
</div>
<Button
danger
size="small"
onClick={() => {
const next = [...(semantic.action_beats ?? [])]
next.splice(index, 1)
onSemanticChange({ action_beats: next })
}}
>
删除
</Button>
</div>
)) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-white px-3 py-3 text-[12px] text-slate-500">
当前还没有动作拍点。可以先用 AI 提取结果,再按需要补 2-4 条关键动作变化。
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
+3
View File
@@ -7,6 +7,7 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { ActionBeatPhaseRead } from './models/ActionBeatPhaseRead';
export type { ApiResponse_AsyncTaskCreateRead_ } from './models/ApiResponse_AsyncTaskCreateRead_';
export type { ApiResponse_ChapterRead_ } from './models/ApiResponse_ChapterRead_';
export type { ApiResponse_CharacterPortraitAnalysisResult_ } from './models/ApiResponse_CharacterPortraitAnalysisResult_';
@@ -109,6 +110,7 @@ export type { FileTypeEnum } from './models/FileTypeEnum';
export type { FileUpdate } from './models/FileUpdate';
export type { FileUsageRead } from './models/FileUsageRead';
export type { FileUsageWrite } from './models/FileUsageWrite';
export type { FrameGuidanceDecisionRead } from './models/FrameGuidanceDecisionRead';
export type { GenerationTaskLinkCreate } from './models/GenerationTaskLinkCreate';
export type { GenerationTaskLinkRead } from './models/GenerationTaskLinkRead';
export type { GenerationTaskLinkUpdate } from './models/GenerationTaskLinkUpdate';
@@ -213,6 +215,7 @@ export type { ShotPromptAssetRef } from './models/ShotPromptAssetRef';
export type { ShotPromptCameraInfo } from './models/ShotPromptCameraInfo';
export type { ShotRead } from './models/ShotRead';
export type { ShotRuntimeSummaryRead } from './models/ShotRuntimeSummaryRead';
export type { ShotSemanticSuggestion } from './models/ShotSemanticSuggestion';
export type { ShotSkipExtractionUpdate } from './models/ShotSkipExtractionUpdate';
export type { ShotStatus } from './models/ShotStatus';
export type { ShotUpdate } from './models/ShotUpdate';
@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* 动作拍点的轻量阶段推断结果。
*/
export type ActionBeatPhaseRead = {
/**
* 动作拍点原文
*/
text: string;
/**
* 推断阶段:触发 / 峰值 / 收束
*/
phase: 'trigger' | 'peak' | 'aftermath';
};
@@ -0,0 +1,26 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* 分镜帧 guidance 的保留/压缩决策结果。
*/
export type FrameGuidanceDecisionRead = {
/**
* guidance 原文
*/
text: string;
/**
* guidance 分类,如 summary / continuity / composition / screen
*/
category: string;
/**
* 简短原因标签,如 首帧保空间 / 关键帧保轴线
*/
reason_tag?: string;
/**
* 该 guidance 被保留或压缩的原因说明
*/
reason: string;
};
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FrameGuidanceDecisionRead } from './FrameGuidanceDecisionRead';
import type { ShotFramePromptMappingRead } from './ShotFramePromptMappingRead';
/**
* 关键帧最终生成提示词渲染结果。
@@ -15,6 +16,22 @@ export type RenderedShotFramePromptRead = {
* 最终提交给模型的提示词(含图片映射说明)
*/
rendered_prompt: string;
/**
* 最终 prompt 实际保留的 guidance 列表
*/
selected_guidance?: Array<string>;
/**
* 本次渲染中被压缩掉的 guidance 列表
*/
dropped_guidance?: Array<string>;
/**
* 最终保留 guidance 的决策详情
*/
selected_guidance_details?: Array<FrameGuidanceDecisionRead>;
/**
* 被压缩 guidance 的决策详情
*/
dropped_guidance_details?: Array<FrameGuidanceDecisionRead>;
/**
* 最终参考图 file_id 列表,顺序与 mappings 一致
*/
@@ -59,6 +59,10 @@ export type ShotDetailCreate = {
* 视效说明
*/
vfx_note?: string;
/**
* 动作拍点(按时间顺序排列)
*/
action_beats?: Array<string>;
/**
* 镜头分镜首帧提示词
*/
@@ -59,6 +59,10 @@ export type ShotDetailRead = {
* 视效说明
*/
vfx_note?: string;
/**
* 动作拍点(按时间顺序排列)
*/
action_beats?: Array<string>;
/**
* 镜头分镜首帧提示词
*/
@@ -19,6 +19,7 @@ export type ShotDetailUpdate = {
has_bgm?: (boolean | null);
vfx_type?: (VFXType | null);
vfx_note?: (string | null);
action_beats?: (Array<string> | null);
first_frame_prompt?: (string | null);
last_frame_prompt?: (string | null);
key_frame_prompt?: (string | null);
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ActionBeatPhaseRead } from './ActionBeatPhaseRead';
import type { ShotAssetsOverviewRead } from './ShotAssetsOverviewRead';
import type { ShotDialogLineRead } from './ShotDialogLineRead';
import type { ShotExtractedDialogueCandidateRead } from './ShotExtractedDialogueCandidateRead';
@@ -30,6 +31,26 @@ export type ShotPreparationStateRead = {
* 当前仍待确认的总数量(资产 + 对白)
*/
pending_confirm_count: number;
/**
* 标题与剧本摘录是否已补齐
*/
basic_info_ready: boolean;
/**
* 镜头语言默认值是否已确认
*/
semantic_defaults_ready: boolean;
/**
* 动作拍点是否已确认
*/
action_beats_ready: boolean;
/**
* 当前已确认动作拍点数量
*/
action_beats_count?: number;
/**
* 当前动作拍点的阶段推断结果
*/
action_beat_phases?: Array<ActionBeatPhaseRead>;
/**
* 当前镜头是否已完成准备,可进入后续生成
*/
@@ -0,0 +1,37 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CameraAngle } from './CameraAngle';
import type { CameraMovement } from './CameraMovement';
import type { CameraShotType } from './CameraShotType';
/**
* 镜头语义默认建议:用于准备阶段初始化镜头语言与动作拍点。
*/
export type ShotSemanticSuggestion = {
/**
* 建议景别
*/
camera_shot?: (CameraShotType | null);
/**
* 建议机位
*/
angle?: (CameraAngle | null);
/**
* 建议运镜
*/
movement?: (CameraMovement | null);
/**
* 建议时长(秒)
*/
duration?: (number | null);
/**
* 按时间顺序排列的动作拍点
*/
action_beats?: Array<string>;
/**
* 不确定项说明
*/
notes?: (string | null);
};
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ActionBeatPhaseRead } from './ActionBeatPhaseRead';
import type { ShotPromptAssetRef } from './ShotPromptAssetRef';
import type { ShotPromptCameraInfo } from './ShotPromptCameraInfo';
/**
@@ -24,6 +25,30 @@ export type ShotVideoPromptPackRead = {
* 动作/场景要点
*/
action_beats?: Array<string>;
/**
* 动作拍点的阶段推断结果
*/
action_beat_phases?: Array<ActionBeatPhaseRead>;
/**
* 上一镜头摘要,用于提示词连续性约束
*/
previous_shot_summary?: string;
/**
* 下一镜头目标,用于提示词连续性约束
*/
next_shot_goal?: string;
/**
* 当前镜头与相邻镜头的承接建议
*/
continuity_guidance?: string;
/**
* 当前镜头的构图与空间锚点建议
*/
composition_anchor?: string;
/**
* 当前镜头的人物朝向、视线与左右轴线建议
*/
screen_direction_guidance?: string;
/**
* 对白摘要
*/
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ShotSemanticSuggestion } from './ShotSemanticSuggestion';
import type { StudioShotDraftDialogueLine } from './StudioShotDraftDialogueLine';
/**
* 镜头草稿:不含 shot_id,由导入 API 生成;引用实体用 name。
@@ -43,5 +44,9 @@ export type StudioShotDraft = {
* 动作/场景描述
*/
actions?: Array<string>;
/**
* 镜头语言默认建议与动作拍点候选
*/
semantic_suggestion?: (ShotSemanticSuggestion | null);
};
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ShotVideoPromptPackRead } from './ShotVideoPromptPackRead';
export type VideoPromptPreviewResponse = {
/**
* 最终用于视频生成的提示词
@@ -11,5 +12,9 @@ export type VideoPromptPreviewResponse = {
* 关联参考图 file_id 列表
*/
images?: Array<string>;
/**
* 视频提示词预览上下文包
*/
pack?: (ShotVideoPromptPackRead | null);
};
@@ -69,6 +69,17 @@ backend/app/services/studio/generation/
- `build_submission`
当前 `preview-prompt` 与 `create video task` 已共享同一份 `reference_mode + images` 上下文。
其中工作室当前使用的 `film/tasks/video/preview-prompt` 也会返回完整 `pack`:
- `previous_shot_summary`
- `next_shot_goal`
- `continuity_guidance`
- `composition_anchor`
- `screen_direction_guidance`
- `action_beats`
- `action_beat_phases`
因此工作室视频提示词预览与 studio 侧底层 pack 现在保持同源,不再出现“提示词有值但连续性上下文始终为空”的接口分叉。
当前视频参数已收口为以 `ratio` 为唯一业务主参数:
@@ -81,6 +92,65 @@ backend/app/services/studio/generation/
- 关键帧图片若用于视频参考,提交时会显式携带 `target_ratio + resolution_profile`
- 后端根据当前默认图片模型 capability 解析对应 `size`,保证关键帧画幅与目标视频保持一致
- 工作室会展示当前关键帧规格预览:`ratio + resolution_profile -> size`
- 视频提示词预览当前会额外暴露 `action_beats / previous_shot_summary / next_shot_goal / continuity_guidance`
- 视频提示词预览当前会额外暴露 `composition_anchor`
- 视频提示词预览当前会额外暴露 `screen_direction_guidance`
- `action_beats` 由当前镜头剧本摘录、镜头描述与对白规则化提炼,用于降低“像静态画面说明”的问题
- continuity 字段由相邻镜头摘要生成,用于降低镜头切换时的突兀感
- `composition_anchor` 由景别、运镜、主场景、主角色与相邻镜头关系规则化生成,用于降低构图和轴线突变
- `screen_direction_guidance` 由机位角度、对白关系、相邻镜头场景连续性规则化生成,用于降低人物翻面与反打跳轴
- 若视频模板未显式消费这些 guidance,系统会在模板渲染结果后自动补一段稳定的“镜头执行约束”,避免新字段只存在于 preview pack 中
- 即便视频走手动 prompt 分支,系统当前也会追加同一层 guidance 补强,避免手动文本完全绕过镜头连续性与构图约束
- 分镜帧 `frame-render-prompt` 当前也会把 `director_command_summary` 与必要的 `continuity_guidance` 轻量补入最终图片提示词
- 分镜帧 `frame-render-prompt` 当前也会把必要的 `frame_specific_guidance` 作为“当前帧职责”候选补入最终图片提示词
- 分镜帧 `frame-render-prompt` 当前也会把 `composition_anchor` 轻量补入最终图片提示词
- 分镜帧 `frame-render-prompt` 当前也会把 `screen_direction_guidance` 轻量补入最终图片提示词
- 因此关键帧提示词预览中看到的高优先级导演约束,不再只停留在调试展示
- 最终提交给图片模型的 render prompt 会显式带上这层收敛后的约束、当前帧职责、构图重心与朝向/视线要求
- 对于首帧,当前系统会优先强调“触发瞬间 / 初始反应 / 未完成态”表达,避免提示词直接落到后续完成动作或最终姿态
- 为避免 prompt 膨胀,这层收敛当前最多只保留 3 条 guidance
- 当前默认优先级为:`director_command_summary` > `continuity_guidance` > `screen_direction_guidance` > `composition_anchor`
- 这层优先级还会按 `frame_type` 做动态微调:
- `first` 更偏向保留 `composition_anchor`
- `key` / `last` 更偏向保留 `screen_direction_guidance`
- 目的是让建立镜头先稳住空间,对峙/反打/收束镜头先稳住视线与左右轴线
- 前端关键帧提示词预览当前会直接展示:
- “基础提示词生成依据”,用于说明 guidance 主要先服务于上游基础提示词生成
- “最终图片提示词收敛结果”,用于说明只有少量 guidance 会被再次补进最终图片 prompt
- 最终 render prompt 实际保留了哪些 guidance
- 哪些 guidance 因压缩策略被舍弃
- 每条 guidance 被保留或压缩的原因说明
- 同时提供更短的 `reason_tag`,例如 `首帧保空间`、`关键帧保轴线`
- 这样可以直接解释“为什么预览里有 4 条规则,但最终 prompt 只用了 3 条”
- 图片任务提交后,`render_context` 当前也会保留这组 guidance 决策详情
- 因此任务链与预览链现在共享同一份“保留 / 压缩 / 原因”上下文
- 项目级信息提取当前还会输出镜头语言默认建议
- `semantic_suggestion.camera_shot`
- `semantic_suggestion.angle`
- `semantic_suggestion.movement`
- `semantic_suggestion.duration`
- `semantic_suggestion.action_beats`
- `extract / extract-async` 在同步资产候选与对白候选之外,会按镜头序号将上述默认建议回写到 `ShotDetail`
- 因此 `camera_shot / angle / movement / duration` 不再只依赖分镜写库时的硬编码初始值
- `action_beats` 也会作为镜头动作拍点真值回写到 `ShotDetail`
- 工作室中的镜头语言微调,修改的也是这同一份 `ShotDetail` 真值
- 分镜准备页聚合状态当前也会显式返回:
- `basic_info_ready`
- `semantic_defaults_ready`
- `action_beats_ready`
- `action_beats_count`
- `action_beat_phases`
- `ready_for_generation`
- 其中 `ready_for_generation` 表示“准备页视角下可进入生成”,不等同于单纯的 `shot.status = ready`
- 视频链当前会优先消费 `ShotDetail.action_beats`
- 只有在镜头尚未确认动作拍点时,才回退到基于 `script_excerpt + description + dialogue` 的规则化提炼
- 关键帧链当前也会优先消费 `ShotDetail.action_beats`
- 后端会先对动作拍点做一层轻量 `trigger / peak / aftermath` 推断
- 首帧优先消费触发阶段拍点
- 关键帧优先消费峰值阶段拍点
- 尾帧优先消费收束阶段拍点
- 视频 prompt 预览当前也会直接暴露这层阶段推断结果
- 因此视频链与关键帧链现在会用同一套 `trigger / peak / aftermath` 标签来展示镜头内部动作过程
### `asset_image`
@@ -24,6 +24,7 @@ weight: 9
最终提交给图片模型的提示词,由后续 `frame-render-prompt` 再基于:
- 基础提示词
- 当前帧职责 guidance
- 当前参考图顺序
进行二次渲染。
@@ -38,6 +39,7 @@ weight: 9
- 景别 / 机位 / 运镜 / 时长
- 氛围 / 情绪标签 / 视效说明
- 对白摘要
- 已确认动作拍点(`action_beats`)
- 项目级 `visual_style`
- 项目级 `style`
- 项目级 `unify_style`
@@ -45,6 +47,9 @@ weight: 9
- 镜头已确认场景及其描述
- 镜头已确认道具及其描述
- 镜头已确认服装及其描述
- 上一镜头标题、剧本摘录与结尾状态摘要
- 下一镜头标题、剧本摘录与起始目标摘要
- 当前镜头与相邻镜头的连续性建议
其中,角色上下文除角色自身描述外,还会尽量带入:
@@ -102,6 +107,24 @@ weight: 9
这条口径用于降低同项目不同镜头之间的提示词漂移。
当前系统还会为基础提示词追加一层“连续性约束”:
- 若存在上一镜头,当前画面应优先承接上一镜头的动作、视线、情绪或空间方向
- 若存在下一镜头,当前画面应形成可自然过渡到下一镜头的收束状态
- 尽量明确主体位置、朝向与动作峰值,减少镜头切换时的突兀感
当前系统还会追加“构图与空间锚点建议”:
- 根据当前景别与运镜判断应优先突出主体还是环境
- 同场景连续镜头优先保持空间轴线与人物朝向稳定
- 若存在主场景和主角色,会显式提示模型锁定场景锚点与主体视线逻辑
当前系统还会追加“朝向与视线建议”:
- 对话或对视场景优先保持说话者与受话者的视线关系连续
- 同场景相邻镜头优先保持人物左右面向稳定,减少反打跳轴
- 过肩镜头优先保持前景肩部位置与被看对象方向稳定
同时,系统会额外向模型提供一条“主体优先级建议”,帮助模型避免平均铺陈所有信息:
- 优先写主角色
@@ -109,6 +132,48 @@ weight: 9
- 道具仅在进入主动作或构图焦点时重点写入
- 服装主要用于保证人物外观一致性,不要求每次都成为画面中心
当前系统还会按帧类型补充专项建议:
- 首帧:优先建立空间、主体初始站位和第一眼视觉印象;若剧本存在连续反应链,优先使用“刚开始 / 尚未完成 / 被打断”的未完成态表达,不直接写成后续完成动作或最终姿态
- 关键帧:优先锁定动作峰值、情绪爆点或信息密度最高的瞬间
- 尾帧:优先强调动作收束、情绪余韵和下一镜头可承接的停留点
若镜头已经在准备阶段确认了 `action_beats`,当前链路会优先消费这组动作拍点:
- 首帧优先围绕 `trigger`(触发 / 起始反应)拍点组织画面
- 关键帧优先围绕 `peak`(动作峰值 / 戏剧张力最高阶段)拍点组织画面
- 尾帧优先围绕 `aftermath`(结果态 / 收束阶段)拍点组织画面
当前这一层仍然是轻量启发式,不要求 `action_beats` 先存成结构化对象:
- 继续使用 `list[str]`
- 后端会基于关键词和相对位置推断 `trigger / peak / aftermath`
- 因此首帧、关键帧、尾帧现在会更稳定地消费同一组动作拍点,但各自抓取不同阶段
- 这组阶段信息当前也会进入关键帧基础提示词任务的 `debug_context`
- 前端关键帧预览会直接展示“动作拍点阶段”和“当前帧优先消费”
- 因此编辑页看到的阶段标签与关键帧链实际使用的阶段信息现在保持一致
只有在镜头尚未确认 `action_beats` 时,系统才继续主要依赖 `script_excerpt + shot_description` 的自然语言切片。
其中首帧当前还有一层轻量启发式:
- 若 `script_excerpt + shot_description` 中同时出现明显的连续反应链关键词
- 例如:`突然 / 下意识 / 脱手 / 蹲下 / 转身 / 回头`
- 系统会额外追加更硬的时间切片约束
- 目标是让首帧更稳定地落在“触发后最早的可见瞬间”,而不是直接落到反应完成态
当前系统还会进一步把上述 guidance 压缩成 `director_command_summary`:
- 这不是简单拼接,而是会按帧类型重新排序
- 若镜头存在同场景连续关系、对白或明显对视风险
- `screen_direction_guidance` 会被自动上提
- 更早进入 `必须` 级约束
- 因此首帧、关键帧、尾帧看到的“必须 / 优先”摘要并不完全相同
- 目标是让模型先守住最容易导致突兀感的事项,例如:
- 同场景镜头的左右轴线
- 对话关系中的视线连续
- 推进镜头中的构图重心
## 任务结果可观察性
基础提示词任务在成功后,除了返回最终 `prompt`,还会在任务结果中附带:
@@ -53,6 +53,18 @@ ChapterStudio
- 资产候选:关联 / 新建 / 忽略
- 对白候选:接受 / 忽略 / 批量接受 / 批量忽略
- 单镜头基础信息修正
- AI 提取的镜头语言默认值确认
- `camera_shot`
- `angle`
- `movement`
- `duration`
- AI 提取的动作拍点候选确认
- `action_beats`
- 准备页会同时展示系统当前推断的阶段标签
- `trigger`
- `peak`
- `aftermath`
- 用于帮助判断首帧 / 关键帧 / 尾帧后续会如何消费这组动作拍点
### 这页的目标
@@ -60,9 +72,22 @@ ChapterStudio
- 资产候选确认
- 对白候选确认
- 镜头语言默认值主确认
- 动作拍点主确认
- `skip_extraction` 等准备决策
- `shot.status = ready`
其中需要特别区分两层语义:
- `shot.status = ready`
- 只表示资产 / 对白等信息提取确认已经完成
- 准备页聚合态里的 `ready_for_generation = true`
- 表示当前镜头已经同时补齐:
- 标题与剧本摘录
- 镜头语言默认值
- 动作拍点
- 提取确认结果
可以把这页理解成:
```text
@@ -81,6 +106,9 @@ ChapterStudio
- 单镜头视频生成
- 批量生成前预检
- 批量生成
- 镜头语言微调
- 允许对 `camera_shot / angle / movement / duration` 做快捷修正
- 但仍然写回同一份 `ShotDetail` 真值,而不是任务级临时覆盖
### 这页的目标