Merge pull request #226 from 0xDevNinja/feat/publish-tier-export-bundle

feat(publish): add export_bundle, the first PUBLISH-tier tool
This commit is contained in:
Calesthio 2026-07-02 15:06:03 -07:00 committed by GitHub
commit 0c202b507a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 489 additions and 15 deletions

View File

@ -257,7 +257,7 @@ stages:
- proposal_packet
produces:
- publish_log
tools_available: []
tools_available: [export_bundle]
checkpoint_required: true
human_approval_default: true
review_focus:

View File

@ -85,41 +85,53 @@ Each chapter maps to a script section's `start_seconds`.
### Step 5: Package Export
Create the export directory structure:
Use the `export_bundle` tool (capability `publish`) to do the packaging
deterministically — pass it the final `video_path` (from `render_report`), the
`title`, and the metadata you prepared (`description`, `tags`, `hashtags`,
`chapters`, optional `subtitles_path` and `thumbnail_path`/`thumbnail_concept`).
It lays out the export directory, writes the metadata files, and returns a
schema-valid `publish_log` (`status: "exported"`) in `data["publish_log"]` that
you persist as the stage artifact.
It produces this structure:
```
exports/
<project_name>/
video/
output.mp4 # Final rendered video
output.mp4 # Final rendered video (subtitles.srt alongside if provided)
metadata/
metadata.json # All SEO metadata
chapters.txt # Chapter markers
description.txt # Ready-to-paste description
description.txt # Ready-to-paste description (+ chapters)
tags.txt # One tag per line
thumbnails/
concept.json # Thumbnail concept (or generated image)
concept.json # Thumbnail concept (or the copied thumbnail image)
```
`export_bundle` is a local, offline packager — it does not upload. A networked
publisher (e.g. a YouTube uploader) would be a separate `publish`-capability
provider.
### Step 6: Build Publish Log
`export_bundle` already returns a schema-valid `publish_log` in `data["publish_log"]` — persist that directly rather than hand-building one. Do **not** add extra entry fields (the schema sets `additionalProperties: false`; only `platform`, `status`, `url`, `video_id`, `visibility`, `export_path`, `timestamp`, `metadata_used`, `error` are allowed). The shape it returns:
```json
{
"version": "1.0",
"entries": [
{
"platform": "youtube",
"status": "draft",
"timestamp": "2024-01-15T10:30:00Z",
"metadata": {
"status": "exported",
"export_path": "projects/vector-db-explainer/exports",
"timestamp": "2026-01-15T10:30:00+00:00",
"metadata_used": {
"title": "Vector Databases Explained in 60 Seconds",
"description_length": 450,
"tags_count": 8,
"chapters_count": 6,
"thumbnail_ready": false
},
"export_path": "exports/vector-db-explainer/",
"video_path": "renders/output.mp4"
"description": "What vector databases are and when to use them.",
"hashtags": ["#ai", "#vectordb"],
"chapters": [{ "start_seconds": 0, "title": "Introduction" }]
}
}
]
}

View File

@ -0,0 +1,155 @@
"""Tests for the export_bundle publisher tool.
Covers the tool contract, registry discovery, the export bundle layout, a
schema-valid publish_log, chapter formatting, and the missing-video error path.
"""
import json
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.publishers.export_bundle import ExportBundle
from tools.base_tool import ToolStatus, ToolTier
from tools.tool_registry import ToolRegistry
from schemas.artifacts import validate_artifact
def _make_video(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\x00\x00\x00\x18ftypmp42fakevideo")
def test_contract_metadata():
tool = ExportBundle()
info = tool.get_info()
assert info["name"] == "export_bundle"
assert info["capability"] == "publish"
assert info["tier"] == ToolTier.PUBLISH.value
assert info["provider"] == "local"
assert info["resource_profile"]["network_required"] is False
assert tool.get_status() == ToolStatus.AVAILABLE
assert tool.estimate_cost({}) == 0.0
def test_missing_video_errors(tmp_path):
result = ExportBundle().execute(
{"video_path": str(tmp_path / "nope.mp4"), "title": "X"}
)
assert result.success is False
assert "not found" in (result.error or "")
def test_export_bundle_layout_and_publish_log(tmp_path):
video = tmp_path / "projects" / "demo" / "renders" / "final.mp4"
_make_video(video)
subs = tmp_path / "subs.srt"
subs.write_text("1\n00:00:00,000 --> 00:00:01,000\nhi\n", encoding="utf-8")
result = ExportBundle().execute(
{
"video_path": str(video),
"title": "Vector Databases Explained in 60 Seconds",
"export_dir": str(tmp_path / "out"),
"description": "A quick explainer.",
"tags": ["vector db", "explainer"],
"hashtags": ["#ai", "#database"],
"chapters": [
{"start_seconds": 0, "title": "Intro"},
{"start_seconds": 75, "title": "How it works"},
],
"subtitles_path": str(subs),
"thumbnail_concept": {"text_overlay": "100x FASTER"},
"platform": "youtube",
"visibility": "unlisted",
"timestamp": "2026-06-29T10:30:00+00:00",
}
)
assert result.success is True
root = Path(result.data["export_path"])
# Layout
assert (root / "video" / "output.mp4").is_file()
assert (root / "video" / "subtitles.srt").is_file()
assert (root / "metadata" / "metadata.json").is_file()
assert (root / "metadata" / "description.txt").is_file()
assert (root / "metadata" / "tags.txt").is_file()
assert (root / "metadata" / "chapters.txt").is_file()
assert (root / "thumbnails" / "concept.json").is_file()
# tags one-per-line
assert (root / "metadata" / "tags.txt").read_text().splitlines() == ["vector db", "explainer"]
# chapter formatting (75s -> 1:15)
assert "1:15 - How it works" in (root / "metadata" / "chapters.txt").read_text()
# publish_log is schema-valid and shaped right
plog = result.data["publish_log"]
validate_artifact("publish_log", plog)
entry = plog["entries"][0]
assert entry["status"] == "exported"
assert entry["platform"] == "youtube"
assert entry["visibility"] == "unlisted"
assert entry["export_path"] == str(root)
assert entry["metadata_used"]["title"].startswith("Vector Databases")
def test_chapter_time_formatting_hours(tmp_path):
video = tmp_path / "p" / "renders" / "final.mp4"
_make_video(video)
result = ExportBundle().execute(
{
"video_path": str(video),
"title": "Long",
"export_dir": str(tmp_path / "out"),
"chapters": [{"time_seconds": 3725, "label": "Deep dive"}], # 1:02:05
}
)
assert result.success is True
txt = (Path(result.data["export_path"]) / "metadata" / "chapters.txt").read_text()
assert "1:02:05 - Deep dive" in txt
def test_infer_project_name(tmp_path):
video = tmp_path / "projects" / "my-cool-video" / "renders" / "final.mp4"
_make_video(video)
result = ExportBundle().execute(
{"video_path": str(video), "title": "T", "export_dir": str(tmp_path / "out")}
)
# export still works; project name inference exercised via no-export_dir path below
assert result.success is True
def test_missing_optional_asset_errors(tmp_path):
video = tmp_path / "p" / "renders" / "final.mp4"
_make_video(video)
for key in ("subtitles_path", "thumbnail_path"):
result = ExportBundle().execute(
{
"video_path": str(video),
"title": "T",
"export_dir": str(tmp_path / "out"),
key: str(tmp_path / "does_not_exist.x"),
}
)
assert result.success is False, key
assert key in (result.error or "")
def test_default_export_dir_inside_project_workspace(tmp_path):
# projects/<name>/renders/final.mp4 -> projects/<name>/exports (no export_dir given)
video = tmp_path / "projects" / "demo" / "renders" / "final.mp4"
_make_video(video)
result = ExportBundle().execute({"video_path": str(video), "title": "T"})
assert result.success is True
assert Path(result.data["export_path"]) == (tmp_path / "projects" / "demo" / "exports").resolve()
def test_registry_discovers_export_bundle():
reg = ToolRegistry()
reg.discover()
assert reg.get("export_bundle") is not None
assert reg.get_by_capability("publish")[0].name == "export_bundle"

View File

@ -0,0 +1,307 @@
"""Local export bundler — the first PUBLISH-tier tool.
Every pipeline ends in a `publish` stage that produces a `publish_log` artifact,
but `tools/publishers/` shipped empty, so the mechanical packaging (copying the
render, writing metadata files, laying out the export directory, and emitting a
schema-valid `publish_log`) had to be hand-rolled by the agent each time.
This tool does that packaging deterministically and locally no external
account, no upload, no cost. It takes the final render path plus the SEO
metadata the publish-director skill prepares and writes a self-contained export
bundle a creator can hand to any platform, returning a validated `publish_log`
entry with `status: "exported"`.
A networked publisher (e.g. a YouTube uploader) can be added later as a separate
`provider` under the same `publish` capability.
"""
from __future__ import annotations
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
ToolResult,
ToolRuntime,
ToolStability,
ToolStatus,
ToolTier,
)
class ExportBundle(BaseTool):
name = "export_bundle"
version = "0.1.0"
tier = ToolTier.PUBLISH
capability = "publish"
provider = "local"
stability = ToolStability.BETA
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
runtime = ToolRuntime.LOCAL
dependencies = [] # pure filesystem packaging
install_instructions = "No setup required — runs locally with the Python standard library."
agent_skills = []
capabilities = ["package_export", "write_publish_log"]
supports = {
"local_offline": True,
"free": True,
"uploads": False,
}
best_for = [
"packaging a finished render for hand-off to any platform",
"producing a schema-valid publish_log without an external account",
"offline / no-API-key publishing",
]
not_good_for = [
"uploading directly to YouTube/TikTok/etc. (no network publish)",
"generating SEO metadata or thumbnails (the publish-director prepares those)",
]
input_schema = {
"type": "object",
"required": ["video_path", "title"],
"properties": {
"video_path": {
"type": "string",
"description": "Path to the final rendered video (from render_report.outputs[].path).",
},
"title": {"type": "string", "description": "Video title / SEO title."},
"project_name": {
"type": "string",
"description": "Project name; used for the export folder. Defaults to the video's parent-of-parent dir name.",
},
"export_dir": {
"type": "string",
"description": "Override the export root. Defaults to 'exports/<project_name>'.",
},
"description": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"hashtags": {"type": "array", "items": {"type": "string"}},
"chapters": {
"type": "array",
"items": {
"type": "object",
"description": "Either {start_seconds, title} or {time, label}.",
},
},
"subtitles_path": {"type": "string"},
"thumbnail_path": {"type": "string"},
"thumbnail_concept": {
"type": "object",
"description": "Thumbnail concept JSON when no rendered thumbnail exists.",
},
"platform": {
"type": "string",
"description": "Target platform label for the publish_log entry. Defaults to 'local'.",
},
"visibility": {"type": "string", "enum": ["public", "private", "unlisted"]},
"timestamp": {
"type": "string",
"description": "Override the ISO-8601 timestamp (mainly for deterministic tests).",
},
},
}
output_schema = {
"type": "object",
"properties": {
"publish_log": {"type": "object"},
"export_path": {"type": "string"},
"files_written": {"type": "array", "items": {"type": "string"}},
},
}
resource_profile = ResourceProfile(
cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=0, network_required=False
)
side_effects = ["writes an export bundle directory to disk"]
user_visible_verification = [
"Open the export folder and confirm the video, metadata, and chapters are present and correct",
]
# ---- Helpers ----
@staticmethod
def _format_chapter_time(seconds: float) -> str:
seconds = int(round(seconds))
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def _chapter_lines(self, chapters: list[dict[str, Any]]) -> list[str]:
lines: list[str] = []
for ch in chapters:
label = ch.get("title") or ch.get("label") or ""
if "start_seconds" in ch or "time_seconds" in ch:
ts = self._format_chapter_time(ch.get("start_seconds", ch.get("time_seconds", 0)))
elif "time" in ch:
ts = str(ch["time"])
else:
ts = "0:00"
lines.append(f"{ts} - {label}".rstrip(" -"))
return lines
# ---- Execution ----
def execute(self, inputs: dict[str, Any]) -> ToolResult:
video_path = Path(inputs["video_path"]).expanduser()
if not video_path.is_file():
return ToolResult(success=False, error=f"video_path not found: {video_path}")
title = inputs["title"]
project_name = inputs.get("project_name") or self._infer_project_name(video_path)
# Explicitly-provided optional assets must exist — silently dropping them
# would ship a publish package missing part of an approved deliverable.
for key in ("subtitles_path", "thumbnail_path"):
val = inputs.get(key)
if val and not Path(val).expanduser().is_file():
return ToolResult(success=False, error=f"{key} provided but not found: {val}")
export_root = (
Path(inputs["export_dir"]).expanduser()
if inputs.get("export_dir")
else self._default_export_dir(video_path, project_name)
)
video_dir = export_root / "video"
meta_dir = export_root / "metadata"
thumb_dir = export_root / "thumbnails"
for d in (video_dir, meta_dir, thumb_dir):
d.mkdir(parents=True, exist_ok=True)
files_written: list[str] = []
# Video
out_video = video_dir / f"output{video_path.suffix or '.mp4'}"
shutil.copy2(video_path, out_video)
files_written.append(str(out_video))
# Subtitles (optional)
subs_in = inputs.get("subtitles_path")
if subs_in:
subs_in = Path(subs_in).expanduser()
if subs_in.is_file():
out_subs = video_dir / f"subtitles{subs_in.suffix or '.srt'}"
shutil.copy2(subs_in, out_subs)
files_written.append(str(out_subs))
description = inputs.get("description", "")
tags = inputs.get("tags", []) or []
hashtags = inputs.get("hashtags", []) or []
chapters = inputs.get("chapters", []) or []
chapter_lines = self._chapter_lines(chapters)
# metadata.json
metadata = {
"title": title,
"description": description,
"tags": tags,
"hashtags": hashtags,
"chapters": chapters,
}
meta_json = meta_dir / "metadata.json"
meta_json.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
files_written.append(str(meta_json))
# description.txt (description + chapters appended, ready to paste)
desc_parts = [description] if description else []
if chapter_lines:
desc_parts.append("\n".join(chapter_lines))
desc_txt = meta_dir / "description.txt"
desc_txt.write_text("\n\n".join(desc_parts) + ("\n" if desc_parts else ""), encoding="utf-8")
files_written.append(str(desc_txt))
# tags.txt (one per line)
if tags:
tags_txt = meta_dir / "tags.txt"
tags_txt.write_text("\n".join(tags) + "\n", encoding="utf-8")
files_written.append(str(tags_txt))
# chapters.txt
if chapter_lines:
chapters_txt = meta_dir / "chapters.txt"
chapters_txt.write_text("\n".join(chapter_lines) + "\n", encoding="utf-8")
files_written.append(str(chapters_txt))
# Thumbnail: real image if given, else concept JSON
thumb_in = inputs.get("thumbnail_path")
if thumb_in and Path(thumb_in).expanduser().is_file():
thumb_in = Path(thumb_in).expanduser()
out_thumb = thumb_dir / f"thumbnail{thumb_in.suffix or '.png'}"
shutil.copy2(thumb_in, out_thumb)
files_written.append(str(out_thumb))
elif inputs.get("thumbnail_concept"):
concept = thumb_dir / "concept.json"
concept.write_text(json.dumps(inputs["thumbnail_concept"], indent=2), encoding="utf-8")
files_written.append(str(concept))
timestamp = inputs.get("timestamp") or datetime.now(timezone.utc).isoformat()
entry: dict[str, Any] = {
"platform": inputs.get("platform", "local"),
"status": "exported",
"export_path": str(export_root),
"timestamp": timestamp,
"metadata_used": {
"title": title,
"description": description,
"hashtags": hashtags,
"chapters": chapters,
},
}
if inputs.get("visibility"):
entry["visibility"] = inputs["visibility"]
publish_log = {"version": "1.0", "entries": [entry]}
# Validate against the canonical schema so a bad entry fails here, not at checkpoint.
try:
from schemas.artifacts import validate_artifact
validate_artifact("publish_log", publish_log)
except Exception as exc: # pragma: no cover - defensive
return ToolResult(success=False, error=f"publish_log failed schema validation: {exc}")
return ToolResult(
success=True,
data={
"publish_log": publish_log,
"export_path": str(export_root),
"files_written": files_written,
},
artifacts=[str(out_video)],
)
@staticmethod
def _default_export_dir(video_path: Path, project_name: str) -> Path:
"""Keep run output inside the project workspace.
When the render lives at ``projects/<name>/renders/...`` (the OpenMontage
convention), default the bundle to ``projects/<name>/exports/`` alongside
``artifacts/``, ``assets/`` and ``renders/``. Otherwise fall back to a
top-level ``exports/<project_name>/``.
"""
resolved = video_path.resolve()
if resolved.parent.name == "renders":
return resolved.parent.parent / "exports"
return Path("exports") / project_name
@staticmethod
def _infer_project_name(video_path: Path) -> str:
# projects/<name>/renders/final.mp4 -> <name>; fall back to the file stem.
parents = video_path.resolve().parents
if len(parents) >= 2:
return parents[1].name
return video_path.stem