chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# frame_strip.sh — produce a side-by-side comparison strip from two videos.
|
||||
#
|
||||
# Used to debug failing render_diff.sh runs visually: pick a sample timestamp
|
||||
# range, extract frames from both videos, lay them out as a grid for review.
|
||||
#
|
||||
# Usage:
|
||||
# frame_strip.sh <baseline.mp4> <translated.mp4> [output-dir] [samples]
|
||||
#
|
||||
# Defaults: output-dir=./strip-out, samples=8 (evenly spaced across duration).
|
||||
# Output:
|
||||
# strip.png — single PNG with `samples` rows, each row is
|
||||
# (baseline frame | translated frame) at one timestamp
|
||||
# timestamps.txt — the timestamps sampled
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 || $# -gt 4 ]]; then
|
||||
echo "usage: $0 <baseline.mp4> <translated.mp4> [output-dir] [samples]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BASELINE="$1"
|
||||
TRANSLATED="$2"
|
||||
OUTDIR="${3:-./strip-out}"
|
||||
SAMPLES="${4:-8}"
|
||||
|
||||
if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then
|
||||
echo "error: ffmpeg/ffprobe not on PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# Build the strip in a single ffmpeg invocation. Two inputs (baseline and
|
||||
# translated) are sampled at N evenly-spaced timestamps via the `select`
|
||||
# filter, then assembled with hstack (per-row pairs) + vstack (rows).
|
||||
# Earlier versions spawned 3 ffmpeg calls per timestamp + a final vstack;
|
||||
# this is one call regardless of N.
|
||||
python3 - "$BASELINE" "$TRANSLATED" "$OUTDIR" "$SAMPLES" <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
baseline, translated, outdir, samples = sys.argv[1], sys.argv[2], Path(sys.argv[3]), int(sys.argv[4])
|
||||
|
||||
# Read fps + duration from the baseline so we can map timestamps to frame
|
||||
# indexes for the `select` filter (frame-accurate, doesn't depend on
|
||||
# keyframe alignment).
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=r_frame_rate,nb_read_frames,duration",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "json", "-count_frames", baseline],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
data = json.loads(probe.stdout)
|
||||
stream = data["streams"][0]
|
||||
num, den = stream["r_frame_rate"].split("/")
|
||||
fps = float(num) / float(den)
|
||||
nb_frames = int(stream.get("nb_read_frames") or 0)
|
||||
if nb_frames <= 0:
|
||||
duration = float(stream.get("duration") or data["format"]["duration"])
|
||||
nb_frames = int(duration * fps)
|
||||
|
||||
# Even-spaced sample frames in the 5%-95% window (skip fade-in/out noise).
|
||||
start = max(0, int(nb_frames * 0.05))
|
||||
end = max(start, int(nb_frames * 0.95) - 1)
|
||||
if samples == 1:
|
||||
frames = [start]
|
||||
else:
|
||||
step = (end - start) / (samples - 1)
|
||||
frames = [int(start + i * step) for i in range(samples)]
|
||||
|
||||
(outdir / "timestamps.txt").write_text(
|
||||
"\n".join(f"{f / fps:.3f}" for f in frames) + "\n"
|
||||
)
|
||||
|
||||
# select='eq(n,F1)+eq(n,F2)+...' picks exactly the listed frames from each
|
||||
# input. We then hstack per-frame pairs and vstack the result.
|
||||
select_expr = "+".join(f"eq(n,{f})" for f in frames)
|
||||
n = len(frames)
|
||||
filter_parts = [
|
||||
f"[0:v]select='{select_expr}',setpts=N/FRAME_RATE/TB,split={n}"
|
||||
+ "".join(f"[b{i}]" for i in range(n)),
|
||||
f"[1:v]select='{select_expr}',setpts=N/FRAME_RATE/TB,split={n}"
|
||||
+ "".join(f"[t{i}]" for i in range(n)),
|
||||
]
|
||||
for i in range(n):
|
||||
filter_parts.append(f"[b{i}][t{i}]hstack=inputs=2[row{i}]")
|
||||
filter_parts.append(
|
||||
"".join(f"[row{i}]" for i in range(n)) + f"vstack=inputs={n}[out]"
|
||||
)
|
||||
filter_graph = ";".join(filter_parts)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
||||
"-i", baseline, "-i", translated,
|
||||
"-filter_complex", filter_graph,
|
||||
"-map", "[out]", "-frames:v", "1",
|
||||
str(outdir / "strip.png"),
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"wrote {outdir / 'strip.png'} ({n} samples)")
|
||||
PY
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint a Remotion project for patterns that don't translate cleanly to HyperFrames.
|
||||
|
||||
The skill should run this *before* attempting a translation. If any blocker
|
||||
findings come back, the recommendation is to use the runtime interop pattern
|
||||
from PR #214 instead of producing broken HTML.
|
||||
|
||||
Usage:
|
||||
lint_source.py <path-to-remotion-src> [--json]
|
||||
|
||||
Output (default human-readable, --json for machine-readable):
|
||||
For each .ts/.tsx file, a list of findings with:
|
||||
- severity: blocker | warning | info
|
||||
- line, column
|
||||
- rule id
|
||||
- message
|
||||
- recommendation
|
||||
|
||||
Blockers (skill should refuse to translate):
|
||||
- r2hf/use-state React state machine drives animation
|
||||
- r2hf/use-effect-deps useEffect/useLayoutEffect with non-empty deps (side effects)
|
||||
- r2hf/use-reducer useReducer drives animation
|
||||
- r2hf/async-metadata calculateMetadata returns a Promise
|
||||
- r2hf/third-party-react-ui Imports a React UI library (shadcn, mui, antd, mantine, chakra)
|
||||
|
||||
Warnings (translate but flag — drop the construct, keep the rest):
|
||||
- r2hf/lambda-import @remotion/lambda configuration — drop, HF is single-machine
|
||||
- r2hf/delay-render delayRender() — HF handles asset loading differently
|
||||
- r2hf/use-callback useCallback — usually decorative, drop
|
||||
- r2hf/use-memo useMemo — usually decorative, drop
|
||||
- r2hf/custom-hook Custom hook (use*) defined locally; may need manual rewrite
|
||||
|
||||
Info (translate and document):
|
||||
- r2hf/static-file staticFile("x") — convert to relative path
|
||||
- r2hf/interpolate-colors interpolateColors — translate to GSAP color tween
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
BLOCKER = "blocker"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
|
||||
THIRD_PARTY_UI_PACKAGES = {
|
||||
"@mui/material",
|
||||
"@mui/icons-material",
|
||||
"@chakra-ui/react",
|
||||
"@mantine/core",
|
||||
"antd",
|
||||
"@shadcn/ui",
|
||||
"@radix-ui",
|
||||
"@nextui-org/react",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
file: str
|
||||
line: int
|
||||
column: int
|
||||
severity: str
|
||||
rule: str
|
||||
message: str
|
||||
recommendation: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""A lint rule: a matcher that yields hits, plus the metadata each hit gets.
|
||||
|
||||
A matcher is a function `src -> Iterable[(offset, override_message)]`. If
|
||||
`override_message` is None, the rule's default `message` is used; matchers
|
||||
that need to embed the matched text (custom-hook name, third-party package
|
||||
name) return the customized message instead.
|
||||
"""
|
||||
|
||||
rule_id: str
|
||||
severity: str
|
||||
matcher: Callable[[str], Iterable[tuple[int, str | None]]]
|
||||
message: str
|
||||
recommendation: str
|
||||
|
||||
|
||||
def _regex_matcher(pattern: re.Pattern[str]) -> Callable[[str], Iterable[tuple[int, str | None]]]:
|
||||
def _match(src: str) -> Iterable[tuple[int, str | None]]:
|
||||
for m in pattern.finditer(src):
|
||||
yield m.start(), None
|
||||
|
||||
return _match
|
||||
|
||||
|
||||
def _use_effect_with_deps(src: str) -> Iterable[tuple[int, str | None]]:
|
||||
# Find use(Layout)?Effect(, walk to its matching ), and check if the call
|
||||
# ends with `, [<non-empty>])`. Empty `[]` is mount-only, allowed.
|
||||
for m in re.finditer(r"\buse(?:Layout)?Effect\s*\(", src):
|
||||
end = _find_matching_paren(src, m.end() - 1)
|
||||
if end is None:
|
||||
continue
|
||||
call = src[m.start() : end + 1]
|
||||
m2 = re.search(r",\s*\[([^\]]*)\]\s*$", call[:-1])
|
||||
if m2 and m2.group(1).strip():
|
||||
yield m.start(), None
|
||||
|
||||
|
||||
_CUSTOM_HOOK_DECL = re.compile(
|
||||
r"^\s*(?:export\s+(?:default\s+)?)?(?:function|const|let|var)\s+(use[A-Z]\w+)\b",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_REMOTION_BUILTIN_HOOKS = {"useCurrentFrame", "useVideoConfig"}
|
||||
|
||||
|
||||
def _custom_hook(src: str) -> Iterable[tuple[int, str | None]]:
|
||||
for m in _CUSTOM_HOOK_DECL.finditer(src):
|
||||
name = m.group(1)
|
||||
if name in _REMOTION_BUILTIN_HOOKS:
|
||||
continue
|
||||
yield m.start(), f"Custom hook `{name}` defined locally — may need manual rewrite"
|
||||
|
||||
|
||||
_IMPORT_FROM = re.compile(r"from\s+['\"]([^'\"]+)['\"]")
|
||||
|
||||
|
||||
def _third_party_react_ui(src: str) -> Iterable[tuple[int, str | None]]:
|
||||
for m in _IMPORT_FROM.finditer(src):
|
||||
pkg = m.group(1)
|
||||
if any(pkg.startswith(p) for p in THIRD_PARTY_UI_PACKAGES):
|
||||
yield m.start(), f"Imports `{pkg}` — third-party React UI library has no HF equivalent"
|
||||
|
||||
|
||||
RULES: list[Rule] = [
|
||||
Rule(
|
||||
"r2hf/use-state",
|
||||
BLOCKER,
|
||||
_regex_matcher(re.compile(r"\buseState\s*[(<]")),
|
||||
"useState detected — Remotion compositions that drive animation via React state are not deterministic frame-capture targets in HyperFrames",
|
||||
"Use the runtime interop pattern from PR #214 instead of attempting a translation",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/use-reducer",
|
||||
BLOCKER,
|
||||
_regex_matcher(re.compile(r"\buseReducer\s*[(<]")),
|
||||
"useReducer detected — same issue as useState",
|
||||
"Use the runtime interop pattern from PR #214",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/use-effect-deps",
|
||||
BLOCKER,
|
||||
_use_effect_with_deps,
|
||||
"useEffect/useLayoutEffect with non-empty deps — side effects don't translate to HF's seek-driven model",
|
||||
"Move the side-effect work into a build step, or use the runtime interop pattern",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/async-metadata",
|
||||
BLOCKER,
|
||||
_regex_matcher(
|
||||
re.compile(
|
||||
r"calculateMetadata[^=]*=\s*async\b|async\s+calculateMetadata\b|calculateMetadata\s*:\s*async"
|
||||
)
|
||||
),
|
||||
"calculateMetadata returns a Promise — HF needs composition metadata up front",
|
||||
"Resolve metadata at build time and pass concrete values, or use runtime interop",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/third-party-react-ui",
|
||||
BLOCKER,
|
||||
_third_party_react_ui,
|
||||
"Imports a third-party React UI library — no HF equivalent",
|
||||
"Use runtime interop, or rewrite the affected components as HTML+CSS",
|
||||
),
|
||||
# Lambda is a warning, not a blocker: it's deployment config, orthogonal
|
||||
# to the rendered composition. The skill drops the import and translates
|
||||
# the rest. See references/escape-hatch.md.
|
||||
Rule(
|
||||
"r2hf/lambda-import",
|
||||
WARNING,
|
||||
_regex_matcher(re.compile(r"from\s+['\"]@remotion/lambda['\"]")),
|
||||
"@remotion/lambda is Remotion-specific distributed rendering — no HF equivalent today",
|
||||
"Drop the Lambda config; HF runs single-machine. Document the gap in TRANSLATION_NOTES.md.",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/delay-render",
|
||||
WARNING,
|
||||
_regex_matcher(re.compile(r"\bdelayRender\s*\(")),
|
||||
"delayRender() — HF waits on asset readiness via the Frame Adapter pattern",
|
||||
"Drop the call; HF handles this transparently",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/use-callback",
|
||||
WARNING,
|
||||
_regex_matcher(re.compile(r"\buseCallback\s*\(")),
|
||||
"useCallback — typically decorative for render performance, no HF equivalent needed",
|
||||
"Drop the wrapper, inline the function",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/use-memo",
|
||||
WARNING,
|
||||
_regex_matcher(re.compile(r"\buseMemo\s*\(")),
|
||||
"useMemo — typically decorative, no HF equivalent needed",
|
||||
"Drop the wrapper, compute inline",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/custom-hook",
|
||||
WARNING,
|
||||
_custom_hook,
|
||||
"Custom hook defined locally — may need manual rewrite",
|
||||
"Inline the hook body if pure; bow out to runtime interop if it uses useState/useEffect",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/static-file",
|
||||
INFO,
|
||||
_regex_matcher(re.compile(r"\bstaticFile\s*\(")),
|
||||
"staticFile() reference — convert to a relative path in the HF composition",
|
||||
"Replace `staticFile(\"x.png\")` with `\"x.png\"` and copy the asset alongside the HTML",
|
||||
),
|
||||
Rule(
|
||||
"r2hf/interpolate-colors",
|
||||
INFO,
|
||||
_regex_matcher(re.compile(r"\binterpolateColors\s*\(")),
|
||||
"interpolateColors() — translate to a GSAP color tween",
|
||||
"See references/timing.md for the GSAP equivalent",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _find_matching_paren(src: str, open_idx: int) -> int | None:
|
||||
"""Given the index of an open `(`, return the index of its matching `)`.
|
||||
|
||||
Skips parens that appear inside `'...'`, `"..."`, or `` `...` `` string
|
||||
literals. Returns None if no matching close paren is found.
|
||||
|
||||
This is good enough for hand-written Remotion source. It does not handle
|
||||
template-literal interpolations `${...}` recursively or comments — both
|
||||
are uncommon in Remotion code we expect to lint and would only matter
|
||||
if the unbalanced paren landed inside such a region.
|
||||
"""
|
||||
if open_idx >= len(src) or src[open_idx] != "(":
|
||||
return None
|
||||
depth = 0
|
||||
i = open_idx
|
||||
in_str: str | None = None
|
||||
while i < len(src):
|
||||
c = src[i]
|
||||
if in_str is not None:
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == in_str:
|
||||
in_str = None
|
||||
i += 1
|
||||
continue
|
||||
if c in ("'", '"', "`"):
|
||||
in_str = c
|
||||
i += 1
|
||||
continue
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def lint_file(path: Path) -> list[Finding]:
|
||||
src = path.read_text()
|
||||
findings: list[Finding] = []
|
||||
|
||||
def loc(offset: int) -> tuple[int, int]:
|
||||
line = src.count("\n", 0, offset) + 1
|
||||
col = offset - (src.rfind("\n", 0, offset) + 1) + 1
|
||||
return line, col
|
||||
|
||||
for rule in RULES:
|
||||
for offset, override_message in rule.matcher(src):
|
||||
line, col = loc(offset)
|
||||
findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
line,
|
||||
col,
|
||||
rule.severity,
|
||||
rule.rule_id,
|
||||
override_message or rule.message,
|
||||
rule.recommendation,
|
||||
)
|
||||
)
|
||||
|
||||
findings.sort(key=lambda f: (f.file, f.line, f.column))
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("path", type=Path, help="Directory or file to lint")
|
||||
ap.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.path.exists():
|
||||
print(f"error: {args.path} does not exist", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files: list[Path]
|
||||
if args.path.is_file():
|
||||
files = [args.path]
|
||||
else:
|
||||
files = sorted(
|
||||
p
|
||||
for p in args.path.rglob("*")
|
||||
if p.is_file()
|
||||
and p.suffix in {".ts", ".tsx", ".jsx", ".js"}
|
||||
and "node_modules" not in p.parts
|
||||
)
|
||||
|
||||
all_findings: list[Finding] = []
|
||||
for f in files:
|
||||
all_findings.extend(lint_file(f))
|
||||
|
||||
blockers = sum(1 for f in all_findings if f.severity == BLOCKER)
|
||||
warnings = sum(1 for f in all_findings if f.severity == WARNING)
|
||||
infos = sum(1 for f in all_findings if f.severity == INFO)
|
||||
|
||||
if args.json:
|
||||
json.dump(
|
||||
{
|
||||
"files_scanned": len(files),
|
||||
"blockers": blockers,
|
||||
"warnings": warnings,
|
||||
"infos": infos,
|
||||
"findings": [asdict(f) for f in all_findings],
|
||||
},
|
||||
sys.stdout,
|
||||
indent=2,
|
||||
)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
for f in all_findings:
|
||||
print(f"{f.file}:{f.line}:{f.column} [{f.severity}] {f.rule}: {f.message}")
|
||||
print(f" -> {f.recommendation}")
|
||||
print()
|
||||
print(f"{len(files)} files scanned · {blockers} blocker · {warnings} warning · {infos} info")
|
||||
if blockers:
|
||||
print("RECOMMENDATION: do not attempt translation. Use the runtime interop pattern from PR #214.")
|
||||
|
||||
return 1 if blockers else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# render_diff.sh — compute per-frame SSIM between two video files.
|
||||
#
|
||||
# The eval primitive for the remotion-to-hyperframes skill: given a Remotion
|
||||
# render and a HyperFrames render of the same composition, report whether the
|
||||
# translation is visually equivalent.
|
||||
#
|
||||
# Usage:
|
||||
# render_diff.sh <baseline.mp4> <translated.mp4> [output-dir]
|
||||
#
|
||||
# Output (in output-dir, defaults to ./diff-out):
|
||||
# ssim.log — per-frame SSIM lines from ffmpeg
|
||||
# summary.json — { mean, min, p05, p95, frame_count, pass, threshold }
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — pass (mean SSIM >= threshold)
|
||||
# 1 — fail (mean SSIM < threshold)
|
||||
# 2 — usage / setup error
|
||||
#
|
||||
# Threshold defaults to 0.85 (loose; tier-specific thresholds are applied by
|
||||
# the orchestrator). Override with R2HF_SSIM_THRESHOLD=0.95 in the environment.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
THRESHOLD="${R2HF_SSIM_THRESHOLD:-0.85}"
|
||||
|
||||
if [[ $# -lt 2 || $# -gt 3 ]]; then
|
||||
echo "usage: $0 <baseline.mp4> <translated.mp4> [output-dir]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BASELINE="$1"
|
||||
TRANSLATED="$2"
|
||||
OUTDIR="${3:-./diff-out}"
|
||||
|
||||
if [[ ! -f "$BASELINE" ]]; then
|
||||
echo "error: baseline not found: $BASELINE" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$TRANSLATED" ]]; then
|
||||
echo "error: translated not found: $TRANSLATED" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "error: ffmpeg not on PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
SSIM_LOG="$OUTDIR/ssim.log"
|
||||
SUMMARY="$OUTDIR/summary.json"
|
||||
|
||||
# ffmpeg's ssim filter writes one line per frame to stats_file and a single
|
||||
# Mean SSIM line to stderr. We capture both — per-frame for distribution
|
||||
# stats, and the mean for the headline number.
|
||||
ffmpeg -hide_banner -nostats -loglevel info \
|
||||
-i "$BASELINE" -i "$TRANSLATED" \
|
||||
-lavfi "[0:v]scale=iw:ih[ref];[1:v]scale=iw:ih[main];[main][ref]ssim=stats_file=$SSIM_LOG" \
|
||||
-f null - 2>"$OUTDIR/ffmpeg.stderr"
|
||||
|
||||
# Parse: each line in ssim.log looks like
|
||||
# n:1 Y:0.987655 U:0.992345 V:0.991234 All:0.989012 (19.512345)
|
||||
# We want the All:N column.
|
||||
python3 - "$SSIM_LOG" "$SUMMARY" "$THRESHOLD" <<'PY'
|
||||
import json, math, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
log_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
threshold = float(sys.argv[3])
|
||||
|
||||
values = []
|
||||
pattern = re.compile(r"All:([\d.]+)")
|
||||
for line in log_path.read_text().splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
try:
|
||||
values.append(float(m.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not values:
|
||||
print(f"error: no SSIM samples parsed from {log_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
values.sort()
|
||||
n = len(values)
|
||||
mean = sum(values) / n
|
||||
p_idx = lambda p: min(n - 1, max(0, int(math.floor(p * n))))
|
||||
summary = {
|
||||
"frame_count": n,
|
||||
"mean": round(mean, 6),
|
||||
"min": round(values[0], 6),
|
||||
"max": round(values[-1], 6),
|
||||
"p05": round(values[p_idx(0.05)], 6),
|
||||
"p95": round(values[p_idx(0.95)], 6),
|
||||
"threshold": threshold,
|
||||
"pass": bool(mean >= threshold),
|
||||
}
|
||||
out_path.write_text(json.dumps(summary, indent=2) + "\n")
|
||||
print(json.dumps(summary, indent=2))
|
||||
sys.exit(0 if summary["pass"] else 1)
|
||||
PY
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect, useLayoutEffect } from "react";
|
||||
import { useCurrentFrame, AbsoluteFill, delayRender, continueRender } from "remotion";
|
||||
import { Button } from "@mui/material";
|
||||
|
||||
// Custom hook in `export const useFoo = ...` form — earlier custom-hook
|
||||
// regex anchored to `^\s*(?:function|const|let)` and missed the `export`
|
||||
// prefix. This covers the regression.
|
||||
export const useFadeMixed = (n: number) => {
|
||||
const f = useCurrentFrame();
|
||||
return f / n;
|
||||
};
|
||||
|
||||
export const BadComposition: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const [data, setData] = useState<string | null>(null);
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
// Multi-line useEffect body with commas inside (fillRect args) — regression
|
||||
// coverage for r2hf/use-effect-deps. An earlier regex `[^,]+` would stop at
|
||||
// the first comma inside the body and miss the deps array entirely.
|
||||
useEffect(() => {
|
||||
fetch("/api/data")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const ctx = document.createElement("canvas").getContext("2d");
|
||||
ctx?.fillRect(0, 0, 100, 100);
|
||||
setData(d.text);
|
||||
continueRender(handle);
|
||||
});
|
||||
}, [handle]);
|
||||
|
||||
// Expression-bodied useEffect — the form `useEffect(() => fetch(...), [deps])`
|
||||
// has no closing `}`, which an earlier regex anchored on. This and the
|
||||
// useLayoutEffect below cover the false-negative cases Miguel surfaced.
|
||||
useEffect(() => fetch("/api/heartbeat"), [frame]);
|
||||
useLayoutEffect(() => (document.title = `frame ${frame}`), [frame]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Button>{data ?? "loading"}</Button>
|
||||
<span>{frame}</span>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const calculateMetadata = async () => {
|
||||
const res = await fetch("/api/duration");
|
||||
const { duration } = await res.json();
|
||||
return { durationInFrames: duration };
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
Sequence,
|
||||
staticFile,
|
||||
Audio,
|
||||
Img,
|
||||
} from "remotion";
|
||||
|
||||
// Mount-only useEffect with empty deps + a later expression containing a
|
||||
// non-empty array — regression coverage for the over-match Miguel reported:
|
||||
// the earlier regex spanned past `[]` and matched `[frame]` from `pick(...)`,
|
||||
// falsely flagging this clean fixture as having a blocker.
|
||||
function pick<T>(_key: string, items: T[]): T {
|
||||
return items[0];
|
||||
}
|
||||
|
||||
const TitleCard: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
useEffect(() => {
|
||||
console.log("mounted");
|
||||
}, []);
|
||||
const _picked = pick("x", [frame]);
|
||||
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
|
||||
const scale = spring({ frame, fps, config: { damping: 12 } });
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div style={{ fontSize: 72, opacity, transform: `scale(${scale})` }}>Hello</div>
|
||||
<Img src={staticFile("logo.png")} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const MyComposition: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<Sequence from={0} durationInFrames={90}>
|
||||
<TitleCard />
|
||||
</Sequence>
|
||||
<Audio src={staticFile("music.mp3")} volume={0.5} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# smoke.sh — exercise the eval harness scripts against synthetic inputs.
|
||||
#
|
||||
# Generates two synthetic videos with ffmpeg's testsrc filter, runs render_diff
|
||||
# and frame_strip against them, and runs lint_source against fixture .tsx files.
|
||||
# Asserts the harness produces sensible output without depending on a real
|
||||
# Remotion or HyperFrames render pipeline being installed.
|
||||
#
|
||||
# Usage: ./smoke.sh
|
||||
# Exit 0 on pass.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPTS_DIR="$(cd "$THIS_DIR/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
echo "==> smoke: render_diff.sh against identical inputs"
|
||||
# Generate the same test pattern twice. Identical inputs → SSIM should be ~1.0.
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "testsrc=duration=2:size=320x240:rate=30" \
|
||||
-pix_fmt yuv420p "$WORK/baseline.mp4"
|
||||
cp "$WORK/baseline.mp4" "$WORK/translated.mp4"
|
||||
|
||||
R2HF_SSIM_THRESHOLD=0.99 "$SCRIPTS_DIR/render_diff.sh" \
|
||||
"$WORK/baseline.mp4" "$WORK/translated.mp4" "$WORK/diff" >/dev/null
|
||||
|
||||
MEAN=$(python3 -c "import json,sys; print(json.load(open('$WORK/diff/summary.json'))['mean'])")
|
||||
PASS=$(python3 -c "import json,sys; print(json.load(open('$WORK/diff/summary.json'))['pass'])")
|
||||
if [[ "$PASS" != "True" ]]; then
|
||||
echo "FAIL: identical inputs failed pass check (mean=$MEAN)"
|
||||
exit 1
|
||||
fi
|
||||
echo " identical inputs → mean SSIM=$MEAN (pass=True)"
|
||||
|
||||
echo "==> smoke: render_diff.sh against different inputs"
|
||||
# Different test pattern → SSIM should be lower. With a high threshold it should fail.
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "testsrc2=duration=2:size=320x240:rate=30" \
|
||||
-pix_fmt yuv420p "$WORK/different.mp4"
|
||||
|
||||
set +e
|
||||
R2HF_SSIM_THRESHOLD=0.99 "$SCRIPTS_DIR/render_diff.sh" \
|
||||
"$WORK/baseline.mp4" "$WORK/different.mp4" "$WORK/diff2" >/dev/null
|
||||
RC=$?
|
||||
set -e
|
||||
if [[ "$RC" -eq 0 ]]; then
|
||||
echo "FAIL: different inputs unexpectedly passed at threshold 0.99"
|
||||
exit 1
|
||||
fi
|
||||
DIFF_MEAN=$(python3 -c "import json; print(json.load(open('$WORK/diff2/summary.json'))['mean'])")
|
||||
echo " different inputs → mean SSIM=$DIFF_MEAN (correctly failed at 0.99)"
|
||||
|
||||
echo "==> smoke: frame_strip.sh produces a strip"
|
||||
"$SCRIPTS_DIR/frame_strip.sh" "$WORK/baseline.mp4" "$WORK/different.mp4" "$WORK/strip" 4 >/dev/null
|
||||
if [[ ! -f "$WORK/strip/strip.png" ]]; then
|
||||
echo "FAIL: frame_strip.sh did not produce strip.png"
|
||||
exit 1
|
||||
fi
|
||||
echo " strip.png written ($(stat -c%s "$WORK/strip/strip.png" 2>/dev/null || stat -f%z "$WORK/strip/strip.png") bytes)"
|
||||
|
||||
echo "==> smoke: lint_source.py on clean fixture (expect exit 0)"
|
||||
set +e
|
||||
python3 "$SCRIPTS_DIR/lint_source.py" "$THIS_DIR/fixtures/clean.tsx" --json >"$WORK/clean.json"
|
||||
RC=$?
|
||||
set -e
|
||||
BLOCKERS=$(python3 -c "import json; print(json.load(open('$WORK/clean.json'))['blockers'])")
|
||||
if [[ "$RC" -ne 0 || "$BLOCKERS" -ne 0 ]]; then
|
||||
echo "FAIL: clean fixture reported $BLOCKERS blockers (rc=$RC)"
|
||||
exit 1
|
||||
fi
|
||||
INFOS=$(python3 -c "import json; print(json.load(open('$WORK/clean.json'))['infos'])")
|
||||
echo " clean.tsx → 0 blockers, $INFOS info findings"
|
||||
|
||||
echo "==> smoke: lint_source.py on blocker fixture (expect exit 1)"
|
||||
set +e
|
||||
python3 "$SCRIPTS_DIR/lint_source.py" "$THIS_DIR/fixtures/blocker.tsx" --json >"$WORK/blocker.json"
|
||||
RC=$?
|
||||
set -e
|
||||
BLOCKERS=$(python3 -c "import json; print(json.load(open('$WORK/blocker.json'))['blockers'])")
|
||||
if [[ "$RC" -eq 0 || "$BLOCKERS" -lt 3 ]]; then
|
||||
echo "FAIL: blocker fixture reported $BLOCKERS blockers, expected >=3 (rc=$RC)"
|
||||
cat "$WORK/blocker.json"
|
||||
exit 1
|
||||
fi
|
||||
echo " blocker.tsx → $BLOCKERS blockers detected (correctly refused)"
|
||||
|
||||
echo
|
||||
echo "✅ smoke tests passed"
|
||||
Reference in New Issue
Block a user