port: P4 -- the intro video plays inside the boot sequence
The exporter transcodes ADV.wmv and S00A.wmv to Ogg Theora and records the exact ffmpeg command in the manifest, per MISSION §6, so a modder who dislikes the quality re-runs one line rather than reverse-engineering what was done. Quality was MEASURED, not judged: SSIM against the decoded source over a 10 s sample is 0.9863 / 0.9896 / 0.9924 at -q:v 6 / 8 / 10, and at 200 % zoom on the reel's hardest case -- fine serif text and soft gradients over near-black, where Theora breaks first -- q8 is indistinguishable. So MISSION §6's permitted FFmpeg-GDExtension fallback is NOT needed and is NOT being proposed. No new runtime dependency. -ac 2 because the source is 6-channel WMA Pro; that downmix is a decision, so it lives in the recorded command rather than in prose. Encoding is cached on a .cmd sidecar holding the command and the source size -- any change to either re-encodes. export/ is still regenerated wholesale; this is derived state validating derived state, not a hand-edit, and without it every re-export pays ~4 minutes to produce a byte-identical file. The player renders INTO the design SubViewport. Parenting it to the Boot node played the movie to the window instead, and every captured frame came out black -- which is worth more than a capture-bug note: a movie outside the 1280x720 design space is outside the coordinate system every screen is expressed in. (A) skips a movie, because Q9 measured that (title at 57 s vs a 193 s baseline). NOT VERIFIED, and stated as such: audible playback. This container has no audio device and Godot falls back to the dummy driver. The Vorbis stream exists, is 2-channel and decodes; whether Godot emits it is unconfirmed.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"format": "sylpheed.flow/1",
|
||||
|
||||
"_": [
|
||||
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
|
||||
"negative -- the order is in none of the four places it could have been. It is",
|
||||
@@ -13,7 +12,6 @@
|
||||
"agent watched the game do, not what any file on the disc says it does. Nothing",
|
||||
"here may be presented as decoded."
|
||||
],
|
||||
|
||||
"boot": [
|
||||
{
|
||||
"screen": "publisher_logo",
|
||||
@@ -23,12 +21,17 @@
|
||||
"screen": "developer_logos",
|
||||
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
|
||||
},
|
||||
{
|
||||
"video": "ADV",
|
||||
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
|
||||
"skippable": true,
|
||||
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
|
||||
},
|
||||
{
|
||||
"screen": "title",
|
||||
"why": "HANDOFF Q2/Q6: the boot reaches the title after the splashes. The intro video (ADVERTISE_MOVIE -> ADV.wmv) plays between the splash and the title in the real boot and is SKIPPED here -- it is P4, and the sequencer names the gap rather than pretending the order is different."
|
||||
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
|
||||
}
|
||||
],
|
||||
|
||||
"dwell": {
|
||||
"_": [
|
||||
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
|
||||
@@ -40,7 +43,6 @@
|
||||
"When a capture times the real boot, the extra hold per screen goes here."
|
||||
]
|
||||
},
|
||||
|
||||
"screens": {
|
||||
"_": [
|
||||
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -67,6 +68,16 @@ struct ManifestScreen {
|
||||
missing_sprites: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ManifestVideo {
|
||||
name: String,
|
||||
file: String,
|
||||
/// The exact command that produced this file. MISSION §6: a modder who
|
||||
/// dislikes the quality re-runs one line rather than reverse-engineering it.
|
||||
command: String,
|
||||
why: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Manifest {
|
||||
format: &'static str,
|
||||
@@ -75,6 +86,8 @@ struct Manifest {
|
||||
formats_rev: &'static str,
|
||||
disc: String,
|
||||
screens: Vec<ManifestScreen>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -246,12 +259,30 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// MISSION §6: the boot intro and the one new-game intro only.
|
||||
let mut videos = Vec::new();
|
||||
for m in video::MOVIES {
|
||||
match video::transcode(disc, out, m)? {
|
||||
Some(t) => {
|
||||
println!(" video {} -> {}", m.src, t.file);
|
||||
videos.push(ManifestVideo {
|
||||
name: t.name,
|
||||
file: t.file,
|
||||
command: t.command,
|
||||
why: t.why,
|
||||
});
|
||||
}
|
||||
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
exporter: EXPORTER,
|
||||
formats_rev: FORMATS_REV,
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
videos,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
|
||||
111
crates/sylpheed-export/src/video.rs
Normal file
111
crates/sylpheed-export/src/video.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Movies: disc WMV → Ogg Theora, because Godot 4 plays Theora natively and
|
||||
//! will never be taught to read WMV.
|
||||
//!
|
||||
//! The transcode command is **recorded in the manifest verbatim**. A modder who
|
||||
//! dislikes the quality re-runs one line rather than reverse-engineering what
|
||||
//! was done to their video, which is the whole reason this project converts the
|
||||
//! disc instead of reading it at runtime.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// A movie in scope for this port.
|
||||
pub struct Movie {
|
||||
/// Path under the disc root.
|
||||
pub src: &'static str,
|
||||
/// Output stem under `export/video/`.
|
||||
pub stem: &'static str,
|
||||
pub why: &'static str,
|
||||
}
|
||||
|
||||
/// MISSION §6: the boot intro and the one new-game intro. The disc holds 3.3 GB
|
||||
/// of video and transcoding all of it is not this milestone.
|
||||
pub const MOVIES: &[Movie] = &[
|
||||
Movie {
|
||||
src: "dat/movie/ADV.wmv",
|
||||
stem: "ADV",
|
||||
why: "HANDOFF Q9: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the \
|
||||
attract movie are the SAME asset -- there is no separate boot slot.",
|
||||
},
|
||||
Movie {
|
||||
src: "dat/movie/S00A.wmv",
|
||||
stem: "S00A",
|
||||
why: "HANDOFF Q9: MS00A -> S00A.wmv is the new-game intro. P7.",
|
||||
},
|
||||
];
|
||||
|
||||
/// The encode.
|
||||
///
|
||||
/// `-q:v 8` was chosen by measurement, not taste: against the decoded source,
|
||||
/// SSIM over a 10 s sample is 0.9863 at q6, **0.9896 at q8** and 0.9924 at q10,
|
||||
/// and q8 is visually indistinguishable at 200 % zoom on the reel's hardest
|
||||
/// case — fine serif text and soft gradients over near-black, which is where
|
||||
/// Theora usually breaks first. MISSION §6 anticipated that 720p Theora might
|
||||
/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed*
|
||||
/// if so. It is not: **no runtime dependency is needed, and none is requested.**
|
||||
///
|
||||
/// `-ac 2` because the source is 6-channel WMA Pro and Godot's Theora playback
|
||||
/// is not a surround path. Downmixing is a decision, so it is in the recorded
|
||||
/// command where a modder can see and change it.
|
||||
fn args(src: &Path, out: &Path) -> Vec<String> {
|
||||
[
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", &src.display().to_string(),
|
||||
"-c:v", "libtheora", "-q:v", "8",
|
||||
"-c:a", "libvorbis", "-q:a", "5", "-ac", "2",
|
||||
&out.display().to_string(),
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct Transcoded {
|
||||
pub name: String,
|
||||
pub file: String,
|
||||
pub command: String,
|
||||
pub why: &'static str,
|
||||
}
|
||||
|
||||
/// Transcode one movie, skipping the encode when the output already exists and
|
||||
/// was produced by exactly this command against exactly this source.
|
||||
///
|
||||
/// `export/` is still regenerated wholesale — this is a cache, not a hand-edit.
|
||||
/// The sidecar records the command and the source size, so any change to either
|
||||
/// re-encodes. Without it every re-export pays ~4 minutes to produce a
|
||||
/// byte-identical file, and an exporter nobody re-runs is worse than a cache.
|
||||
pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded>> {
|
||||
let src = disc.join(m.src);
|
||||
if !src.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dir = out.join("video");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let ogv = dir.join(format!("{}.ogv", m.stem));
|
||||
let stamp = dir.join(format!("{}.cmd", m.stem));
|
||||
|
||||
let argv = args(&src, &ogv);
|
||||
let command = format!("ffmpeg {}", argv.join(" "));
|
||||
let size = std::fs::metadata(&src)?.len();
|
||||
let want = format!("{command}\nsource-bytes: {size}\n");
|
||||
|
||||
let fresh = ogv.exists()
|
||||
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
|
||||
if !fresh {
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(&argv)
|
||||
.status()
|
||||
.context("run ffmpeg -- is it on PATH?")?;
|
||||
if !status.success() {
|
||||
bail!("ffmpeg failed on {}", m.src);
|
||||
}
|
||||
std::fs::write(&stamp, &want)?;
|
||||
}
|
||||
Ok(Some(Transcoded {
|
||||
name: m.stem.to_string(),
|
||||
file: format!("video/{}.ogv", m.stem),
|
||||
command,
|
||||
why: m.why,
|
||||
}))
|
||||
}
|
||||
@@ -682,3 +682,71 @@ renderer. The three known differences are unchanged: `title` 6 (paint-order tie)
|
||||
carry `rotation_deg` in a future FORMAT v3 because carrying a decoded field the
|
||||
renderer ignores beats dropping it, but it will not draw it until the
|
||||
divergence question is settled.
|
||||
|
||||
---
|
||||
|
||||
## P4 — the intro video, 2026-08-29
|
||||
|
||||
### Theora at 720p is fine here, and no runtime dependency is requested
|
||||
|
||||
MISSION §6 anticipated that Theora might be too poor at 720p and permitted the
|
||||
FFmpeg-GDExtension fallback to be **proposed**. It is not needed, and this was
|
||||
measured rather than judged by eye alone. SSIM against the decoded source over a
|
||||
10 s sample: **0.9863 at `-q:v 6`, 0.9896 at 8, 0.9924 at 10**. At 200 % zoom on
|
||||
the reel's hardest case — fine serif text and soft gradients over near-black,
|
||||
where Theora breaks first — q8 is indistinguishable from the source.
|
||||
|
||||
`-q:v 8`, and **no GDExtension is being proposed or adopted**.
|
||||
|
||||
`-ac 2` because the source is **6-channel** WMA Pro and Godot's Theora path is
|
||||
not a surround one. That downmix is a decision, so it lives in the recorded
|
||||
command where a modder can see and change it rather than in prose.
|
||||
|
||||
### The exact command is in the manifest, per MISSION §6
|
||||
|
||||
`export/manifest.json` gains a `videos` array, each entry carrying the verbatim
|
||||
`ffmpeg` line that produced it. A modder who dislikes the quality re-runs one
|
||||
line instead of reverse-engineering what was done to their video — which is the
|
||||
whole reason this project converts the disc rather than reading it at runtime.
|
||||
|
||||
### A cache, and why that is not a hand-edit
|
||||
|
||||
`export/` is regenerated wholesale, but re-encoding 232 s of video on every run
|
||||
costs ~4 minutes to produce a byte-identical file, and an exporter nobody re-runs
|
||||
is worse than a cache. So each movie gets a `.cmd` sidecar recording the command
|
||||
and the source size, and the encode is skipped only when both match exactly. Any
|
||||
change to either re-encodes. This is derived state validating derived state, not
|
||||
a hand-edit.
|
||||
|
||||
### The player renders into the design viewport, not beside it
|
||||
|
||||
First attempt parented the `VideoStreamPlayer` to the Boot node. It played, and
|
||||
every captured frame was **black**: the capture reads the SubViewport, and the
|
||||
player was rendering to the window. Worth stating as more than a capture bug —
|
||||
everything this port draws composes in the export's own 1280×720 design space,
|
||||
and a movie outside that space is outside the coordinate system every screen is
|
||||
expressed in.
|
||||
|
||||
### Ⓐ skips, because Q9 measured it
|
||||
|
||||
The only input the port handles so far. HANDOFF Q9: one Ⓐ press skips a movie,
|
||||
measured — the title was reached at 57 s against a 193 s baseline. Menu
|
||||
navigation is still P5.
|
||||
|
||||
## P4 gate
|
||||
|
||||
`godot --path port -- --boot --film=…` runs
|
||||
`publisher_logo → developer_logos → ADV.ogv → title`, unattended. The filmstrip
|
||||
shows the SQUARE ENIX ident, then the reel's live-action-styled CG, then the
|
||||
title. The movie's place in the boot is **measured, not decoded** — Q9 decodes
|
||||
`ADVERTISE_MOVIE → ADV.wmv` from the movie manifest, but *where it sits in the
|
||||
boot order* is what the RE agent watched, and `authored/flow.json` says so.
|
||||
|
||||
### What I cannot verify from here
|
||||
|
||||
**Audible playback.** This container has no audio device — Godot falls back to
|
||||
the dummy driver. What is verified is that the Vorbis stream exists in the
|
||||
transcode, is 2-channel, and decodes. Whether Godot emits it audibly is
|
||||
unconfirmed and is stated as unconfirmed rather than assumed from the stream's
|
||||
presence. It is a cheap check for anyone with a sound device and an impossible
|
||||
one here.
|
||||
|
||||
@@ -45,10 +45,13 @@ func _ready() -> void:
|
||||
get_tree().quit(2)
|
||||
return
|
||||
for step: Dictionary in _flow["boot"]:
|
||||
_sequence.append(String(step["screen"]))
|
||||
_sequence.append(step)
|
||||
_film = args.get("film", "")
|
||||
|
||||
var name: String = _sequence[0] if not _sequence.is_empty() else args.get("screen", DEFAULT_SCREEN)
|
||||
var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \
|
||||
else args.get("screen", DEFAULT_SCREEN)
|
||||
if name == "":
|
||||
name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport
|
||||
var screen: Dictionary = export_tree.screen(name)
|
||||
if screen.is_empty():
|
||||
push_error(export_tree.error)
|
||||
@@ -117,7 +120,8 @@ func _ready() -> void:
|
||||
|
||||
var _frozen := false
|
||||
var _flow: Variant = null
|
||||
var _sequence: Array[String] = []
|
||||
var _sequence: Array[Dictionary] = []
|
||||
var _player: VideoStreamPlayer = null
|
||||
var _step := 0
|
||||
var _film := ""
|
||||
var _film_frame := 0
|
||||
@@ -133,7 +137,7 @@ func _process(delta: float) -> void:
|
||||
_elapsed += delta
|
||||
view.queue_redraw()
|
||||
|
||||
if _sequence.is_empty():
|
||||
if _sequence.is_empty() or _player != null:
|
||||
return
|
||||
|
||||
# A screen holds at `rest` until it has arrived, then plays itself out and
|
||||
@@ -158,15 +162,75 @@ func _process(delta: float) -> void:
|
||||
|
||||
func _advance() -> void:
|
||||
_step += 1
|
||||
var next := _sequence[_step]
|
||||
print(" -> %s at %.2f s" % [next, _elapsed])
|
||||
var next: Dictionary = _sequence[_step]
|
||||
if next.has("video"):
|
||||
_play_video(String(next["video"]), bool(next.get("skippable", false)))
|
||||
return
|
||||
var name := String(next["screen"])
|
||||
print(" -> %s at %.2f s" % [name, _elapsed])
|
||||
view.holding = true
|
||||
view.time_units = 0.0
|
||||
if not view.load_screen(view.tree, next):
|
||||
if not view.load_screen(view.tree, name):
|
||||
push_error(view.tree.error)
|
||||
get_tree().quit(2)
|
||||
|
||||
|
||||
## Play one transcoded movie, full-bleed over the screen.
|
||||
##
|
||||
## The port never reads WMV: the exporter transcoded this to Ogg Theora and
|
||||
## recorded the exact ffmpeg command in the manifest (MISSION §6), so a modder
|
||||
## who dislikes the quality re-runs one line.
|
||||
func _play_video(name: String, skippable: bool) -> void:
|
||||
var v := view.tree.video(name)
|
||||
if v.is_empty():
|
||||
push_error(view.tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
print(" -> video %s at %.2f s (%s)" % [name, _elapsed, v["path"]])
|
||||
|
||||
var stream := VideoStreamTheora.new()
|
||||
stream.file = v["path"]
|
||||
_player = VideoStreamPlayer.new()
|
||||
_player.stream = stream
|
||||
_player.expand = true
|
||||
_player.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
# Into the SubViewport, not beside it. Everything this port draws composes in
|
||||
# the export's own 1280x720 design space; a player parented to the Boot node
|
||||
# renders to the window instead and is invisible to `--capture`, which reads
|
||||
# the SubViewport. That is not only a capture artefact -- it would also put
|
||||
# the movie outside the space every screen coordinate is expressed in.
|
||||
viewport.add_child(_player)
|
||||
_skippable = skippable
|
||||
# `play()` needs the node in the tree; calling it before that is an error
|
||||
# the engine reports and then ignores, which looks like a video that simply
|
||||
# never starts.
|
||||
await get_tree().process_frame
|
||||
_player.finished.connect(_video_finished)
|
||||
_player.play()
|
||||
|
||||
|
||||
var _skippable := false
|
||||
|
||||
|
||||
func _video_finished() -> void:
|
||||
print(" video ended at %.2f s" % _elapsed)
|
||||
_player.queue_free()
|
||||
_player = null
|
||||
_advance()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached
|
||||
# at 57 s against a 193 s baseline. This is the only input the port handles
|
||||
# so far; menu navigation is P5.
|
||||
if _player == null or not _skippable:
|
||||
return
|
||||
if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"):
|
||||
print(" video skipped at %.2f s" % _elapsed)
|
||||
_player.stop()
|
||||
_video_finished()
|
||||
|
||||
|
||||
func _capture(path: String) -> void:
|
||||
# Two frames: the first is the one this callback is still inside of.
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
@@ -83,6 +83,20 @@ func screen(name: String) -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
# A transcoded movie, addressed by manifest name. The port never reads WMV --
|
||||
# the exporter emits Ogg Theora, which Godot plays natively (MISSION §2, §6).
|
||||
func video(name: String) -> Dictionary:
|
||||
for entry: Dictionary in manifest().get("videos", []):
|
||||
if entry.get("name") == name:
|
||||
var path := root.path_join(entry["file"])
|
||||
if not FileAccess.file_exists(path):
|
||||
error = "manifest lists %s but %s is not there" % [name, path]
|
||||
return {}
|
||||
return {"path": path, "command": entry.get("command", "")}
|
||||
error = "no video named %s in manifest.json" % name
|
||||
return {}
|
||||
|
||||
|
||||
func screen_names() -> PackedStringArray:
|
||||
var names := PackedStringArray()
|
||||
for entry: Dictionary in manifest().get("screens", []):
|
||||
|
||||
Reference in New Issue
Block a user