diff --git a/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx index 3b83635b3c..c9add21b55 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/content-media-workbench.tsx @@ -5,7 +5,7 @@ import "./content-media-workbench.css"; type Provider = {status: string; enabled: boolean | null; next_action: string | null}; type Composition = { composition_id: string; revision: number; status: string; title: string; duration_seconds: number; - shots: {title: string; seconds: number; kind: string; boundary: string; source_ref: string}[]; + shots: {title: string; seconds: number; kind: string; boundary: string; source_ref: string; framing?: "full_frame"}[]; result: {files: Record; qa: {frames: number; full_decode: string; narration: {mode: string}; blender_scenes?: {project_reopened: boolean}[]}; cost: {render_wall_seconds: number}} | null; provider: Provider; }; @@ -84,9 +84,9 @@ export function ContentMediaWorkbench({goalId}: {goalId: string}) { {selected.result.files["blender-project.zip"] ? {copy("Blender 工程与画面", "Blender project and passes")} : null}

{copy("完整解码通过", "Full decode passed")} · {selected.result.qa.frames} {copy("帧", "frames")} · {copy("渲染耗时", "Render time")} {selected.result.cost.render_wall_seconds}s

{selected.result.qa.blender_scenes?.length ?

{copy("三维工程已重新打开并检查,颜色与深度帧完整。", "The 3D project was reopened and checked; color and depth frames are complete.")}

: null} - :
    {selected.shots.map((shot, i) =>
  1. {shot.title}{shot.seconds}s · {shot.boundary}
  2. )}
} + :
    {selected.shots.map((shot, i) =>
  1. {shot.title}{shot.seconds}s · {shot.framing === "full_frame" ? copy("大画面 · ", "Full frame · ") : ""}{shot.boundary}
  2. )}
} {["failed", "damaged"].includes(selected.status) ?

{hasBlender ? copy("请让 Agent 检查 Blender 依赖、场景范围与制作时限。修复本机依赖后可重试;修改场景需保存新修订。", "Ask your Agent to check the Blender extra, scene bounds and render time. Retry after dependency repair; save a new revision for scene changes.") : copy("请让 Agent 检查素材、字体和配音时长;修改脚本后保存新修订,或修复本机依赖后重试。", "Ask your Agent to check sources, fonts and narration timing. Save a new revision for story changes, or repair the local dependencies and retry.")}

: null} -
{copy("来源与检查", "Sources and checks")} +
{copy("来源与检查", "Sources and checks")}
    {selected.shots.map((shot, i) =>
  • {shot.title} — {shot.framing === "full_frame" ? copy("大画面 · ", "Full frame · ") : ""}{shot.boundary}
    {shot.source_ref}
  • )}

{copy("本机私有作品。编码通过不代表创意质量、理解效果或发布许可;请观看并试听。", "Private local work. Encoding does not establish creative quality, audience understanding or publication approval. Watch and listen before sharing.")}

: null} ; diff --git a/docs/reference/protocols/content-ops-media-v0.md b/docs/reference/protocols/content-ops-media-v0.md index d33d476a43..c8c0522022 100644 --- a/docs/reference/protocols/content-ops-media-v0.md +++ b/docs/reference/protocols/content-ops-media-v0.md @@ -169,3 +169,44 @@ missing bpy does not disable ordinary 2D work. A failed 3D attempt retains its prepared input and supports retry after dependency repair. No fallback replaces 3D with a code projection. Mechanical readiness still does not establish creative quality, audience understanding or publication approval. + +## Per-shot picture framing + +An `excerpt` or `blender` shot can opt into `framing: "full_frame"` with +`loopx-media` 0.3.0. The source fits the available width/height without cropping +or changing its aspect ratio; a small image may be enlarged. Its complete +`boundary` is wrapped in a separate measured strip below the picture. Neither +that strip nor the optional player captions are baked over source pixels. +The shot title remains timeline/source metadata; put a title that the viewer +must see in the source artwork, a title shot or timed captions. The fixed studio +header, drawn shot title and progress bar are omitted for this shot. The +selected layout's background/text tokens still apply. This is a rendering +choice, not an audience-understanding or creative-quality judgment. + +Omitted framing and explicit `"editorial"` normalize to the original plan +structure and retain the existing digest/layout. Other values and framing on +non-spatial shot kinds are refused. Nonempty spatial `body` remains refused. +Full-frame framing changes the source-bound digest and needs a new revision; +it cannot inherit earlier content approval. The result additionally requires +`qa.full_frame_shots` to match the admitted ordered shot IDs. An older provider +that ignores the field cannot produce a ready result; explicitly upgrade the +optional package/manifest and retry after doctor. Default framing remains +compatible with the older provider. No core setting or global installation is +changed by preparing a full-frame plan. + +The Agent prepares this choice in the same brief; the packaged workbench shows +“Full frame” beside its timeline/source entry and renders, cancels, retries and +reads back the same composition. Users do not enter source paths or JSON again. +Disabling the existing extension blocks new renders and keeps verified movies +readable. To return to editorial framing, prepare a revision without the field +and render it; an older LoopX core will refuse a plan containing the new field. + +中文:`excerpt` 与 `blender` 镜头可选 `framing: "full_frame"`。素材按原比例 +完整放入较大的画面区,必要时放大;来源说明在独立底栏完整换行,工作室页眉、 +片内标题和进度条不再绘制。标题仍在分镜与来源信息中,需要观众看到的标题由素材、 +标题镜头或字幕承载。省略该字段或显式选择 `editorial` 保留原计划摘要与构图。 +此选项需要显式升级 `loopx-media` 0.3.0、注册新 manifest 并运行 doctor;旧渲染器 +若忽略新字段,结果会被拒绝就绪。修改构图需新修订,仍会撤销旧内容批准。 +工作台沿现有入口显示“大画面”并制作、取消、重试和查看;停用扩展阻止新制作但 +保留已校验影片。回到旧构图时,移除字段后保存新修订再制作;旧版核心不会接受 +含新字段的计划。该选项不改变来源可见性、发布权限或全局配置。 diff --git a/loopx/capabilities/content_ops/media.py b/loopx/capabilities/content_ops/media.py index 23a62ed918..e8d022cc9d 100644 --- a/loopx/capabilities/content_ops/media.py +++ b/loopx/capabilities/content_ops/media.py @@ -157,7 +157,7 @@ def inspect(self, composition_id: str) -> dict: status = "damaged"; break return {"ok": True, "composition_id": composition_id, "revision": record["revision"], "status": status, "title": record["plan"]["title"], "duration_seconds": record["duration_seconds"], - "plan_digest": record["plan_digest"], "shots": [{k: shot[k] for k in ("title", "seconds", "kind", "boundary", "source_ref")} for shot in record["plan"]["shots"]], + "plan_digest": record["plan_digest"], "shots": [{k: shot[k] for k in ("title", "seconds", "kind", "boundary", "source_ref", "framing") if k in shot} for shot in record["plan"]["shots"]], "updated_at": record["updated_at"], "result": result, "item_state": record["item"]["state"], "error_code": record.get("error_code"), "provider": self.binding().public_readiness(), "visibility": "local_private", "publish_authorized": False} @@ -219,9 +219,11 @@ def _execute(self, composition_id: str, attempt: str) -> None: "sources": record["sources"], "attempt_directory": str(_safe_path(bundle, attempt)), "cancel_file": str(cancel)}) spatial = [{"id": shot["id"], "frames": round(shot["seconds"] * record["plan"]["fps"])} for shot in record["plan"]["shots"] if shot["kind"] == "blender"] + full_frame = [shot["id"] for shot in record["plan"]["shots"] if shot.get("framing") == "full_frame"] result = _owner("result", {"result": result, "plan_digest": record["plan_digest"], "frame_count": record["frame_count"], "narration_mode": record["plan"]["narration"]["mode"], **({"blender_shots": spatial} if spatial else {}), + **({"full_frame_shots": full_frame} if full_frame else {}), **{k: record["plan"][k] for k in ("width", "height", "fps")}}) required = {"video.mp4", "poster.png", "captions.vtt", "captions.srt", "transcript.txt"} if not required <= set(result["files"]) or set(result["files"]) - RESULT_FILES: diff --git a/loopx/control_plane/capabilities/content_media.ts b/loopx/control_plane/capabilities/content_media.ts index 3bebe3a355..28266a6dbe 100644 --- a/loopx/control_plane/capabilities/content_media.ts +++ b/loopx/control_plane/capabilities/content_media.ts @@ -112,15 +112,20 @@ export function validateMediaPlan(input: unknown) { if (new Set(assets.map(asset => asset.id)).size !== assets.length) fail("Duplicate source reference"); const shots = array(raw.shots, 30).map(value => { const shot = object(value); - fields(shot, ["id", "kind", "seconds", "title", "body", "nodes", "asset_id", "start_seconds", "source_ref", "boundary", "scene"]); + fields(shot, ["id", "kind", "seconds", "title", "body", "nodes", "asset_id", "start_seconds", "source_ref", "boundary", "scene", "framing"]); const kind = shot.kind; if (typeof kind !== "string" || !["title", "diagram", "excerpt", "blender"].includes(kind)) fail("Unsupported shot kind"); + // Composition-local presentation vocabulary; omitted/editorial retains the + // original canonical bytes. It changes neither source nor publication scope. + if (shot.framing !== undefined && shot.framing !== "editorial" && shot.framing !== "full_frame") fail("Unsupported shot framing"); + if (shot.framing !== undefined && kind !== "excerpt" && kind !== "blender") fail("Framing requires an excerpt or Blender shot"); + const framing = shot.framing === "full_frame" ? {framing: "full_frame" as const} : {}; if (kind !== "blender" && shot.scene !== undefined) fail("Scene geometry requires a Blender shot"); const base = {id: token(shot.id), kind, seconds: number(shot.seconds, 1, 30), title: text(shot.title, 100), - body: shot.body == null || shot.body === "" ? "" : text(shot.body, 300), source_ref: text(shot.source_ref, 240), boundary: text(shot.boundary, 180)}; + body: shot.body == null || shot.body === "" ? "" : text(shot.body, 300), source_ref: text(shot.source_ref, 240), boundary: text(shot.boundary, 180), ...framing}; if ((kind === "excerpt" || kind === "blender") && base.body) fail("Excerpt and Blender shots do not support body text; use a title, boundary or timed captions"); if (kind === "blender") { - fields(shot, ["id", "kind", "seconds", "title", "body", "source_ref", "boundary", "scene"]); + fields(shot, ["id", "kind", "seconds", "title", "body", "source_ref", "boundary", "scene", "framing"]); const frames = number(base.seconds * fps, 12, 120, true); return {...base, scene: blenderScene(shot.scene, frames)}; } @@ -200,6 +205,9 @@ export function validateMediaResult(input: unknown) { }) : []; if (spatial.length !== expectedScenes.length || Boolean(files["blender-project.zip"]) !== Boolean(expectedScenes.length)) fail("Blender project output is missing or out of scope"); const narration = object(qa.narration); + const expectedFullFrame = array(request.full_frame_shots ?? [], 30).map(token); + const fullFrame = array(qa.full_frame_shots ?? [], 30).map(token); + if (new Set(fullFrame).size !== fullFrame.length || stable(fullFrame) !== stable(expectedFullFrame)) fail("Renderer did not verify the requested full-frame shots; upgrade the local media provider"); if (!["silent", "supplied", "system_speech"].includes(String(narration.mode)) || narration.mode !== request.narration_mode) fail("Narration result differs from the admitted plan"); if (narration.mode !== "silent" && !files["narration.wav"]) fail("Narration output is missing"); const audioMetrics = narration.mode === "silent" ? {} : { @@ -209,6 +217,7 @@ export function validateMediaResult(input: unknown) { return {ok: true, protocol: MEDIA_PROTOCOL, plan_digest: request.plan_digest, files, qa: {full_decode: "passed", frames: qa.frames, width: qa.width, height: qa.height, fps: qa.fps, source_hashes_verified: true, narration: {mode: narration.mode, ...audioMetrics, subjective_listening: "not_tested"}, + ...(fullFrame.length ? {full_frame_shots: fullFrame} : {}), ...(spatial.length ? {blender_scenes: spatial} : {}), audience_understanding: "not_tested", creative_review: "not_tested"}, cost: {render_wall_seconds: number(cost.render_wall_seconds, 0, 120)}, publish_authorized: false}; diff --git a/packages/loopx-media/README.md b/packages/loopx-media/README.md index 880d512423..8c08df0d56 100644 --- a/packages/loopx-media/README.md +++ b/packages/loopx-media/README.md @@ -39,12 +39,43 @@ RTL shaping and arbitrary font discovery are not qualified. macOS speech is optional; other platforms require supplied audio or explicit silence. No cloud voice, generative image/video or game engine is bundled here. -Excerpt and Blender layouts display the title and source boundary; nonempty +The default excerpt and Blender layouts display the title and source boundary; nonempty `body` text is explicitly refused. Preserve a needed qualifier in those visible fields, a separate title scene or timed captions before preparing a new revision. The renderer also refuses a stale/direct request with unsupported body text; it cannot return ready while silently dropping that text. +## Picture framing + +For a source-led composition, an Agent can set `framing: "full_frame"` on an +image/video excerpt or Blender shot. Version 0.3.0 fits the complete picture +without cropping and keeps its source boundary in a separate strip below it. +The studio header, drawn shot title and progress bar are omitted; the title +remains in the timeline/source readback. Include needed titles in the artwork, +a title shot or timed captions. Each shot can choose independently. Omitted +framing or `"editorial"` keeps the original canonical plan and layout. + +Use the explicit package upgrade/reinstall and extension upgrade/doctor route +below for an existing provider. The core refuses full-frame readiness when an +older renderer omits its framing readback. Retry the unchanged plan after the +upgrade. The workbench displays the choice and retains its existing render, +cancel, retry and verified-download journey. To roll back the composition, +prepare a new revision without framing and render it. No global setting, +source classification or publication authority changes. See the +[bilingual contract](../../docs/reference/protocols/content-ops-media-v0.md#per-shot-picture-framing). + +中文:0.3.0 在原 content-ops 镜头中加入可选“大画面”构图。素材保持完整比例, +来源说明另置底栏;需要观众看到的标题由素材、标题镜头或字幕承载。Agent 准备 +分镜,工作台沿原入口制作和查看。已有安装应显式升级可选包及 manifest 并运行 +doctor;移除字段、保存新修订并重制即可恢复原构图。默认计划和发布边界不变。 + +Public synthetic packaged-workbench captures (macOS, source qualification; +not an installed-App or comprehension claim): + +| Editorial | Full frame | Narrow, scrolled to movie | +| --- | --- | --- | +| ![Editorial](examples/framing/editorial-desktop.png) | ![Full frame](examples/framing/full-frame-desktop.png) | ![Narrow](examples/framing/full-frame-narrow.png) | + ## Optional Blender scenes For real 3D geometry, install the same provider with its optional Blender extra diff --git a/packages/loopx-media/examples/framing/editorial-desktop.png b/packages/loopx-media/examples/framing/editorial-desktop.png new file mode 100644 index 0000000000..757a0b6a55 Binary files /dev/null and b/packages/loopx-media/examples/framing/editorial-desktop.png differ diff --git a/packages/loopx-media/examples/framing/full-frame-desktop.png b/packages/loopx-media/examples/framing/full-frame-desktop.png new file mode 100644 index 0000000000..eec876b5b1 Binary files /dev/null and b/packages/loopx-media/examples/framing/full-frame-desktop.png differ diff --git a/packages/loopx-media/examples/framing/full-frame-narrow.png b/packages/loopx-media/examples/framing/full-frame-narrow.png new file mode 100644 index 0000000000..a3249f7240 Binary files /dev/null and b/packages/loopx-media/examples/framing/full-frame-narrow.png differ diff --git a/packages/loopx-media/extension.toml b/packages/loopx-media/extension.toml index df2d584cac..5d4895979f 100644 --- a/packages/loopx-media/extension.toml +++ b/packages/loopx-media/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-media" -version = "0.2.0" +version = "0.3.0" requires_loopx_api = ">=1,<2" permissions = ["content_media_write"] diff --git a/packages/loopx-media/pyproject.toml b/packages/loopx-media/pyproject.toml index f8f70f5152..6305075fe7 100644 --- a/packages/loopx-media/pyproject.toml +++ b/packages/loopx-media/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-media" -version = "0.2.0" +version = "0.3.0" description = "Optional local composition renderer for LoopX content-ops" requires-python = ">=3.11" dependencies = ["Pillow>=11,<13", "imageio-ffmpeg>=0.6,<0.7", "fonttools>=4,<5"] diff --git a/packages/loopx-media/src/loopx_media/renderer.py b/packages/loopx-media/src/loopx_media/renderer.py index ce8c1b9662..91e105278e 100644 --- a/packages/loopx-media/src/loopx_media/renderer.py +++ b/packages/loopx-media/src/loopx_media/renderer.py @@ -122,6 +122,9 @@ def render(request: dict) -> dict: plan, out = request["plan"], Path(request["attempt_directory"]) if any(shot["kind"] in {"excerpt", "blender"} and shot.get("body") for shot in plan["shots"]): raise ValueError("The spatial layout does not support body text; revise the brief") + if any(shot.get("framing", "editorial") not in {"editorial", "full_frame"} + or ("framing" in shot and shot["kind"] not in {"excerpt", "blender"}) for shot in plan["shots"]): + raise ValueError("Unsupported shot framing") out.mkdir(mode=0o700, exist_ok=False) started = time.monotonic() cancel = Path(request["cancel_file"]) @@ -168,9 +171,8 @@ def font(size): fonts[pixels] = ImageFont.truetype(font_path, pixels) if font_path else ImageFont.load_default(size=pixels) return fonts[pixels] - def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4): + def text_lines(draw, text, size, limit, max_lines): selected = font(size) - limit = max_width or width - position[0] * 2 lines, current = [], "" # Preserve English words and allow CJK line breaks without inventing # spaces or silently drawing missing-glyph boxes. @@ -186,6 +188,11 @@ def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4 if current: lines.append(current) if len(lines) > max_lines or any(draw.textlength(line, font=selected) > limit for line in lines): raise ValueError("Text exceeds the layout; revise or split the shot") + return lines + + def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4): + selected = font(size) + lines = text_lines(draw, text, size, max_width or width - position[0] * 2, max_lines) x, y = position for line in lines: draw.text((x, y), line, fill=color, font=selected) @@ -198,6 +205,21 @@ def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4 try: for index, shot in enumerate(plan["shots"]): count = round(shot["seconds"] * fps) + full_frame = shot.get("framing") == "full_frame" + media_height = height + if full_frame: + # Keep every source edge visible, including a source's own text. + # Attribution has a measured strip, never an occluding overlay. + inset = max(8, round(20 * scale)) + boundary_font = font(22) + boundary_lines = text_lines(ImageDraw.Draw(Image.new("RGB", (1, 1))), + shot["boundary"], 22, width - 2 * inset, 3) + ascent, descent = boundary_font.getmetrics() + line_height = ascent + descent + max(2, round(4 * scale)) + strip_height = len(boundary_lines) * line_height + 2 * inset + media_height = height - strip_height + if media_height < height / 2: + raise ValueError("Source boundary leaves insufficient picture space; shorten or split the shot") decoder = None source_image = None if shot["kind"] == "excerpt": @@ -207,29 +229,39 @@ def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4 else: # Set the frame clock before scaling; a trailing fps filter # after swscale can lose the final source frame at EOF. - decoder = subprocess.Popen([ff, "-v", "error", "-ss", str(shot["start_seconds"]), "-i", str(sources[asset["id"]]), "-t", str(shot["seconds"]), "-vf", f"fps={fps},scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color={style['background']}", "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + resize = f"scale={width}:{height}:force_original_aspect_ratio=decrease" + if full_frame: + # Raw RGB carries no sample-aspect metadata. Bake the + # display aspect into pixel dimensions before dropping it. + resize = f"scale=w='min({width},{media_height}*dar)':h='min({media_height},{width}/dar)',setsar=1" + decoder = subprocess.Popen([ff, "-v", "error", "-ss", str(shot["start_seconds"]), "-i", str(sources[asset["id"]]), "-t", str(shot["seconds"]), "-vf", f"fps={fps},{resize},pad={width}:{media_height}:(ow-iw)/2:(oh-ih)/2:color={style['background']}", "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) try: for n in range(count): check() im = Image.new("RGB", (width, height), style["background"]) draw = ImageDraw.Draw(im) margin = round(56 * scale) - draw_text(draw, (margin, round(27 * scale)), "LOOPX / CONTENT STUDIO", 16, accent) - draw.line((margin, round(65 * scale), width - margin, round(65 * scale)), fill=LINE) + if not full_frame: + draw_text(draw, (margin, round(27 * scale)), "LOOPX / CONTENT STUDIO", 16, accent) + draw.line((margin, round(65 * scale), width - margin, round(65 * scale)), fill=LINE) if shot["kind"] in {"excerpt", "blender"}: frame = source_image if shot["kind"] == "blender": with Image.open(spatial_frames[shot["id"]] / f"frame-{n+1:04}.png") as image: frame = image.convert("RGBA") if decoder: - raw = decoder.stdout.read(width * height * 3) - if len(raw) != width * height * 3: + raw = decoder.stdout.read(width * media_height * 3) + if len(raw) != width * media_height * 3: raise ValueError("Excerpt is shorter than the declared shot; no frozen-frame substitution") - frame = Image.frombytes("RGB", (width, height), raw) - fitted = frame.copy() - fitted.thumbnail((width - 2 * margin, round(height * 0.62))) - im.paste(fitted, ((width - fitted.width) // 2, round(height * 0.27)), fitted.getchannel("A") if fitted.mode == "RGBA" else None) - draw_text(draw, (margin, round(88 * scale)), shot["title"], 32, ink, max_lines=2) + frame = Image.frombytes("RGB", (width, media_height), raw) + if full_frame: + fitted = ImageOps.contain(frame, (width, media_height)) + im.paste(fitted, ((width - fitted.width) // 2, (media_height - fitted.height) // 2), fitted.getchannel("A") if fitted.mode == "RGBA" else None) + else: + fitted = frame.copy() + fitted.thumbnail((width - 2 * margin, round(height * 0.62))) + im.paste(fitted, ((width - fitted.width) // 2, round(height * 0.27)), fitted.getchannel("A") if fitted.mode == "RGBA" else None) + draw_text(draw, (margin, round(88 * scale)), shot["title"], 32, ink, max_lines=2) else: enter = 1 - (1 - min(1, n / max(1, fps * 0.6))) ** 3 y = round((125 + 16 * (1 - enter)) * scale) @@ -255,9 +287,13 @@ def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4 draw.line((a, y + h / 2, a + (b - a) * progress, y + h / 2), fill=accent, width=2) if progress == 1: draw.polygon(((b, y+h/2), (b-6, y+h/2-4), (b-6, y+h/2+4)), fill=accent) - draw_text(draw, (margin, height - round(56 * scale)), shot["boundary"], 16, muted, max_lines=2) - bar = margin + (width - 2 * margin) * (frames / request["frame_count"]) - draw.line((margin, height-8, bar, height-8), fill=accent, width=3) + if full_frame: + for row, line in enumerate(boundary_lines): + draw.text((inset, media_height + inset + row * line_height), line, fill=ink, font=boundary_font) + else: + draw_text(draw, (margin, height - round(56 * scale)), shot["boundary"], 16, muted, max_lines=2) + bar = margin + (width - 2 * margin) * (frames / request["frame_count"]) + draw.line((margin, height-8, bar, height-8), fill=accent, width=3) if frames == 0: im.save(out / "poster.png") encoder.stdin.write(im.tobytes()); frames += 1 finally: @@ -292,6 +328,7 @@ def draw_text(draw, position, text, size, color=INK, max_width=None, max_lines=4 result = {"ok": True, "protocol": PROTOCOL, "plan_digest": request["plan_digest"], "files": files, "qa": {"full_decode": "passed", "frames": frames, "width": width, "height": height, "fps": fps, "source_hashes_verified": True, "narration": audio, **({"blender_scenes": spatial_qa} if spatial_qa else {}), + **({"full_frame_shots": [shot["id"] for shot in plan["shots"] if shot.get("framing") == "full_frame"]} if any(shot.get("framing") == "full_frame" for shot in plan["shots"]) else {}), "audience_understanding": "not_tested", "creative_review": "not_tested"}, "cost": {"render_wall_seconds": round(time.monotonic() - started, 3)}, "publish_authorized": False} (out / "manifest.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/tests/control_plane_ts/content_media.test.ts b/tests/control_plane_ts/content_media.test.ts index 9e411da2a9..0ca6fd72ea 100644 --- a/tests/control_plane_ts/content_media.test.ts +++ b/tests/control_plane_ts/content_media.test.ts @@ -85,6 +85,39 @@ test("caption admission preserves plain text and refuses structural paragraph se const spatialBrief = () => JSON.parse(readFileSync(new URL("../../packages/loopx-media/examples/blender-handoff.json", import.meta.url), "utf8")); +test("full-frame is an explicit, source-bound spatial choice; editorial defaults retain their digest", () => { + const original = spatialBrief(), before = plan({plan: original}); + original.shots[1].framing = "editorial"; + assert.deepEqual(plan({plan: original}), before); + original.shots[1].framing = "full_frame"; + const framed = plan({plan: original}); + assert.notEqual(framed.plan_digest, before.plan_digest); + assert.equal(framed.plan.shots[1]?.framing, "full_frame"); + assert.equal(plan({plan: framed.plan}).plan_digest, framed.plan_digest); + for (const bad of ["cover", null, ["full_frame"], true]) { + original.shots[1].framing = bad; + assert.throws(() => plan({plan: original}), /framing/); + } + const diagram = {...brief(), shots: [{...brief().shots[0], framing: "full_frame"}]}; + assert.throws(() => plan({plan: diagram}), /requires an excerpt or Blender/); +}); + +test("an older provider cannot silently ignore full-frame framing and return ready", () => { + const expected = {plan_digest: "sha256:" + "a".repeat(64), frame_count: 12, width: 640, height: 360, fps: 12, narration_mode: "silent"}; + const output = {ok: true, protocol: "content_ops_media_renderer_v0", plan_digest: expected.plan_digest, + files: Object.fromEntries(["video.mp4", "poster.png", "captions.vtt", "captions.srt", "transcript.txt"].map(name => [name, {sha256: "b".repeat(64), bytes: 16}])), + qa: {full_decode: "passed", frames: 12, width: 640, height: 360, fps: 12, source_hashes_verified: true, narration: {mode: "silent"}}, + cost: {render_wall_seconds: 1}, publish_authorized: false}; + assert.equal("full_frame_shots" in result({...expected, result: output}).qa, false); + assert.throws(() => result({...expected, full_frame_shots: ["view"], result: output}), /upgrade/); + for (const ids of [["other"], ["view", "view"], []]) { + assert.throws(() => result({...expected, full_frame_shots: ["view"], result: {...output, qa: {...output.qa, full_frame_shots: ids}}}), /full-frame/); + } + const revised = {...output, qa: {...output.qa, full_frame_shots: ["view"]}}; + assert.deepEqual(result({...expected, full_frame_shots: ["view"], result: revised}).qa.full_frame_shots, ["view"]); + assert.throws(() => result({...expected, result: revised}), /full-frame/); +}); + test("spatial shots refuse body text instead of producing a movie that loses a qualifier", () => { const excerpt = {...brief(), assets: [{id: "still", kind: "image", path: "source.png", sha256: "a".repeat(64), source_ref: "synthetic:still", visibility: "local_private"}], shots: [{id: "view", kind: "excerpt", seconds: 6, title: "Source", asset_id: "still", source_ref: "synthetic:still", boundary: "Illustration"}]}; diff --git a/tests/test_content_media.py b/tests/test_content_media.py index 74227afd24..4f983094a8 100644 --- a/tests/test_content_media.py +++ b/tests/test_content_media.py @@ -566,12 +566,14 @@ def test_real_blender_project_passes_reopen_and_http_download(http_workspace): plan = json.loads( (root / "packages/loopx-media/examples/blender-handoff.json").read_text() ) + plan["shots"][1]["framing"] = "full_frame" prepared = work.prepare(plan, source_directory=work.project) ident = prepared["composition_id"] ready = work.render(ident, expected_revision=1) assert ready["status"] == "ready", ready result = ready["result"] assert result["qa"]["frames"] == 60 + assert result["qa"]["full_frame_shots"] == ["route"] spatial = result["qa"]["blender_scenes"][0] assert spatial["id"] == "route" and spatial["frames"] == 36 assert spatial["rgba_frames"] == spatial["depth_frames"] == 36 @@ -671,3 +673,98 @@ def test_real_blender_project_passes_reopen_and_http_download(http_workspace): attempt = work._record(ident)["attempt"] assert work.render(ident, expected_revision=1)["status"] == "ready" assert work._record(ident)["attempt"] == attempt + + +@pytest.mark.skipif( + os.environ.get("LOOPX_MEDIA_RENDERER_TEST") != "1", + reason="explicit optional local renderer qualification", +) +@pytest.mark.parametrize("kind", ["image", "video"]) +def test_full_frame_retains_source_edges_and_boundary_through_real_renderer(http_workspace, kind): + """A four-corner source falsifies silent crop, old thumbnail and overlay loss.""" + import subprocess + import imageio_ffmpeg + from PIL import Image, ImageDraw + + work, exchange = http_workspace + _install_source_renderer(work) + source = Image.new("RGB", (1200, 600), "#203050") + draw = ImageDraw.Draw(source) + corners = [(0, 0, 99, 99), (1100, 0, 1199, 99), (0, 500, 99, 599), (1100, 500, 1199, 599)] + colors = [(220, 30, 30), (30, 220, 30), (30, 30, 220), (220, 190, 30)] + for box, color in zip(corners, colors): + draw.rectangle(box, fill=color) + source.save(work.project / "source.png") + name = "source.png" + if kind == "video": + name = "source.mp4" + subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), "-v", "error", "-loop", "1", "-i", str(work.project / "source.png"), + "-t", "3", "-r", "12", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(work.project / name)], check=True) + plan = {**brief(), "assets": [{"id": "picture", "path": name, "kind": kind, + "sha256": hashlib.sha256((work.project / name).read_bytes()).hexdigest(), + "source_ref": "synthetic:four-corner-source", "visibility": "public_safe"}], + "shots": [{"id": "view", "kind": "excerpt", "seconds": 3, "title": "Title remains in the timeline", + "asset_id": "picture", "source_ref": "synthetic:four-corner-source", "boundary": "Synthetic source"}]} + original = work.prepare(plan, source_directory=work.project) + ident = original["composition_id"] + legacy = work.render(ident, expected_revision=1) + assert legacy["status"] == "ready" + with Image.open(work.file(ident, "poster.png")) as image: + legacy_pixels = list(image.convert("RGB").getdata()) + plan["shots"][0]["framing"] = "full_frame" + changed = work.prepare(plan, source_directory=work.project, composition_id=ident, expected_revision=1) + assert changed["revision"] == 2 and changed["status"] == "prepared" + assert changed["shots"][0]["framing"] == "full_frame" + with pytest.raises(ValueError, match="revision"): + work.render(ident, expected_revision=1) + framed = work.render(ident, expected_revision=2) + assert framed["status"] == "ready", framed + assert framed["result"]["qa"]["full_frame_shots"] == ["view"] + with Image.open(work.file(ident, "poster.png")) as image: + picture = image.convert("RGB") + pixels = list(picture.getdata()) + def close(pixel, color): + return max(abs(a - b) for a, b in zip(pixel, color)) < 20 + for color in colors: + # Each 100px source corner should occupy more area when the inset is removed. + assert sum(close(p, color) for p in pixels) > 2 * sum(close(p, color) for p in legacy_pixels) + # Full-width source corners remain at the correct sides, including both bottom edges. + for color, right, lower in zip(colors, [False, True, False, True], [False, False, True, True]): + points = [(i % 640, i // 640) for i, p in enumerate(pixels) if close(p, color)] + assert points and (min(x for x, _ in points) > 570 if right else max(x for x, _ in points) < 70) + assert (min(y for _, y in points) > 250 if lower else max(y for _, y in points) < 70) + # The attribution strip is outside the source, with visible dark text; no crop/overlay. + assert any(max(p) < 100 for p in pixels[640 * 330:]) + from urllib.parse import urlencode + query = urlencode({"goal_id": "media-demo", "composition_id": ident, "file": "video.mp4"}) + status, _, movie = exchange("GET", "/api/chat/content-media/file?" + query) + assert status == 200 and hashlib.sha256(movie).hexdigest() == framed["result"]["files"]["video.mp4"]["sha256"] + + +@pytest.mark.skipif( + os.environ.get("LOOPX_MEDIA_RENDERER_TEST") != "1", + reason="explicit optional local renderer qualification", +) +def test_full_frame_video_preserves_display_aspect_before_raw_pixel_conversion(http_workspace): + import subprocess + import imageio_ffmpeg + from PIL import Image + + work, _ = http_workspace + _install_source_renderer(work) + source = work.project / "anamorphic.mp4" + subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), "-v", "error", "-f", "lavfi", "-i", + "color=c=0x203050:s=320x160:r=12:d=1", "-vf", "setsar=2", "-c:v", "libx264", + "-pix_fmt", "yuv420p", str(source)], check=True) + plan = {**brief(), "captions": [], "assets": [{"id": "movie", "path": source.name, "kind": "video", + "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "source_ref": "synthetic:anamorphic", + "visibility": "public_safe"}], "shots": [{"id": "view", "kind": "excerpt", "seconds": 1, + "title": "Anamorphic source", "asset_id": "movie", "framing": "full_frame", + "source_ref": "synthetic:anamorphic", "boundary": "Synthetic source"}]} + prepared = work.prepare(plan, source_directory=work.project) + ready = work.render(prepared["composition_id"], expected_revision=1) + assert ready["status"] == "ready", ready + with Image.open(work.file(prepared["composition_id"], "poster.png")) as image: + rows = [y for y in range(360) if max(abs(a-b) for a,b in zip(image.getpixel((320,y)), (32,48,80))) < 8] + # 320x160 with SAR=2 displays at 4:1: a full 640px width needs 160px height. + assert 158 <= len(rows) <= 162