<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Astorie AI]]></title><description><![CDATA[Astorie AI]]></description><link>https://astorie-ai.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aab63a617e78586ee76964b/0050d62c-1b3d-41c5-84fa-1add3d6e7b52.png</url><title>Astorie AI</title><link>https://astorie-ai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 14:21:46 GMT</lastBuildDate><atom:link href="https://astorie-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Which Video Assets Need Review After a Reference Changes?]]></title><description><![CDATA[Compare asset manifests with Python to find which images, crops, and video cuts need review after a reference or dependency changes.]]></description><link>https://astorie-ai.hashnode.dev/which-video-assets-need-review-after-a-reference-changes</link><guid isPermaLink="true">https://astorie-ai.hashnode.dev/which-video-assets-need-review-after-a-reference-changes</guid><category><![CDATA[Python]]></category><category><![CDATA[graph theory]]></category><category><![CDATA[Workflow Automation]]></category><category><![CDATA[video production]]></category><dc:creator><![CDATA[Astorie AI]]></dc:creator><pubDate>Sun, 25 Oct 2026 07:18:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aab63a617e78586ee76964b/fa7204e1-231b-450b-bd2d-76aee7a372f7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Change the approved product reference halfway through a short ad, and the question quickly becomes which finished assets still depend on the old version.</p>
<p>I want a review list before deciding what to remake. The hero image may need attention; its vertical crop and the assembled cut may need it too. A separately recorded voice track can remain outside that chain.</p>
<p>A small dependency graph is enough to record this. This example uses local JSON files and Python's standard library.</p>
<h2>Record the parents of each asset</h2>
<p>Save this as <code>old.json</code>:</p>
<pre><code class="language-json">{
  "product_ref": {"rev": "r1", "deps": []},
  "hero": {"rev": "r1", "deps": ["product_ref"]},
  "vertical": {"rev": "r1", "deps": ["hero"]},
  "voice": {"rev": "r1", "deps": []},
  "cut": {"rev": "r1", "deps": ["vertical", "voice"]}
}
</code></pre>
<p><code>deps</code> lists inputs, not outputs. The hero uses the product reference; the vertical image uses the hero. Each changed file or meaningful source decision gets a new revision ID.</p>
<p>Save the checker as <code>asset_review.py</code>. It needs Python 3.9 or later:</p>
<pre><code class="language-python">import argparse
from collections import defaultdict
from graphlib import CycleError, TopologicalSorter
import json
from pathlib import Path


def validate(data):
    if not isinstance(data, dict):
        raise ValueError("manifest must be an object keyed by asset ID")
    graph = {}
    for name, node in data.items():
        if not isinstance(name, str) or not name.strip() or not isinstance(node, dict):
            raise ValueError("each asset needs an ID and an object")
        revision, parents = node.get("rev"), node.get("deps")
        if not isinstance(revision, str) or not revision.strip():
            raise ValueError(f"{name}: rev must be a nonempty string")
        if not isinstance(parents, list) or not all(isinstance(p, str) for p in parents):
            raise ValueError(f"{name}: deps must be a list of IDs")
        if len(set(parents)) != len(parents):
            raise ValueError(f"{name}: duplicate dependency")
        missing = set(parents) - data.keys()
        if missing:
            raise ValueError(f"{name}: missing dependencies {sorted(missing)}")
        graph[name] = {"rev": revision, "deps": sorted(parents)}
    try:
        tuple(TopologicalSorter({n: v["deps"] for n, v in graph.items()}).static_order())
    except CycleError as error:
        raise ValueError(f"dependency cycle: {error.args[1]}") from error
    return graph


def compare(old, new):
    old, new = validate(old), validate(new)
    changed = {name for name in old.keys() | new.keys()
               if old.get(name) != new.get(name)}
    children = defaultdict(set)
    for graph in (old, new):
        for child, node in graph.items():
            for parent in node["deps"]:
                children[parent].add(child)
    affected, pending = set(changed), list(changed)
    while pending:
        for child in children[pending.pop()]:
            if child not in affected:
                affected.add(child)
                pending.append(child)
    return {
        "changed": sorted(changed),
        "review": sorted((affected - changed) &amp; new.keys()),
        "removed": sorted(old.keys() - new.keys()),
    }


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("old")
    parser.add_argument("new")
    args = parser.parse_args()
    try:
        before = json.loads(Path(args.old).read_text(encoding="utf-8"))
        after = json.loads(Path(args.new).read_text(encoding="utf-8"))
        result = compare(before, after)
    except (OSError, ValueError) as error:
        parser.exit(2, f"error: {error}\n")
    print(json.dumps(result, indent=2))
</code></pre>
<h2>Change one reference</h2>
<p>Make a new manifest with only the reference revision changed:</p>
<pre><code class="language-python">import json
from pathlib import Path

data = json.loads(Path("old.json").read_text())
data["product_ref"]["rev"] = "r2"
Path("new.json").write_text(json.dumps(data, indent=2))
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">python asset_review.py old.json new.json
</code></pre>
<p>Output:</p>
<pre><code class="language-json">{
  "changed": ["product_ref"],
  "review": ["cut", "hero", "vertical"],
  "removed": []
}
</code></pre>
<p>The voice track is untouched. The cut appears because one of its visual inputs is downstream of the changed reference.</p>
<p>This is the bookkeeping side of <a href="https://astorie.ai/blog/brand-consistency-workflow">reference ownership and asset lineage</a>. Astorie's workflow discussion covers the wider production decisions; the graph gives a local project a way to query its recorded relationships.</p>
<h2>Review does not mean regenerate</h2>
<p>Suppose the reference update corrects a seam on the back of a product. The hero might show only its front. I still want the hero on the review list, because someone needs to check whether that correction is visible in this asset. Keeping a valid take is a legitimate outcome of the review.</p>
<p>The checker follows edges from both manifests. Removing an old dependency should not erase the history needed to assess that change. A revision change, an added or removed asset, or a changed dependency list counts as a direct change. Sorting dependencies avoids treating a reordered list as a new relationship.</p>
<p>Missing IDs and cycles stop the comparison. Those errors make the review list unreliable. An intentionally cyclic production process needs versioned snapshots or separate iteration records before it can fit this DAG model.</p>
<p>The local checks covered direct and indirect descendants, an unrelated branch, removed edges, deleted nodes, and invalid graphs. The remaining discipline is recording revisions: replacing a file without updating its revision is invisible to this checker. A file hash can serve as the revision when byte-level changes are the right trigger.</p>
<p>Reference: <a href="https://docs.python.org/3/library/graphlib.html">Python graphlib documentation</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Build a Video Handoff Validator With Python and ffprobe]]></title><description><![CDATA[Check a video handoff package against a JSON manifest using Python and ffprobe.]]></description><link>https://astorie-ai.hashnode.dev/build-a-video-handoff-validator-with-python-and-ffprobe</link><guid isPermaLink="true">https://astorie-ai.hashnode.dev/build-a-video-handoff-validator-with-python-and-ffprobe</guid><category><![CDATA[Python]]></category><category><![CDATA[FFmpeg]]></category><category><![CDATA[Video Processing]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Astorie AI]]></dc:creator><pubDate>Sat, 24 Oct 2026 13:56:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aab63a617e78586ee76964b/07495c80-40d9-47ff-bda7-814937a384a2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Before handing a media folder to an editor, I want to check that the files inside match the delivery brief. A filename such as <code>hero_final_1080p.mp4</code> gives me something to search for, but I still need to read the file's metadata.</p>
<p>I put the expected properties in a small JSON manifest and compare them with <code>ffprobe</code> output.</p>
<h2>Write down what the package should contain</h2>
<p>Save this as <code>handoff.json</code> at the root of the media package:</p>
<pre><code class="language-json">[
  {
    "file": "picture/hero.mp4",
    "width": 1280,
    "height": 720,
    "audio": false
  },
  {
    "file": "audio/narration.wav",
    "audio": true
  }
]
</code></pre>
<p>Paths are relative to that JSON file. Here, I expect a 1280 x 720 picture file without an audio stream, plus a separate narration file with audio. Omitting a property means it won't be checked.</p>
<p>Astorie's guide to <a href="https://astorie.ai/blog/video-production-pipeline">what belongs in an NLE handoff package</a> covers the broader brief, captions, and asset-history decisions. I keep these file checks alongside those editorial notes.</p>
<h2>Compare the manifest with the files</h2>
<p>This script uses Python's standard library. Install FFmpeg first and confirm that <code>ffprobe -version</code> works in your terminal.</p>
<p>Save the script as <code>check_handoff.py</code>:</p>
<pre><code class="language-python">import json
import shutil
import subprocess
import sys
from pathlib import Path


def validate(manifest_path):
    entries = json.loads(manifest_path.read_text(encoding="utf-8"))
    errors = []

    for item in entries:
        label = item["file"]
        path = (manifest_path.parent / label).resolve()
        if not path.is_file():
            errors.append(f"{label}: missing")
            continue

        try:
            result = subprocess.run(
                [
                    "ffprobe", "-v", "error",
                    "-show_entries", "stream=codec_type,width,height",
                    "-of", "json", "-i", str(path),
                ],
                check=True, capture_output=True, text=True, timeout=20,
            )
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
            errors.append(f"{label}: probe failed")
            continue

        streams = json.loads(result.stdout).get("streams", [])
        video = next(
            (s for s in streams if s.get("codec_type") == "video"), {}
        )
        for key in ("width", "height"):
            if key in item and video.get(key) != item[key]:
                errors.append(
                    f"{label}: {key} expected {item[key]}, got {video.get(key)}"
                )

        has_audio = any(s.get("codec_type") == "audio" for s in streams)
        if "audio" in item and has_audio != item["audio"]:
            errors.append(
                f"{label}: audio expected {item['audio']}, got {has_audio}"
            )

    print("\n".join(errors) if errors else f"PASS: {len(entries)} files match manifest")
    return 1 if errors else 0


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("Usage: python check_handoff.py handoff.json")
    if shutil.which("ffprobe") is None:
        raise SystemExit("Install FFmpeg and put ffprobe on PATH.")
    raise SystemExit(validate(Path(sys.argv[1]).resolve()))
</code></pre>
<p>Run it with the path to the manifest:</p>
<pre><code class="language-bash">python check_handoff.py handoff.json
</code></pre>
<p>For a matching package:</p>
<pre><code class="language-text">PASS: 2 files match manifest
</code></pre>
<p>If I change the expected width to 1920 while the file remains 1280 pixels wide, the report becomes:</p>
<pre><code class="language-text">picture/hero.mp4: width expected 1920, got 1280
</code></pre>
<p>A missing narration file produces its own message:</p>
<pre><code class="language-text">audio/narration.wav: missing
</code></pre>
<p>The script continues through the list and exits with status <code>1</code> if anything fails. That makes the same check usable in a terminal or a build step.</p>
<p>I resolve file paths against the manifest's location so moving the whole package doesn't require rewriting the list. Width and height refer to the stored video dimensions. The audio check only detects a stream; listening to the narration is still a separate review task.</p>
]]></content:encoded></item><item><title><![CDATA[Validate an AI Shot Manifest Before It Reaches the Editor]]></title><description><![CDATA[Build a Python validator to catch duplicate shot IDs, stale references, outdated approvals, and missing files before an AI video handoff.]]></description><link>https://astorie-ai.hashnode.dev/validate-ai-shot-manifest-python</link><guid isPermaLink="true">https://astorie-ai.hashnode.dev/validate-ai-shot-manifest-python</guid><category><![CDATA[Python]]></category><category><![CDATA[json]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Astorie AI]]></dc:creator><pubDate>Tue, 20 Oct 2026 07:20:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aab63a617e78586ee76964b/b7afe070-b8b4-4df4-ab94-559b3cb584f6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An editor receives two files for shot S010. Both appear to be approved, but one used an older product reference. A later revision changed the shot without reopening its approval.</p>
<p>The filenames alone cannot settle which asset belongs in the cut. Someone has to reconstruct the decision from messages and folders.</p>
<p>A small validator can catch these contradictions before the package leaves the creator's machine. This walkthrough builds one in Python, using an explicit shot manifest and ordinary local files.</p>
<p>The format is an independent handoff example. It does not call an Astorie API or depend on a platform export.</p>
<h2>Describe the selected asset, not every generation attempt</h2>
<p>Keep one selected asset per shot in the handoff manifest. Put rejected candidates in a separate archive. This makes a repeated shot ID an error instead of an invitation for the editor to choose a take.</p>
<p>The example has three parts:</p>
<ul>
<li><code>schema_version</code> identifies the format the validator understands.</li>
<li><code>references</code> records the accepted reference versions for this package.</li>
<li><code>shots</code> connects each selected asset to its version, approval, and reference versions.</li>
</ul>
<p>Save this as <code>package/manifest.json</code>:</p>
<pre><code class="language-json">{
  "schema_version": 1,
  "references": {
    "PRODUCT": 2
  },
  "shots": [
    {
      "shot_id": "S010",
      "version": 2,
      "asset": "picture/S010_v02.txt",
      "approval": {
        "status": "approved",
        "version": 2
      },
      "references": {
        "PRODUCT": 2
      }
    }
  ]
}
</code></pre>
<p>The <code>.txt</code> asset is deliberately a test fixture. It lets you run the path checks without generating or downloading media. In a real handoff, use the path to the reviewed image or video.</p>
<p>Create that fixture from the directory containing your script:</p>
<pre><code class="language-python">from pathlib import Path

picture = Path("package/picture")
picture.mkdir(parents=True, exist_ok=True)
(picture / "S010_v02.txt").write_text("Path-check fixture only.\n", encoding="utf-8")
</code></pre>
<h2>Turn approval into a checkable relationship</h2>
<p>A status of <code>approved</code> is insufficient when the asset has changed since review. Store the approved version separately and compare it with the selected shot version.</p>
<p>Here, shot version 2 requires an approval for version 2. Changing the shot to version 3 leaves a visible mismatch until the review record is updated.</p>
<p>References need a similar comparison. A shot using <code>PRODUCT: 1</code> fails when the package catalog specifies <code>PRODUCT: 2</code>. An unknown reference ID also fails. The catalog is maintained by the team; the script cannot independently discover the latest approved reference.</p>
<p>For the wider production context, <a href="https://astorie.ai/blog/video-production-pipeline">Astorie's AI video production pipeline</a> discusses how shot selection connects to the editing handoff. The validator below adds a local check for the metadata you send with those assets.</p>
<h2>Run the validator</h2>
<p>Use Python 3.9 or later. No third-party packages are needed. Save the following as <code>validate_manifest.py</code>:</p>
<pre><code class="language-python">import argparse
import json
from pathlib import Path, PurePosixPath


def positive_int(value):
    return type(value) is int and value &gt; 0


def validate(data, root):
    errors = []
    root = Path(root).resolve()
    if not isinstance(data, dict):
        return ["manifest must be an object"]
    if type(data.get("schema_version")) is not int or data["schema_version"] != 1:
        errors.append("schema_version must be 1")
    catalog = data.get("references")
    if not isinstance(catalog, dict) or not catalog or any(
        not key.strip() or not positive_int(version)
        for key, version in catalog.items()
    ):
        errors.append("references catalog requires IDs and positive integer versions")
        catalog = {}
    shots = data.get("shots")
    if not isinstance(shots, list) or not shots:
        return errors + ["shots must be a non-empty array"]
    seen = set()
    for index, shot in enumerate(shots):
        label = f"shots[{index}]"
        if not isinstance(shot, dict):
            errors.append(f"{label}: must be an object")
            continue
        shot_id = shot.get("shot_id")
        if not isinstance(shot_id, str) or not shot_id.strip():
            errors.append(f"{label}: shot_id must be a non-empty string")
        elif shot_id in seen:
            errors.append(f"{label}: duplicate shot_id {shot_id}")
        else:
            seen.add(shot_id)
        version = shot.get("version")
        if not positive_int(version):
            errors.append(f"{label}: version must be a positive integer")
        approval = shot.get("approval")
        if not isinstance(approval, dict) or (
            approval.get("status") != "approved"
            or not positive_int(approval.get("version"))
            or approval.get("version") != version
        ):
            errors.append(f"{label}: approval must be approved for version {version}")
        refs = shot.get("references")
        if not isinstance(refs, dict) or not refs:
            errors.append(f"{label}: references must be a non-empty object")
        else:
            for ref_id, ref_version in refs.items():
                if ref_id not in catalog:
                    errors.append(f"{label}: unknown reference {ref_id}")
                elif not positive_int(ref_version) or ref_version != catalog[ref_id]:
                    errors.append(
                        f"{label}: reference {ref_id} must use version {catalog[ref_id]}"
                    )
        asset = shot.get("asset")
        if not isinstance(asset, str) or not asset.strip():
            errors.append(f"{label}: asset must be a non-empty string")
            continue
        if "\\" in asset or ":" in asset or PurePosixPath(asset).is_absolute():
            errors.append(f"{label}: asset must be a relative path using forward slashes")
            continue
        try:
            target = (root / asset).resolve()
            if not target.is_relative_to(root):
                errors.append(f"{label}: asset escapes package root")
            elif not target.is_file():
                errors.append(f"{label}: missing file {asset}")
        except (OSError, RuntimeError, ValueError):
            errors.append(f"{label}: cannot resolve asset path")
    return errors


def main():
    parser = argparse.ArgumentParser(description="Check shot metadata and local paths.")
    parser.add_argument("manifest", type=Path)
    args = parser.parse_args()
    try:
        data = json.loads(args.manifest.read_text(encoding="utf-8"))
        errors = validate(data, args.manifest.parent)
    except (OSError, UnicodeError, ValueError, RuntimeError) as exc:
        print(f"ERROR: {exc}")
        return 2
    if errors:
        for error in errors:
            print(f"ERROR: {error}")
        return 1
    print(f"OK: {len(data['shots'])} shot record(s) passed.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
</code></pre>
<p>Run it from your project directory:</p>
<pre><code class="language-sh">python validate_manifest.py package/manifest.json
</code></pre>
<p>The supplied fixture produces:</p>
<pre><code class="language-text">OK: 1 shot record(s) passed.
</code></pre>
<p>The program returns exit code 0. Validation errors return 1. An unreadable manifest or malformed JSON returns 2, so a packaging script can stop when either occurs.</p>
<p>The validator collects problems across shot entries instead of stopping at the first rejected take. It also checks types before comparing values. In particular, <code>type(value) is int</code> prevents JSON <code>true</code> from passing as version 1 through Python's boolean-integer relationship.</p>
<p>Asset paths are resolved relative to the manifest's directory. Resolving before checking containment lets the script reject a path or symbolic link that points outside the package. Existing files inside the package pass the path check, regardless of their media content.</p>
<h2>Break the example before trusting it</h2>
<p>Create a failure case with an old reference, a repeated shot ID, a newer unapproved version, a missing reference set, and a missing file:</p>
<pre><code class="language-python">import copy
import json
from pathlib import Path

path = Path("package/manifest.json")
data = json.loads(path.read_text(encoding="utf-8"))
second = copy.deepcopy(data["shots"][0])
data["shots"][0]["references"]["PRODUCT"] = 1
second.update(version=3, references={}, asset="picture/missing.txt")
data["shots"].append(second)
Path("package/manifest.bad.json").write_text(
    json.dumps(data, indent=2), encoding="utf-8"
)
</code></pre>
<p>Then run:</p>
<pre><code class="language-sh">python validate_manifest.py package/manifest.bad.json
</code></pre>
<p>Actual output from the supplied failure case:</p>
<pre><code class="language-text">ERROR: shots[0]: reference PRODUCT must use version 2
ERROR: shots[1]: duplicate shot_id S010
ERROR: shots[1]: approval must be approved for version 3
ERROR: shots[1]: references must be a non-empty object
ERROR: shots[1]: missing file picture/missing.txt
</code></pre>
<p>This case returns exit code 1. Fixing the duplicate ID alone still leaves the other problems unresolved.</p>
<p>Do not fix the approval error by copying the new version number into the approval record automatically. That would make the fields agree while skipping the review the fields are meant to record.</p>
<h2>Put the check at the packaging boundary</h2>
<p>Run the validator after selecting assets and before sending the folder to the editor. Re-run it if someone changes the selected files, references, or approval records.</p>
<p>A passing result means the declared relationships and local paths meet these rules. It does not prove that a person approved the asset, that its bytes have remained unchanged, or that the picture and sound work in sequence. Those require a trusted review record, integrity checks where needed, and media review.</p>
<p>The useful boundary is narrow: the editor gets a package with consistent shot metadata and resolvable asset paths. Creative approval stays with the people responsible for the work.</p>
]]></content:encoded></item></channel></rss>