)}
+
OPENTAKE / ORIGINAL MOTION
+
MAKE
YOUR
NEXT CUT.
+
FORM / LIGHT / RHYTHM
+
+};
diff --git a/marketing/official-beta-2026-09/src/scenes/Import.tsx b/marketing/official-beta-2026-09/src/scenes/Import.tsx
new file mode 100644
index 00000000..ac51ef37
--- /dev/null
+++ b/marketing/official-beta-2026-09/src/scenes/Import.tsx
@@ -0,0 +1,3 @@
+import React from 'react';
+import {Background,Brand,Footer,Heading,Screen} from '../theme';
+export const ImportScene:React.FC=()=>
素材
↓
预览
↓
时间线
;
diff --git a/marketing/official-beta-2026-09/src/scenes/Intro.tsx b/marketing/official-beta-2026-09/src/scenes/Intro.tsx
new file mode 100644
index 00000000..41f59bb5
--- /dev/null
+++ b/marketing/official-beta-2026-09/src/scenes/Intro.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import {AbsoluteFill,Img,interpolate,staticFile,useCurrentFrame} from 'remotion';
+import {Background,VERSION} from '../theme';
+export const Intro:React.FC=()=>{const f=useCurrentFrame();return
+
+
+
+
OpenTake
+ 把想法,
剪成作品。
+ {VERSION} / PUBLIC BETA
+
+ A NEW TAKE ON EDITING
+ };
diff --git a/marketing/official-beta-2026-09/src/scenes/Outro.tsx b/marketing/official-beta-2026-09/src/scenes/Outro.tsx
new file mode 100644
index 00000000..4f6663b9
--- /dev/null
+++ b/marketing/official-beta-2026-09/src/scenes/Outro.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import {Img,interpolate,staticFile,useCurrentFrame} from 'remotion';
+import {Background,VERSION} from '../theme';
+export const Outro:React.FC=()=>{const f=useCurrentFrame();return
+ })
OpenTake
下一部作品,从这里开始。
{VERSION} · PUBLIC BETA
开源视频编辑器 / GPL-3.0
github.com/appergb/OpenTake
+ Public Beta 持续迭代中 · 功能与平台支持以项目发布说明为准
};
diff --git a/marketing/official-beta-2026-09/src/scenes/TextEffects.tsx b/marketing/official-beta-2026-09/src/scenes/TextEffects.tsx
new file mode 100644
index 00000000..2f8c4ba8
--- /dev/null
+++ b/marketing/official-beta-2026-09/src/scenes/TextEffects.tsx
@@ -0,0 +1,4 @@
+import React from 'react';
+import {Sequence} from 'remotion';
+import {Background,Brand,Footer,Heading,Screen} from '../theme';
+export const TextEffects:React.FC=()=>
写下想说的
调出你的风格
;
diff --git a/marketing/official-beta-2026-09/src/scenes/Tracks.tsx b/marketing/official-beta-2026-09/src/scenes/Tracks.tsx
new file mode 100644
index 00000000..e92405ce
--- /dev/null
+++ b/marketing/official-beta-2026-09/src/scenes/Tracks.tsx
@@ -0,0 +1,3 @@
+import React from 'react';
+import {Background,Brand,Footer,Heading,Screen} from '../theme';
+export const Tracks:React.FC=()=>
MORE
THAN
ONE.
;
diff --git a/marketing/official-beta-2026-09/src/theme.tsx b/marketing/official-beta-2026-09/src/theme.tsx
new file mode 100644
index 00000000..2e38180d
--- /dev/null
+++ b/marketing/official-beta-2026-09/src/theme.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import {AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame} from 'remotion';
+export const VERSION='1.0.0-beta.6';
+export const Background:React.FC<{children?:React.ReactNode}>=({children})=>{const f=useCurrentFrame();return
+
+
+
+ {children}
+ };
+export const Brand:React.FC=()=>
})
OpenTake
;
+export const Footer:React.FC<{note?:string}>=({note='历史安装包实拍 · 界面以实际版本为准'})=>
{note}PUBLIC BETA
;
+export const Heading:React.FC<{eyebrow:string;title:string;sub:string}>=({eyebrow,title,sub})=>{const f=useCurrentFrame();return
};
+export const Screen:React.FC<{src:string;left?:number;top?:number;width?:number;height?:number;origin?:string;zoom?:number}>=({src,left=694,top=345,width=1130,height=610,origin='50% 50%',zoom=1})=>{const f=useCurrentFrame();return
})
};
diff --git a/marketing/official-beta-2026-09/tsconfig.json b/marketing/official-beta-2026-09/tsconfig.json
new file mode 100644
index 00000000..7bfaf1ab
--- /dev/null
+++ b/marketing/official-beta-2026-09/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2018",
+ "module": "Preserve",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "lib": ["es2015"],
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noUnusedLocals": true
+ },
+ "exclude": ["remotion.config.ts"]
+}
diff --git a/plugins/motion-canvas-studio/package-lock.json b/plugins/motion-canvas-studio/package-lock.json
index 78f1d368..c5032952 100644
--- a/plugins/motion-canvas-studio/package-lock.json
+++ b/plugins/motion-canvas-studio/package-lock.json
@@ -1016,9 +1016,9 @@
}
},
"node_modules/@xmldom/xmldom": {
- "version": "0.9.10",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
- "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
+ "version": "0.9.12",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
+ "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==",
"license": "MIT",
"engines": {
"node": ">=14.6"
diff --git a/plugins/motion-canvas-studio/package.json b/plugins/motion-canvas-studio/package.json
index 8ce626af..03555b20 100644
--- a/plugins/motion-canvas-studio/package.json
+++ b/plugins/motion-canvas-studio/package.json
@@ -4,6 +4,9 @@
"private": true,
"type": "module",
"license": "MIT",
+ "overrides": {
+ "@xmldom/xmldom": "0.9.12"
+ },
"scripts": {
"build": "tsc --noEmit && vite build && vite build --config vite.runner.config.ts && node scripts/normalize-bundle.mjs",
"test": "node --experimental-strip-types --test src/job.test.ts",
diff --git a/scripts/check_docs.py b/scripts/check_docs.py
new file mode 100644
index 00000000..f2cb74ba
--- /dev/null
+++ b/scripts/check_docs.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""Inventory maintained Markdown and check local links, without dependencies.
+
+Uses Git's tracked and non-ignored untracked file inventory. Generated media,
+marketing work and runtime caches are excluded. Historical missing evidence is
+reported, never restored. --json emits the complete machine-readable inventory.
+This is a file-link audit, not a heading-anchor or external-URL validator.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import re
+import subprocess
+from urllib.parse import unquote, urlsplit
+
+ROOT = Path(__file__).resolve().parents[1]
+EXCLUDED = ("marketing/", "output/", ".playwright-cli/", "target/", "web/node_modules/")
+OWNED_ELSEWHERE = {
+ "docs/releases/1.0.0-beta.6.md",
+ "docs/plans/active/2026-09-06-public-beta.md",
+ "docs/capabilities/CAPABILITY-LEDGER.md",
+ "docs/knowledge/2026-09-06-semantic-search-model.md",
+}
+PENDING: set[str] = set()
+LINK = re.compile(r'!?\[[^\]\n]*\]\((<[^>\n]+>|[^\s)]+)(?:\s+["\'][^\n]*?["\'])?\)')
+REFERENCE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*(<[^>]+>|\S+)')
+HTML_LINK = re.compile(r'\b(?:href|src)=["\']([^"\']+)["\']')
+
+
+def git_files(*args: str) -> list[str]:
+ result = subprocess.check_output(["git", *args, "-z"], cwd=ROOT)
+ return [name for name in result.decode().split("\0") if name]
+
+
+def classification(name: str) -> str:
+ if name == "docs/documentation-sync-2026-09-06.md":
+ return "生成的同步报告"
+ if name in OWNED_ELSEWHERE or name.startswith("docs/audit/2026-09-06/"):
+ return "主代理维护"
+ if (
+ name == "CLAUDE.md"
+ or name.startswith(("docs/audit/", "docs/releases/", "docs/superpowers/archive/", "update-summary/", ".superpowers/"))
+ or name.startswith("docs/superpowers/plans/")
+ or Path(name).name in {"HANDOFF-2026-07.md", "PORT-1TO1-GAP.md", "FULL_PROJECT_SCAN_REPORT.md", "BUGS.md", "ROADMAP.md", "CAPCUT-GAP.md"}
+ ):
+ return "历史保留"
+ if name.startswith(("docs/specs/", "docs/port-map/", "docs/upstream-analysis/", "docs/superpowers/specs/")) or Path(name).name == "SPEC.md":
+ return "设计与来源参考"
+ if name.startswith("crates/") or "assets/" in name or name.endswith("THIRD_PARTY_NOTICES.md"):
+ return "运行资源或归属记录"
+ return "维护文档"
+
+
+def local_targets(content: str):
+ fence: str | None = None
+ for line_number, line in enumerate(content.splitlines(), 1):
+ marker = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
+ if marker:
+ token = marker.group(1)
+ if fence is None:
+ fence = token
+ elif token[0] == fence[0] and len(token) >= len(fence):
+ fence = None
+ continue
+ if fence is not None:
+ continue
+ # Backticked paths describe code/history, not clickable Markdown links.
+ line = re.sub(r"(`+).*?\1", "", line)
+ matches = list(LINK.finditer(line)) + list(HTML_LINK.finditer(line))
+ reference = REFERENCE.match(line)
+ if reference:
+ matches.append(reference)
+ for match in matches:
+ target = match.group(1).strip("<>")
+ parsed = urlsplit(target)
+ if parsed.scheme or parsed.netloc or not parsed.path:
+ continue
+ yield line_number, target, unquote(parsed.path)
+
+
+def audit() -> dict:
+ names = sorted(set(git_files("ls-files", "--cached", "--others", "--exclude-standard")))
+ deleted = set(git_files("diff", "--name-only", "--diff-filter=D"))
+ changed = set(git_files("diff", "--name-only"))
+ tracked = set(git_files("ls-files", "--cached"))
+ inventory = []
+ issues = []
+ link_count = 0
+ for name in names:
+ path = ROOT / name
+ if path.suffix.lower() != ".md" or name.startswith(EXCLUDED) or not path.is_file():
+ continue
+ category = classification(name)
+ content = path.read_text(encoding="utf-8")
+ count = 0
+ for line, target, destination in local_targets(content):
+ count += 1
+ resolved = (path.parent / destination).resolve()
+ if resolved.exists():
+ continue
+ try:
+ relative = resolved.relative_to(ROOT).as_posix()
+ except ValueError:
+ relative = str(resolved)
+ if relative in deleted:
+ kind = "保留用户删除"
+ elif relative in PENDING:
+ kind = "候选文档待主代理生成"
+ elif relative.startswith("docs/audit/2026-09-06/"):
+ kind = "主代理范围待处理"
+ elif category == "主代理维护":
+ kind = "主代理范围待处理"
+ elif category == "历史保留" and not relative.endswith(('.md', '.MD')):
+ kind = "历史证据或外部路径缺失"
+ else:
+ kind = "断链"
+ issues.append(dict(file=name, line=line, target=target, resolved=relative, kind=kind))
+ link_count += count
+ inventory.append(dict(path=name, category=category, changed=name in changed or name not in tracked, local_links=count))
+ entries = [row["path"] for row in inventory if Path(row["path"]).name.lower() in {"agent.md", "agents.md"}]
+ errors = sum(issue["kind"] == "断链" for issue in issues)
+ if entries != ["AGENTS.md"]:
+ errors += 1
+ return dict(markdown_count=len(inventory), local_link_count=link_count, errors=errors,
+ agent_entries=entries, inventory=inventory, issues=issues)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--json", action="store_true", help="emit complete audit JSON to stdout")
+ args = parser.parse_args()
+ result = audit()
+ if args.json:
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ else:
+ print(f"Markdown: {result['markdown_count']}; local links: {result['local_link_count']}; errors: {result['errors']}")
+ print("Agent entries: " + ", ".join(result["agent_entries"]))
+ for issue in result["issues"]:
+ print(f"{issue['kind']}: {issue['file']}:{issue['line']} -> {issue['target']}")
+ return 1 if result["errors"] else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/check_release_workflow.py b/scripts/check_release_workflow.py
index ac0efa91..16c2f49b 100644
--- a/scripts/check_release_workflow.py
+++ b/scripts/check_release_workflow.py
@@ -29,13 +29,13 @@
RELEASE_NOTES_PATH = Path(
os.environ.get(
"OPENTAKE_RELEASE_NOTES_PATH",
- REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.5.md",
+ REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.6.md",
)
).resolve()
-CURRENT_RELEASE_VERSION = "1.0.0-beta.5"
+CURRENT_RELEASE_VERSION = "1.0.0-beta.6"
APPROVED_REPOSITORY_IDENTITIES = {
"1.0.0-beta.4": ("1.0.0.4", "Beta 4"),
- CURRENT_RELEASE_VERSION: ("1.0.0.5", "Beta 5"),
+ CURRENT_RELEASE_VERSION: ("1.0.0.6", "Beta 6"),
}
PINNED_ACTIONS = {
"actions/checkout": "11d5960a326750d5838078e36cf38b85af677262",
@@ -175,16 +175,16 @@
}
APPROVED_COMPLEX_RUN_SHA256 = {
- ("validate", "Validate tag, source SHA, versions, and notes"): "eefb9d97ad80e8090b817178093824af2b897ae339a144b2e16a91de3f9f8334",
+ ("validate", "Validate tag, source SHA, versions, and notes"): "ae045c61e3f1a8bb1cb98115ee2c818679fa531766dca64d6f64ab962375d907",
("validate", "Reassert exact source after validation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2",
("quality", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd",
("quality", "Free disk space"): "5848415c4d0e696f46965d62a2e17c8b7a0dd45ae600d28102af0b04108d9bf6",
("quality", "Install system deps (ffmpeg + Tauri/GTK)"): "ee466d2d3fff1c3703d50f9dabe4d21e1cee4b399924d064c6d2714dae34d16b",
("quality", "Audit Motion Canvas dependencies and licenses"): "a3517fae1a8663e519138196c9f3721d8f4df19ac8f115c49a079c4aaa60c8b3",
("quality", "Test and reproduce Motion Canvas runner"): "8bcd55de9b045f9d7be6343163a5422cba0ab545f7844da50ca1a7c8623fe640",
- ("quality", "Validate Windows and release workflow contracts"): "76d3d0db11222f5121e5b404470296695c8cfea0b4e41f0c3a1d853612331e7d",
+ ("quality", "Validate Windows and release workflow contracts"): "cb9d3625194c0827915a05a79643547477241cabb1032a4c45485e73717d3c34",
("quality", "Provisioner unit tests"): "f57d4d7d6df403d573d31bbda02589804109596040c2cefcca60f3e9352e891a",
- ("quality", "Validate Web dependency licenses"): "5b914e6aaddab4c9ca0ac03ff0cc03d23b44fc3d66621b8c9fa5eeac07a5cfcd",
+ ("quality", "Validate Web dependency licenses"): "bd7252d73461802613ac259514ee9242afa9e799f47bc46e1b62330ac79ebcf3",
("quality", "Live playback transport integration"): "461f79546009551e5e7adbf50f869abb9449c2ae7666a66a425c7cd3c24acea9",
("quality", "Reassert exact source after quality gates"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2",
("macos_arm64", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd",
@@ -221,8 +221,8 @@
}
APPROVED_JOB_SHA256 = {
- "validate": "ce61e176161ab7ec892d92ed9cf1a3e379c6b5f7b5781bf1addc4f29379a85b2",
- "quality": "d0078d8cd49919be4a1f71f1208c0d05369282c13f5ae5c38142063d648816c2",
+ "validate": "9dac25c2b76e56677452e781bd5f163c423891646ee5965611d917498cda5fc1",
+ "quality": "5204e0241bbc8c35f2a59d93d6b8d584b3ae7aa5c640a7d5062da168fc2b7f08",
"macos_arm64": "1785d765c96278190c25e312c9e610070619e17b7b2b0d922f0bd234501df525",
"windows_x64": "63bd70d85e40a3f1177e9059d4674d7f93d4502181fc378f7706e839af953378",
"publish": "ea3fe6d18a94c0850d3ac7f21c4e23fb8fcf572189f77f659e5b34eab776bd85",
@@ -1479,8 +1479,8 @@ def validate_workflow(workflow: str) -> list[str]:
'if event_name == "workflow_dispatch":',
'expected_version = "1.0.0-beta.4"',
'expected_wix_version = "1.0.0.4"',
- 'expected_version = "1.0.0-beta.5"',
- 'expected_wix_version = "1.0.0.5"',
+ 'expected_version = "1.0.0-beta.6"',
+ 'expected_wix_version = "1.0.0.6"',
'if versions != {version}:',
'if version != expected_version:',
'wix_version = tauri["bundle"]["windows"]["wix"]["version"]',
@@ -1494,7 +1494,7 @@ def validate_workflow(workflow: str) -> list[str]:
'if "+" in tag:',
'raise SystemExit("SemVer build metadata is unsupported for updater asset URLs")',
'if SEMVER_RE.fullmatch(tag) is None:',
- 'if version == "1.0.0-beta.5" and not prerelease:',
+ 'if version == "1.0.0-beta.6" and not prerelease:',
'emit("prerelease", "true")',
),
):
@@ -1595,7 +1595,7 @@ def validate_workflow(workflow: str) -> list[str]:
license_step,
(
'case "$OPENTAKE_EXPECTED_RELEASE_VERSION" in',
- "1.0.0-beta.5)",
+ "1.0.0-beta.6)",
"python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py'",
"python3 -B scripts/check_license_inventory.py",
"1.0.0-beta.4)",
@@ -1637,7 +1637,7 @@ def validate_workflow(workflow: str) -> list[str]:
'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/provision_ffmpeg_sidecars.py" \\',
'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/tests/test_provision_ffmpeg_sidecars.py" \\',
'git cat-file blob "$RELEASE_TOOLING_SHA:.github/workflows/release.yml" \\',
- 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.5.md" \\',
+ 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.6.md" \\',
'OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \\',
'OPENTAKE_RELEASE_WORKFLOW_PATH="$tooling_root/release.yml" \\',
'OPENTAKE_RELEASE_NOTES_PATH="$tooling_root/release-notes.md" \\',
@@ -2545,7 +2545,7 @@ def validate_release_notes_contract(notes_path: Path) -> list[str]:
try:
notes = notes_path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
- return ["Beta 5 release notes document dual-SHA recovery provenance"]
+ return ["Beta 6 release notes document dual-SHA recovery provenance"]
normalized = " ".join(notes.split())
required = (
"正常 tag push",
@@ -2568,7 +2568,7 @@ def validate_release_notes_contract(notes_path: Path) -> list[str]:
"notes commit",
)
if not notes.strip() or any(marker not in normalized for marker in required):
- return ["Beta 5 release notes document dual-SHA recovery provenance"]
+ return ["Beta 6 release notes document dual-SHA recovery provenance"]
return []
diff --git a/scripts/prepare_search_model_fixtures.py b/scripts/prepare_search_model_fixtures.py
new file mode 100644
index 00000000..9d0bfbec
--- /dev/null
+++ b/scripts/prepare_search_model_fixtures.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""Fetch opt-in real-model test fixtures using the exported product manifest."""
+
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import tempfile
+import urllib.request
+
+
+def fetch(url, destination, expected_hash, expected_size=None):
+ def valid(path):
+ if not path.is_file() or (expected_size is not None and path.stat().st_size != expected_size):
+ return False
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest() == expected_hash
+
+ if valid(destination):
+ return
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as staged:
+ temporary = Path(staged.name)
+ try:
+ with urllib.request.urlopen(url, timeout=120) as response:
+ total = 0
+ while chunk := response.read(1024 * 1024):
+ total += len(chunk)
+ if total > (expected_size if expected_size is not None else 10_000_000):
+ raise ValueError(f"Oversized fixture: {destination.name}")
+ staged.write(chunk)
+ staged.close()
+ if not valid(temporary):
+ raise ValueError(f"Fixture checksum/size mismatch: {destination.name}")
+ temporary.replace(destination)
+ finally:
+ staged.close()
+ temporary.unlink(missing_ok=True)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--manifest", type=Path, required=True)
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ exported = json.loads(args.manifest.read_text(encoding="utf-8-sig"))
+ root = args.output.resolve()
+ for key in ("imageEncoder", "textEncoder", "tokenizer"):
+ asset = exported["manifest"][key]
+ target = (root / asset["name"]).resolve()
+ if not target.is_relative_to(root):
+ raise ValueError("Fixture path must remain inside the output directory")
+ fetch(f'{exported["base_url"]}/{asset["name"]}', target, asset["sha256"], asset["bytes"])
+ images = (
+ ("cats.png", "coco_sample.png", "cf6f3c4befa148732c7453e0de5afab00f682427435fead2d88b07a9615cdac2"),
+ ("parrots.png", "hub/parrots.png", "d14e9adf584087f478dc9231c64caf6631d363dfd2188b10a4bd1c0a4020d082"),
+ )
+ for name, source, digest in images:
+ fetch(f"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/{source}", root / name, digest)
+ print("Verified all real-model fixtures")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_check_release_workflow.py b/scripts/test_check_release_workflow.py
index ee25f351..382cb32e 100644
--- a/scripts/test_check_release_workflow.py
+++ b/scripts/test_check_release_workflow.py
@@ -23,7 +23,7 @@
RELEASE_NOTES_PATH = Path(
os.environ.get(
"OPENTAKE_RELEASE_NOTES_PATH",
- REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.5.md",
+ REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.6.md",
)
).resolve()
WORKFLOW = WORKFLOW_PATH.read_text(encoding="utf-8") if WORKFLOW_PATH.is_file() else ""
@@ -1227,8 +1227,8 @@ def test_recovery_release_notes_are_loaded_from_exact_tooling_commit(
) -> None:
mutations = (
(
- 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.5.md" \\\n',
- 'cp docs/releases/1.0.0-beta.5.md \\\n',
+ 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.6.md" \\\n',
+ 'cp docs/releases/1.0.0-beta.6.md \\\n',
"exact release tooling provenance",
),
(
@@ -1264,7 +1264,7 @@ def test_validation_must_compare_all_public_versions(self) -> None:
def test_validation_must_bind_windows_installer_version(self) -> None:
mutated = self.mutate(
- 'expected_wix_version = "1.0.0.5"',
+ 'expected_wix_version = "1.0.0.6"',
'expected_wix_version = "1.0.0.4"',
)
self.assert_rejected(mutated, "Cargo, Tauri, and Web versions match tag")
@@ -1272,7 +1272,7 @@ def test_validation_must_bind_windows_installer_version(self) -> None:
def test_recovery_must_bind_the_beta4_installer_identity(self) -> None:
mutated = self.mutate(
'expected_wix_version = "1.0.0.4"',
- 'expected_wix_version = "1.0.0.5"',
+ 'expected_wix_version = "1.0.0.6"',
)
self.assert_rejected(mutated, "Cargo, Tauri, and Web versions match tag")
@@ -1284,8 +1284,8 @@ def test_validation_binds_product_identity_to_the_authenticated_event_path(
'if event_name == "workflow_dispatch":',
'expected_version = "1.0.0-beta.4"',
'expected_wix_version = "1.0.0.4"',
- 'expected_version = "1.0.0-beta.5"',
- 'expected_wix_version = "1.0.0.5"',
+ 'expected_version = "1.0.0-beta.6"',
+ 'expected_wix_version = "1.0.0.6"',
"if version != expected_version:",
"if wix_version != expected_wix_version:",
):
@@ -1298,9 +1298,9 @@ def test_release_tag_must_reject_semver_build_metadata(self) -> None:
mutated = self.mutate(guard, ' if False:\n')
self.assert_rejected(mutated, "SemVer build metadata is unsupported")
- def test_beta5_candidate_must_keep_its_explicit_prerelease_guard(self) -> None:
+ def test_beta6_candidate_must_keep_its_explicit_prerelease_guard(self) -> None:
mutated = self.mutate(
- 'if version == "1.0.0-beta.5" and not prerelease:',
+ 'if version == "1.0.0-beta.6" and not prerelease:',
'if version == "1.0.0-beta.4" and not prerelease:',
)
self.assert_rejected(mutated, "SemVer build metadata is unsupported")
@@ -1880,7 +1880,7 @@ def test_quality_runs_web_license_inventory_fail_closed(self) -> None:
" run: |\n"
" set -euo pipefail\n"
" case \"$OPENTAKE_EXPECTED_RELEASE_VERSION\" in\n"
- " 1.0.0-beta.5)\n"
+ " 1.0.0-beta.6)\n"
" python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py'\n"
" python3 -B scripts/check_license_inventory.py\n"
" ;;\n"
@@ -2166,7 +2166,7 @@ def test_publish_command_cannot_be_faked_by_echo(self) -> None:
)
self.assert_rejected(mutated, "verified prerelease publication")
- def test_repository_metadata_is_beta5_and_release_notes_exist(self) -> None:
+ def test_repository_metadata_is_beta6_and_release_notes_exist(self) -> None:
self.assertEqual(
[],
contract.validate_repository_metadata(
@@ -2185,7 +2185,7 @@ def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None:
"tag must always equal current main\n", encoding="utf-8"
)
self.assertEqual(
- ["Beta 5 release notes document dual-SHA recovery provenance"],
+ ["Beta 6 release notes document dual-SHA recovery provenance"],
contract.validate_release_notes_contract(notes),
)
canonical = RELEASE_NOTES_PATH.read_text(encoding="utf-8")
@@ -2204,7 +2204,7 @@ def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None:
)
self.assertEqual(
[
- "Beta 5 release notes document dual-SHA recovery provenance"
+ "Beta 6 release notes document dual-SHA recovery provenance"
],
contract.validate_release_notes_contract(notes),
)
@@ -2214,8 +2214,8 @@ class ReleaseRepositoryMetadataTests(unittest.TestCase):
def make_repository(
self,
*,
- version: str = "1.0.0-beta.5",
- wix_version: str = "1.0.0.5",
+ version: str = "1.0.0-beta.6",
+ wix_version: str = "1.0.0.6",
) -> tuple[tempfile.TemporaryDirectory[str], Path]:
temporary = tempfile.TemporaryDirectory()
root = Path(temporary.name)
@@ -2258,7 +2258,7 @@ def test_non_object_json_metadata_returns_a_contract_error(self) -> None:
def test_release_notes_io_error_returns_a_contract_error(self) -> None:
temporary, root = self.make_repository()
self.addCleanup(temporary.cleanup)
- notes = root / "docs" / "releases" / "1.0.0-beta.5.md"
+ notes = root / "docs" / "releases" / "1.0.0-beta.6.md"
real_read_text = Path.read_text
def fail_notes(path: Path, *args, **kwargs):
@@ -2268,7 +2268,7 @@ def fail_notes(path: Path, *args, **kwargs):
with mock.patch.object(Path, "read_text", autospec=True, side_effect=fail_notes):
self.assertEqual(
- ["Beta 5 release notes exist"],
+ ["Beta 6 release notes exist"],
contract.validate_repository_metadata(
root, expected_version=contract.CURRENT_RELEASE_VERSION
),
@@ -2280,14 +2280,14 @@ def test_wrong_windows_installer_version_returns_a_contract_error(self) -> None:
(root / "src-tauri" / "tauri.conf.json").write_text(
json.dumps(
{
- "version": "1.0.0-beta.5",
+ "version": "1.0.0-beta.6",
"bundle": {"windows": {"wix": {"version": "1.0.0.4"}}},
}
),
encoding="utf-8",
)
self.assertEqual(
- ["Windows installer version is 1.0.0.5"],
+ ["Windows installer version is 1.0.0.6"],
contract.validate_repository_metadata(
root, expected_version=contract.CURRENT_RELEASE_VERSION
),
@@ -2301,7 +2301,7 @@ def test_each_stale_beta4_product_identity_is_rejected(self) -> None:
json.dumps(
{
"version": "1.0.0-beta.4",
- "bundle": {"windows": {"wix": {"version": "1.0.0.5"}}},
+ "bundle": {"windows": {"wix": {"version": "1.0.0.6"}}},
}
),
),
@@ -2313,22 +2313,22 @@ def test_each_stale_beta4_product_identity_is_rejected(self) -> None:
self.addCleanup(temporary.cleanup)
(root / relative_path).write_text(contents, encoding="utf-8")
self.assertIn(
- "repository metadata is OpenTake 1.0.0-beta.5",
+ "repository metadata is OpenTake 1.0.0-beta.6",
contract.validate_repository_metadata(
root, expected_version=contract.CURRENT_RELEASE_VERSION
),
)
- def test_beta4_notes_do_not_satisfy_the_beta5_release_contract(self) -> None:
+ def test_beta4_notes_do_not_satisfy_the_beta6_release_contract(self) -> None:
temporary, root = self.make_repository()
self.addCleanup(temporary.cleanup)
- (root / "docs" / "releases" / "1.0.0-beta.5.md").unlink()
+ (root / "docs" / "releases" / "1.0.0-beta.6.md").unlink()
(root / "docs" / "releases" / "1.0.0-beta.4.md").write_text(
"historical release notes\n", encoding="utf-8"
)
self.assertEqual(
- ["Beta 5 release notes exist"],
+ ["Beta 6 release notes exist"],
contract.validate_repository_metadata(
root, expected_version=contract.CURRENT_RELEASE_VERSION
),
@@ -2346,10 +2346,10 @@ def test_approved_beta4_recovery_identity_is_accepted(self) -> None:
),
)
- def test_beta4_and_beta5_identities_cannot_cross_authenticated_paths(self) -> None:
+ def test_beta4_and_beta6_identities_cannot_cross_authenticated_paths(self) -> None:
cases = (
- ("1.0.0-beta.4", "1.0.0.4", "1.0.0-beta.5"),
- ("1.0.0-beta.5", "1.0.0.5", "1.0.0-beta.4"),
+ ("1.0.0-beta.4", "1.0.0.4", "1.0.0-beta.6"),
+ ("1.0.0-beta.6", "1.0.0.6", "1.0.0-beta.4"),
)
for source_version, source_wix, expected_version in cases:
with self.subTest(source=source_version, expected=expected_version):
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index ffc8705a..6eaaea04 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -75,6 +75,7 @@ futures-util = "0.3"
quick-xml = "=0.41.0"
crossbeam-channel = "0.5"
velato = { workspace = true }
+zip = { version = "4", default-features = false, features = ["deflate"] }
sentry = { workspace = true }
http-range = "0.1.5"
glob = "0.3"
diff --git a/src-tauri/README.md b/src-tauri/README.md
index 2b479381..02c86d6a 100644
--- a/src-tauri/README.md
+++ b/src-tauri/README.md
@@ -1,5 +1,9 @@
# src-tauri — Tauri 2 desktop shell
+> Status: draft · Stage: implementation-backed · Updated: 2026-09-06.
+> This page describes the shell boundary and early command examples. Use the [current module index](../docs/modules/src-tauri/INDEX.md), [export contract](../docs/modules/src-tauri/export.md), and [candidate validation](../docs/audit/2026-09-06/public-beta-validation.md) for the full current surface. Command registration lives in `src/lib.rs`; the table below is not an exhaustive catalog.
+
+
The Tauri 2 desktop shell for OpenTake. It is a workspace member
(`members = [..., "src-tauri"]` in `../Cargo.toml`) and holds the authoritative
[`opentake_core::AppCore`] as managed state, exposing a thin `#[tauri::command]`
diff --git a/src-tauri/src/advanced.rs b/src-tauri/src/advanced.rs
index 72f041cc..5f836373 100644
--- a/src-tauri/src/advanced.rs
+++ b/src-tauri/src/advanced.rs
@@ -3283,7 +3283,7 @@ fn decode_color_sample(
fn mean_linear_rgb(frame: &RgbaFrame) -> Result
{
let mut sum = [0.0_f64; 3];
let mut weight = 0.0_f64;
- for pixel in frame.rgba.chunks_exact(4) {
+ for pixel in frame.rgba.as_chunks::<4>().0.iter() {
let alpha = f64::from(pixel[3]) / 255.0;
if alpha <= 0.01 {
continue;
@@ -3832,13 +3832,19 @@ fn encode_matte_frame(
.infer(&frame, cancel)
.map_err(media_workflow_error)?;
let mut rgba = Vec::with_capacity(matte.alpha.len() * 4);
- for (rgb, alpha) in matte.foreground_rgb.chunks_exact(3).zip(matte.alpha) {
+ for (rgb, alpha) in matte
+ .foreground_rgb
+ .as_chunks::<3>()
+ .0
+ .iter()
+ .zip(matte.alpha)
+ {
rgba.extend_from_slice(rgb);
rgba.push(alpha);
}
frame = RgbaFrame::new(frame.width, frame.height, rgba);
} else {
- for pixel in frame.rgba.chunks_exact_mut(4) {
+ for pixel in frame.rgba.as_chunks_mut::<4>().0.iter_mut() {
pixel[3] = 255;
}
}
diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs
index 00b7fe0e..2665602e 100644
--- a/src-tauri/src/export.rs
+++ b/src-tauri/src/export.rs
@@ -7,7 +7,8 @@
//! (`opentake_media::VideoEncoder`) to produce a real `.mp4` on disk.
//!
//! Scope of this first cut (SPEC §2.4 / §8.2):
-//! - **H.264 / .mp4**, **H.265 / .mp4**, and **ProRes 422 / .mov** are wired.
+//! - **H.264 / .mp4**, **H.265 / .mp4**, **ProRes 422 / .mov**, and transparent
+//! **ProRes 4444 / .mov** are wired.
//! - **Linear audio mixdown**: every audio-bearing clip's source window is
//! decoded to mono f32 at the mix rate, placed at its frame-derived sample
//! offset, scaled by its `volume_at` envelope, summed, hard-limited, and mux'd
@@ -57,7 +58,7 @@ use opentake_media::encode::ClipAudio;
use opentake_media::encode::{mix, MIX_SAMPLE_RATE};
use opentake_media::{
decode_frame_at, extract_pcm, extract_pcm_cancellable_with_progress, interpolate_frame_pair,
- probe, ExportPreset, ExportResolution as EncodeResolution, FrameInterpolationFallback,
+ ExportPreset, ExportResolution as EncodeResolution, FrameInterpolationFallback,
FrameInterpolationMode, FrameRequest, MediaCancelToken, PcmBuffer, PcmFormat,
PcmProgressCallback, PcmSpec, RgbaFrame, VideoCodec, VideoEncoder,
};
@@ -90,6 +91,8 @@ pub enum ExportCodec {
H265,
/// Apple ProRes 422 / `.mov`.
Prores,
+ /// Apple ProRes 4444 with an alpha plane / `.mov`.
+ Prores4444,
}
/// Requested output short-edge resolution, projected from the front-end.
@@ -171,9 +174,9 @@ pub const CANCELLED_SENTINEL: &str = "export cancelled";
/// Claiming a lease and publishing its fresh token happen under one mutex, so a
/// concurrent cancel can only target the previous operation or the new one; it
/// can never be erased by a later reset.
-#[derive(Default)]
+#[derive(Clone, Default)]
pub struct ExportControl {
- operation: Mutex,
+ operation: Arc>,
}
#[derive(Default)]
@@ -188,14 +191,14 @@ struct ActiveExport {
cancel: MediaCancelToken,
}
-pub(crate) struct ExportGuard<'a> {
- control: &'a ExportControl,
+pub(crate) struct ExportGuard {
+ control: ExportControl,
generation: u64,
operation_id: String,
cancel: MediaCancelToken,
}
-impl std::fmt::Debug for ExportGuard<'_> {
+impl std::fmt::Debug for ExportGuard {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ExportGuard")
@@ -205,7 +208,7 @@ impl std::fmt::Debug for ExportGuard<'_> {
}
}
-impl Drop for ExportGuard<'_> {
+impl Drop for ExportGuard {
fn drop(&mut self) {
let mut state = self
.control
@@ -262,7 +265,26 @@ impl ExportControl {
.unwrap_or_default()
}
- pub(crate) fn try_begin(&self, operation_id: &str) -> Result, String> {
+ /// Linearize a normal export's final success against cancellation. Save-as
+ /// workflows use `ExportGuard::commit` later, after their manifest
+ /// transaction has passed its own identity checks.
+ pub(crate) fn commit_active(&self) -> Result<(), String> {
+ let mut state = self
+ .operation
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let active = state
+ .active
+ .as_ref()
+ .ok_or_else(|| "export generation is no longer active".to_string())?;
+ if active.cancel.is_cancelled() {
+ return Err(CANCELLED_SENTINEL.to_string());
+ }
+ state.active = None;
+ Ok(())
+ }
+
+ pub(crate) fn try_begin(&self, operation_id: &str) -> Result {
self.try_begin_with_hook(operation_id, || {})
}
@@ -270,7 +292,7 @@ impl ExportControl {
&self,
operation_id: &str,
after_publish: impl FnOnce(),
- ) -> Result, String> {
+ ) -> Result {
validate_export_operation_id(operation_id)?;
let mut state = self
.operation
@@ -290,7 +312,7 @@ impl ExportControl {
after_publish();
drop(state);
Ok(ExportGuard {
- control: self,
+ control: self.clone(),
generation,
operation_id: operation_id.to_string(),
cancel,
@@ -298,7 +320,7 @@ impl ExportControl {
}
}
-impl ExportGuard<'_> {
+impl ExportGuard {
/// Observe cancellation for this exact export generation.
pub(crate) fn checkpoint(&self) -> Result<(), String> {
if self.cancel.checkpoint() {
@@ -355,6 +377,17 @@ fn validate_export_operation_id(operation_id: &str) -> Result<(), String> {
Ok(())
}
+fn validate_export_cancel_sources(
+ control: Option<&ExportControl>,
+ external_cancel: Option<&MediaCancelToken>,
+) -> Result<(), String> {
+ if control.is_some() && external_cancel.is_some() {
+ Err("export cannot combine control and external cancellation sources".to_string())
+ } else {
+ Ok(())
+ }
+}
+
/// `cancel_export`: request that the in-flight export (if any) stop at its next
/// cancellation checkpoint. The request must name the operation that exposed
/// the cancel control; stale requests cannot target a successor generation.
@@ -438,6 +471,23 @@ fn resolve_preset(
quality.encode_resolution(),
))
}
+ ExportCodec::Prores4444 => {
+ if ext.as_deref() != Some("mov") {
+ return Err("ProRes 4444 export requires a .mov output path".to_string());
+ }
+ Ok(ExportPreset::new(
+ VideoCodec::ProRes4444,
+ quality.encode_resolution(),
+ ))
+ }
+ }
+}
+
+fn export_clear_rgba(codec: ExportCodec) -> [f64; 4] {
+ if codec == ExportCodec::Prores4444 {
+ [0.0, 0.0, 0.0, 0.0]
+ } else {
+ [0.0, 0.0, 0.0, 1.0]
}
}
@@ -787,8 +837,18 @@ where
decode(path, &AUDIO_DECODE_SPEC, range, &cancel, progress)
}
+#[cfg(test)]
fn check_audio_cancel(control: &ExportControl) -> Result<(), String> {
- if control.is_cancelled() {
+ check_audio_cancel_with_external(Some(control), None)
+}
+
+fn check_audio_cancel_with_external(
+ control: Option<&ExportControl>,
+ external_cancel: Option<&MediaCancelToken>,
+) -> Result<(), String> {
+ if control.is_some_and(ExportControl::is_cancelled)
+ || external_cancel.is_some_and(MediaCancelToken::is_cancelled)
+ {
Err(CANCELLED_SENTINEL.to_string())
} else {
Ok(())
@@ -801,10 +861,20 @@ fn retime_pcm_to_len(samples: &[f32], target_len: usize) -> Vec {
.expect("retime without cancellation cannot fail")
}
+#[cfg(test)]
fn retime_pcm_to_len_with_control(
samples: &[f32],
target_len: usize,
control: Option<&ExportControl>,
+) -> Result, String> {
+ retime_pcm_to_len_with_external(samples, target_len, control, None)
+}
+
+fn retime_pcm_to_len_with_external(
+ samples: &[f32],
+ target_len: usize,
+ control: Option<&ExportControl>,
+ external_cancel: Option<&MediaCancelToken>,
) -> Result, String> {
if samples.is_empty() || target_len == 0 {
return Ok(Vec::new());
@@ -815,9 +885,7 @@ fn retime_pcm_to_len_with_control(
let mut retimed = Vec::with_capacity(target_len);
for index in 0..target_len {
if index.is_multiple_of(AUDIO_CANCEL_CHUNK_SAMPLES) {
- if let Some(control) = control {
- check_audio_cancel(control)?;
- }
+ check_audio_cancel_with_external(control, external_cancel)?;
}
let value = if samples.len() == 1 || target_len == 1 {
samples[0]
@@ -976,17 +1044,29 @@ fn project_clip_audio(
}))
}
+#[cfg(test)]
fn apply_export_denoise(
samples: &[f32],
channels: usize,
config: Option,
control: Option<&ExportControl>,
+) -> Result, String> {
+ apply_export_denoise_with_external(samples, channels, config, control, None)
+}
+
+fn apply_export_denoise_with_external(
+ samples: &[f32],
+ channels: usize,
+ config: Option,
+ control: Option<&ExportControl>,
+ external_cancel: Option<&MediaCancelToken>,
) -> Result, String> {
let Some(config) = config else {
return Ok(samples.to_vec());
};
let cancel = control
.map(ExportControl::media_cancel_token)
+ .or_else(|| external_cancel.cloned())
.unwrap_or_default();
opentake_media::analysis::denoise_interleaved(
samples,
@@ -1032,6 +1112,7 @@ fn mix_timeline_audio(
start_frame: 0,
end_frame: timeline.total_frames(),
control,
+ external_cancel: None,
on_progress,
},
|samples| {
@@ -1053,6 +1134,7 @@ struct AudioStreamOptions<'a> {
start_frame: i32,
end_frame: i32,
control: Option<&'a ExportControl>,
+ external_cancel: Option<&'a MediaCancelToken>,
on_progress: Option,
}
@@ -1067,6 +1149,7 @@ fn stream_flattened_audio(
start_frame,
end_frame,
control,
+ external_cancel,
on_progress,
} = options;
if timeline_fps <= 0 || start_frame >= end_frame {
@@ -1104,12 +1187,11 @@ fn stream_flattened_audio(
.min_by(f64::total_cmp);
let cancel = control
.map(ExportControl::media_cancel_token)
+ .or_else(|| external_cancel.cloned())
.unwrap_or_default();
for relative_start in (0..total_samples).step_by(AUDIO_STREAM_WINDOW_SAMPLES) {
- if let Some(control) = control {
- check_audio_cancel(control)?;
- }
+ check_audio_cancel_with_external(control, external_cancel)?;
let window_len = AUDIO_STREAM_WINDOW_SAMPLES.min(total_samples - relative_start);
let window_start = range_start.saturating_add(relative_start);
let window_end = window_start.saturating_add(window_len);
@@ -1147,7 +1229,16 @@ fn stream_flattened_audio(
None,
extract_pcm_cancellable_with_progress,
),
- None => extract_pcm(&info.path, &AUDIO_DECODE_SPEC, Some(source_range)),
+ None => match external_cancel {
+ Some(cancel) => extract_pcm_cancellable_with_progress(
+ &info.path,
+ &AUDIO_DECODE_SPEC,
+ Some(source_range),
+ cancel,
+ None,
+ ),
+ None => extract_pcm(&info.path, &AUDIO_DECODE_SPEC, Some(source_range)),
+ },
};
let pcm = match decoded {
Ok(pcm) => pcm,
@@ -1163,14 +1254,23 @@ fn stream_flattened_audio(
}
};
let target_len = overlap_end - overlap_start;
- let retimed = retime_pcm_to_len_with_control(&pcm.samples_f32, target_len, control)?;
- let processed = apply_export_denoise(&retimed, 1, plan.audio_denoise(), control)?;
+ let retimed = retime_pcm_to_len_with_external(
+ &pcm.samples_f32,
+ target_len,
+ control,
+ external_cancel,
+ )?;
+ let processed = apply_export_denoise_with_external(
+ &retimed,
+ 1,
+ plan.audio_denoise(),
+ control,
+ external_cancel,
+ )?;
let output_start = overlap_start - window_start;
for (offset, sample) in processed.into_iter().take(target_len).enumerate() {
if offset.is_multiple_of(AUDIO_CANCEL_CHUNK_SAMPLES) {
- if let Some(control) = control {
- check_audio_cancel(control)?;
- }
+ check_audio_cancel_with_external(control, external_cancel)?;
}
let absolute_sample = overlap_start.saturating_add(offset);
let timeline_frame = ((absolute_sample as f64 / MIX_SAMPLE_RATE as f64)
@@ -1191,7 +1291,7 @@ fn stream_flattened_audio(
AUDIO_MIX_START + (completed.saturating_mul(span) / total_samples.max(1)) as i32;
report(mapped, AUDIO_PROGRESS_TOTAL);
}
- if cancel.checkpoint() {
+ if cancel.checkpoint() || external_cancel.is_some_and(MediaCancelToken::is_cancelled) {
return Err(CANCELLED_SENTINEL.to_string());
}
}
@@ -1236,6 +1336,7 @@ pub(crate) fn write_timeline_audio_wav_for_manifest_with_control(
start_frame: 0,
end_frame,
control: Some(control),
+ external_cancel: None,
on_progress: on_progress.clone(),
},
|samples| {
@@ -1291,16 +1392,16 @@ pub(crate) fn write_timeline_audio_wav_for_manifest_with_control(
/// to opaque black, which is the correct clear color, not an error.
///
/// Emits throttled `"export://progress"` events via `app` and polls `control`
-/// for a mid-encode cancel every frame (see the module doc). This is a sync
-/// (non-`async`) command, so Tauri runs it on a worker thread — `cancel_export`
-/// (and the WebView's event loop delivering `"export://progress"`) keep running
-/// concurrently while this call is in flight.
+/// for a mid-encode cancel every frame (see the module doc). The async command
+/// claims its lease and snapshots the project before handing the blocking work
+/// to `spawn_blocking`, leaving the UI and `cancel_export` responsive. The
+/// worker owns the lease until it actually finishes, even if its caller drops.
///
/// GPU acquisition / decode / encode failures surface to the front-end as
/// `Err(String)` (the Tauri boundary contract); a mid-export cancel surfaces as
/// `Err(`[`CANCELLED_SENTINEL`]`)`.
#[tauri::command]
-pub fn export_video(
+pub async fn export_video(
app: AppHandle,
core: State<'_, AppCore>,
control: State<'_, ExportControl>,
@@ -1308,6 +1409,7 @@ pub fn export_video(
operation_id: String,
) -> Result {
let guard = control.try_begin(&operation_id)?;
+ let owned_control = control.inner().clone();
// Snapshot the session up front; no session lock is held during GPU/encode.
let snapshot = core.runtime_snapshot();
let timeline = snapshot.timeline;
@@ -1324,17 +1426,22 @@ pub fn export_video(
},
);
});
- run_export_with_control(
- &timeline,
- &manifest,
- &project_dir,
- &req,
- ExportRunOptions {
- control: Some(&control),
- on_progress: Some(on_progress),
- ..ExportRunOptions::default()
- },
- )
+ tauri::async_runtime::spawn_blocking(move || {
+ let _guard = guard;
+ run_export_with_control(
+ &timeline,
+ &manifest,
+ &project_dir,
+ &req,
+ ExportRunOptions {
+ control: Some(&owned_control),
+ on_progress: Some(on_progress),
+ ..ExportRunOptions::default()
+ },
+ )
+ })
+ .await
+ .map_err(|error| format!("export worker failed: {error}"))?
}
/// The export orchestration, decoupled from Tauri/`AppCore` so it can be driven
@@ -1374,6 +1481,155 @@ pub(crate) struct ExportRunOptions<'a> {
pub(crate) defer_completion: bool,
}
+/// Best-effort cleanup for a normal video export's partial output. The parent
+/// directory is opened before the output file is created and the output handle
+/// is retained by the guard, so even encoder initialization failures clean the
+/// same inode through the identity-safe removal path. Reserved project-media
+/// outputs stay owned by `ProjectMediaOutput`, whose descriptor/identity-safe
+/// Drop path performs the stronger cleanup contract.
+struct ExportOutputCleanup {
+ path: PathBuf,
+ enabled: bool,
+ active: bool,
+ succeeded: bool,
+ directory: Option,
+ file: Option,
+ final_name: Option,
+}
+
+impl ExportOutputCleanup {
+ fn new(path: PathBuf, enabled: bool) -> Result {
+ if !enabled {
+ return Ok(Self {
+ path,
+ enabled,
+ active: false,
+ succeeded: false,
+ directory: None,
+ file: None,
+ final_name: None,
+ });
+ }
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
+ let final_name = path
+ .file_name()
+ .ok_or_else(|| "export output has no file name".to_string())?
+ .to_os_string();
+ let directory = open_media_directory_nofollow(parent)?;
+ Ok(Self {
+ path,
+ enabled,
+ active: true,
+ succeeded: false,
+ directory: Some(directory),
+ file: None,
+ final_name: Some(final_name),
+ })
+ }
+
+ fn attach_output(&mut self, output: File) {
+ // Reserved outputs retain their outer cleanup owner, but this guard
+ // still needs the same file authority for encoding and verification.
+ self.file = Some(output);
+ }
+
+ fn open_output_file(&self) -> Result {
+ let directory = self
+ .directory
+ .as_ref()
+ .ok_or_else(|| "export cleanup directory handle is missing".to_string())?;
+ open_output_file_in_parent(&self.path, directory)
+ }
+
+ fn encoder_file(&self) -> Result {
+ self.file
+ .as_ref()
+ .ok_or_else(|| "export cleanup output handle is not attached".to_string())?
+ .try_clone()
+ .map_err(|error| format!("clone export output for encoder: {error}"))
+ }
+
+ fn probe_output(&self) -> Result {
+ let file = self
+ .file
+ .as_ref()
+ .ok_or_else(|| "export output handle is not attached".to_string())?;
+ // In particular on Windows, a DELETE-capable pinned handle may not be
+ // reopened by ffprobe's CRT sharing mode. Probe the retained authority.
+ opentake_media::probe::probe_file(file)
+ .map_err(|error| format!("output validation failed: {error}"))
+ }
+
+ fn mark_success(&mut self) {
+ self.succeeded = true;
+ }
+
+ fn verify_visible_identity(&self) -> Result<(), String> {
+ if !self.enabled {
+ return Ok(());
+ }
+ let directory = self
+ .directory
+ .as_ref()
+ .ok_or_else(|| "export cleanup directory handle is missing".to_string())?;
+ let file = self
+ .file
+ .as_ref()
+ .ok_or_else(|| "export cleanup output handle is missing".to_string())?;
+ let visible_parent =
+ std::fs::symlink_metadata(self.path.parent().unwrap_or_else(|| Path::new(".")))
+ .map_err(|error| format!("identify visible export directory: {error}"))?;
+ if metadata_is_symlink_or_reparse(&visible_parent) || !visible_parent.is_dir() {
+ return Err("export output parent must remain a real directory".to_string());
+ }
+ let visible_directory =
+ FileIdentity::from_path(self.path.parent().unwrap_or_else(|| Path::new(".")))
+ .map_err(|error| format!("identify visible export directory: {error}"))?;
+ let retained_directory = FileIdentity::from_file(
+ directory
+ .try_clone()
+ .map_err(|error| format!("clone retained export directory: {error}"))?,
+ )
+ .map_err(|error| format!("identify retained export directory: {error}"))?;
+ if visible_directory != retained_directory {
+ return Err("export output parent changed during export".to_string());
+ }
+ let visible_file_metadata = std::fs::symlink_metadata(&self.path)
+ .map_err(|error| format!("identify visible export output: {error}"))?;
+ if metadata_is_symlink_or_reparse(&visible_file_metadata)
+ || !visible_file_metadata.is_file()
+ {
+ return Err("export output must remain a real file".to_string());
+ }
+ let visible_file = FileIdentity::from_path(&self.path)
+ .map_err(|error| format!("identify visible export output: {error}"))?;
+ let retained_file = FileIdentity::from_file(
+ file.try_clone()
+ .map_err(|error| format!("clone retained export output: {error}"))?,
+ )
+ .map_err(|error| format!("identify retained export output: {error}"))?;
+ if visible_file != retained_file {
+ return Err("export output changed during export".to_string());
+ }
+ Ok(())
+ }
+}
+
+impl Drop for ExportOutputCleanup {
+ fn drop(&mut self) {
+ if self.enabled && self.active && !self.succeeded {
+ if let (Some(directory), Some(file), Some(final_name)) =
+ (&self.directory, &self.file, &self.final_name)
+ {
+ if let Err(error) = destroy_and_remove_reserved_output(directory, file, final_name)
+ {
+ eprintln!("[export] failed to clean ordinary partial output: {error}");
+ }
+ }
+ }
+ }
+}
+
pub(crate) fn run_export_with_control(
timeline: &opentake_domain::Timeline,
manifest: &opentake_domain::MediaManifest,
@@ -1383,6 +1639,10 @@ pub(crate) fn run_export_with_control(
) -> Result {
let control = options.control;
let external_cancel = options.external_cancel.clone();
+ validate_export_cancel_sources(control, external_cancel.as_ref())?;
+ // A queued cancellation must win before GPU setup or opening/truncating
+ // an existing output. Later frame/audio checks still cover running work.
+ check_audio_cancel_with_external(control, external_cancel.as_ref())?;
let on_progress = options.on_progress;
let defer_completion = options.defer_completion;
let reserved_output = options.output_file.is_some();
@@ -1430,23 +1690,23 @@ pub(crate) fn run_export_with_control(
// the preview path (render.rs) deliberately stays lenient.
ensure_text_export_fonts(!plan.text_plans.is_empty(), &text_rasterizer)?;
- let mut encoder = match options.output_file.take() {
- Some(output) => VideoEncoder::new_with_file(
- &out_path,
- output,
- render_size.width,
- render_size.height,
- plan.fps,
- &preset,
- ),
- None => VideoEncoder::new(
- &out_path,
- render_size.width,
- render_size.height,
- plan.fps,
- &preset,
- ),
- }
+ // Declare this before the encoder so Rust drops the encoder first (which
+ // reaps ffmpeg) and only then removes an error/cancelled partial output.
+ let mut output_cleanup = ExportOutputCleanup::new(out_path.clone(), !reserved_output)?;
+ let output = match options.output_file.take() {
+ Some(output) => output,
+ None => output_cleanup.open_output_file()?,
+ };
+ output_cleanup.attach_output(output);
+ let encoder_output = output_cleanup.encoder_file()?;
+ let mut encoder = VideoEncoder::new_with_file(
+ &out_path,
+ encoder_output,
+ render_size.width,
+ render_size.height,
+ plan.fps,
+ &preset,
+ )
.map_err(|e| format!("encoder init failed: {e}"))?;
let (start_frame, end_frame) = match options.frame_range {
@@ -1475,13 +1735,11 @@ pub(crate) fn run_export_with_control(
// Best-effort cleanup of the partial file — a leftover half-encoded
// video must not look like a finished export. Missing/unwritable is
// not itself an error worth surfacing over the cancel.
- if !reserved_output {
- let _ = std::fs::remove_file(&out_path);
- }
return Err(CANCELLED_SENTINEL.to_string());
}
- let frame_plan = plan.frame(timeline, f);
+ let mut frame_plan = plan.frame(timeline, f);
+ frame_plan.clear_rgba = export_clear_rgba(req.codec);
let mut resolver = MediaResolver {
device: &dev.device,
queue: &dev.queue,
@@ -1515,11 +1773,8 @@ pub(crate) fn run_export_with_control(
.map_err(|e| format!("composite render failed at frame {f}: {e}"))?;
if let Some(error) = resolver.materialization_error.take() {
encoder.abort();
- if !reserved_output {
- let _ = std::fs::remove_file(&out_path);
- }
return Err(format!(
- "Lottie materialization failed at frame {f}: {error}"
+ "export materialization failed at frame {f}: {error}"
));
}
encoder
@@ -1560,6 +1815,7 @@ pub(crate) fn run_export_with_control(
});
let cancel = control
.map(ExportControl::media_cancel_token)
+ .or_else(|| external_cancel.clone())
.unwrap_or_default();
let has_audio = stream_flattened_audio(
&plan.audio_clips,
@@ -1569,6 +1825,7 @@ pub(crate) fn run_export_with_control(
start_frame,
end_frame,
control,
+ external_cancel: external_cancel.as_ref(),
on_progress: audio_progress,
},
|samples| {
@@ -1594,24 +1851,22 @@ pub(crate) fn run_export_with_control(
) {
Ok(()) => {}
Err(opentake_media::MediaError::Cancelled) => {
- if !reserved_output {
- let _ = std::fs::remove_file(&out_path);
- }
return Err(CANCELLED_SENTINEL.to_string());
}
Err(error) => {
- if !reserved_output {
- let _ = std::fs::remove_file(&out_path);
- }
return Err(format!("encoder finish failed: {error}"));
}
}
- if control.is_some_and(ExportControl::is_cancelled) {
- if !reserved_output {
- let _ = std::fs::remove_file(&out_path);
- }
+ if control.is_some_and(ExportControl::is_cancelled)
+ || external_cancel
+ .as_ref()
+ .is_some_and(MediaCancelToken::is_cancelled)
+ {
return Err(CANCELLED_SENTINEL.to_string());
}
+ // Bind the visible pathname to the retained output before any probe reads
+ // it. Keep the second verification below as a post-probe race check.
+ output_cleanup.verify_visible_identity()?;
// Post-encode verification (mirrors motion.rs's post-encode probe): the
// ffmpeg child may exit 0 while the output is truncated or corrupt, so a
// clean exit alone is not proof of a usable file. Probe the produced file
@@ -1633,20 +1888,30 @@ pub(crate) fn run_export_with_control(
expected_duration_secs: range_total as f64 / fps,
duration_tolerance_secs: 1.5 / fps,
};
- let probe_result = probe(&out_path)
- .map_err(|error| format!("output validation failed: {error}"))
+ let probe_result = output_cleanup
+ .probe_output()
.and_then(|probe| validate_export_probe(&probe, &expectations));
- if let Err(error) = probe_result {
- if !reserved_output {
- let _ = std::fs::remove_file(&out_path);
+ probe_result?;
+ }
+ output_cleanup.verify_visible_identity()?;
+ if !defer_completion {
+ if let Some(control) = control {
+ control.commit_active()?;
+ }
+ if let Some(external_cancel) = external_cancel.as_ref() {
+ if !external_cancel.try_commit() {
+ return Err(CANCELLED_SENTINEL.to_string());
}
- return Err(error);
}
}
+ // Revalidate immediately after the cancellation commit as well. A path
+ // replacement in either side of the final linearization fails closed and
+ // leaves the retained original for the cleanup guard.
+ output_cleanup.verify_visible_identity()?;
+ output_cleanup.mark_success();
if let Some(emit) = &on_progress {
emit(completion_progress(defer_completion), AUDIO_PROGRESS_TOTAL);
}
-
Ok(ExportSummary {
out_path: req.out_path.clone(),
width: render_size.width,
@@ -1994,6 +2259,73 @@ fn open_media_directory_nofollow(path: &Path) -> Result {
Ok(directory)
}
+/// Create/truncate a normal export output relative to the retained parent
+/// directory. Unix uses `openat` so a parent rename between directory capture
+/// and file creation cannot redirect the output into a replacement directory;
+/// Windows keeps the retained directory handle open with delete sharing denied.
+#[cfg(unix)]
+fn open_output_file_in_parent(path: &Path, parent: &File) -> Result {
+ use std::ffi::CString;
+ use std::os::fd::{AsRawFd, FromRawFd};
+ use std::os::unix::ffi::OsStrExt;
+
+ let name = path
+ .file_name()
+ .ok_or_else(|| "export output has no file name".to_string())?;
+ let name = CString::new(name.as_bytes())
+ .map_err(|_| "export output contains a NUL byte".to_string())?;
+ let flags = libc::O_RDWR | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW | libc::O_CLOEXEC;
+ let fd = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags, 0o600) };
+ if fd < 0 {
+ return Err(format!(
+ "open export output relative to retained parent: {}",
+ io::Error::last_os_error()
+ ));
+ }
+ // SAFETY: `fd` is a fresh descriptor returned by openat and is now owned by
+ // the File constructed below.
+ let file = unsafe { File::from_raw_fd(fd) };
+ let metadata = file
+ .metadata()
+ .map_err(|error| format!("inspect export output: {error}"))?;
+ if metadata_is_symlink_or_reparse(&metadata) || !metadata.is_file() {
+ return Err("export output must be a regular file".to_string());
+ }
+ Ok(file)
+}
+
+#[cfg(not(unix))]
+fn open_output_file_in_parent(path: &Path, _parent: &File) -> Result {
+ let mut options = OpenOptions::new();
+ options.read(true).write(true).create(true).truncate(true);
+ #[cfg(windows)]
+ {
+ use std::os::windows::fs::OpenOptionsExt;
+ const DELETE: u32 = 0x0001_0000;
+ const GENERIC_READ: u32 = 0x8000_0000;
+ const GENERIC_WRITE: u32 = 0x4000_0000;
+ const FILE_SHARE_READ: u32 = 0x1;
+ const FILE_SHARE_WRITE: u32 = 0x2;
+ const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
+ options
+ // Our retained handle performs deletion on cancellation; denying
+ // delete sharing still prevents other handles replacing the name.
+ .access_mode(GENERIC_READ | GENERIC_WRITE | DELETE)
+ .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
+ .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
+ }
+ let file = options
+ .open(path)
+ .map_err(|error| format!("open export output: {error}"))?;
+ let metadata = file
+ .metadata()
+ .map_err(|error| format!("inspect export output: {error}"))?;
+ if metadata_is_symlink_or_reparse(&metadata) || !metadata.is_file() {
+ return Err("export output must be a regular file".to_string());
+ }
+ Ok(file)
+}
+
#[cfg(unix)]
fn reserve_output_file(path: &Path, parent_handle: &File) -> Result {
use std::ffi::CString;
@@ -2149,7 +2481,7 @@ impl ProjectMediaOutput {
pub(crate) fn prepare_commit_cancellable(
&self,
- guard: &ExportGuard<'_>,
+ guard: &ExportGuard,
after_sync: impl FnOnce(),
) -> Result<(), String> {
guard.checkpoint()?;
@@ -2647,6 +2979,52 @@ fn clip_source_window_secs(clip: &Clip, timeline_fps: i32) -> Option<(f64, f64)>
#[cfg(test)]
mod tests {
use super::*;
+
+ #[test]
+ fn owned_export_lease_preserves_cancellation_across_worker_handoff() {
+ let control = ExportControl::default();
+ let guard = control.try_begin("worker-handoff").unwrap();
+ let (ready_tx, ready_rx) = std::sync::mpsc::channel();
+ let (finish_tx, finish_rx) = std::sync::mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ ready_tx.send(()).unwrap();
+ finish_rx.recv().unwrap();
+ guard.checkpoint()
+ });
+ ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ assert!(control.try_begin("overlapping-export").is_err());
+ assert!(control.request_cancel("worker-handoff"));
+ finish_tx.send(()).unwrap();
+ assert_eq!(worker.join().unwrap(), Err(CANCELLED_SENTINEL.to_string()));
+ assert!(!control.request_cancel("worker-handoff"));
+ assert!(control.try_begin("next-export").is_ok());
+ }
+
+ #[test]
+ fn pre_cancelled_export_leaves_existing_output_untouched() {
+ let temp = tempfile::tempdir().unwrap();
+ let output = temp.path().join("existing.mp4");
+ fs::write(&output, b"keep existing output").unwrap();
+ let control = ExportControl::default();
+ let _guard = control.try_begin("cancel-before-worker").unwrap();
+ assert!(control.request_cancel("cancel-before-worker"));
+ let result = run_export_with_control(
+ &opentake_domain::Timeline::new(),
+ &opentake_domain::MediaManifest::default(),
+ &None,
+ &ExportRequest {
+ out_path: output.to_string_lossy().into_owned(),
+ codec: ExportCodec::H264,
+ quality: ExportQuality::P720,
+ },
+ ExportRunOptions {
+ control: Some(&control),
+ ..Default::default()
+ },
+ );
+ assert_eq!(result.unwrap_err(), CANCELLED_SENTINEL);
+ assert_eq!(fs::read(&output).unwrap(), b"keep existing output");
+ }
use std::fs;
use std::path::Path;
@@ -2749,6 +3127,29 @@ mod tests {
assert!(!control.is_cancelled());
}
+ #[test]
+ fn external_cancel_is_seen_by_audio_checkpoint() {
+ let external = MediaCancelToken::new();
+ assert!(check_audio_cancel_with_external(None, Some(&external)).is_ok());
+ external.cancel();
+ assert_eq!(
+ check_audio_cancel_with_external(None, Some(&external)).unwrap_err(),
+ CANCELLED_SENTINEL
+ );
+ }
+
+ #[test]
+ fn export_rejects_ambiguous_cancel_sources() {
+ let control = ExportControl::default();
+ let external = MediaCancelToken::new();
+ assert_eq!(
+ validate_export_cancel_sources(Some(&control), Some(&external)).unwrap_err(),
+ "export cannot combine control and external cancellation sources"
+ );
+ assert!(validate_export_cancel_sources(Some(&control), None).is_ok());
+ assert!(validate_export_cancel_sources(None, Some(&external)).is_ok());
+ }
+
#[test]
fn export_control_rejects_invalid_external_operation_ids() {
let control = ExportControl::default();
@@ -2804,6 +3205,40 @@ mod tests {
assert!(!control.is_cancelled());
}
+ #[test]
+ fn export_commit_active_linearizes_against_concurrent_cancel() {
+ for generation in 0..32 {
+ let control = std::sync::Arc::new(ExportControl::default());
+ let operation_id = format!("race-{generation}");
+ let guard = control.try_begin(&operation_id).expect("start export");
+ let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
+ let commit_control = std::sync::Arc::clone(&control);
+ let cancel_control = std::sync::Arc::clone(&control);
+ let commit_barrier = std::sync::Arc::clone(&barrier);
+ let cancel_barrier = std::sync::Arc::clone(&barrier);
+ let commit_thread = std::thread::spawn(move || {
+ commit_barrier.wait();
+ commit_control.commit_active()
+ });
+ let cancel_id = operation_id.clone();
+ let cancel_thread = std::thread::spawn(move || {
+ cancel_barrier.wait();
+ cancel_control.request_cancel(&cancel_id)
+ });
+ barrier.wait();
+ let commit_result = commit_thread.join().expect("commit thread");
+ let cancel_result = cancel_thread.join().expect("cancel thread");
+
+ if commit_result.is_ok() {
+ assert!(!cancel_result, "cancel must lose after commit linearizes");
+ } else {
+ assert!(cancel_result, "cancel must win before a rejected commit");
+ }
+ drop(guard);
+ assert!(!control.is_cancelled());
+ }
+ }
+
#[test]
fn export_guard_cancel_wins_before_commit() {
let control = ExportControl::default();
@@ -3032,6 +3467,27 @@ mod tests {
assert_eq!(preset.resolution, EncodeResolution::P1080);
}
+ #[test]
+ fn resolve_preset_accepts_prores_4444_mov() {
+ let preset = resolve_preset(
+ ExportCodec::Prores4444,
+ ExportQuality::P1080,
+ Path::new("/out.mov"),
+ )
+ .expect("prores 4444 mov should resolve");
+ assert_eq!(preset.codec, VideoCodec::ProRes4444);
+ assert_eq!(preset.resolution, EncodeResolution::P1080);
+ }
+
+ #[test]
+ fn prores_4444_export_uses_transparent_clear_color() {
+ assert_eq!(
+ export_clear_rgba(ExportCodec::Prores4444),
+ [0.0, 0.0, 0.0, 0.0]
+ );
+ assert_eq!(export_clear_rgba(ExportCodec::Prores), [0.0, 0.0, 0.0, 1.0]);
+ }
+
#[test]
fn resolve_preset_rejects_wrong_extension_for_prores() {
let err = resolve_preset(
@@ -3479,6 +3935,209 @@ mod tests {
assert!(!output.exists());
}
+ #[test]
+ fn export_output_cleanup_removes_active_partial_output_on_drop() {
+ let project = tempfile::tempdir().expect("project");
+ let output = project.path().join("partial.mp4");
+ let mut cleanup = ExportOutputCleanup::new(output.clone(), true).expect("create cleanup");
+ let mut output_file = cleanup
+ .open_output_file()
+ .expect("production output handle");
+ output_file.write_all(b"partial").expect("partial output");
+ cleanup.attach_output(output_file);
+ let encoder_file = cleanup.encoder_file().expect("clone encoder file");
+ drop(cleanup);
+ assert_eq!(encoder_file.metadata().unwrap().len(), 0);
+ // Windows finalizes delete disposition after the last duplicated
+ // encoder handle closes; the payload must already have been destroyed.
+ drop(encoder_file);
+ assert!(!output.exists());
+ }
+
+ #[test]
+ fn export_output_probe_reads_the_retained_file_before_cleanup() {
+ let project = tempfile::tempdir().unwrap();
+ let output = project.path().join("retained.wav");
+ let mut cleanup = ExportOutputCleanup::new(output.clone(), true).unwrap();
+ let mut file = cleanup.open_output_file().unwrap();
+ write_wav_s16le_cancellable_to_file(
+ &vec![0.0; 4800],
+ 48_000,
+ &mut file,
+ &MediaCancelToken::new(),
+ None,
+ None,
+ )
+ .unwrap();
+ cleanup.attach_output(file);
+ let parsed = cleanup
+ .probe_output()
+ .expect("probe retained output without reopening its name");
+ assert!(parsed.has_audio);
+ assert!((parsed.duration_secs - 0.1).abs() < 0.01);
+ drop(cleanup);
+ assert!(!output.exists());
+
+ let reserved = reserve_project_media_output(project.path(), "probe", "wav").unwrap();
+ let reserved_path = reserved.path().to_path_buf();
+ let mut writer = reserved.writer().unwrap();
+ write_wav_s16le_cancellable_to_file(
+ &vec![0.0; 4800],
+ 48_000,
+ &mut writer,
+ &MediaCancelToken::new(),
+ None,
+ None,
+ )
+ .unwrap();
+ drop(writer);
+ let mut verification = ExportOutputCleanup::new(reserved_path.clone(), false).unwrap();
+ verification.attach_output(reserved.writer().unwrap());
+ assert!(verification.probe_output().unwrap().has_audio);
+ drop(verification);
+ assert!(
+ reserved_path.exists(),
+ "outer reservation retains cleanup ownership"
+ );
+ drop(reserved);
+ assert!(!reserved_path.exists());
+ }
+
+ #[test]
+ fn export_output_cleanup_keeps_successful_output_and_reserved_output() {
+ let project = tempfile::tempdir().expect("project");
+ let successful = project.path().join("successful.mp4");
+ fs::write(&successful, b"complete").expect("successful output");
+ let successful_file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(&successful)
+ .expect("open successful output");
+ let mut keep = ExportOutputCleanup::new(successful.clone(), true).expect("create cleanup");
+ keep.attach_output(successful_file);
+ let _encoder_file = keep.encoder_file().expect("clone encoder file");
+ keep.verify_visible_identity()
+ .expect("verify successful output");
+ keep.mark_success();
+ drop(keep);
+ assert!(successful.exists());
+
+ let reserved = project.path().join("reserved.mp4");
+ fs::write(&reserved, b"reserved").expect("reserved output");
+ let reserved_file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(&reserved)
+ .expect("open reserved output");
+ let mut reserved_cleanup =
+ ExportOutputCleanup::new(reserved.clone(), false).expect("create cleanup");
+ reserved_cleanup.attach_output(reserved_file);
+ drop(reserved_cleanup);
+ assert!(reserved.exists());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn export_output_cleanup_does_not_remove_a_replaced_visible_path() {
+ let project = tempfile::tempdir().expect("project");
+ let output = project.path().join("race.mp4");
+ let moved = project.path().join("race-original.mp4");
+ fs::write(&output, b"original").expect("original output");
+ let output_file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(&output)
+ .expect("open original output");
+ let mut cleanup = ExportOutputCleanup::new(output.clone(), true).expect("create cleanup");
+ cleanup.attach_output(output_file);
+ fs::rename(&output, &moved).expect("move original output");
+ fs::write(&output, b"replacement").expect("replacement output");
+ drop(cleanup);
+
+ assert_eq!(fs::read(&output).expect("read replacement"), b"replacement");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn export_output_identity_rejects_a_symlinked_visible_path() {
+ let project = tempfile::tempdir().expect("project");
+ let output = project.path().join("race.mp4");
+ let moved = project.path().join("race-original.mp4");
+ fs::write(&output, b"original").expect("original output");
+ let output_file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(&output)
+ .expect("open original output");
+ let mut cleanup = ExportOutputCleanup::new(output.clone(), true).expect("create cleanup");
+ cleanup.attach_output(output_file);
+ fs::rename(&output, &moved).expect("move original output");
+ std::os::unix::fs::symlink(&moved, &output).expect("replace output with symlink");
+
+ assert!(
+ cleanup.verify_visible_identity().is_err(),
+ "a symlinked visible output must never be accepted as the retained file"
+ );
+ drop(cleanup);
+ assert!(
+ output.exists(),
+ "cleanup must not remove the replacement link"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn export_output_cleanup_uses_retained_parent_after_parent_swap() {
+ let project = tempfile::tempdir().expect("project");
+ let parent = project.path().join("parent");
+ let moved_parent = project.path().join("parent-original");
+ fs::create_dir(&parent).expect("parent");
+ let output = parent.join("race.mp4");
+ let moved_output = moved_parent.join("race.mp4");
+ fs::write(&output, b"original").expect("original output");
+ let output_file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(&output)
+ .expect("open original output");
+ let mut cleanup = ExportOutputCleanup::new(output.clone(), true).expect("create cleanup");
+ cleanup.attach_output(output_file);
+ fs::rename(&parent, &moved_parent).expect("move original parent");
+ fs::create_dir(&parent).expect("replacement parent");
+ fs::write(&output, b"replacement").expect("replacement output");
+ drop(cleanup);
+
+ assert_eq!(fs::read(&output).expect("read replacement"), b"replacement");
+ assert!(!moved_output.exists(), "retained original must be cleaned");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn export_output_creation_stays_in_retained_parent_after_parent_swap() {
+ let project = tempfile::tempdir().expect("project");
+ let parent = project.path().join("parent");
+ let moved_parent = project.path().join("parent-original");
+ fs::create_dir(&parent).expect("parent");
+ let output = parent.join("race.mp4");
+ let moved_output = moved_parent.join("race.mp4");
+ let mut cleanup = ExportOutputCleanup::new(output.clone(), true).expect("create cleanup");
+
+ fs::rename(&parent, &moved_parent).expect("move original parent");
+ fs::create_dir(&parent).expect("replacement parent");
+ let output_file = cleanup
+ .open_output_file()
+ .expect("create output in retained parent");
+ cleanup.attach_output(output_file);
+ let _encoder_file = cleanup.encoder_file().expect("clone encoder file");
+ drop(cleanup);
+
+ assert!(
+ !output.exists(),
+ "replacement parent must not receive output"
+ );
+ assert!(!moved_output.exists(), "retained output must be cleaned");
+ }
+
#[test]
fn save_range_validates_half_open_bounds_before_output_path_creation() {
assert!(validate_save_range(100, 10, 20).is_ok());
diff --git a/src-tauri/src/generation.rs b/src-tauri/src/generation.rs
index cbf11882..3e462c95 100644
--- a/src-tauri/src/generation.rs
+++ b/src-tauri/src/generation.rs
@@ -1023,7 +1023,10 @@ impl TauriGenerationBridge {
let start_frame = span.start_frame;
let end_frame = span.end_frame;
let output = destination.clone();
- let export_cancel = cancel.clone();
+ // Export has its own final success boundary; committing its child
+ // token must not make the later upload/download workflow immune
+ // to cancellation on the parent generation token.
+ let export_cancel = cancel.child();
tokio::task::spawn_blocking(move || {
crate::export::run_export_with_control(
&timeline,
diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs
index ab787743..e9ad2901 100644
--- a/src-tauri/src/mcp.rs
+++ b/src-tauri/src/mcp.rs
@@ -984,6 +984,7 @@ impl TauriMotionDocumentOperation {
start_frame: request.start_frame.expect("validated at Agent boundary"),
duration_frames: request.duration_frames,
track_index: request.track_index,
+ transparent: request.transparent,
},
cancel,
)
@@ -1533,20 +1534,38 @@ impl MediaBridge for TauriMediaBridge {
} else {
SearchIndexState::Ready
};
- let moments: Vec =
- crate::search::visual_hits_by_id(&self.engine, &visual_paths, query, fps, limit)
- .into_iter()
- .map(|h| SearchVisualHit {
- media_ref: h.media_id,
- start_seconds: h.start_sec,
- end_seconds: h.end_sec,
- score: h.score,
- is_image: h.is_image,
- })
- .collect();
+ let (visual_hits, visual_failed) = match crate::search::visual_hits_by_id(
+ &self.engine,
+ &visual_paths,
+ query,
+ fps,
+ limit,
+ ) {
+ Ok(hits) => (hits, false),
+ Err(_) => (Vec::new(), true),
+ };
+ let status = if visual_failed {
+ SearchIndexState::Failed
+ } else {
+ status
+ };
+ let moments: Vec = visual_hits
+ .into_iter()
+ .map(|h| SearchVisualHit {
+ media_ref: h.media_id,
+ start_seconds: h.start_sec,
+ end_seconds: h.end_sec,
+ score: h.score,
+ is_image: h.is_image,
+ })
+ .collect();
// `indexedAssets` is only meaningful when the model is loaded
// (upstream sets it only when an embedder spec exists).
- let indexed_opt = if installed { Some(indexed) } else { None };
+ let indexed_opt = if installed && !visual_failed {
+ Some(indexed)
+ } else {
+ None
+ };
(status, indexable, indexed_opt, moments)
};
@@ -1761,7 +1780,12 @@ impl TauriMediaBridge {
"source.url staging identity changed before probe",
));
}
- let probed = probe(staged.file(), &extension, expected_kind)?;
+ let probed = if expected_kind == "lottie" {
+ crate::media::probe_lottie_file(staged.path(), staged.file())
+ .map_err(|error| BridgeError::new(format!("Invalid Lottie document: {error}")))?
+ } else {
+ probe(staged.file(), &extension, expected_kind)?
+ };
cancelled_checkpoint(cancel)?;
if !project_media
.matches_leaf(&staged)
@@ -1922,12 +1946,15 @@ impl TauriMediaBridge {
.unwrap_or_default();
if importable_clip_type(&file_url).is_none() {
return Err(BridgeError::new(format!(
- "Unsupported file extension '.{ext}'. Supported: mov/mp4/m4v, mp3/wav/aac/m4a, png/jpg/jpeg/tiff/heic."
+ "Unsupported file extension '.{ext}'. Supported: mov/mp4/m4v, mp3/wav/aac/m4a, png/jpg/jpeg/tiff/heic, json/lottie."
)));
}
let source = RetainedExternalSource::open(&file_url)?;
cancelled_checkpoint(cancel)?;
- let probe =
+ let probe = if crate::media::is_lottie_path(&file_url) {
+ crate::media::probe_lottie_file(&file_url, source.file())
+ .map_err(|error| BridgeError::new(format!("Invalid Lottie document: {error}")))?
+ } else {
match self
.engine
.probe_file_cancellable(source.file(), cancel, MCP_MEDIA_PROBE_TIMEOUT)
@@ -1944,7 +1971,8 @@ impl TauriMediaBridge {
return Err(BridgeError::new("source.path import was cancelled"));
}
Err(_) => ProbedMedia::default(),
- };
+ }
+ };
cancelled_checkpoint(cancel)?;
let display_name = name
.map(str::to_owned)
@@ -2119,7 +2147,10 @@ impl TauriMediaBridge {
"source.bytes staging identity changed before probe",
));
}
- let probe =
+ let probe = if crate::media::is_lottie_path(staged.path()) {
+ crate::media::probe_lottie_file(staged.path(), staged.file())
+ .map_err(|error| BridgeError::new(format!("Invalid Lottie document: {error}")))?
+ } else {
match self
.engine
.probe_file_cancellable(staged.file(), cancel, MCP_MEDIA_PROBE_TIMEOUT)
@@ -2136,7 +2167,8 @@ impl TauriMediaBridge {
return Err(BridgeError::new("source.bytes import was cancelled"));
}
Err(_) => ProbedMedia::default(),
- };
+ }
+ };
cancelled_checkpoint(cancel)?;
if !project_media
.matches_leaf(&staged)
@@ -2250,6 +2282,8 @@ fn allowed_url_extension(extension: &str) -> Option<(&'static str, &'static str)
"jpg" | "jpeg" => Some(("jpg", "image")),
"tiff" => Some(("tiff", "image")),
"heic" => Some(("heic", "image")),
+ "json" => Some(("json", "lottie")),
+ "lottie" => Some(("lottie", "lottie")),
_ => None,
}
}
@@ -2310,7 +2344,7 @@ fn resolve_url_media_type(
.map(|value| {
allowed_url_extension(value).ok_or_else(|| {
BridgeError::new(format!(
- "Unsupported source.url extension '.{value}'. Supported: mov/mp4/m4v, mp3/wav/aac/m4a, png/jpg/jpeg/tiff/heic."
+ "Unsupported source.url extension '.{value}'. Supported: mov/mp4/m4v, mp3/wav/aac/m4a, png/jpg/jpeg/tiff/heic, json/lottie."
))
})
})
@@ -2919,7 +2953,7 @@ fn encode_jpeg(frame: &DecodedFrame) -> Option> {
/// to feed the alpha-less JPEG encoder.
fn rgba_to_rgb(rgba: &[u8]) -> Vec {
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
- for px in rgba.chunks_exact(4) {
+ for px in rgba.as_chunks::<4>().0.iter() {
rgb.extend_from_slice(&px[..3]);
}
rgb
@@ -5214,6 +5248,33 @@ mod tests {
assert_eq!(manifest.entries[0].kind, ClipType::Video);
}
+ #[test]
+ fn import_from_path_lottie_registers_valid_animation_metadata() {
+ let tmp = tempfile::tempdir().unwrap();
+ let lottie = tmp.path().join("Title.json");
+ std::fs::write(
+ &lottie,
+ br#"{"v":"5.5.2","fr":2,"ip":0,"op":2,"w":16,"h":16,"ddd":0,"assets":[],"layers":[]}"#,
+ )
+ .unwrap();
+ let core = AppCore::new();
+ core.save_project(Some(tmp.path().join("LottiePath.opentake")))
+ .unwrap();
+ let bridge =
+ TauriMediaBridge::new(core, tmp.path().join("cache"), tmp.path().join("models"));
+
+ let out = bridge
+ .import_from_path(&lottie.to_string_lossy(), None, None)
+ .expect("Lottie path import");
+
+ assert_eq!(out.asset_count, 1);
+ let manifest = bridge.core.media();
+ assert_eq!(manifest.entries.len(), 1);
+ assert_eq!(manifest.entries[0].kind, ClipType::Lottie);
+ assert_eq!(manifest.entries[0].source_width, Some(16));
+ assert_eq!(manifest.entries[0].source_height, Some(16));
+ }
+
#[test]
fn import_from_path_missing_file_errors() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs
index b40de585..de26c75e 100644
--- a/src-tauri/src/media.rs
+++ b/src-tauri/src/media.rs
@@ -40,6 +40,7 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, State};
use opentake_core::{
importable_clip_type, AppCore, CommittedMediaImport, CoreError, DeferredCoreEvents,
DerivedStemProvenance, PreparedMediaFolderRef, PreparedMediaImportOp, ProbedMedia,
+ SUPPORTED_LOTTIE_EXTENSIONS,
};
use opentake_domain::{
AudioDenoise, Clip, ClipType, DenoiseMode, GenerationInput, GenerationJobStatus,
@@ -813,15 +814,86 @@ fn timed_poster_path_for(cache_root: &Path, key: &str, time_secs: f64) -> PathBu
visual_cache_dir(cache_root).join(format!("{key}.thumb.{millis}.png"))
}
+/// Complete PNGs are immutable at their content-keyed poster path. Invalid or
+/// non-regular targets are retained and reported, never silently replaced.
+fn cached_poster_dimensions(path: &Path) -> Result