Skip to content

Commit 4746dd0

Browse files
committed
fix(v0.21.0): avoid redundant timeline track reselection
session: codex130 What: Removed the duplicate timeline track click path, made slotted clip clicks stay on the clip path, and added a no-op guard when selecting the already selected clip. Added a track-click-stable BDD regression and quality report. Why: A single clip-first track click was resolving through two handlers and reselecting the same clip. That no-op reselection still stopped playback, rewrote component attributes, and rerendered the composition preview, which made the editor feel like the whole card refreshed. Co-Authored-By: Claude Opus 4.7
1 parent cbf9275 commit 4746dd0

8 files changed

Lines changed: 274 additions & 23 deletions

File tree

frontend/nf-components/src/components/timeline.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -268,31 +268,20 @@ export class NfTimeline extends NfBase {
268268
});
269269
this.root.addEventListener("track-select", (event) => {
270270
const detail = (event as CustomEvent<TimelineTrackSelectDetail>).detail;
271+
if (this.dataset.mode === "clip-first") {
272+
const clipId = this.dataset.selectedClipId;
273+
if (!clipId) return;
274+
this.emit<TimelineClipSelectDetail>("clip-select", {
275+
track: detail.track,
276+
"clip-id": clipId,
277+
});
278+
return;
279+
}
271280
this.emit<TimelineClipSelectDetail>("clip-select", {
272281
track: detail.track,
273282
"clip-id": detail["track-id"],
274283
});
275284
});
276-
this.root.querySelectorAll<HTMLElement>("nf-track[data-track-id]").forEach((row) => {
277-
row.addEventListener("click", () => {
278-
if (this.dataset.mode === "clip-first") {
279-
const clipId = this.dataset.selectedClipId;
280-
if (!clipId) return;
281-
this.emit<TimelineClipSelectDetail>("clip-select", {
282-
track: (row.getAttribute("kind") ?? "component") as TimelineClipSelectDetail["track"],
283-
"clip-id": clipId,
284-
});
285-
return;
286-
}
287-
if (this.dataset.mode === "clip-all") return;
288-
const trackId = row.dataset.trackId ?? "";
289-
if (!trackId) return;
290-
this.emit<TimelineClipSelectDetail>("clip-select", {
291-
track: (row.getAttribute("kind") ?? "component") as TimelineClipSelectDetail["track"],
292-
"clip-id": trackId,
293-
});
294-
});
295-
});
296285
this.root.addEventListener("anchor-hover", (event) => {
297286
const detail = (event as CustomEvent<AnchorHoverDetail>).detail;
298287
this.emit<AnchorHoverDetail>("anchor-hover", detail);

frontend/nf-components/src/components/track.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ export class NfTrack extends NfBase {
4848
`;
4949
this.root.querySelector(".tl-row")?.addEventListener("click", (event) => {
5050
if (!trackId) return;
51+
if ((event.target as Element | null)?.closest("slot")) return;
5152
this.emit<TimelineTrackSelectDetail>("track-select", {
5253
track: kind as ClipKind,
5354
"track-id": trackId,
5455
});
55-
if ((event.target as Element | null)?.closest("slot")) return;
5656
});
5757
}
5858
}

frontend/nf-components/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,13 +452,14 @@ function hasGeneratedAudioForClip(clipId: string): boolean {
452452
}
453453

454454
function selectClip(clipId: string, clip?: NfDataClip): void {
455+
const selected = clip ?? getMockData().episodes[0]?.clips.find((item) => item.id === clipId);
456+
if (clipId === selectedClipId && selected) return;
455457
stopPlayback({ keepButtonState: false });
456458
selectedClipId = clipId;
457459
document.querySelector("nf-clips")?.setAttribute("selected-id", clipId);
458460
document.querySelector("nf-inspector")?.setAttribute("clip-id", clipId);
459461
document.querySelector("nf-timeline")?.setAttribute("selected-id", clipId);
460462
document.querySelector("nf-timeline")?.setAttribute("data-selected-track-id", clipId);
461-
const selected = clip ?? getMockData().episodes[0]?.clips.find((item) => item.id === clipId);
462463
if (selected) {
463464
const duration = previewDurationForClip(selected);
464465
currentPreviewTime = compositionSource && selected.id !== ALL_COMPOSITION_CLIP_ID ? 0 : selected.start;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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>track-click-stable</title>
7+
<style>
8+
:root {
9+
color-scheme: dark;
10+
--bg: #101417;
11+
--panel: #171d22;
12+
--ink: #f4f6f4;
13+
--muted: #a9b2ae;
14+
--line: #303a40;
15+
--accent: #a78bfa;
16+
--ok: #5eead4;
17+
}
18+
* { box-sizing: border-box; }
19+
body {
20+
margin: 0;
21+
min-height: 100vh;
22+
display: grid;
23+
place-items: center;
24+
background: var(--bg);
25+
color: var(--ink);
26+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27+
}
28+
.stage {
29+
width: min(940px, calc(100vw - 32px));
30+
min-height: 500px;
31+
display: grid;
32+
grid-template-columns: 220px 1fr;
33+
grid-template-rows: 1fr 170px 44px;
34+
border: 1px solid var(--line);
35+
background: var(--panel);
36+
overflow: hidden;
37+
}
38+
.clips {
39+
grid-row: 1 / span 2;
40+
border-right: 1px solid var(--line);
41+
padding: 18px 12px;
42+
}
43+
.clip {
44+
height: 48px;
45+
display: flex;
46+
align-items: center;
47+
gap: 10px;
48+
padding: 0 12px;
49+
margin-bottom: 8px;
50+
border: 1px solid #263139;
51+
color: var(--muted);
52+
}
53+
.clip.active {
54+
border-color: var(--accent);
55+
background: rgba(167,139,250,.12);
56+
color: var(--ink);
57+
}
58+
.mark {
59+
width: 3px;
60+
height: 16px;
61+
background: var(--accent);
62+
}
63+
.preview {
64+
margin: 42px auto 26px;
65+
width: min(620px, calc(100% - 70px));
66+
aspect-ratio: 16 / 9;
67+
border: 1px solid #25313a;
68+
background: linear-gradient(120deg, rgba(94,234,212,.16), rgba(167,139,250,.10)), #0d1215;
69+
display: grid;
70+
place-items: center;
71+
color: rgba(244,246,244,.72);
72+
font-size: 26px;
73+
font-weight: 720;
74+
}
75+
.timeline {
76+
border-top: 1px solid var(--line);
77+
background: #0d1215;
78+
padding: 18px 20px;
79+
}
80+
.row {
81+
height: 28px;
82+
position: relative;
83+
margin-bottom: 12px;
84+
border: 1px solid #2a363d;
85+
background: #12181c;
86+
animation: clickRow 8s steps(1) infinite;
87+
}
88+
.row:nth-child(2) { animation-delay: 2s; }
89+
.row:nth-child(3) { animation-delay: 4s; }
90+
.bar {
91+
position: absolute;
92+
inset: 4px;
93+
background: rgba(94,234,212,.35);
94+
}
95+
.cursor {
96+
position: absolute;
97+
width: 18px;
98+
height: 18px;
99+
border: 2px solid var(--ink);
100+
border-radius: 50%;
101+
right: 22%;
102+
top: 4px;
103+
animation: cursor 8s linear infinite;
104+
}
105+
.status {
106+
grid-column: 1 / span 2;
107+
position: relative;
108+
border-top: 1px solid var(--line);
109+
background: #0b1013;
110+
}
111+
.progress {
112+
height: 4px;
113+
width: 100%;
114+
transform-origin: left;
115+
background: var(--ok);
116+
animation: progress 8s linear infinite;
117+
}
118+
.toast {
119+
position: absolute;
120+
right: 14px;
121+
top: 10px;
122+
color: #c7f8df;
123+
font-size: 13px;
124+
opacity: 0;
125+
animation: toast 8s linear infinite;
126+
}
127+
@keyframes clickRow {
128+
0%, 20% { border-color: var(--accent); }
129+
21%, 100% { border-color: #2a363d; }
130+
}
131+
@keyframes cursor {
132+
0%, 18% { transform: translate(0, 0); opacity: 1; }
133+
19%, 38% { transform: translate(-130px, 40px); opacity: 1; }
134+
39%, 58% { transform: translate(-40px, 80px); opacity: 1; }
135+
59%, 100% { transform: translate(-40px, 80px); opacity: 0; }
136+
}
137+
@keyframes progress {
138+
from { transform: scaleX(0); }
139+
to { transform: scaleX(1); }
140+
}
141+
@keyframes toast {
142+
0%, 78% { opacity: 0; }
143+
84%, 95% { opacity: 1; }
144+
100% { opacity: 0; }
145+
}
146+
</style>
147+
</head>
148+
<body>
149+
<main class="stage">
150+
<aside class="clips">
151+
<div class="clip"><span class="mark"></span>全部</div>
152+
<div class="clip active"><span class="mark"></span>Intro</div>
153+
<div class="clip"><span class="mark"></span>Local Tracks</div>
154+
<div class="clip"><span class="mark"></span>Voice Timeline</div>
155+
</aside>
156+
<section class="preview">local preview stays mounted</section>
157+
<section class="timeline">
158+
<div class="row"><span class="bar"></span><span class="cursor"></span></div>
159+
<div class="row"><span class="bar"></span></div>
160+
<div class="row"><span class="bar"></span></div>
161+
</section>
162+
<footer class="status"><div class="progress"></div><div class="toast">round complete</div></footer>
163+
</main>
164+
</body>
165+
</html>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"tts-subtitle-timeline",
1616
"export-clip-first",
1717
"voice-preview-audible",
18-
"clip-all-and-local-preview"
18+
"clip-all-and-local-preview",
19+
"track-click-stable"
1920
],
2021
"status": "implemented"
2122
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"id": "track-click-stable",
3+
"given": "A clip-first composition is open and one local clip is selected.",
4+
"when": "The user clicks a timeline track row or a clip item inside the selected local clip.",
5+
"then": "The editor does not perform a redundant whole-card reselection or preview remount.",
6+
"human_actions": ["select Intro", "click local timeline row", "click local timeline item"],
7+
"ai_tools": [
8+
{ "tool": "open", "command": "NEXTFRAME_HOME=examples target/debug/nf open --project v2-showcase --composition showreel-clip-first --t 0" },
9+
{ "tool": "click", "command": "NEXTFRAME_HOME=examples target/debug/nf click --project v2-showcase --episode showreel-clip-first --selector 'nf-clips::shadow .clip-row[data-id=\"intro\"]'" },
10+
{ "tool": "click", "command": "NEXTFRAME_HOME=examples target/debug/nf click --project v2-showcase --episode showreel-clip-first --selector 'nf-timeline::shadow nf-track[data-track-id=\"stage\"]::shadow .tl-row'" },
11+
{ "tool": "capture", "command": "NEXTFRAME_HOME=examples target/debug/nf capture --project v2-showcase --episode showreel-clip-first --out ../NextFrame.archive/v0.21.0-verify/track-click-stable.png" }
12+
]
13+
}

spec/devlog/02.md

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

3+
## 2026-04-25 14:27 · v0.21.0 · bug-fix · [editor, timeline, perf]
4+
5+
**Session**: codex130
6+
**Revises**: 2026-04-25 14:21 · v0.21.0 · bug-fix · [editor, timeline, perf]
7+
**Related**: bdd:clip-first-composition/scenarios/track-click-stable
8+
9+
### 根因
10+
Timeline track click had two selection paths:
11+
- `nf-track` emitted `track-select`.
12+
- `nf-timeline` also attached an outer click listener to every `nf-track[data-track-id]`.
13+
14+
For clip-first rows, both paths resolved back to the selected clip and called `selectClip` again. `selectClip` stops playback, rewrites clip/timeline/inspector attributes, and rerenders the composition preview, so a no-op track click looked like a whole-card refresh.
15+
16+
### 修复
17+
- Removed the duplicate outer `nf-track` click binding in `nf-timeline`.
18+
- Made `nf-track` ignore clicks that originate from slotted clip items before emitting `track-select`.
19+
- Added a no-op guard in `selectClip` for selecting the already selected clip.
20+
- Added BDD scenario/demo `track-click-stable`.
21+
22+
### 回归
23+
- `NEXTFRAME_HOME=... target/debug/nf click --project v2-showcase --episode showreel-clip-first --selector 'nf-clips::shadow .clip-row[data-id="intro"]'` returned `ok=true`.
24+
- `NEXTFRAME_HOME=... target/debug/nf click --project v2-showcase --episode showreel-clip-first --selector 'nf-timeline::shadow nf-track[data-track-id="stage"]::shadow .tl-row'` returned `ok=true`.
25+
- Shell log after the track click showed no extra `projects.show` / `compositions.show` reload request.
26+
- `cargo fmt --check && cargo check -p nf-cli -p nf-project -p nf-shell && cargo build -p nf-cli -p nf-shell` passed.
27+
- `cd frontend/nf-components && npm run check && npm run build` passed.
28+
- `find spec/bdd/clip-first-composition -name '*.json' -print0 | xargs -0 -n1 jq . >/dev/null` passed.
29+
- `./scripts/check-structure.sh` passed.
30+
- `./scripts/audit.sh --gate-only` passed with report `spec/quality-reports/2026-04-25-1426.md` (G1=A, G2=A, P1/P2=N/A).
31+
32+
### 状态
33+
fixed
34+
35+
---
36+
37+
## 2026-04-25 14:21 · v0.21.0 · bug-fix · [editor, timeline, perf]
38+
39+
**Session**: codex130
40+
41+
### 症状
42+
点击 clip-first 时间线轨道时,整个编辑器有明显卡顿/重绘感。
43+
44+
### 复现
45+
- 打开 `v2-showcase/showreel-clip-first` 桌面端。
46+
- 点击底部 timeline 的轨道条或轨道空白区域。
47+
- 预期:只选中当前局部轨道/片段,界面稳定。
48+
- 实际:整卡片像重新刷新一次,交互不跟手。
49+
50+
### 严重度
51+
p1 重要:clip-first authoring 主要交互不跟手。
52+
53+
### 状态
54+
open
55+
56+
---
57+
358
## 2026-04-25 14:16 · v0.21.0 · Execute · [clip-first, editor, preview]
459

560
**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:26
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)