Skip to content

Commit 9901afd

Browse files
committed
feat(v0.14.0): wire voice subtitle preview loop
session: codex-2026-04-24-v014-build What: Add v2 subtitle tracks to the showreel, render synced subtitle overlays in desktop preview, expose audio/subtitle tracks in the inspector, support array field patch paths, add a voice/subtitle smoke composition, and record build evidence plus the full-showreel export blocker. Why: The next product slice needs the visible voice+subtitle loop in the editor before the larger showreel export performance issue is solved. The smoke export proves the saved JSON -> source -> MP4 audio/subtitle path while keeping the 24s showreel performance failure explicit instead of hiding it. Co-Authored-By: Claude Opus 4.7
1 parent 6122885 commit 9901afd

19 files changed

Lines changed: 568 additions & 64 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ Example projects live under `examples/`; runtime projects are copied to `~/.next
1515
## AI 验证接口
1616

1717
`target/debug/nf open --project=<slug> --composition=<slug>` — open a v2 composition in the desktop editor.
18-
`target/debug/nf screenshot --project=<slug> --episode=<slug> --region=editor --out=<png>` — capture a desktop editor region.
18+
`target/debug/nf screenshot --project=<slug> --episode=<slug> --region=editor --out=<png>` — probe a desktop editor region and write a size stub PNG; use `capture` for visual evidence.
19+
`target/debug/nf capture --project=<slug> --episode=<slug> --out=<png>` — capture the native macOS window PNG for real visual verification.
1920
`target/debug/nf devtools --project=<slug> --episode=<slug> --query=<css> --get=<prop>` — inspect live DOM, including shadow DOM selectors.
2021
`target/debug/nf composition show --project=<slug> --composition=<slug> [--track=<id>] [--field=<path>]` — read raw v2 composition JSON or one track field.
2122
`target/debug/nf composition patch --project=<slug> --composition=<slug> --track=<id> --field=<path> --value=<json-or-string>` — patch one v2 track field such as `params.title`, `style.x`, or `time.start`.
@@ -38,9 +39,9 @@ Do not write generated videos, screenshots, node_modules, Cargo targets, or one-
3839

3940
## Current Focus
4041

41-
v0.13.0 is the project skeleton governance version: clean root layout, spec in the main repo, archived generated artifacts, refreshed entry docs, and a structure gate.
42+
v0.14.0 is the voice/subtitle loop version: JSON carries paired voice and subtitle tracks, the desktop editor previews and edits subtitles, and smoke export proves AAC audio plus burned-in captions. Full 24s showreel export is still blocked by recorder performance near the finale.
4243

4344
Specs and acceptance scenarios:
4445

45-
- `spec/versions/v0.13.0/spec.json`
46-
- `spec/bdd/repo-skeleton-governance/feature.json`
46+
- `spec/versions/v0.14.0/spec.json`
47+
- `spec/bdd/voice-subtitle-loop/feature.json`

crates/nf-project/src/lib.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
22
use std::fs;
33
use std::io::Write;
44
use std::path::{Path, PathBuf};
5+
use std::time::{SystemTime, UNIX_EPOCH};
56

67
use directories::BaseDirs;
78
use once_cell::sync::Lazy;
@@ -231,7 +232,7 @@ pub fn atomic_write<T: Serialize>(path: &Path, value: &T) -> Result<(), ProjectE
231232
fs::create_dir_all(parent).map_err(|err| ProjectError::StorageFailed(err.to_string()))?;
232233
}
233234

234-
let tmp_path = path.with_extension("tmp");
235+
let tmp_path = unique_tmp_path(path);
235236
let result = (|| -> Result<(), ProjectError> {
236237
let json = serde_json::to_string_pretty(value)?;
237238
let mut tmp = fs::File::create(&tmp_path)?;
@@ -249,6 +250,18 @@ pub fn atomic_write<T: Serialize>(path: &Path, value: &T) -> Result<(), ProjectE
249250
result
250251
}
251252

253+
fn unique_tmp_path(path: &Path) -> PathBuf {
254+
let nonce = SystemTime::now()
255+
.duration_since(UNIX_EPOCH)
256+
.map(|duration| duration.as_nanos())
257+
.unwrap_or(0);
258+
let file_name = path
259+
.file_name()
260+
.and_then(|value| value.to_str())
261+
.unwrap_or("nextframe.json");
262+
path.with_file_name(format!("{file_name}.tmp-{}-{nonce}", std::process::id()))
263+
}
264+
252265
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, ProjectError> {
253266
let raw =
254267
fs::read_to_string(path).map_err(|err| ProjectError::StorageFailed(err.to_string()))?;

crates/nf-shell/src/handlers/compositions.rs

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ fn select_composition_value(composition: &Value, params: &Value) -> Result<Value
123123
fn get_field_path<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
124124
let mut current = value;
125125
for part in field.split('.').map(str::trim).filter(|part| !part.is_empty()) {
126-
current = current.get(part)?;
126+
if let Some(index) = path_index(part) {
127+
current = current.as_array()?.get(index)?;
128+
} else {
129+
current = current.get(part)?;
130+
}
127131
}
128132
Some(current)
129133
}
@@ -137,17 +141,73 @@ fn set_field_path(target: &mut Value, field: &str, value: Value) -> Result<(), N
137141
if parts.is_empty() {
138142
return Err(NfError::ValidationFailed("field path is required".to_string()));
139143
}
140-
let mut current = target;
141-
for part in &parts[..parts.len() - 1] {
142-
if !current.get(part).is_some_and(Value::is_object) {
143-
current[part] = json!({});
144+
set_path_part(target, &parts, value, field)
145+
}
146+
147+
fn set_path_part(target: &mut Value, parts: &[&str], value: Value, field: &str) -> Result<(), NfError> {
148+
let Some((part, rest)) = parts.split_first() else {
149+
*target = value;
150+
return Ok(());
151+
};
152+
if rest.is_empty() {
153+
if let Some(index) = path_index(part) {
154+
ensure_array(target);
155+
let array = target
156+
.as_array_mut()
157+
.ok_or_else(|| NfError::ValidationFailed(format!("invalid array path: {field}")))?;
158+
if array.len() <= index {
159+
array.resize(index + 1, Value::Null);
160+
}
161+
array[index] = value;
162+
return Ok(());
163+
}
164+
ensure_object(target);
165+
target[*part] = value;
166+
return Ok(());
167+
}
168+
169+
let next_is_array = path_index(rest[0]).is_some();
170+
if let Some(index) = path_index(part) {
171+
ensure_array(target);
172+
let array = target
173+
.as_array_mut()
174+
.ok_or_else(|| NfError::ValidationFailed(format!("invalid array path: {field}")))?;
175+
if array.len() <= index {
176+
array.resize(index + 1, Value::Null);
144177
}
145-
current = current
146-
.get_mut(part)
147-
.ok_or_else(|| NfError::ValidationFailed(format!("invalid field path: {field}")))?;
178+
if !array[index].is_object() && !array[index].is_array() {
179+
array[index] = if next_is_array { json!([]) } else { json!({}) };
180+
}
181+
return set_path_part(&mut array[index], rest, value, field);
182+
}
183+
184+
ensure_object(target);
185+
if !target.get(part).is_some_and(|item| item.is_object() || item.is_array()) {
186+
target[*part] = if next_is_array { json!([]) } else { json!({}) };
187+
}
188+
let child = target
189+
.get_mut(part)
190+
.ok_or_else(|| NfError::ValidationFailed(format!("invalid field path: {field}")))?;
191+
set_path_part(child, rest, value, field)
192+
}
193+
194+
fn ensure_object(value: &mut Value) {
195+
if !value.is_object() {
196+
*value = json!({});
197+
}
198+
}
199+
200+
fn ensure_array(value: &mut Value) {
201+
if !value.is_array() {
202+
*value = json!([]);
203+
}
204+
}
205+
206+
fn path_index(part: &str) -> Option<usize> {
207+
if part.is_empty() || (part.len() > 1 && part.starts_with('0')) {
208+
return None;
148209
}
149-
current[parts[parts.len() - 1]] = value;
150-
Ok(())
210+
part.parse::<usize>().ok()
151211
}
152212

153213
impl OpHandler for CompositionsOpHandler {

crates/nf-tracks/official/subtitle.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ export function render(t, params, viewport) {
333333
containerStyle +
334334
'">' +
335335
_vpTag +
336-
spanParts.join("") +
336+
spanParts.join(" ") +
337337
"</div>"
338338
);
339339
}

examples/v2-showcase/compositions/showreel-24s.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,66 @@
141141
"steps": ["JSON", "DOM", "SVG", "Canvas", "Edit", "Export"]
142142
}
143143
},
144+
{
145+
"id": "subtitle-main",
146+
"kind": "subtitle",
147+
"z": 80,
148+
"time": { "start": "intro", "end": "out" },
149+
"style": {
150+
"active_color": "#ffca66",
151+
"color": "#ffffff",
152+
"size_px": 34,
153+
"position": "bottom",
154+
"padding": 62
155+
},
156+
"params": {
157+
"words": [
158+
{ "text": "One", "start_ms": 0, "end_ms": 420 },
159+
{ "text": "JSON", "start_ms": 420, "end_ms": 920 },
160+
{ "text": "drives", "start_ms": 920, "end_ms": 1320 },
161+
{ "text": "the", "start_ms": 1320, "end_ms": 1540 },
162+
{ "text": "whole", "start_ms": 1540, "end_ms": 1980 },
163+
{ "text": "video.", "start_ms": 1980, "end_ms": 2520 },
164+
{ "text": "HTML,", "start_ms": 3180, "end_ms": 3700 },
165+
{ "text": "SVG,", "start_ms": 3700, "end_ms": 4140 },
166+
{ "text": "Canvas,", "start_ms": 4140, "end_ms": 4680 },
167+
{ "text": "CSS", "start_ms": 4680, "end_ms": 5060 },
168+
{ "text": "motion", "start_ms": 5060, "end_ms": 5560 },
169+
{ "text": "share", "start_ms": 5560, "end_ms": 6020 },
170+
{ "text": "one", "start_ms": 6020, "end_ms": 6300 },
171+
{ "text": "timeline.", "start_ms": 6300, "end_ms": 7040 },
172+
{ "text": "The", "start_ms": 7600, "end_ms": 7900 },
173+
{ "text": "editor", "start_ms": 7900, "end_ms": 8420 },
174+
{ "text": "changes", "start_ms": 8420, "end_ms": 9020 },
175+
{ "text": "the", "start_ms": 9020, "end_ms": 9300 },
176+
{ "text": "saved", "start_ms": 9300, "end_ms": 9780 },
177+
{ "text": "JSON.", "start_ms": 9780, "end_ms": 10420 },
178+
{ "text": "Preview", "start_ms": 11600, "end_ms": 12140 },
179+
{ "text": "and", "start_ms": 12140, "end_ms": 12440 },
180+
{ "text": "export", "start_ms": 12440, "end_ms": 13020 },
181+
{ "text": "read", "start_ms": 13020, "end_ms": 13420 },
182+
{ "text": "that", "start_ms": 13420, "end_ms": 13780 },
183+
{ "text": "same", "start_ms": 13780, "end_ms": 14220 },
184+
{ "text": "file.", "start_ms": 14220, "end_ms": 14820 },
185+
{ "text": "Voice", "start_ms": 15600, "end_ms": 16080 },
186+
{ "text": "and", "start_ms": 16080, "end_ms": 16380 },
187+
{ "text": "subtitles", "start_ms": 16380, "end_ms": 17140 },
188+
{ "text": "stay", "start_ms": 17140, "end_ms": 17540 },
189+
{ "text": "locked", "start_ms": 17540, "end_ms": 18120 },
190+
{ "text": "to", "start_ms": 18120, "end_ms": 18360 },
191+
{ "text": "the", "start_ms": 18360, "end_ms": 18620 },
192+
{ "text": "playhead.", "start_ms": 18620, "end_ms": 19440 },
193+
{ "text": "The", "start_ms": 20000, "end_ms": 20320 },
194+
{ "text": "final", "start_ms": 20320, "end_ms": 20780 },
195+
{ "text": "MP4", "start_ms": 20780, "end_ms": 21320 },
196+
{ "text": "keeps", "start_ms": 21320, "end_ms": 21740 },
197+
{ "text": "both", "start_ms": 21740, "end_ms": 22160 },
198+
{ "text": "sound", "start_ms": 22160, "end_ms": 22680 },
199+
{ "text": "and", "start_ms": 22680, "end_ms": 22980 },
200+
{ "text": "captions.", "start_ms": 22980, "end_ms": 23840 }
201+
]
202+
}
203+
},
144204
{
145205
"id": "voice-intro",
146206
"kind": "audio",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"schema": "nextframe.composition.v2",
3+
"id": "voice-subtitle-smoke",
4+
"name": "Voice Subtitle Smoke",
5+
"duration": "4s",
6+
"viewport": { "w": 1920, "h": 1080, "ratio": "16:9" },
7+
"theme": "launch.dark",
8+
"export": { "resolution": "720p" },
9+
"anchors": {
10+
"intro": "0s",
11+
"out": "4s"
12+
},
13+
"tracks": [
14+
{
15+
"id": "stage-bg",
16+
"kind": "component",
17+
"component": "html.stage-background",
18+
"z": 0,
19+
"time": { "start": "intro", "end": "out" },
20+
"params": {
21+
"pulse": 0.95
22+
}
23+
},
24+
{
25+
"id": "subtitle-main",
26+
"kind": "subtitle",
27+
"z": 80,
28+
"time": { "start": "intro", "end": "out" },
29+
"style": {
30+
"active_color": "#ffca66",
31+
"color": "#ffffff",
32+
"size_px": 42,
33+
"position": "bottom",
34+
"padding": 68
35+
},
36+
"params": {
37+
"words": [
38+
{ "text": "JSON", "start_ms": 0, "end_ms": 600 },
39+
{ "text": "edits", "start_ms": 600, "end_ms": 1180 },
40+
{ "text": "become", "start_ms": 1180, "end_ms": 1780 },
41+
{ "text": "live", "start_ms": 1780, "end_ms": 2280 },
42+
{ "text": "captions.", "start_ms": 2280, "end_ms": 3400 }
43+
]
44+
}
45+
},
46+
{
47+
"id": "voice-main",
48+
"kind": "audio",
49+
"time": { "start": "intro", "end": "out" },
50+
"src": "audio/showreel-narration.mp3",
51+
"volume": 1
52+
}
53+
]
54+
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export class NfInspector extends NfBase {
324324
const voiceError = this.getAttribute("voice-error") ?? "";
325325
const voiceAudio = this.getAttribute("voice-audio") ?? "";
326326
const compositionTrackId = clip?.track_id ?? clip?.id ?? clipId ?? "";
327-
const compositionTrack = clip?.kind === "component" ? getCompositionTrack(compositionTrackId) : undefined;
327+
const compositionTrack = compositionTrackId ? getCompositionTrack(compositionTrackId) : undefined;
328328
if (compositionTrack) {
329329
this.renderCompositionTrack({
330330
trackId: compositionTrackId,
@@ -484,10 +484,15 @@ export class NfInspector extends NfBase {
484484
exportProfile: string;
485485
exportProgress: ExportProgressState;
486486
}): void {
487-
const component = stringValue(state.track.component) ?? "component";
487+
const kind = stringValue(state.track.kind) ?? "component";
488+
const component = stringValue(state.track.component) ?? kind;
488489
const time = recordValue(state.track.time);
489490
const params = recordValue(state.track.params);
490491
const style = recordValue(state.track.style);
492+
const topLevelFields = [
493+
state.track.src !== undefined ? this.compositionField("src", state.track.src, "src") : "",
494+
state.track.volume !== undefined ? this.compositionField("volume", state.track.volume, "volume") : "",
495+
].join("");
491496
this.root.innerHTML = `
492497
<div class="insp" data-inspector-track-id="${escapeAttr(state.trackId)}">
493498
${renderExportPanel(state)}
@@ -503,6 +508,12 @@ export class NfInspector extends NfBase {
503508
<span class="n">${escapeHtml(state.trackId)}</span>
504509
<span class="m">${escapeHtml(component)} · ${state.duration.toFixed(1)}s</span>
505510
</div>
511+
${topLevelFields ? `
512+
<div class="insp-card">
513+
<h4>轨道</h4>
514+
${topLevelFields}
515+
</div>
516+
` : ""}
506517
<div class="insp-card">
507518
<h4>时间</h4>
508519
${this.compositionField("time.start", time.start ?? "", "start")}

0 commit comments

Comments
 (0)