Skip to content

Commit f0d0f17

Browse files
committed
fix(v0.21.0): keep export stage background behind transparent clips
session: codex130 What: Resolve the exported stage background from the source theme and apply it to the export stage, add regression BDD coverage, and record the bug-fix evidence. Why: Draft export was leaking the recorder magenta body canary whenever later clip-first segments used transparent component tracks. The stage should carry the composition background while the canary remains behind it for real viewport holes. Co-Authored-By: Claude Opus 4.7
1 parent af8b884 commit f0d0f17

6 files changed

Lines changed: 237 additions & 2 deletions

File tree

crates/nf-recorder/src/export_api.rs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ pub async fn run_export_from_source(
211211
let preset = resolve_export_preset(&source_json, &opts)?;
212212
override_source_viewport(&mut source_json, preset.viewport);
213213
let tracks_map_json = build_tracks_map_json(&source_json);
214+
let stage_background = resolve_stage_background(&source_json);
214215
let source_text = serde_json::to_string(&source_json).map_err(|e| {
215216
RecordError::BundleLoadFailed(format!("serialize source.json for export HTML: {e}"))
216217
})?;
@@ -223,6 +224,7 @@ pub async fn run_export_from_source(
223224
vp_w,
224225
vp_h,
225226
requested_duration_ms,
227+
&stage_background,
226228
);
227229

228230
// 写 tmp file · macOS /tmp 没 gitignore 问题 · 独占进程 pid + nanos 防撞。
@@ -363,6 +365,56 @@ fn override_source_viewport(source_json: &mut serde_json::Value, viewport: (u32,
363365
);
364366
}
365367

368+
fn resolve_stage_background(source_json: &serde_json::Value) -> String {
369+
let theme = source_json.get("theme").unwrap_or(&serde_json::Value::Null);
370+
for pointer in ["/background", "/bg", "/colors/background", "/colors/bg"] {
371+
if let Some(raw) = theme.pointer(pointer).and_then(serde_json::Value::as_str) {
372+
if let Some(value) = sanitize_stage_background(raw) {
373+
return value;
374+
}
375+
}
376+
}
377+
378+
if let Some(css) = theme.get("css").and_then(serde_json::Value::as_str) {
379+
if let Some(raw) = extract_css_custom_property(css, "--nfv2-bg") {
380+
if let Some(value) = sanitize_stage_background(raw) {
381+
return value;
382+
}
383+
}
384+
}
385+
386+
"#000".to_string()
387+
}
388+
389+
fn extract_css_custom_property<'a>(css: &'a str, name: &str) -> Option<&'a str> {
390+
let start = css.find(name)?;
391+
let rest = &css[start + name.len()..];
392+
let colon = rest.find(':')?;
393+
let rest = &rest[colon + 1..];
394+
let end = rest.find(';')?;
395+
Some(rest[..end].trim())
396+
}
397+
398+
fn sanitize_stage_background(raw: &str) -> Option<String> {
399+
let value = raw.trim();
400+
if value.is_empty() || value.len() > 96 || value.contains([';', '{', '}']) {
401+
return None;
402+
}
403+
let lower = value.to_ascii_lowercase();
404+
let named = matches!(
405+
lower.as_str(),
406+
"black" | "white" | "transparent" | "canvas" | "currentcolor"
407+
);
408+
let functional = lower.starts_with("rgb(")
409+
|| lower.starts_with("rgba(")
410+
|| lower.starts_with("hsl(")
411+
|| lower.starts_with("hsla(");
412+
let hex = lower.starts_with('#')
413+
&& matches!(lower.len(), 4 | 5 | 7 | 9)
414+
&& lower[1..].chars().all(|ch| ch.is_ascii_hexdigit());
415+
(named || functional || hex).then(|| value.to_string())
416+
}
417+
366418
/// 构造自包含 export HTML · 含 runtime + __NF_SOURCE__ + mount。
367419
///
368420
/// 关键点(ADR-064):
@@ -377,6 +429,7 @@ fn build_export_html(
377429
vp_w: u32,
378430
vp_h: u32,
379431
requested_duration_ms: u64,
432+
stage_background: &str,
380433
) -> String {
381434
format!(
382435
r#"<!DOCTYPE html>
@@ -387,7 +440,7 @@ fn build_export_html(
387440
<title>nf-export</title>
388441
<style>
389442
html,body{{margin:0;padding:0;background:#000;width:{vp_w}px;height:{vp_h}px;overflow:hidden}}
390-
#nf-stage{{position:absolute;top:0;left:0;width:{vp_w}px;height:{vp_h}px;transform-origin:top left}}
443+
#nf-stage{{position:absolute;top:0;left:0;width:{vp_w}px;height:{vp_h}px;transform-origin:top left;background:{stage_background}}}
391444
</style>
392445
</head>
393446
<body>
@@ -532,3 +585,31 @@ window.__NF_TRACKS__ = {tracks_map_json};
532585
runtime = RUNTIME_IIFE,
533586
)
534587
}
588+
589+
#[cfg(test)]
590+
mod tests {
591+
use super::*;
592+
use serde_json::json;
593+
594+
#[test]
595+
fn stage_background_uses_theme_css_var() {
596+
let source = json!({
597+
"theme": {
598+
"css": ":root { --nfv2-bg: #05070a; --nfv2-text: #fff; }"
599+
}
600+
});
601+
602+
assert_eq!(resolve_stage_background(&source), "#05070a");
603+
}
604+
605+
#[test]
606+
fn stage_background_rejects_css_injection() {
607+
let source = json!({
608+
"theme": {
609+
"background": "#000; } body { background: red"
610+
}
611+
});
612+
613+
assert_eq!(resolve_stage_background(&source), "#000");
614+
}
615+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>export-stage-background</title>
7+
<style>
8+
html, body { margin: 0; width: 100%; height: 100%; background: #05070a; color: #f6f7f2; font: 16px/1.4 Inter, system-ui, sans-serif; overflow: hidden; }
9+
.stage { position: fixed; inset: 0; display: grid; place-items: center; background: #05070a; }
10+
.frame { width: min(980px, 86vw); aspect-ratio: 16 / 9; position: relative; overflow: hidden; border: 1px solid rgba(255,255,255,.18); background: #05070a; }
11+
.bad, .good { position: absolute; inset: 0; display: grid; place-items: end center; padding-bottom: 42px; box-sizing: border-box; animation: swap 10s infinite; }
12+
.bad { background: #ff00ff; }
13+
.good { background: #05070a; opacity: 0; }
14+
.good i { position: absolute; width: 7px; height: 7px; background: #62f5d2; left: 58%; top: 34%; box-shadow: -210px 30px #c8ff5d, 170px 70px #78a7ff, 60px 180px #62f5d2, -40px 230px #78a7ff; }
15+
.subtitle { font-size: 30px; text-shadow: 0 2px 14px rgba(0,0,0,.7); }
16+
.toast { position: fixed; right: 24px; bottom: 24px; padding: 10px 14px; border: 1px solid rgba(255,255,255,.18); background: rgba(5,7,10,.82); animation: toast 10s infinite; }
17+
.bar { position: fixed; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(255,255,255,.14); }
18+
.bar::after { content: ""; display: block; height: 100%; width: 100%; background: #62f5d2; transform-origin: left; animation: progress 10s linear infinite; }
19+
@keyframes swap {
20+
0%, 38% { opacity: 1; }
21+
48%, 100% { opacity: 0; }
22+
}
23+
.good { animation-name: swapGood; }
24+
@keyframes swapGood {
25+
0%, 42% { opacity: 0; }
26+
52%, 100% { opacity: 1; }
27+
}
28+
@keyframes progress {
29+
from { transform: scaleX(0); }
30+
to { transform: scaleX(1); }
31+
}
32+
@keyframes toast {
33+
0%, 82% { opacity: 0; transform: translateY(8px); }
34+
88%, 100% { opacity: 1; transform: translateY(0); }
35+
}
36+
</style>
37+
</head>
38+
<body>
39+
<main class="stage">
40+
<section class="frame" aria-label="Export stage background regression">
41+
<div class="bad"><div class="subtitle">Transparent clip exposes magenta canary.</div></div>
42+
<div class="good"><i></i><div class="subtitle">Transparent clip renders on the stage background.</div></div>
43+
</section>
44+
</main>
45+
<div class="toast">本轮完成</div>
46+
<div class="bar"></div>
47+
</body>
48+
</html>

spec/bdd/clip-first-composition/feature.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"export-clip-first",
1717
"voice-preview-audible",
1818
"clip-all-and-local-preview",
19-
"track-click-stable"
19+
"track-click-stable",
20+
"export-stage-background"
2021
],
2122
"status": "implemented"
2223
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"id": "export-stage-background",
3+
"given": "A clip-first composition stitches transparent component clips after the first clip.",
4+
"when": "The user exports a draft MP4 and inspects frames after clip boundaries.",
5+
"then": "Later clips render on the composition stage background instead of exposing the recorder magenta canary.",
6+
"human_actions": ["export draft", "inspect second clip frame", "inspect third clip frame"],
7+
"ai_tools": [
8+
{
9+
"tool": "export",
10+
"command": "NEXTFRAME_HOME=examples target/debug/nf export --project=v2-showcase --composition=showreel-clip-first --profile=draft --diagnostics --out=/tmp/nextframe-clip-first-stage-bg.mp4"
11+
},
12+
{
13+
"tool": "frame",
14+
"command": "ffmpeg -y -ss 6 -i /tmp/nextframe-clip-first-stage-bg.mp4 -frames:v 1 /tmp/nextframe-clip-first-stage-bg-06s.png"
15+
},
16+
{
17+
"tool": "pixel",
18+
"command": "magick /tmp/nextframe-clip-first-stage-bg-06s.png -format '%[pixel:p{640,360}]' info: # expected srgb(3,5,9), not srgb(255,0,255)"
19+
}
20+
]
21+
}

spec/devlog/02.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,62 @@
11
---
22

3+
## 2026-04-25 14:36 · v0.21.0 · bug-fix · [export, clip-first, recorder]
4+
5+
**Session**: codex130
6+
7+
### 症状
8+
9+
`showreel-clip-first` 草稿导出后,第一个 clip 正常,后续 clip 变成粉色背景屏幕。
10+
11+
### 复现计划
12+
13+
- 用 `nf export --profile draft --diagnostics` 导出当前 clip-first composition。
14+
- 抽取 1s / 6s / 12s 帧,确认粉屏从哪个 clip 开始。
15+
- 查 recorder/runtime 是否在组件切换后触发错误 fallback。
16+
17+
### 状态
18+
19+
open。
20+
21+
---
22+
23+
## 2026-04-25 14:44 · v0.21.0 · bug-fix · [export, clip-first, recorder]
24+
25+
**Session**: codex130
26+
27+
### Root cause
28+
29+
Draft export uses a magenta body canary (`#ff00ff`) in record mode to expose uncovered viewport holes.
30+
The first clip had a full-cover `html.stage-background`, but later transparent component clips (`canvas.particle-field`, title-only outro) did not cover the stage, so the canary leaked into the MP4.
31+
32+
### Fix
33+
34+
- `nf-recorder` export HTML now gives `#nf-stage` a stable background resolved from the source theme (`theme.background`, `theme.bg`, or `theme.css --nfv2-bg`, fallback `#000`).
35+
- The body canary remains behind the stage, so real stage sizing holes are still detectable.
36+
- Added unit coverage for theme CSS background extraction and CSS-injection rejection.
37+
- Added BDD regression `export-stage-background`.
38+
39+
### Verification
40+
41+
- Repro before fix:
42+
- `NEXTFRAME_HOME=.../examples target/debug/nf export --project=v2-showcase --composition=showreel-clip-first --profile=draft --diagnostics --out=.../showreel-clip-first-pink-repro.mp4`
43+
- 6s and 12s extracted frames were magenta.
44+
- After fix:
45+
- `NEXTFRAME_HOME=.../examples target/debug/nf export --project=v2-showcase --composition=showreel-clip-first --profile=draft --diagnostics --out=.../showreel-clip-first-fixed.mp4`
46+
- `magick fixed-frame-06s.png -format '%[pixel:p{640,360}]' info:` -> `srgb(3,5,9)`
47+
- `magick fixed-frame-12s.png -format '%[pixel:p{640,360}]' info:` -> `srgb(3,5,9)`
48+
- `cargo test -p nf-recorder export_api::tests -- --nocapture` passed.
49+
- `cargo fmt --check && cargo check -p nf-cli -p nf-recorder` passed.
50+
- `find spec/bdd/clip-first-composition -name '*.json' -print0 | xargs -0 -n1 jq . >/dev/null` passed.
51+
- `./scripts/check-structure.sh` passed.
52+
- `./scripts/audit.sh --gate-only` passed, report `spec/quality-reports/2026-04-25-1443.md`.
53+
54+
### 状态
55+
56+
fixed。
57+
58+
---
59+
360
## 2026-04-25 14:27 · v0.21.0 · bug-fix · [editor, timeline, perf]
461

562
**Session**: codex130
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# NextFrame 质量审计 · 2026-04-25 14:43
2+
3+
**版本**: `v0.13.0`
4+
**模式**: gate
5+
**总分**: 10.0 / 10
6+
**门禁**: 2 绿 / 0 红 / 2 N/A (4 硬门禁中)
7+
8+
## 维度打分
9+
10+
| # | 维度 | 硬度 || 说明 |
11+
|---|---|---|---|---|
12+
| G1 | 编译 + lint | 门禁 | **A** | make check 三绿(rust=1 clippy=1 fmt=1 ts=1) |
13+
| G2 | 架构边界 | 门禁 | **A** | 0 违约 · 依赖方向单向 |
14+
| P1 | frame pure | 门禁 | **NA** | nf-engine 未实现(4 行) · v0.3+ 上 property test |
15+
| P2 | 3 模式像素 | 门禁 | **NA** | nf-runtime 未实现(5 行) · v0.3+ 上 diff harness |
16+
| G3 | AI 可操作 | 报告 | **** | (未跑) |
17+
| P3 | 视觉 token | 报告 | **** | (未跑) |
18+
| P4 | 零框架 | 报告 | **** | (未跑) |
19+
20+
## 关注项
21+
22+
-**P1** N/A: nf-engine 未实现(4 行) · v0.3+ 上 property test
23+
-**P2** N/A: nf-runtime 未实现(5 行) · v0.3+ 上 diff harness
24+
25+
---
26+
27+
_自动产出 · `./scripts/audit.sh` · 标准见 `spec/standards/`_

0 commit comments

Comments
 (0)