Skip to content

Commit e7debdd

Browse files
committed
fix(v0.13.1): restore v2 showreel audio
session: codex-2026-04-24-v0131-audio What: Add two TTS narration assets and audio tracks to the v2 showreel, resolve relative composition audio paths to file URLs during source compile, and add a BDD regression for preview/export audio. Why: The desktop preview had visuals but no sound because the showcase JSON contained only visual component tracks, so both preview and export correctly produced silent output. Resolving project-relative audio at compile time keeps JSON portable while giving the preview and export muxer the same concrete audio source. Co-Authored-By: Claude Opus 4.7
1 parent c661d69 commit e7debdd

12 files changed

Lines changed: 324 additions & 29 deletions

File tree

crates/nf-project/src/lib.rs

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use directories::BaseDirs;
77
use once_cell::sync::Lazy;
88
use regex::Regex;
99
use serde::{Deserialize, Serialize};
10-
use serde_json::{Value, json};
10+
use serde_json::{json, Value};
1111
use thiserror::Error;
1212

1313
static SLUG_RE: Lazy<Result<Regex, regex::Error>> =
@@ -79,7 +79,7 @@ pub trait Storage {
7979
fn load_project(&self, slug: &str) -> Result<Project, ProjectError>;
8080
fn save_project(&self, project: &Project) -> Result<(), ProjectError>;
8181
fn load_episode(&self, project_slug: &str, episode_slug: &str)
82-
-> Result<Episode, ProjectError>;
82+
-> Result<Episode, ProjectError>;
8383
fn save_episode(&self, project_slug: &str, episode: &Episode) -> Result<(), ProjectError>;
8484
}
8585

@@ -297,7 +297,9 @@ pub fn compile_composition_source(
297297
let tracks = object
298298
.get("tracks")
299299
.and_then(Value::as_array)
300-
.ok_or_else(|| ProjectError::ValidationFailed("composition.tracks must be an array".to_string()))?;
300+
.ok_or_else(|| {
301+
ProjectError::ValidationFailed("composition.tracks must be an array".to_string())
302+
})?;
301303

302304
let mut warnings = Vec::new();
303305
let mut source_tracks = Vec::new();
@@ -349,7 +351,8 @@ pub fn compile_composition_source(
349351
components.insert(component_id.to_string(), Value::String(src));
350352
}
351353
params.insert("component".to_string(), json!(component_id));
352-
let mut component_params = track.get("params").cloned().unwrap_or_else(|| json!({}));
354+
let mut component_params =
355+
track.get("params").cloned().unwrap_or_else(|| json!({}));
353356
if let (Some(target), Some(style)) = (
354357
component_params.as_object_mut(),
355358
track.get("style").and_then(Value::as_object),
@@ -365,19 +368,25 @@ pub fn compile_composition_source(
365368
"style".to_string(),
366369
track.get("style").cloned().unwrap_or_else(|| json!({})),
367370
);
368-
params.insert("track".to_string(), json!({
369-
"id": track_id,
370-
"z": track.get("z").and_then(Value::as_i64).unwrap_or(index as i64),
371-
"kind": kind
372-
}));
371+
params.insert(
372+
"track".to_string(),
373+
json!({
374+
"id": track_id,
375+
"z": track.get("z").and_then(Value::as_i64).unwrap_or(index as i64),
376+
"kind": kind
377+
}),
378+
);
373379
has_visual = true;
374380
}
375381
"audio" => {
376382
let Some(src) = track.get("src").and_then(Value::as_str) else {
377383
warnings.push(format!("ignored audio track '{track_id}' without src"));
378384
continue;
379385
};
380-
params.insert("src".to_string(), json!(src));
386+
params.insert(
387+
"src".to_string(),
388+
json!(composition_audio_src(storage.root(), project_slug, src)),
389+
);
381390
copy_number_param(track, &mut params, "from_ms");
382391
copy_number_param(track, &mut params, "to_ms");
383392
copy_number_param(track, &mut params, "volume");
@@ -806,6 +815,31 @@ fn audio_params(object: &serde_json::Map<String, Value>) -> Option<serde_json::M
806815
Some(params)
807816
}
808817

818+
fn composition_audio_src(root: &Path, project_slug: &str, src: &str) -> String {
819+
if src.starts_with("file://") || src.starts_with("data:") {
820+
return src.to_string();
821+
}
822+
let path = if Path::new(src).is_absolute() {
823+
PathBuf::from(src)
824+
} else {
825+
root.join(project_slug).join(src)
826+
};
827+
file_url(&path)
828+
}
829+
830+
fn file_url(path: &Path) -> String {
831+
let mut encoded = String::new();
832+
for byte in path.to_string_lossy().as_bytes() {
833+
match *byte {
834+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
835+
encoded.push(char::from(*byte))
836+
}
837+
other => encoded.push_str(&format!("%{other:02X}")),
838+
}
839+
}
840+
format!("file://{encoded}")
841+
}
842+
809843
fn copy_string_param(
810844
source: &serde_json::Map<String, Value>,
811845
target: &mut serde_json::Map<String, Value>,
@@ -994,7 +1028,11 @@ fn track_time_ms(
9941028
.and_then(|value| value.get("end"))
9951029
.or_else(|| track.get("end"))
9961030
.unwrap_or(&end_default);
997-
let start = time_value_ms(Some(start_value), anchors, &format!("track '{track_id}' start"))?;
1031+
let start = time_value_ms(
1032+
Some(start_value),
1033+
anchors,
1034+
&format!("track '{track_id}' start"),
1035+
)?;
9981036
let end = time_value_ms(Some(end_value), anchors, &format!("track '{track_id}' end"))?;
9991037
Ok((start.min(duration_ms), end.min(duration_ms)))
10001038
}
@@ -1073,7 +1111,10 @@ fn load_theme_css(root: &Path, project_slug: &str, theme_id: &str) -> Result<Str
10731111
let path = theme_dir.join(file);
10741112
if path.exists() {
10751113
let raw = fs::read_to_string(&path).map_err(|err| {
1076-
ProjectError::StorageFailed(format!("theme CSS read failed: {}: {err}", path.display()))
1114+
ProjectError::StorageFailed(format!(
1115+
"theme CSS read failed: {}: {err}",
1116+
path.display()
1117+
))
10771118
})?;
10781119
css.push_str(&raw);
10791120
css.push('\n');
@@ -1117,8 +1158,8 @@ fn copy_number_param(
11171158
#[cfg(test)]
11181159
mod tests {
11191160
use super::{
1120-
Episode, JsonStorage, Project, Registry, RegistryProject, Storage,
1121-
compile_composition_source,
1161+
compile_composition_source, Episode, JsonStorage, Project, Registry, RegistryProject,
1162+
Storage,
11221163
};
11231164

11241165
#[test]
@@ -1250,29 +1291,40 @@ mod tests {
12501291
"component": "html.hero-title",
12511292
"time": { "start": "in", "end": "out" },
12521293
"params": { "title": "Hello" }
1294+
}, {
1295+
"id": "voice",
1296+
"kind": "audio",
1297+
"time": { "start": "in", "end": "out" },
1298+
"src": "audio/demo.mp3",
1299+
"volume": 0.8
12531300
}]
12541301
});
12551302

12561303
let compiled = compile_composition_source(&storage, "demo", &composition)?;
12571304

12581305
assert_eq!(compiled.source["duration"], 4000);
12591306
assert_eq!(compiled.source["tracks"][0]["kind"], "component");
1307+
assert_eq!(compiled.source["tracks"][1]["kind"], "audio");
1308+
assert!(compiled.source["tracks"][1]["clips"][0]["params"]["src"]
1309+
.as_str()
1310+
.unwrap_or_default()
1311+
.starts_with("file://"));
1312+
assert!(compiled.source["tracks"][1]["clips"][0]["params"]["src"]
1313+
.as_str()
1314+
.unwrap_or_default()
1315+
.ends_with("/demo/audio/demo.mp3"));
12601316
assert_eq!(
12611317
compiled.source["tracks"][0]["clips"][0]["params"]["component"],
12621318
"html.hero-title"
12631319
);
1264-
assert!(
1265-
compiled.source["components"]["html.hero-title"]
1266-
.as_str()
1267-
.unwrap_or_default()
1268-
.contains("update")
1269-
);
1270-
assert!(
1271-
compiled.source["theme"]["css"]
1272-
.as_str()
1273-
.unwrap_or_default()
1274-
.contains("--accent")
1275-
);
1320+
assert!(compiled.source["components"]["html.hero-title"]
1321+
.as_str()
1322+
.unwrap_or_default()
1323+
.contains("update"));
1324+
assert!(compiled.source["theme"]["css"]
1325+
.as_str()
1326+
.unwrap_or_default()
1327+
.contains("--accent"));
12761328
cleanup(storage.root())?;
12771329
Ok(())
12781330
}
64 KB
Binary file not shown.
71.3 KB
Binary file not shown.

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,28 @@
140140
"params": {
141141
"steps": ["JSON", "DOM", "SVG", "Canvas", "Edit", "Export"]
142142
}
143+
},
144+
{
145+
"id": "voice-intro",
146+
"kind": "audio",
147+
"time": { "start": "intro", "end": "data" },
148+
"src": "audio/showreel-narration.mp3",
149+
"volume": 1,
150+
"tts": {
151+
"voice": "en-US-AriaNeural",
152+
"text": "NextFrame V2 showreel. One JSON controls live HTML components, SVG, canvas, editor changes, and final video export."
153+
}
154+
},
155+
{
156+
"id": "voice-outro",
157+
"kind": "audio",
158+
"time": { "start": "data", "end": "out" },
159+
"src": "audio/showreel-narration-2.mp3",
160+
"volume": 1,
161+
"tts": {
162+
"voice": "en-US-AriaNeural",
163+
"text": "The second half keeps the same JSON timeline alive. You can move tracks in the editor, save the composition, and export the exact same result."
164+
}
143165
}
144166
]
145167
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { tracks } from "./mock.js";
2+
3+
const root = document.querySelector("#demo");
4+
5+
root.innerHTML = `
6+
<section class="stage">
7+
<div class="preview">
8+
<div class="wave">${Array.from({ length: 24 }, (_, index) => `<i style="--i:${index}"></i>`).join("")}</div>
9+
</div>
10+
<div class="timeline">
11+
${tracks.map((track) => `
12+
<div class="track" aria-label="${track.label}">
13+
<div class="clip" style="--left:${track.left};--width:${track.width}"></div>
14+
</div>
15+
`).join("")}
16+
</div>
17+
<div class="toast">本轮完成:预览有旁白,导出 MP4 有 AAC 音轨。</div>
18+
<div class="progress" aria-hidden="true"></div>
19+
</section>
20+
`;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>V2 Editor Authoring · Preview And Export Audio</title>
7+
<link rel="stylesheet" href="./style.css">
8+
</head>
9+
<body>
10+
<main id="demo" class="app" aria-label="preview and export audio demo"></main>
11+
<script type="module" src="./animation.js"></script>
12+
</body>
13+
</html>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const tracks = [
2+
{ label: "html/canvas/svg", left: "0%", width: "100%" },
3+
{ label: "voice-intro", left: "0%", width: "48%" },
4+
{ label: "voice-outro", left: "48%", width: "52%" },
5+
];
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
@import "../../../../design/tokens.css";
2+
3+
html,
4+
body {
5+
margin: 0;
6+
min-height: 100%;
7+
background: #07080d;
8+
color: #f6f7fb;
9+
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
10+
}
11+
12+
.app {
13+
min-height: 100vh;
14+
display: grid;
15+
place-items: center;
16+
}
17+
18+
.stage {
19+
width: min(980px, calc(100vw - 48px));
20+
}
21+
22+
.preview {
23+
aspect-ratio: 16 / 9;
24+
border: 1px solid rgba(255,255,255,0.16);
25+
background: radial-gradient(circle at 30% 28%, rgba(98,245,210,0.2), transparent 32%), #090b10;
26+
position: relative;
27+
overflow: hidden;
28+
}
29+
30+
.wave {
31+
position: absolute;
32+
left: 8%;
33+
right: 8%;
34+
bottom: 14%;
35+
height: 74px;
36+
display: grid;
37+
grid-template-columns: repeat(24, 1fr);
38+
gap: 8px;
39+
align-items: end;
40+
}
41+
42+
.wave i {
43+
display: block;
44+
background: #62f5d2;
45+
height: 18px;
46+
animation: pulse 1.2s ease-in-out infinite;
47+
animation-delay: calc(var(--i) * -46ms);
48+
}
49+
50+
.timeline {
51+
margin-top: 18px;
52+
display: grid;
53+
gap: 8px;
54+
}
55+
56+
.track {
57+
height: 34px;
58+
border: 1px solid rgba(255,255,255,0.14);
59+
background: rgba(255,255,255,0.05);
60+
position: relative;
61+
}
62+
63+
.clip {
64+
position: absolute;
65+
top: 5px;
66+
bottom: 5px;
67+
left: var(--left);
68+
width: var(--width);
69+
background: rgba(98,245,210,0.32);
70+
border: 1px solid #62f5d2;
71+
}
72+
73+
.toast {
74+
margin-top: 18px;
75+
height: 36px;
76+
color: #c8ff5d;
77+
}
78+
79+
.progress {
80+
margin-top: 10px;
81+
height: 4px;
82+
background: rgba(255,255,255,0.14);
83+
overflow: hidden;
84+
}
85+
86+
.progress::before {
87+
content: "";
88+
display: block;
89+
height: 100%;
90+
width: 100%;
91+
background: #c8ff5d;
92+
transform-origin: left;
93+
animation: run 10s linear infinite;
94+
}
95+
96+
@keyframes pulse {
97+
0%, 100% { height: 18px; opacity: 0.45; }
98+
50% { height: 74px; opacity: 1; }
99+
}
100+
101+
@keyframes run {
102+
from { transform: scaleX(0); }
103+
to { transform: scaleX(1); }
104+
}

spec/bdd/v2-editor-authoring/feature.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
"edit-params",
99
"drag-position",
1010
"edit-timing",
11-
"export-after-save"
11+
"export-after-save",
12+
"preview-and-export-audio"
1213
],
1314
"completion": {
14-
"total": 5,
15+
"total": 6,
1516
"done": 0
1617
}
1718
}

spec/bdd/v2-editor-authoring/scenarios/export-after-save.bdd.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"title": "保存后的修改用于最终导出视频",
44
"given": "用户已修改 final-title.title 并点击保存,桌面端显示 saved 状态。",
55
"when": "用户点击导出按钮并等待进度完成。",
6-
"then": "导出完成后出现打开按钮,MP4 抽帧能看到修改后的标题,导出源 track_count 仍是 10。",
6+
"then": "导出完成后出现打开按钮,MP4 抽帧能看到修改后的标题,导出源 track_count 仍包含 10 条视觉轨和 2 条音频轨",
77
"human_actions": ["edit final-title title", "click save", "click export", "watch progress", "click open output"],
88
"ai_tools": [
99
{

0 commit comments

Comments
 (0)