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",
|
"format": "sylpheed.flow/1",
|
||||||
|
|
||||||
"_": [
|
"_": [
|
||||||
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
|
"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",
|
"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",
|
"agent watched the game do, not what any file on the disc says it does. Nothing",
|
||||||
"here may be presented as decoded."
|
"here may be presented as decoded."
|
||||||
],
|
],
|
||||||
|
|
||||||
"boot": [
|
"boot": [
|
||||||
{
|
{
|
||||||
"screen": "publisher_logo",
|
"screen": "publisher_logo",
|
||||||
@@ -23,12 +21,17 @@
|
|||||||
"screen": "developer_logos",
|
"screen": "developer_logos",
|
||||||
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
|
"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",
|
"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": {
|
"dwell": {
|
||||||
"_": [
|
"_": [
|
||||||
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
|
"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."
|
"When a capture times the real boot, the extra hold per screen goes here."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"screens": {
|
"screens": {
|
||||||
"_": [
|
"_": [
|
||||||
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
|
"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.
|
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||||
|
|
||||||
mod check;
|
mod check;
|
||||||
|
mod video;
|
||||||
mod screen;
|
mod screen;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -67,6 +68,16 @@ struct ManifestScreen {
|
|||||||
missing_sprites: Vec<String>,
|
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)]
|
#[derive(Serialize)]
|
||||||
struct Manifest {
|
struct Manifest {
|
||||||
format: &'static str,
|
format: &'static str,
|
||||||
@@ -75,6 +86,8 @@ struct Manifest {
|
|||||||
formats_rev: &'static str,
|
formats_rev: &'static str,
|
||||||
disc: String,
|
disc: String,
|
||||||
screens: Vec<ManifestScreen>,
|
screens: Vec<ManifestScreen>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
videos: Vec<ManifestVideo>,
|
||||||
warnings: Vec<String>,
|
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 {
|
let manifest = Manifest {
|
||||||
format: "sylpheed.manifest/1",
|
format: "sylpheed.manifest/1",
|
||||||
exporter: EXPORTER,
|
exporter: EXPORTER,
|
||||||
formats_rev: FORMATS_REV,
|
formats_rev: FORMATS_REV,
|
||||||
disc: disc.display().to_string(),
|
disc: disc.display().to_string(),
|
||||||
screens,
|
screens,
|
||||||
|
videos,
|
||||||
warnings: vec![
|
warnings: vec![
|
||||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||||
.into(),
|
.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
|
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
|
renderer ignores beats dropping it, but it will not draw it until the
|
||||||
divergence question is settled.
|
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)
|
get_tree().quit(2)
|
||||||
return
|
return
|
||||||
for step: Dictionary in _flow["boot"]:
|
for step: Dictionary in _flow["boot"]:
|
||||||
_sequence.append(String(step["screen"]))
|
_sequence.append(step)
|
||||||
_film = args.get("film", "")
|
_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)
|
var screen: Dictionary = export_tree.screen(name)
|
||||||
if screen.is_empty():
|
if screen.is_empty():
|
||||||
push_error(export_tree.error)
|
push_error(export_tree.error)
|
||||||
@@ -117,7 +120,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
var _frozen := false
|
var _frozen := false
|
||||||
var _flow: Variant = null
|
var _flow: Variant = null
|
||||||
var _sequence: Array[String] = []
|
var _sequence: Array[Dictionary] = []
|
||||||
|
var _player: VideoStreamPlayer = null
|
||||||
var _step := 0
|
var _step := 0
|
||||||
var _film := ""
|
var _film := ""
|
||||||
var _film_frame := 0
|
var _film_frame := 0
|
||||||
@@ -133,7 +137,7 @@ func _process(delta: float) -> void:
|
|||||||
_elapsed += delta
|
_elapsed += delta
|
||||||
view.queue_redraw()
|
view.queue_redraw()
|
||||||
|
|
||||||
if _sequence.is_empty():
|
if _sequence.is_empty() or _player != null:
|
||||||
return
|
return
|
||||||
|
|
||||||
# A screen holds at `rest` until it has arrived, then plays itself out and
|
# 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:
|
func _advance() -> void:
|
||||||
_step += 1
|
_step += 1
|
||||||
var next := _sequence[_step]
|
var next: Dictionary = _sequence[_step]
|
||||||
print(" -> %s at %.2f s" % [next, _elapsed])
|
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.holding = true
|
||||||
view.time_units = 0.0
|
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)
|
push_error(view.tree.error)
|
||||||
get_tree().quit(2)
|
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:
|
func _capture(path: String) -> void:
|
||||||
# Two frames: the first is the one this callback is still inside of.
|
# Two frames: the first is the one this callback is still inside of.
|
||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ func screen(name: String) -> Dictionary:
|
|||||||
return {}
|
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:
|
func screen_names() -> PackedStringArray:
|
||||||
var names := PackedStringArray()
|
var names := PackedStringArray()
|
||||||
for entry: Dictionary in manifest().get("screens", []):
|
for entry: Dictionary in manifest().get("screens", []):
|
||||||
|
|||||||
Reference in New Issue
Block a user