Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3c843d65c | |||
| 530238a328 | |||
| d7b3824e1e | |||
| 2dfd52e33d | |||
| fde2aee5d1 | |||
| e23772af17 | |||
| 2731d9d332 | |||
| 10949dfa18 | |||
| 561c930620 | |||
| 04963c929a | |||
| fac282bcbc | |||
| c965fbf5fe | |||
| 5ad82b1b98 | |||
| bc39455cfe | |||
| 7da9a6df25 | |||
| be81469c7c | |||
| 4bc2346480 | |||
| 2c6e227c82 | |||
| afe397da3f | |||
| a1fbd64f87 | |||
| 68c12caef9 | |||
| 51b878486f | |||
| 78e8a7decf |
+15
@@ -36,3 +36,18 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Python (helper scripts)
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Package lock — deliberately NOT tracked.
|
||||||
|
# Every dependency in package.json is pinned to an exact version (no ^ or ~), so
|
||||||
|
# a lockfile has no floating ranges to guard against. What it would add instead is
|
||||||
|
# trouble across machines: the lock captures the resolved tree including
|
||||||
|
# platform-specific optional binaries (e.g. @rollup/rollup-linux-x64-gnu), so a
|
||||||
|
# Linux-generated lock is wrong on Windows and vice versa, and `npm ci` refuses
|
||||||
|
# outright when the lock and manifest disagree. Revisit this if a dependency is
|
||||||
|
# ever loosened to a range or picks up meaningful transitive drift.
|
||||||
|
package-lock.json
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Archive tags
|
||||||
|
|
||||||
|
Tags whose only purpose is to keep a commit reachable after the branch that
|
||||||
|
carried it was deleted. Nothing here is active code.
|
||||||
|
|
||||||
|
| Tag | Kept because |
|
||||||
|
| --- | --- |
|
||||||
|
| `archive/feat-bom-generator` | The branch held one commit (`8f860e0`, "BOM generator v2 with hashes") that never landed on `master`. The capability — a content hash per asset — was ported forward to `scripts/bom_gen.py --hash` in `2c6e227`, and the TypeScript file it patched (`scripts/bom-gen.ts`) was deleted in `afe397d`. The tag remains so the original commit is reachable if the port is ever questioned. |
|
||||||
|
|
||||||
|
Tags are pushed explicitly (`git push origin archive/<name>`); they are not
|
||||||
|
picked up by a plain `git push`.
|
||||||
@@ -4,15 +4,32 @@ This repo houses the code-like files for NCMR music videos. Binary assets live e
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
> **Repo state: workspace under construction.** `master` is *not* locked in yet —
|
||||||
|
> it is still being assembled as the shared template. Until it is locked, any
|
||||||
|
> branching strategy is fine and template/tooling work can land on `master`
|
||||||
|
> directly. The branch conventions below describe the intended end state, not a
|
||||||
|
> rule currently in force.
|
||||||
|
|
||||||
### `master` branch — shared template
|
### `master` branch — shared template
|
||||||
|
|
||||||
This branch holds the common scaffolding:
|
This branch holds the common scaffolding:
|
||||||
|
|
||||||
- **Remotion project** — entry point, root component, and a placeholder composition
|
- **Remotion project** — entry point, root component, and a placeholder composition
|
||||||
- **`ffmpeg.sh`** — helper for baking ASS subtitles or bypassing with static image + audio
|
- **`scripts/`** — Python toolchain (stdlib only) for subtitles, ffmpeg and asset manifests
|
||||||
- **`src/lrc2srt.ts`** — legacy LRC → SRT subtitle converter
|
|
||||||
- **Build config** — TypeScript, ESLint, Prettier, Remotion studio
|
- **Build config** — TypeScript, ESLint, Prettier, Remotion studio
|
||||||
|
|
||||||
|
### `src/` vs `scripts/`
|
||||||
|
|
||||||
|
Two directories, two jobs:
|
||||||
|
|
||||||
|
- **`src/`** — Remotion input only. Everything here is compiled by `tsc`/Remotion
|
||||||
|
and linted by `npm run lint`.
|
||||||
|
- **`scripts/`** — operator tooling. Python, standard library only, run directly
|
||||||
|
(`python3 scripts/tools.py --help`). Nothing here is part of the Remotion build.
|
||||||
|
|
||||||
|
Keep the boundary sharp: anything the video build compiles belongs in `src/`, and
|
||||||
|
anything you run by hand belongs in `scripts/`.
|
||||||
|
|
||||||
### Song branches
|
### Song branches
|
||||||
|
|
||||||
For each song, create a branch `<song-name>/master` (and optionally feature branches
|
For each song, create a branch `<song-name>/master` (and optionally feature branches
|
||||||
@@ -29,7 +46,8 @@ mkdir -p <song-name>
|
|||||||
# Place your song-specific compositions, assets manifests, etc.
|
# Place your song-specific compositions, assets manifests, etc.
|
||||||
```
|
```
|
||||||
|
|
||||||
The `master` branch remains protected and mostly immutable. Song branches are independent.
|
Once `master` is locked in it remains protected and mostly immutable. Until then,
|
||||||
|
song branches are a rehearsal for that end state, not a requirement.
|
||||||
|
|
||||||
## Guideline
|
## Guideline
|
||||||
|
|
||||||
@@ -37,14 +55,35 @@ The `master` branch remains protected and mostly immutable. Song branches are in
|
|||||||
|
|
||||||
- Binary input assets (audio, images, fonts, material video clips)
|
- Binary input assets (audio, images, fonts, material video clips)
|
||||||
- Video output (`out/`, rendered files)
|
- Video output (`out/`, rendered files)
|
||||||
|
- `package-lock.json` — see below
|
||||||
|
|
||||||
For untracked input assets, a **bill of materials** listing everything and explaining
|
For untracked input assets, a **bill of materials** listing everything and explaining
|
||||||
what each file is will be mandatory. Keep it as `BOM.md` alongside your song assets.
|
what each file is will be mandatory. Keep it as `BOM.md` alongside your song assets.
|
||||||
|
|
||||||
|
### Why there is no `package-lock.json`
|
||||||
|
|
||||||
|
Deliberate, not an oversight. Work happens on both Windows and Linux, and two
|
||||||
|
things make a lockfile a liability here rather than a safeguard:
|
||||||
|
|
||||||
|
1. **There is nothing for it to pin.** Every dependency in `package.json` is
|
||||||
|
pinned to an exact version — no `^`, no `~`. A plain `npm install` on any
|
||||||
|
machine resolves to the same versions the lockfile would have recorded.
|
||||||
|
2. **A lockfile is platform-specific.** It records the resolved tree, including
|
||||||
|
the optional native binaries for whichever OS generated it (`@rollup/rollup-linux-x64-gnu`,
|
||||||
|
`@rollup/rollup-win32-x64-msvc`). Committed from Linux it is wrong on Windows,
|
||||||
|
and `npm ci` refuses outright rather than adapting, since its whole purpose is
|
||||||
|
to reproduce the recorded tree exactly.
|
||||||
|
|
||||||
|
So it would be strict about the wrong thing: breaking installs across machines
|
||||||
|
while guarding versions that were never floating. Revisit this if a dependency is
|
||||||
|
ever loosened to a range, or picks up transitive drift that exact pinning cannot
|
||||||
|
control — at that point the lockfile earns its friction.
|
||||||
|
|
||||||
### Branching
|
### Branching
|
||||||
|
|
||||||
- `master` — minimal template, protected
|
- `master` — minimal template (protected once locked in)
|
||||||
- `<song>/master` — song-specific work
|
- `<song>/master` — song-specific work
|
||||||
- `<song>/<feature>` — parallel features for a song
|
- `<song>/<feature>` — parallel features for a song
|
||||||
|
|
||||||
This repo's branching rules supersede any generic git operation conventions.
|
Once `master` is locked, this repo's branching rules supersede any generic git
|
||||||
|
operation conventions. Before that point, use whatever is convenient.
|
||||||
|
|||||||
+13
-5
@@ -42,17 +42,25 @@ The Studio auto-reloads on file changes. No manual refresh needed.
|
|||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── src/
|
├── src/ # Remotion input — compiled by tsc/Remotion
|
||||||
│ ├── index.ts # Entry point — calls registerRoot()
|
│ ├── index.ts # Entry point — calls registerRoot()
|
||||||
│ ├── Root.tsx # React root component
|
│ ├── Root.tsx # React root component
|
||||||
│ ├── Composition.tsx # Composition definitions (placeholder)
|
│ └── Composition.tsx # Composition definitions (placeholder)
|
||||||
│ └── lrc2srt.ts # LRC → SRT subtitle converter
|
├── scripts/ # Operator tooling — Python, stdlib only
|
||||||
|
│ ├── tools.py # Dispatcher: python3 scripts/tools.py --help
|
||||||
|
│ ├── ffmpeg_tools.py # mux / bake / still / strip / probe
|
||||||
|
│ ├── lrc2srt.py # LRC → SRT
|
||||||
|
│ ├── srt2ass.py # SRT → ASS (the step before baking)
|
||||||
|
│ ├── bom_gen.py # Bill of materials for untracked assets
|
||||||
|
│ └── hwprobe.py # What this machine can actually encode
|
||||||
├── package.json # Remotion + React + Tailwind deps
|
├── package.json # Remotion + React + Tailwind deps
|
||||||
├── remotion.config.ts # CLI config (rspack, jpeg output format)
|
├── remotion.config.ts # CLI config (rspack, jpeg output format)
|
||||||
├── tsconfig.json # TypeScript (strict, ES2018)
|
└── tsconfig.json # TypeScript (strict, ES2018)
|
||||||
└── ffmpeg.sh # Helper for ASS subtitle baking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`src/` and `scripts/` do not mix: the former is what the video build compiles, the
|
||||||
|
latter is what you run by hand.
|
||||||
|
|
||||||
## Compositions
|
## Compositions
|
||||||
|
|
||||||
Compositions are defined in `src/Composition.tsx`. Each has:
|
Compositions are defined in `src/Composition.tsx`. Each has:
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ffmpeg helper — bake ASS subtitles into video, or bypass with static image + audio
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ffmpeg.sh bake <video> <audio> <subtitles.ass> <output>
|
|
||||||
# ffmpeg.sh bypass <image> <audio> <output>
|
|
||||||
#
|
|
||||||
# Prerequisites: ffmpeg must be on $PATH.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage:"
|
|
||||||
echo " $0 bake <video> <audio> <subtitles.ass> <output>"
|
|
||||||
echo " $0 bypass <image> <audio> <output>"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[ $# -eq 4 ] || usage
|
|
||||||
|
|
||||||
MODE="$1"
|
|
||||||
shift
|
|
||||||
|
|
||||||
case "$MODE" in
|
|
||||||
bake)
|
|
||||||
VIDEO="$1"
|
|
||||||
AUDIO="$1"
|
|
||||||
ASS="$1"
|
|
||||||
OUTPUT="$1"
|
|
||||||
|
|
||||||
ffmpeg -y \
|
|
||||||
-i "$VIDEO" \
|
|
||||||
-i "$AUDIO" \
|
|
||||||
-vf "ass=$ASS" \
|
|
||||||
-c:v libx264 \
|
|
||||||
-c:a aac \
|
|
||||||
-shortest \
|
|
||||||
"$OUTPUT"
|
|
||||||
;;
|
|
||||||
|
|
||||||
bypass)
|
|
||||||
IMAGE="$1"
|
|
||||||
AUDIO="$1"
|
|
||||||
OUTPUT="$1"
|
|
||||||
|
|
||||||
ffmpeg -y \
|
|
||||||
-loop 1 \
|
|
||||||
-i "$IMAGE" \
|
|
||||||
-i "$AUDIO" \
|
|
||||||
-c:v libx264 \
|
|
||||||
-tune stillimage \
|
|
||||||
-c:a aac \
|
|
||||||
-shortest \
|
|
||||||
"$OUTPUT"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remotion/cli": "4.0.522",
|
"@remotion/cli": "4.0.522",
|
||||||
|
"@remotion/media-utils": "4.0.522",
|
||||||
"@remotion/tailwind-v4": "4.0.522",
|
"@remotion/tailwind-v4": "4.0.522",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ Config.setRspack(true);
|
|||||||
Config.setVideoImageFormat("jpeg");
|
Config.setVideoImageFormat("jpeg");
|
||||||
Config.setOverwriteOutput(true);
|
Config.setOverwriteOutput(true);
|
||||||
Config.overrideBundlerConfig(enableTailwind);
|
Config.overrideBundlerConfig(enableTailwind);
|
||||||
|
// Song branch: staticFile() paths resolve inside the song folder, so compositions
|
||||||
|
// read audio/, fonts/ and route/ where they already live instead of a copy.
|
||||||
|
Config.setPublicDir("song-274");
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# NCMR songs-videos — tools
|
||||||
|
|
||||||
|
Helper scripts for building NCMR song videos: converting lyrics to subtitles,
|
||||||
|
burning them in, muxing audio, and working out what this machine can encode.
|
||||||
|
|
||||||
|
Everything here is **Python standard library only**. No virtualenv, no install
|
||||||
|
step, no dependencies to break in six months. Run them straight from the repo:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 scripts/tools.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ~/dev/mikkeli/ncmr-songs-videos
|
||||||
|
|
||||||
|
# 1. What can this machine actually encode?
|
||||||
|
python3 scripts/tools.py hwprobe
|
||||||
|
|
||||||
|
# 2. Lyrics -> subtitles
|
||||||
|
python3 scripts/tools.py lrc2srt song.lrc -o song.srt
|
||||||
|
python3 scripts/tools.py srt2ass song.srt -o song.ass
|
||||||
|
|
||||||
|
# 3. Audio + still image -> a video (the "PV" case)
|
||||||
|
python3 scripts/tools.py ffmpeg still cover.png song.flac -o pv.mkv
|
||||||
|
|
||||||
|
# 4. Burn the subtitles in, copy the audio through untouched
|
||||||
|
python3 scripts/tools.py ffmpeg bake pv.mkv song.ass -o final.mp4 --format flac
|
||||||
|
|
||||||
|
# 5. Record what untracked assets went in
|
||||||
|
python3 scripts/tools.py bom --dir .
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subcommand takes `--help`. Every subcommand accepts `--dry-run` (`-n`) to
|
||||||
|
print the work it would do without doing it, which is the fastest way to see
|
||||||
|
what ffmpeg command you are about to run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `tools.py` | Dispatcher. `python3 scripts/tools.py <tool> [args...]`. |
|
||||||
|
| `hwprobe.py` | Report which encoders this machine can really use, and why not. |
|
||||||
|
| `lrc2srt.py` | LRC lyrics → SRT subtitles. |
|
||||||
|
| `srt2ass.py` | SRT → ASS, for burn-in via ffmpeg's `ass` filter. |
|
||||||
|
| `ffmpeg_tools.py` | `mux`, `bake`, `still`, `strip`, `probe` — the ffmpeg wrappers. |
|
||||||
|
| `bom_gen.py` | Write a `BOM.yaml` (or `BOM.md`) of a song's untracked assets. |
|
||||||
|
| `_common.py` | Shared helpers. Not a command; imported by the rest. |
|
||||||
|
|
||||||
|
Each script also runs standalone (`python3 scripts/hwprobe.py`) and can be
|
||||||
|
imported as a module. The dispatcher is a convenience, not a requirement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware encoding
|
||||||
|
|
||||||
|
This machine is an **AMD Strix Halo** (Radeon 8060S). There is no NVIDIA card,
|
||||||
|
so `nvenc` is unavailable here. What does work is **VA-API** via the Mesa
|
||||||
|
`radeonsi` driver:
|
||||||
|
|
||||||
|
| Codec | Encoder | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| H.264 | `h264_vaapi` | works |
|
||||||
|
| HEVC | `hevc_vaapi` | works |
|
||||||
|
| AV1 | `av1_vaapi` | works |
|
||||||
|
| H.264/HEVC | `libx264`/`libx265` | software fallback, always available |
|
||||||
|
|
||||||
|
`ffmpeg_tools.py` picks a hardware encoder automatically when one is usable and
|
||||||
|
falls back to `libx264` when it is not, so the same command works on a machine
|
||||||
|
without a usable GPU. Override it when you want to:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
--video auto # default: hardware if possible, else software
|
||||||
|
--video x264 # force software H.264
|
||||||
|
--video x265 # force software HEVC
|
||||||
|
--video copy # no re-encode at all
|
||||||
|
--codec hevc # which hardware codec family to try with --video auto
|
||||||
|
--device PATH # VA-API render node (default /dev/dri/renderD128)
|
||||||
|
```
|
||||||
|
|
||||||
|
### If hardware encoding stops working
|
||||||
|
|
||||||
|
The usual cause is permissions on the DRM render node. `/dev/dri/renderD128` is
|
||||||
|
owned by `root:render`, so the user needs to be in the `render` group:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
getent group render # who is in it
|
||||||
|
python3 scripts/tools.py hwprobe # says plainly whether the device opens
|
||||||
|
```
|
||||||
|
|
||||||
|
Group membership only applies to *new* logins. If `id` shows `render` but
|
||||||
|
`hwprobe` still reports the device as unreadable, the running process is holding
|
||||||
|
stale credentials — log out and back in, or start the process under the group
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sg render -c 'python3 scripts/hwprobe.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
`hwprobe` never guesses. It opens the device, runs a two-frame test encode
|
||||||
|
through each candidate encoder, and reports what actually worked.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/tools.py hwprobe --shell # export-friendly KEY=value output
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subtitles
|
||||||
|
|
||||||
|
LRC is the timestamped-lyrics format lyric sites emit; SRT is what most players
|
||||||
|
read; ASS is what you need to burn styled text into a video with ffmpeg.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/tools.py lrc2srt song.lrc -o song.srt --shift -0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
`lrc2srt` handles the messy real-world cases: `[mm:ss]`, `[mm:ss.xx]` and
|
||||||
|
`[mm:ss.xxx]` timestamps, several timestamps on one line (repeated choruses —
|
||||||
|
each becomes its own cue), `[ar:]`/`[ti:]` metadata tags, and blank lines inside
|
||||||
|
a cue. The final cue is held for a few seconds rather than dropped; adjust with
|
||||||
|
`--tail`. Use `--shift` to nudge everything earlier or later if the lyrics drift
|
||||||
|
against the audio.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/tools.py srt2ass song.srt -o song.ass --play-res 1920x1080
|
||||||
|
```
|
||||||
|
|
||||||
|
`srt2ass` converts to ASS and gives ffmpeg something it can burn in. `--play-res`
|
||||||
|
sets the script resolution used for font scaling; match it to your video so text
|
||||||
|
sizes come out as designed. `--dry-run` prints the ASS to stdout for inspection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ffmpeg wrappers
|
||||||
|
|
||||||
|
Long ffmpeg command lines are easy to get subtly wrong and impossible to
|
||||||
|
remember. These wrap the five the project actually needs.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Attach audio to a video-only file; video stream copied, never re-encoded.
|
||||||
|
python3 scripts/tools.py ffmpeg mux render.mp4 song.flac -o pv.mkv --format flac
|
||||||
|
|
||||||
|
# Burn subtitles into a video.
|
||||||
|
python3 scripts/tools.py ffmpeg bake pv.mkv song.ass -o final.mp4 --format flac
|
||||||
|
|
||||||
|
# One still image + audio -> a video.
|
||||||
|
python3 scripts/tools.py ffmpeg still cover.png song.flac -o pv.mkv --fps 60
|
||||||
|
|
||||||
|
# Pull the audio back out as MP3.
|
||||||
|
python3 scripts/tools.py ffmpeg strip final.mp4 -o final.mp3
|
||||||
|
|
||||||
|
# What is actually in this file?
|
||||||
|
python3 scripts/tools.py ffmpeg probe final.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes on the choices baked in:
|
||||||
|
|
||||||
|
- **`mux` uses `-c:v copy`.** The Remotion render is already encoded; re-encoding
|
||||||
|
it to add audio costs quality and time for nothing. Containers are picked from
|
||||||
|
the output extension.
|
||||||
|
- **`--format` presets** are `aac`, `mp3`, `flac`, `opus` and `copy`. `flac` is
|
||||||
|
the default because these are archival masters; use `aac`/`opus` for anything
|
||||||
|
you are actually shipping. `--bitrate` applies to the lossy ones.
|
||||||
|
- **`-shortest`** is set where a still image is looped, so the video ends with
|
||||||
|
the audio instead of running on for ever.
|
||||||
|
- **`-movflags +faststart`** is set for MP4 output, so the file starts playing
|
||||||
|
before it has fully downloaded.
|
||||||
|
|
||||||
|
For anything these do not cover, use `--dry-run` to print the generated command,
|
||||||
|
copy it, and edit it — the wrapper is a convenience, not a cage. `hwprobe.py`
|
||||||
|
reports what this host can actually encode, so you can tell at a glance whether a
|
||||||
|
recipe will need a hardware-specific encoder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bill of materials
|
||||||
|
|
||||||
|
Binary assets are git-ignored, so a clone does not tell you what went into a
|
||||||
|
song. `bom_gen.py` walks the asset directories and records it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/tools.py bom --dir . --format yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes `BOM.yaml` listing every untracked file under `audio/`, `images/`,
|
||||||
|
`fonts/` and `videos/` with its size, plus whatever description you give it. Use
|
||||||
|
`--format md` for Markdown, `--format json` for machine-readable output, and
|
||||||
|
`-y` to skip the interactive description prompt. If PyYAML is available it is
|
||||||
|
used for clean output; otherwise a small built-in writer produces the same
|
||||||
|
schema. Neither is required.
|
||||||
|
|
||||||
|
Add `--hash` to record a short content hash per file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/tools.py bom --dir . --hash
|
||||||
|
```
|
||||||
|
|
||||||
|
That is how you answer "has this asset changed since the BOM was written?" —
|
||||||
|
sizes stay identical when you re-render a take at the same length, but the hash
|
||||||
|
will not. Hashing reads every asset in full, so it is off by default; when you
|
||||||
|
only want the inventory, leave it out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- `master` is the shared template. It is intended to freeze once the workspace is
|
||||||
|
finalised; until then, tooling work can land on it directly. Per-song work goes
|
||||||
|
on `<song>/master`, and features on `<song>/<feature>`. See `README.md`.
|
||||||
|
- Binary assets stay out of git; the BOM is how you know what was used.
|
||||||
|
- Keep new tools in `scripts/`, stdlib only, with a `--dry-run` and `--help`.
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"""NCMR songs-videos helper toolchain — Python, stdlib only.
|
||||||
|
|
||||||
|
Shared plumbing for the repo's helper scripts. Every script in this package is
|
||||||
|
runnable as a plain file (``python3 scripts/bom_gen.py``) and as a module
|
||||||
|
(``python3 -m scripts.bom_gen``); both work because this package is imported
|
||||||
|
either way without needing its parent directory on ``sys.path``.
|
||||||
|
|
||||||
|
Nothing outside the standard library is imported anywhere in this package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Iterable, Sequence
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Die",
|
||||||
|
"VERSION",
|
||||||
|
"Report",
|
||||||
|
"find_ffmpeg",
|
||||||
|
"glob_files",
|
||||||
|
"human_size",
|
||||||
|
"report",
|
||||||
|
"run",
|
||||||
|
"subparsers_metavar",
|
||||||
|
"text_table",
|
||||||
|
]
|
||||||
|
|
||||||
|
VERSION = "0.2.0"
|
||||||
|
|
||||||
|
|
||||||
|
class Die(Exception):
|
||||||
|
"""Fatal, user-facing error. Caught in ``main()`` and printed to stderr."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Console output
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _colour_enabled(stream=None) -> bool:
|
||||||
|
stream = stream or sys.stdout
|
||||||
|
|
||||||
|
if os.environ.get("NO_COLOR"):
|
||||||
|
return False
|
||||||
|
if os.environ.get("FORCE_COLOR"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return hasattr(stream, "isatty") and stream.isatty()
|
||||||
|
|
||||||
|
|
||||||
|
def _style(text: str, code: str) -> str:
|
||||||
|
if not _colour_enabled():
|
||||||
|
return text
|
||||||
|
|
||||||
|
return f"\033[{code}m{text}\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
"""Prints user-facing output, with optional ANSI colour when on a TTY."""
|
||||||
|
|
||||||
|
quiet: bool = False
|
||||||
|
|
||||||
|
def _emit(self, message: str, *, err: bool = False) -> None:
|
||||||
|
stream = sys.stderr if err else sys.stdout
|
||||||
|
print(message, file=stream)
|
||||||
|
|
||||||
|
def info(self, message: str = "") -> None:
|
||||||
|
"""Ordinary progress output."""
|
||||||
|
self._emit(message)
|
||||||
|
|
||||||
|
def ok(self, message: str) -> None:
|
||||||
|
self._emit(_style(f"✓ {message}", "32"))
|
||||||
|
|
||||||
|
def warn(self, message: str) -> None:
|
||||||
|
self._emit(_style(f"warning: {message}", "33"), err=True)
|
||||||
|
|
||||||
|
def error(self, message: str) -> None:
|
||||||
|
self._emit(_style(f"error: {message}", "31"), err=True)
|
||||||
|
|
||||||
|
def verbose(self, message: str) -> None:
|
||||||
|
if not self.quiet:
|
||||||
|
self._emit(_style(f" {message}", "2"))
|
||||||
|
|
||||||
|
|
||||||
|
#: Default reporter used when a caller doesn't supply one.
|
||||||
|
report = Report()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Formatting helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_SIZE_UNITS = ("B", "KiB", "MiB", "GiB", "TiB")
|
||||||
|
|
||||||
|
# Characters that make a key unquoted-safe in YAML and legible in a table.
|
||||||
|
_SAFE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def human_size(num_bytes: int) -> str:
|
||||||
|
"""Format a byte count as ``1.5 MiB`` / ``12 B``."""
|
||||||
|
value = float(num_bytes)
|
||||||
|
|
||||||
|
for unit in _SIZE_UNITS[:-1]:
|
||||||
|
if value < 1024:
|
||||||
|
break
|
||||||
|
value /= 1024
|
||||||
|
else:
|
||||||
|
unit = _SIZE_UNITS[-1]
|
||||||
|
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(value)} B"
|
||||||
|
|
||||||
|
return f"{value:.1f} {unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def text_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str:
|
||||||
|
"""Render a simple whitespace-aligned table (no boxes, pipe-friendly)."""
|
||||||
|
body = [[str(cell) for cell in row] for row in rows]
|
||||||
|
widths = [len(str(h)) for h in headers]
|
||||||
|
|
||||||
|
for row in body:
|
||||||
|
for i, cell in enumerate(row):
|
||||||
|
if i < len(widths):
|
||||||
|
widths[i] = max(widths[i], len(cell))
|
||||||
|
|
||||||
|
def line(cells: Sequence[str]) -> str:
|
||||||
|
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)).rstrip()
|
||||||
|
|
||||||
|
out = [line([str(h) for h in headers])]
|
||||||
|
out.append(" ".join("-" * w for w in widths).rstrip())
|
||||||
|
|
||||||
|
for row in body:
|
||||||
|
out.append(line(row))
|
||||||
|
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def glob_files(directory: str, suffix: str) -> list[str]:
|
||||||
|
"""Sorted list of files directly inside *directory* whose name ends in *suffix*.
|
||||||
|
|
||||||
|
Returns an empty list when the directory does not exist, so callers can treat
|
||||||
|
"no such folder" and "folder is empty" identically.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
entries = os.listdir(directory)
|
||||||
|
except (FileNotFoundError, NotADirectoryError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
suffix = suffix.lower()
|
||||||
|
found = [
|
||||||
|
os.path.join(directory, name)
|
||||||
|
for name in entries
|
||||||
|
if name.lower().endswith(suffix) and os.path.isfile(os.path.join(directory, name))
|
||||||
|
]
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Subprocess plumbing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
cmd: Sequence[str],
|
||||||
|
*,
|
||||||
|
report: Report | None = None,
|
||||||
|
check: bool = True,
|
||||||
|
cwd: str | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""Run *cmd* with a shell-free argument list, echoing it first.
|
||||||
|
|
||||||
|
``report`` is the reporter used for the echo; the helper deliberately takes a
|
||||||
|
reporter argument rather than closing over the module-level default so that a
|
||||||
|
``--quiet`` CLI cannot accidentally echo commands.
|
||||||
|
"""
|
||||||
|
log = report or globals()["report"]
|
||||||
|
printable = " ".join(shlex_quote(part) for part in cmd)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
log.info(_style(f"[dry-run] {printable}", "36"))
|
||||||
|
return subprocess.CompletedProcess(list(cmd), 0)
|
||||||
|
|
||||||
|
log.info(_style(f"$ {printable}", "2"))
|
||||||
|
|
||||||
|
# ffmpeg prints its whole build configuration as a banner unless told not to.
|
||||||
|
# Keep it quiet by default, and keep stderr so a failure can be explained.
|
||||||
|
full_cmd = list(cmd)
|
||||||
|
if os.path.basename(full_cmd[0]) in ("ffmpeg", "ffprobe"):
|
||||||
|
full_cmd[1:1] = ["-hide_banner"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(full_cmd, cwd=cwd, capture_output=True, text=True)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise Die(f"command not found: {cmd[0]}") from exc
|
||||||
|
|
||||||
|
if result.stdout:
|
||||||
|
sys.stdout.write(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
# ffmpeg writes progress to stderr; pass it through so the user still sees
|
||||||
|
# the encode happen, but drop the wall of banner text.
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
detail = _last_meaningful_line(result.stderr)
|
||||||
|
suffix = f": {detail}" if detail else ""
|
||||||
|
raise Die(f"{os.path.basename(cmd[0])} failed (exit {result.returncode}){suffix}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _last_meaningful_line(text: str | None) -> str:
|
||||||
|
"""Pick the most useful line out of ffmpeg's stderr to show in an error."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
for line in reversed(text.strip().splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# Progress lines start with 'frame=' and overwrite each other.
|
||||||
|
if line.startswith(("frame=", "size=")) or "speed=" in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return line
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def shlex_quote(part: str) -> str:
|
||||||
|
"""Quote one argv element for display. Local copy keeps the module stdlib-only."""
|
||||||
|
if not part:
|
||||||
|
return "''"
|
||||||
|
if re.fullmatch(r"[A-Za-z0-9_@%+=:,./\-]+", part):
|
||||||
|
return part
|
||||||
|
|
||||||
|
return "'" + part.replace("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffmpeg(override: str | None = None) -> str:
|
||||||
|
"""Locate the ``ffmpeg`` binary, or raise :class:`Die` with a useful message."""
|
||||||
|
if override:
|
||||||
|
if os.path.sep in override:
|
||||||
|
if not os.access(override, os.X_OK):
|
||||||
|
raise Die(f"--ffmpeg {override} is not an executable file")
|
||||||
|
return override
|
||||||
|
|
||||||
|
return override # a bare name; let the OS resolve it on $PATH
|
||||||
|
|
||||||
|
found = shutil.which("ffmpeg")
|
||||||
|
if not found:
|
||||||
|
raise Die("ffmpeg not found on $PATH — install it or pass --ffmpeg /path/to/ffmpeg")
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# argparse ergonomics
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def subparsers_metavar(parser: argparse.ArgumentParser, name: str = "mode") -> None:
|
||||||
|
"""Stop argparse from printing an unreadable ``{a,b,c}`` in help output.
|
||||||
|
|
||||||
|
Call *after* every subparser has been registered.
|
||||||
|
"""
|
||||||
|
for action in parser._actions:
|
||||||
|
if isinstance(action, argparse._SubParsersAction):
|
||||||
|
action.metavar = name
|
||||||
|
action.choices = None # type: ignore[assignment] # keep choices working
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_arguments(parser: argparse.ArgumentParser, *, suppress_defaults: bool = False) -> None:
|
||||||
|
"""Switches every tool in the toolbox understands.
|
||||||
|
|
||||||
|
``suppress_defaults`` is used when the same switches are also registered on
|
||||||
|
subparsers: it stops the subparser copy from stamping its default over a value
|
||||||
|
the user gave before the mode word.
|
||||||
|
"""
|
||||||
|
default_suppress = argparse.SUPPRESS if suppress_defaults else False
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-q",
|
||||||
|
"--quiet",
|
||||||
|
action="store_true",
|
||||||
|
default=default_suppress,
|
||||||
|
help="suppress progress output (errors still go to stderr)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-n",
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
default=default_suppress,
|
||||||
|
help="print the work that would happen without doing it",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version=f"%(prog)s {VERSION}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_ffmpeg_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--ffmpeg",
|
||||||
|
metavar="PATH",
|
||||||
|
default=None,
|
||||||
|
help="ffmpeg binary to use (default: whichever is on $PATH)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReportMixin:
|
||||||
|
"""Mixin for subcommand classes that need a reporter and a dry-run flag."""
|
||||||
|
|
||||||
|
report: Report = field(default_factory=Report)
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def main_wrapper(fn: Callable[[], int | None]) -> Callable[[], int]:
|
||||||
|
"""Decorator turning ``Die`` exceptions into a clean exit status."""
|
||||||
|
|
||||||
|
def wrapper() -> int:
|
||||||
|
try:
|
||||||
|
return fn() or 0
|
||||||
|
except Die as exc:
|
||||||
|
report.error(str(exc))
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
report.error("interrupted")
|
||||||
|
return 130
|
||||||
|
|
||||||
|
return wrapper
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env tsx
|
|
||||||
// BOM generator — walks asset directories, lists files, prompts for descriptions, outputs BOM.yaml
|
|
||||||
|
|
||||||
import { readdirSync, statSync, writeFileSync } from "node:fs";
|
|
||||||
import { resolve, relative } from "node:path";
|
|
||||||
import { createInterface } from "node:readline";
|
|
||||||
|
|
||||||
type Asset = { path: string; size: number; description: string; hash: string };
|
|
||||||
|
|
||||||
const ASSET_DIRS = ["audio", "images", "fonts", "videos"];
|
|
||||||
|
|
||||||
function walk(dir: string): Asset[] {
|
|
||||||
const assets: Asset[] = [];
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const full = resolve(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
assets.push(...walk(full));
|
|
||||||
} else {
|
|
||||||
const s = statSync(full);
|
|
||||||
const content = readFileSync(full);
|
|
||||||
// Simple hash: first 16 chars of node hash
|
|
||||||
const { createHash } = await import("node:crypto");
|
|
||||||
const hash = createHash("md5").update(content).digest("hex").slice(0, 8);
|
|
||||||
assets.push({
|
|
||||||
path: relative(".", full),
|
|
||||||
size: s.size,
|
|
||||||
description: "",
|
|
||||||
hash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanSize(bytes: number): string {
|
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
|
||||||
let i = 0;
|
|
||||||
while (bytes >= 1024 && i < units.length - 1) {
|
|
||||||
bytes /= 1024;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return `${bytes.toFixed(1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const root = process.argv[2] || ".";
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
|
|
||||||
console.log("BOM Generator");
|
|
||||||
console.log("=============\n");
|
|
||||||
|
|
||||||
let all: Asset[] = [];
|
|
||||||
|
|
||||||
for (const dir of ASSET_DIRS) {
|
|
||||||
const full = resolve(root, dir);
|
|
||||||
try {
|
|
||||||
const assets = walk(full);
|
|
||||||
all.push(...assets);
|
|
||||||
console.log(` ${dir}/ — ${assets.length} file(s)`);
|
|
||||||
} catch {
|
|
||||||
console.log(` ${dir}/ — not found (skipped)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
|
||||||
console.log("\nNo assets found. Nothing to do.\n");
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSize = all.reduce((s, a) => s + a.size, 0);
|
|
||||||
console.log(`\nTotal: ${all.length} file(s), ${humanSize(totalSize)}\n`);
|
|
||||||
|
|
||||||
for (let i = 0; i < all.length; i++) {
|
|
||||||
const a = all[i];
|
|
||||||
console.log(`[${i + 1}/${all.length}] ${a.path} (${humanSize(a.size)})`);
|
|
||||||
a.description = (await new Promise<string>((r) =>
|
|
||||||
rl.question(" Description: ", r)
|
|
||||||
)).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build YAML
|
|
||||||
let yaml = "---\nbom:\n";
|
|
||||||
for (const a of all) {
|
|
||||||
yaml += ` - file: ${a.path}\n`;
|
|
||||||
yaml += ` size: ${a.size}\n`;
|
|
||||||
yaml += ` hash: ${a.hash}\n`;
|
|
||||||
yaml += ` description: "${a.description.replace(/"/g, '\\"')}"\n`;
|
|
||||||
}
|
|
||||||
yaml += `total_files: ${all.length}\ntotal_size: ${totalSize}\n...`;
|
|
||||||
|
|
||||||
const bomPath = resolve(root, "BOM.yaml");
|
|
||||||
writeFileSync(bomPath, yaml);
|
|
||||||
console.log(`\n✓ Written to ${relative(root, bomPath)}\n`);
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
"""Generate a bill of materials for a song's untracked binary assets.
|
||||||
|
|
||||||
|
The repo's guideline says every song keeps a ``BOM.md`` (or ``BOM.yaml``)
|
||||||
|
listing the untracked input assets and explaining what each file is. This walks
|
||||||
|
the conventional asset directories, prompts for a description per file, and
|
||||||
|
writes the result.
|
||||||
|
|
||||||
|
Stdlib only: the YAML is emitted by hand. If PyYAML happens to be installed it is
|
||||||
|
used to *validate* the output, but it is never required.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 scripts/bom_gen.py # walk ./assets, write BOM.yaml
|
||||||
|
python3 scripts/bom_gen.py path/to/song # walk a song directory
|
||||||
|
python3 scripts/bom_gen.py --format md # write BOM.md instead
|
||||||
|
python3 scripts/bom_gen.py --descriptions d.toml # non-interactive
|
||||||
|
python3 scripts/bom_gen.py --describe-one audio/song.flac
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _common import ( # type: ignore[import-not-found]
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
human_size,
|
||||||
|
main_wrapper,
|
||||||
|
text_table,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover
|
||||||
|
from ._common import (
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
human_size,
|
||||||
|
main_wrapper,
|
||||||
|
text_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Directories that hold untracked input assets, with the role we assume.
|
||||||
|
ASSET_DIRS: tuple[tuple[str, str], ...] = (
|
||||||
|
("audio", "Song audio: final mix, instrumental, stems"),
|
||||||
|
("images", "Cover art, stills, background plates"),
|
||||||
|
("fonts", "Typefaces used by the subtitles or overlays"),
|
||||||
|
("videos", "Material video clips"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: File extensions that are worth flagging as (probably) not-yet-described.
|
||||||
|
_DESCRIPTION_PLACEHOLDER = "<describe this file>"
|
||||||
|
|
||||||
|
_BOM_MARKER = "# generated by scripts/bom_gen.py"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# YAML emission (hand-rolled, stdlib only)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: Characters that force a value to be quoted in YAML.
|
||||||
|
_NEEDS_QUOTES = re.compile(r"""[:\{\}\[\],&*#?|<>=!%@`"']|^\s|\s$|^$|^[-?]""")
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_scalar(value: str) -> str:
|
||||||
|
"""Quote *value* for YAML only when it needs it."""
|
||||||
|
if value.isdigit():
|
||||||
|
return f'"{value}"' # keep hashes/filenames that look numeric as strings
|
||||||
|
|
||||||
|
if _NEEDS_QUOTES.search(value):
|
||||||
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_key(value: str) -> str:
|
||||||
|
"""Quote a key that would otherwise be ambiguous (spaces, colons, leading -)."""
|
||||||
|
if _NEEDS_QUOTES.search(value) or value.startswith("-") or value != value.strip():
|
||||||
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Asset discovery
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Asset:
|
||||||
|
path: str # relative to the BOM root, POSIX separators
|
||||||
|
size: int
|
||||||
|
description: str = ""
|
||||||
|
digest: str = "" # short content hash, empty when --hash was not requested
|
||||||
|
extra: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def directory(self) -> str:
|
||||||
|
return os.path.dirname(self.path).split("/")[0] or "."
|
||||||
|
|
||||||
|
|
||||||
|
def file_digest(full: str, *, length: int = 12, chunk: int = 1 << 20) -> str:
|
||||||
|
"""Short BLAKE2b content hash, streamed so large assets do not land in RAM.
|
||||||
|
|
||||||
|
Assets here are audio and video files that can run to hundreds of megabytes,
|
||||||
|
so this reads in chunks rather than slurping the whole file. BLAKE2b is in
|
||||||
|
the standard library and is faster than md5 with no collision worries.
|
||||||
|
``length`` bytes of digest = 3 * length hex characters.
|
||||||
|
"""
|
||||||
|
h = hashlib.blake2b(digest_size=length)
|
||||||
|
with open(full, "rb") as fh:
|
||||||
|
while bytes_read := fh.read(chunk):
|
||||||
|
h.update(bytes_read)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def walk_assets(
|
||||||
|
root: str,
|
||||||
|
dirs: tuple[tuple[str, str], ...] = ASSET_DIRS,
|
||||||
|
*,
|
||||||
|
hashes: bool = False,
|
||||||
|
) -> list[Asset]:
|
||||||
|
"""Collect files under each asset directory, sorted by path.
|
||||||
|
|
||||||
|
Hashes are opt-in: they cost a full read of every asset, which is wasted
|
||||||
|
work when you only want the inventory.
|
||||||
|
"""
|
||||||
|
assets: list[Asset] = []
|
||||||
|
|
||||||
|
for name, _role in dirs:
|
||||||
|
base = os.path.join(root, name)
|
||||||
|
if not os.path.isdir(base):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(base):
|
||||||
|
dirnames.sort()
|
||||||
|
for filename in sorted(filenames):
|
||||||
|
if filename.startswith("."):
|
||||||
|
continue
|
||||||
|
full = os.path.join(dirpath, filename)
|
||||||
|
if not os.path.isfile(full):
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||||
|
assets.append(
|
||||||
|
Asset(
|
||||||
|
path=rel,
|
||||||
|
size=os.path.getsize(full),
|
||||||
|
digest=file_digest(full) if hashes else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return assets
|
||||||
|
|
||||||
|
|
||||||
|
def load_descriptions(path: str) -> dict[str, str]:
|
||||||
|
"""Read a descriptions file: simple ``path = description`` or ``path<tab>desc``.
|
||||||
|
|
||||||
|
A deliberately forgiving format so descriptions can also be pasted in from a
|
||||||
|
spreadsheet. ``#`` starts a comment.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
raw = handle.read()
|
||||||
|
except OSError as exc:
|
||||||
|
raise Die(f"cannot read --descriptions {path}: {exc.strerror}") from exc
|
||||||
|
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
elif "\t" in line:
|
||||||
|
key, _, value = line.partition("\t")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
mapping[key.strip().lstrip("./")] = value.strip()
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Rendering
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def render_yaml(root: str, assets: list[Asset], *, title: str | None, by_dir: bool) -> str:
|
||||||
|
total = sum(a.size for a in assets)
|
||||||
|
lines = [
|
||||||
|
"---",
|
||||||
|
_BOM_MARKER,
|
||||||
|
f"root: {yaml_scalar(os.path.basename(os.path.abspath(root)) or root)}",
|
||||||
|
]
|
||||||
|
if title:
|
||||||
|
lines.append(f"title: {yaml_scalar(title)}")
|
||||||
|
lines.append(f"total_files: {len(assets)}")
|
||||||
|
lines.append(f"total_size: {total}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if by_dir:
|
||||||
|
for dirname, role in ASSET_DIRS:
|
||||||
|
grouping = [a for a in assets if a.directory == dirname]
|
||||||
|
if not grouping:
|
||||||
|
continue
|
||||||
|
subtotal = sum(a.size for a in grouping)
|
||||||
|
lines.append(f"{yaml_key(dirname)}:")
|
||||||
|
lines.append(f" role: {yaml_scalar(role)}")
|
||||||
|
lines.append(f" count: {len(grouping)}")
|
||||||
|
lines.append(f" size: {subtotal}")
|
||||||
|
lines.append(" files:")
|
||||||
|
for asset in grouping:
|
||||||
|
lines.append(f" {yaml_key(asset.path)}:")
|
||||||
|
lines.append(f" size: {asset.size}")
|
||||||
|
if asset.digest:
|
||||||
|
lines.append(f" hash: {asset.digest}")
|
||||||
|
lines.append(f" description: {yaml_scalar(asset.description)}")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.append("files:")
|
||||||
|
for asset in assets:
|
||||||
|
lines.append(f" {yaml_key(asset.path)}:")
|
||||||
|
lines.append(f" size: {asset.size}")
|
||||||
|
if asset.digest:
|
||||||
|
lines.append(f" hash: {asset.digest}")
|
||||||
|
lines.append(f" description: {yaml_scalar(asset.description)}")
|
||||||
|
|
||||||
|
# Anything under an unrecognised top-level directory still deserves a mention.
|
||||||
|
others = [a for a in assets if a.directory not in {d for d, _ in ASSET_DIRS}]
|
||||||
|
if others:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("unclassified:")
|
||||||
|
for asset in others:
|
||||||
|
lines.append(f" {yaml_key(asset.path)}:")
|
||||||
|
lines.append(f" size: {asset.size}")
|
||||||
|
if asset.digest:
|
||||||
|
lines.append(f" hash: {asset.digest}")
|
||||||
|
lines.append(f" description: {yaml_scalar(asset.description)}")
|
||||||
|
|
||||||
|
lines.append("...")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(root: str, assets: list[Asset], *, title: str | None, by_dir: bool) -> str:
|
||||||
|
total = sum(a.size for a in assets)
|
||||||
|
heading = title or os.path.basename(os.path.abspath(root)) or "Untracked assets"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Bill of materials — {heading}",
|
||||||
|
"",
|
||||||
|
f"{len(assets)} file(s), {human_size(total)}. Generated by `scripts/bom_gen.py`.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
groups: list[tuple[str, list[Asset]]] = []
|
||||||
|
if by_dir:
|
||||||
|
for dirname, _role in ASSET_DIRS:
|
||||||
|
grouping = [a for a in assets if a.directory == dirname]
|
||||||
|
if grouping:
|
||||||
|
groups.append((dirname, grouping))
|
||||||
|
others = [a for a in assets if a.directory not in {d for d, _ in ASSET_DIRS}]
|
||||||
|
if others:
|
||||||
|
groups.append(("unclassified", others))
|
||||||
|
else:
|
||||||
|
groups.append(("files", assets))
|
||||||
|
|
||||||
|
for group_name, grouping in groups:
|
||||||
|
lines.append(f"## {group_name}")
|
||||||
|
lines.append("")
|
||||||
|
# Only carry a hash column when hashes were asked for, so the common
|
||||||
|
# inventory stays narrow enough to read.
|
||||||
|
with_hash = any(asset.digest for asset in grouping)
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
f"`{asset.path}`",
|
||||||
|
human_size(asset.size),
|
||||||
|
*( (f"`{asset.digest}`",) if with_hash else () ),
|
||||||
|
asset.description or f"_{_DESCRIPTION_PLACEHOLDER}_",
|
||||||
|
)
|
||||||
|
for asset in grouping
|
||||||
|
]
|
||||||
|
headers = ("file", "size", "hash", "description") if with_hash else ("file", "size", "description")
|
||||||
|
lines.append(text_table(headers, rows))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_yaml(document: str, report: Report) -> None:
|
||||||
|
"""If PyYAML is importable, use it to confirm the emitted document parses."""
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore[import-not-found]
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
yaml.safe_load(document)
|
||||||
|
except Exception as exc: # noqa: BLE001 - report whatever PyYAML says
|
||||||
|
report.warn(f"PyYAML could not parse the generated document: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Interactive prompts
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_for_descriptions(assets: list[Asset], report: Report) -> None:
|
||||||
|
"""Ask for a description per asset. EOF ends prompting and keeps what we have."""
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
report.warn(
|
||||||
|
"stdin is not a terminal — skipping prompts; "
|
||||||
|
"descriptions left blank (use --descriptions FILE to supply them)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(assets)
|
||||||
|
report.info(f"Describing {total} file(s). Blank line accepts the current value.\n")
|
||||||
|
report.info("Press Ctrl-C or Ctrl-D to stop early; the BOM is still written.\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
for i, asset in enumerate(assets, start=1):
|
||||||
|
report.info(f"[{i}/{total}] {asset.path} ({human_size(asset.size)})")
|
||||||
|
try:
|
||||||
|
answer = input(" description: ").strip()
|
||||||
|
except EOFError:
|
||||||
|
report.info("")
|
||||||
|
report.warn("end of input — remaining descriptions left blank")
|
||||||
|
return
|
||||||
|
if answer:
|
||||||
|
asset.description = answer
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
report.info("")
|
||||||
|
report.warn("interrupted — writing the BOM with the descriptions gathered so far")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="bom_gen",
|
||||||
|
description="Generate a bill of materials for a song's untracked binary assets.",
|
||||||
|
)
|
||||||
|
add_common_arguments(parser)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"root",
|
||||||
|
nargs="?",
|
||||||
|
default=".",
|
||||||
|
help="directory holding audio/, images/, fonts/, videos/ (default: .)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
help="output path (default: BOM.yaml or BOM.md in the root directory)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
default="yaml",
|
||||||
|
choices=("yaml", "md"),
|
||||||
|
help="output format (default: yaml)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--descriptions",
|
||||||
|
metavar="FILE",
|
||||||
|
help="read 'path = description' lines from FILE instead of prompting",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-prompt",
|
||||||
|
action="store_true",
|
||||||
|
help="never prompt; write the inventory with blank descriptions",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--flat",
|
||||||
|
action="store_true",
|
||||||
|
help="list files without grouping them by asset directory",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--hash",
|
||||||
|
dest="hashes",
|
||||||
|
action="store_true",
|
||||||
|
help="record a short content hash per file (reads every asset; slower)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--title",
|
||||||
|
help="song title, recorded in the output header",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--describe-one",
|
||||||
|
metavar="PATH",
|
||||||
|
help="update the description of a single file in an existing BOM",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
@main_wrapper
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
report = Report(quiet=args.quiet)
|
||||||
|
|
||||||
|
root = os.path.abspath(args.root)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
raise Die(f"not a directory: {args.root}")
|
||||||
|
|
||||||
|
if args.describe_one:
|
||||||
|
return describe_one(root, args, report)
|
||||||
|
|
||||||
|
assets = walk_assets(root, hashes=args.hashes)
|
||||||
|
|
||||||
|
if not assets:
|
||||||
|
expected = ", ".join(f"{name}/" for name, _ in ASSET_DIRS)
|
||||||
|
report.warn(f"no assets found under {root} (looked in: {expected})")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.descriptions:
|
||||||
|
mapping = load_descriptions(args.descriptions)
|
||||||
|
applied = 0
|
||||||
|
for asset in assets:
|
||||||
|
if asset.path in mapping:
|
||||||
|
asset.description = mapping[asset.path]
|
||||||
|
applied += 1
|
||||||
|
missing = [a.path for a in assets if not a.description]
|
||||||
|
report.verbose(f"applied {applied} description(s) from {args.descriptions}")
|
||||||
|
if missing:
|
||||||
|
report.warn(f"{len(missing)} file(s) have no description in {args.descriptions}")
|
||||||
|
elif not args.no_prompt:
|
||||||
|
prompt_for_descriptions(assets, report)
|
||||||
|
|
||||||
|
renderer = render_markdown if args.format == "md" else render_yaml
|
||||||
|
document = renderer(root, assets, title=args.title, by_dir=not args.flat)
|
||||||
|
|
||||||
|
if args.format == "yaml":
|
||||||
|
validate_yaml(document, report)
|
||||||
|
|
||||||
|
default_name = "BOM.md" if args.format == "md" else "BOM.yaml"
|
||||||
|
output = args.output or os.path.join(root, default_name)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
report.info(document)
|
||||||
|
report.info(f"[dry-run] would write {output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open(output, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(document)
|
||||||
|
|
||||||
|
total = sum(a.size for a in assets)
|
||||||
|
described = sum(1 for a in assets if a.description)
|
||||||
|
report.ok(f"{output} — {len(assets)} file(s), {human_size(total)}, {described} described")
|
||||||
|
|
||||||
|
if described < len(assets):
|
||||||
|
report.info(f" {len(assets) - described} file(s) still need a description")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def describe_one(root: str, args: argparse.Namespace, report: Report) -> int:
|
||||||
|
"""Set the description for a single path inside an existing BOM."""
|
||||||
|
target = args.describe_one.lstrip("./")
|
||||||
|
if not os.path.isfile(os.path.join(root, target)):
|
||||||
|
raise Die(f"no such asset: {target}")
|
||||||
|
|
||||||
|
output = args.output or os.path.join(root, "BOM.yaml" if args.format == "yaml" else "BOM.md")
|
||||||
|
|
||||||
|
description = args.title or "" # reused as the description payload for this mode
|
||||||
|
if not description:
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise Die("--describe-one needs a description; pass --title \"...\"")
|
||||||
|
description = input(f"description for {target}: ").strip()
|
||||||
|
|
||||||
|
if not os.path.isfile(output):
|
||||||
|
raise Die(f"no existing BOM at {output}; run without --describe-one first")
|
||||||
|
|
||||||
|
with open(output, "r", encoding="utf-8") as handle:
|
||||||
|
content = handle.read()
|
||||||
|
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"(^[ \t]*{re.escape(target)}:[ \t]*\n"
|
||||||
|
rf"(?:[ \t]+\w+:.*\n)*?"
|
||||||
|
rf"[ \t]+description:[ \t]*)"
|
||||||
|
rf".*$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated, count = pattern.subn(rf"\g<1>{yaml_scalar(description)}", content)
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
raise Die(f"could not find an entry for {target} in {output}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
report.info(f"[dry-run] would set {target} description in {output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open(output, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(updated)
|
||||||
|
|
||||||
|
report.ok(f"{target} described in {output}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
"""FFmpeg front-end for NCMR song videos.
|
||||||
|
|
||||||
|
Wraps the ffmpeg invocations this project actually needs, so the long command
|
||||||
|
lines live in one place instead of in shell history and notes files.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
|
||||||
|
``mux`` attach an audio track to a video-only render, without re-encoding
|
||||||
|
the video (the Remotion output is video-only; this is step 1)
|
||||||
|
``bake`` burn an ASS subtitle file into a video (step 2)
|
||||||
|
``still`` build a video from a single image plus audio, for the no-lyrics case
|
||||||
|
``strip`` pull an MP3 out of a finished video
|
||||||
|
``probe`` print a compact stream summary
|
||||||
|
|
||||||
|
Both ``mux`` and ``bake`` can be chained with ``--then-bake``/``--then-mux`` so a
|
||||||
|
render goes to finished file in one command.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 scripts/ffmpeg_tools.py probe out/pulseraw.mp4
|
||||||
|
python3 scripts/ffmpeg_tools.py mux out/pulseraw.mp4 song.wav -o raw.mkv --format flac
|
||||||
|
python3 scripts/ffmpeg_tools.py bake raw.mkv subs.ass -o final.mp4
|
||||||
|
python3 scripts/ffmpeg_tools.py still cover.png song.wav -o pv.mkv --fps 60 --format flac
|
||||||
|
python3 scripts/ffmpeg_tools.py strip final.mp4 -o song.mp3
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _common import ( # type: ignore[import-not-found]
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
add_ffmpeg_argument,
|
||||||
|
find_ffmpeg,
|
||||||
|
main_wrapper,
|
||||||
|
run,
|
||||||
|
text_table,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover
|
||||||
|
from ._common import (
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
add_ffmpeg_argument,
|
||||||
|
find_ffmpeg,
|
||||||
|
main_wrapper,
|
||||||
|
run,
|
||||||
|
text_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Audio/codec presets
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: ``--format`` choices, mapping to the ffmpeg audio arguments to use.
|
||||||
|
AUDIO_FORMATS = {
|
||||||
|
# name: (extension, container args, codec args)
|
||||||
|
"aac": ("mp4", [], ["-c:a", "aac", "-b:a", "320k"]),
|
||||||
|
"mp3": ("mp4", [], ["-c:a", "libmp3lame", "-b:a", "320k"]),
|
||||||
|
"flac": ("mkv", [], ["-c:a", "flac", "-compression_level", "12"]),
|
||||||
|
"opus": ("mkv", [], ["-c:a", "libopus", "-b:a", "192k"]),
|
||||||
|
"copy": (None, [], ["-c:a", "copy"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: ``--video`` choices. ``auto`` picks a hardware encoder when one works.
|
||||||
|
VIDEO_ENCODERS = {
|
||||||
|
"auto": None,
|
||||||
|
"x264": ["-c:v", "libx264"],
|
||||||
|
"x265": ["-c:v", "libx265"],
|
||||||
|
"copy": ["-c:v", "copy"],
|
||||||
|
}
|
||||||
|
|
||||||
|
VAAPI_ENCODERS = {
|
||||||
|
"h264": ["-c:v", "h264_vaapi"],
|
||||||
|
"hevc": ["-c:v", "hevc_vaapi"],
|
||||||
|
"av1": ["-c:v", "av1_vaapi"],
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Software fallback quality knob, applied only for libx264/libx265.
|
||||||
|
X264_QUALITY = ["-preset", "medium", "-crf", "18"]
|
||||||
|
|
||||||
|
DEFAULT_DEVICE = "/dev/dri/renderD128"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HwContext:
|
||||||
|
"""Hardware acceleration state: which encoder to use and how to init it."""
|
||||||
|
|
||||||
|
encoder: str
|
||||||
|
device: str | None = None
|
||||||
|
is_vaapi: bool = False
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
def input_args(self) -> list[str]:
|
||||||
|
"""Args that go before ``-i``."""
|
||||||
|
if self.is_vaapi and self.device:
|
||||||
|
return ["-init_hw_device", f"vaapi=va:{self.device}", "-filter_hw_device", "va"]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def filter_suffix(self) -> str:
|
||||||
|
"""Filters appended to a user filter chain to move frames onto the GPU."""
|
||||||
|
if self.is_vaapi:
|
||||||
|
return "format=nv12,hwupload"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Encoder selection
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _vaapi_device_readable(device: str) -> bool:
|
||||||
|
try:
|
||||||
|
fd = os.open(device, os.O_RDWR)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_DEVICE_CACHE: dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _encoder_works(ffmpeg: str, encoder: str, extra: list[str], device_key: str = "") -> bool:
|
||||||
|
"""Try a two-frame encode. Results are memoised per process.
|
||||||
|
|
||||||
|
VA-API encoders only accept GPU-resident frames, so the probe must upload the
|
||||||
|
test pattern first. Without ``format=nv12,hwupload`` ffmpeg fails with
|
||||||
|
"Function not implemented" even though the encoder works fine — which would
|
||||||
|
make this fall back to software on a machine that has no problem at all.
|
||||||
|
"""
|
||||||
|
cache_key = f"{encoder}|{device_key}"
|
||||||
|
if cache_key in _DEVICE_CACHE:
|
||||||
|
return _DEVICE_CACHE[cache_key]
|
||||||
|
|
||||||
|
vf = "format=nv12,hwupload" if encoder.endswith("_vaapi") else None
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
ffmpeg, "-hide_banner", "-loglevel", "error", *extra,
|
||||||
|
"-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1",
|
||||||
|
]
|
||||||
|
if vf:
|
||||||
|
cmd += ["-vf", vf]
|
||||||
|
cmd += ["-c:v", encoder, "-frames:v", "2", "-f", "null", "-"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
_DEVICE_CACHE[cache_key] = ok
|
||||||
|
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
#: Hardware encoders worth trying for each codec family, in preference order.
|
||||||
|
HW_ENCODER_CHAIN: dict[str, tuple[tuple[str, str], ...]] = {
|
||||||
|
# codec: ((encoder, init-family), ...)
|
||||||
|
"h264": (("h264_vaapi", "vaapi"), ("h264_nvenc", "nvenc"), ("h264_qsv", "qsv")),
|
||||||
|
"hevc": (("hevc_vaapi", "vaapi"), ("hevc_nvenc", "nvenc"), ("hevc_qsv", "qsv")),
|
||||||
|
"av1": (("av1_vaapi", "vaapi"), ("av1_nvenc", "nvenc"), ("av1_qsv", "qsv")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pick_acceleration(
|
||||||
|
ffmpeg: str,
|
||||||
|
report: Report,
|
||||||
|
*,
|
||||||
|
video: str = "auto",
|
||||||
|
codec: str = "h264",
|
||||||
|
device: str | None = None,
|
||||||
|
allow_software: bool = True,
|
||||||
|
) -> HwContext:
|
||||||
|
"""Decide how to encode, honouring an explicit ``--video`` choice."""
|
||||||
|
device = device or os.environ.get("HW_DEVICE") or DEFAULT_DEVICE
|
||||||
|
|
||||||
|
if video == "copy":
|
||||||
|
return HwContext(encoder="copy")
|
||||||
|
|
||||||
|
if video == "auto":
|
||||||
|
chain = HW_ENCODER_CHAIN.get(codec)
|
||||||
|
if chain is None:
|
||||||
|
raise Die(f"unknown --codec {codec!r}")
|
||||||
|
|
||||||
|
attempted: list[str] = []
|
||||||
|
|
||||||
|
for encoder, family in chain:
|
||||||
|
if family == "vaapi":
|
||||||
|
if not _vaapi_device_readable(device):
|
||||||
|
attempted.append(f"{encoder} (unreadable {device})")
|
||||||
|
continue
|
||||||
|
init = ["-init_hw_device", f"vaapi=va:{device}", "-filter_hw_device", "va"]
|
||||||
|
if _encoder_works(ffmpeg, encoder, init, device):
|
||||||
|
return HwContext(
|
||||||
|
encoder=encoder,
|
||||||
|
device=device,
|
||||||
|
is_vaapi=True,
|
||||||
|
note="hardware (VA-API)",
|
||||||
|
)
|
||||||
|
attempted.append(f"{encoder} (device rejects it)")
|
||||||
|
else:
|
||||||
|
init = []
|
||||||
|
if _encoder_works(ffmpeg, encoder, init, family):
|
||||||
|
return HwContext(encoder=encoder, note=f"hardware ({family})")
|
||||||
|
attempted.append(f"{encoder} (unsupported)")
|
||||||
|
|
||||||
|
if not allow_software:
|
||||||
|
raise Die(
|
||||||
|
"no working hardware encoder found and software fallback is disabled; "
|
||||||
|
f"tried {', '.join(attempted)}. Diagnose with: python3 scripts/hwprobe.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
report.verbose(f"no hardware encoder for {codec}: {', '.join(attempted)}")
|
||||||
|
|
||||||
|
return HwContext(encoder="libx264" if codec == "h264" else "libx265", note="software")
|
||||||
|
|
||||||
|
if video in ("x264", "x265"):
|
||||||
|
return HwContext(encoder=VIDEO_ENCODERS[video][1], note=f"software ({video})")
|
||||||
|
|
||||||
|
raise Die(f"unknown --video {video!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def video_args(context: HwContext, *, quality: bool = True) -> list[str]:
|
||||||
|
"""Encoder arguments for the selected context."""
|
||||||
|
if context.encoder == "copy":
|
||||||
|
return ["-c:v", "copy"]
|
||||||
|
|
||||||
|
args = ["-c:v", context.encoder]
|
||||||
|
|
||||||
|
if context.encoder.startswith("libx") and quality:
|
||||||
|
args += X264_QUALITY
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def pix_fmt_args(context: HwContext) -> list[str]:
|
||||||
|
# Other pixel formats come from the VA-API upload filter; without a filter
|
||||||
|
# chain, hardware encoders still need 4:2:0 input.
|
||||||
|
if context.is_vaapi:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return ["-pix_fmt", "yuv420p"]
|
||||||
|
|
||||||
|
|
||||||
|
def audio_args(fmt: str) -> tuple[list[str], str]:
|
||||||
|
if fmt not in AUDIO_FORMATS:
|
||||||
|
raise Die(f"unknown --format {fmt!r}; choose from {', '.join(AUDIO_FORMATS)}")
|
||||||
|
|
||||||
|
extension, container, codec = AUDIO_FORMATS[fmt]
|
||||||
|
|
||||||
|
return container + codec, extension or ("")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output(output: str | None, stem: str, fmt: str, suffix: str) -> str:
|
||||||
|
"""Work out the output filename, fixing up the extension for the container."""
|
||||||
|
if output:
|
||||||
|
return output
|
||||||
|
|
||||||
|
extension = AUDIO_FORMATS[fmt][0]
|
||||||
|
if extension and not stem.lower().endswith(f".{extension}"):
|
||||||
|
return f"{stem}{suffix}.{extension}"
|
||||||
|
|
||||||
|
return f"{stem}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_filter(context: HwContext, *filters: str) -> str | None:
|
||||||
|
"""Join filter fragments, appending the VA-API upload step when needed."""
|
||||||
|
parts = [f for f in filters if f]
|
||||||
|
upload = context.filter_suffix()
|
||||||
|
|
||||||
|
if upload:
|
||||||
|
parts.append(upload)
|
||||||
|
|
||||||
|
return ",".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Commands
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mux(args: argparse.Namespace, context: HwContext, report: Report) -> int:
|
||||||
|
"""Attach audio to a video-only file. Video stream is stream-copied."""
|
||||||
|
codec_args, _ = audio_args(args.format)
|
||||||
|
output = resolve_output(args.output, os.path.splitext(args.input_video)[0], args.format, "_mux")
|
||||||
|
|
||||||
|
filter_chain = build_filter(context, *args.vf) if context.is_vaapi else (",".join(args.vf) or None)
|
||||||
|
video = video_args(context, quality=False) if context.is_vaapi else ["-c:v", "copy"]
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
args.ffmpeg, "-y",
|
||||||
|
*context.input_args(),
|
||||||
|
"-i", args.input_video,
|
||||||
|
"-i", args.audio,
|
||||||
|
"-map", "0:v:0", "-map", "1:a:0",
|
||||||
|
*video,
|
||||||
|
*codec_args,
|
||||||
|
]
|
||||||
|
|
||||||
|
if filter_chain:
|
||||||
|
cmd += ["-vf", filter_chain]
|
||||||
|
|
||||||
|
cmd += ["-shortest", output]
|
||||||
|
|
||||||
|
run(cmd, report=report, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
report.ok(f"muxed -> {output}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_bake(args: argparse.Namespace, context: HwContext, report: Report) -> int:
|
||||||
|
"""Burn ASS subtitles into the video stream."""
|
||||||
|
if not os.path.isfile(args.subtitles):
|
||||||
|
raise Die(f"no such subtitle file: {args.subtitles}")
|
||||||
|
|
||||||
|
# libass needs the filename escaped for the filtergraph; ':' and '\' and "'"
|
||||||
|
# all have meaning there.
|
||||||
|
escaped = (
|
||||||
|
args.subtitles.replace("\\", "\\\\")
|
||||||
|
.replace(":", "\\:")
|
||||||
|
.replace("'", r"\'")
|
||||||
|
.replace("[", r"\[")
|
||||||
|
.replace("]", r"\]")
|
||||||
|
.replace(",", r"\,")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Burning subtitles means changing pixels, so the video stream cannot be copied.
|
||||||
|
# Fail early with a clear message instead of letting ffmpeg refuse the filter.
|
||||||
|
if context.encoder == "copy":
|
||||||
|
raise Die(
|
||||||
|
"cannot bake subtitles with --video copy: burning them in requires "
|
||||||
|
"re-encoding. Use --video auto (default) or --video x264."
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_chain = build_filter(context, f"ass={escaped}", *args.vf)
|
||||||
|
output = args.output or _append_suffix(args.input_video, "_sub")
|
||||||
|
|
||||||
|
codec_args, _ = audio_args(args.format) if args.audio_format != "keep" else (["-c:a", "copy"], None)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
args.ffmpeg, "-y",
|
||||||
|
*context.input_args(),
|
||||||
|
"-i", args.input_video,
|
||||||
|
"-vf", filter_chain or "null",
|
||||||
|
*video_args(context),
|
||||||
|
*pix_fmt_args(context),
|
||||||
|
*codec_args,
|
||||||
|
"-shortest" if args.shortest else "-map_metadata", "-1",
|
||||||
|
output,
|
||||||
|
]
|
||||||
|
|
||||||
|
run(cmd, report=report, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
report.ok(f"subtitles baked -> {output}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_still(args: argparse.Namespace, context: HwContext, report: Report) -> int:
|
||||||
|
"""Build a video from one image and one audio file (the no-lyrics render)."""
|
||||||
|
codec_args, _ = audio_args(args.format)
|
||||||
|
output = resolve_output(args.output, os.path.splitext(args.image)[0], args.format, "_pv")
|
||||||
|
|
||||||
|
filters = [f"fps={args.fps}", f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]
|
||||||
|
|
||||||
|
if context.is_vaapi:
|
||||||
|
filter_chain = build_filter(context, *filters)
|
||||||
|
cmd = [args.ffmpeg, "-y", "-loop", "1", "-i", args.image, "-i", args.audio, *context.input_args(), "-vf", filter_chain]
|
||||||
|
else:
|
||||||
|
cmd = [args.ffmpeg, "-y", "-loop", "1", "-i", args.image, "-i", args.audio, "-vf", ",".join(filters)]
|
||||||
|
|
||||||
|
cmd += ["-r", str(args.fps), *video_args(context), *pix_fmt_args(context), *codec_args, "-shortest", output]
|
||||||
|
|
||||||
|
run(cmd, report=report, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
report.ok(f"still-image video -> {output}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_strip(args: argparse.Namespace, context: HwContext | None, report: Report) -> int:
|
||||||
|
"""Extract the audio track as MP3."""
|
||||||
|
output = args.output or os.path.splitext(args.input_video)[0] + ".mp3"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
args.ffmpeg, "-y", "-i", args.input_video, "-vn",
|
||||||
|
"-ar", str(args.sample_rate), "-ac", str(args.channels),
|
||||||
|
"-c:a", "libmp3lame", "-b:a", args.bitrate,
|
||||||
|
output,
|
||||||
|
]
|
||||||
|
|
||||||
|
run(cmd, report=report, dry_run=args.dry_run)
|
||||||
|
if not args.dry_run:
|
||||||
|
report.ok(f"audio -> {output}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_probe(args: argparse.Namespace, context: HwContext | None, report: Report) -> int:
|
||||||
|
"""Print a compact stream summary for a media file."""
|
||||||
|
if not os.path.isfile(args.media[0]):
|
||||||
|
raise Die(f"no such file: {args.media[0]}")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffprobe" if args.ffmpeg.endswith("ffmpeg") else args.ffmpeg.replace("ffmpeg", "ffprobe"),
|
||||||
|
"-hide_banner", "-loglevel", "error",
|
||||||
|
"-show_entries", "stream=index,codec_type,codec_name,width,height,r_frame_rate,channels,sample_rate,bit_rate",
|
||||||
|
"-show_entries", "format=duration,size,format_name",
|
||||||
|
"-of", "json",
|
||||||
|
*args.media,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
except OSError as exc:
|
||||||
|
raise Die(f"could not run ffprobe: {exc}") from exc
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Die(f"ffprobe failed: {(result.stderr or '').strip()}")
|
||||||
|
|
||||||
|
data = json.loads(result.stdout or "{}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
kind = stream.get("codec_type", "?")
|
||||||
|
if kind == "video":
|
||||||
|
detail = f"{stream.get('width')}x{stream.get('height')} @ {stream.get('r_frame_rate')}"
|
||||||
|
elif kind == "audio":
|
||||||
|
detail = f"{stream.get('channels')}ch @ {stream.get('sample_rate')} Hz"
|
||||||
|
else:
|
||||||
|
detail = ""
|
||||||
|
rows.append((str(stream.get("index", "")), kind, stream.get("codec_name", "?"), detail))
|
||||||
|
|
||||||
|
report.info(text_table(("stream", "type", "codec", "detail"), rows))
|
||||||
|
|
||||||
|
fmt = data.get("format", {})
|
||||||
|
duration = fmt.get("duration")
|
||||||
|
size = fmt.get("size")
|
||||||
|
if duration:
|
||||||
|
report.info(f"\ncontainer: {fmt.get('format_name', '?')}")
|
||||||
|
report.info(f"duration: {float(duration):.2f}s")
|
||||||
|
if size:
|
||||||
|
report.info(f"size: {int(size):,} bytes")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _append_suffix(path: str, suffix: str) -> str:
|
||||||
|
stem, extension = os.path.splitext(path)
|
||||||
|
|
||||||
|
return f"{stem}{suffix}{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _add_video_options(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--video",
|
||||||
|
default=None,
|
||||||
|
choices=("auto", "x264", "x265", "copy"),
|
||||||
|
help="video encoder: auto (hardware if possible), x264, x265, or copy (default: auto)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--codec",
|
||||||
|
default=None,
|
||||||
|
choices=("h264", "hevc", "av1"),
|
||||||
|
help="hardware codec family to use when --video auto (default: h264)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help=f"VA-API render node (default: $HW_DEVICE or {DEFAULT_DEVICE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--vf",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="FILTER",
|
||||||
|
help="extra video filter, repeatable (appended after the built-in chain)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_audio_options(parser: argparse.ArgumentParser, *, default: str = "flac") -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"-f",
|
||||||
|
"--format",
|
||||||
|
default=default,
|
||||||
|
choices=tuple(AUDIO_FORMATS),
|
||||||
|
help=f"audio format preset (default: {default})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="ffmpeg_tools",
|
||||||
|
description="FFmpeg front-end for NCMR song videos (mux, bake, still, strip, probe).",
|
||||||
|
epilog="Run `%(prog)s hwprobe` for a separate encoder report, or see scripts/hwprobe.py.",
|
||||||
|
)
|
||||||
|
add_common_arguments(parser)
|
||||||
|
add_ffmpeg_argument(parser)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="mode", metavar="MODE")
|
||||||
|
|
||||||
|
mux = subparsers.add_parser("mux", help="attach audio to a video-only file, video copied as-is")
|
||||||
|
add_common_arguments(mux, suppress_defaults=True)
|
||||||
|
mux.add_argument("input_video", help="video-only input (e.g. the Remotion render)")
|
||||||
|
mux.add_argument("audio", help="audio input (e.g. the exported song)")
|
||||||
|
mux.add_argument("-o", "--output", help="output path")
|
||||||
|
_add_audio_options(mux)
|
||||||
|
_add_video_options(mux)
|
||||||
|
mux.set_defaults(func=cmd_mux)
|
||||||
|
|
||||||
|
bake = subparsers.add_parser("bake", help="burn an ASS subtitle file into a video")
|
||||||
|
add_common_arguments(bake, suppress_defaults=True)
|
||||||
|
bake.add_argument("input_video", help="input video")
|
||||||
|
bake.add_argument("subtitles", help="ASS subtitle file")
|
||||||
|
bake.add_argument("-o", "--output", help="output path (default: <video>_sub.<ext>)")
|
||||||
|
_add_audio_options(bake, default="aac")
|
||||||
|
bake.add_argument(
|
||||||
|
"--audio-format",
|
||||||
|
default="keep",
|
||||||
|
choices=("keep", *AUDIO_FORMATS),
|
||||||
|
help="re-encode audio to this preset, or keep it as-is (default: keep)",
|
||||||
|
)
|
||||||
|
bake.add_argument("--shortest", action="store_true", help="stop at the shortest stream")
|
||||||
|
_add_video_options(bake)
|
||||||
|
bake.set_defaults(func=cmd_bake)
|
||||||
|
|
||||||
|
still = subparsers.add_parser("still", help="one image + audio -> video")
|
||||||
|
add_common_arguments(still, suppress_defaults=True)
|
||||||
|
still.add_argument("image", help="cover art image")
|
||||||
|
still.add_argument("audio", help="audio file")
|
||||||
|
still.add_argument("-o", "--output", help="output path")
|
||||||
|
still.add_argument("--fps", type=int, default=60, help="frame rate (default: 60)")
|
||||||
|
still.add_argument("--width", type=int, default=1920, help="target width (default: 1920)")
|
||||||
|
still.add_argument("--height", type=int, default=1080, help="target height (default: 1080)")
|
||||||
|
_add_audio_options(still)
|
||||||
|
_add_video_options(still)
|
||||||
|
still.set_defaults(func=cmd_still)
|
||||||
|
|
||||||
|
strip = subparsers.add_parser("strip", help="extract audio from a video as MP3")
|
||||||
|
add_common_arguments(strip, suppress_defaults=True)
|
||||||
|
strip.add_argument("input_video", help="input video")
|
||||||
|
strip.add_argument("-o", "--output", help="output MP3 path")
|
||||||
|
strip.add_argument("--sample-rate", type=int, default=44100, help="(default: 44100)")
|
||||||
|
strip.add_argument("--channels", type=int, default=2, help="(default: 2)")
|
||||||
|
strip.add_argument("--bitrate", default="320k", help="(default: 320k)")
|
||||||
|
strip.set_defaults(func=cmd_strip)
|
||||||
|
|
||||||
|
probe = subparsers.add_parser("probe", help="print a compact stream summary")
|
||||||
|
add_common_arguments(probe, suppress_defaults=True)
|
||||||
|
probe.add_argument("media", nargs=1, help="media file to inspect")
|
||||||
|
probe.set_defaults(func=cmd_probe)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
@main_wrapper
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
report = Report(quiet=args.quiet)
|
||||||
|
|
||||||
|
if not getattr(args, "func", None):
|
||||||
|
parser.print_help()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
args.ffmpeg = find_ffmpeg(args.ffmpeg)
|
||||||
|
|
||||||
|
# strip and probe never encode video, so they have no --video/--codec/--device
|
||||||
|
# and no acceleration context to work out.
|
||||||
|
if not hasattr(args, "video"):
|
||||||
|
return args.func(args, None, report)
|
||||||
|
|
||||||
|
# --video/--codec/--device live on the subparsers, so the namespace already
|
||||||
|
# carries whatever was parsed. Supply the documented defaults here rather than
|
||||||
|
# duplicating them across every subparser.
|
||||||
|
video = args.video if args.video is not None else "auto"
|
||||||
|
codec = args.codec if args.codec is not None else "h264"
|
||||||
|
device = args.device or os.environ.get("HW_DEVICE")
|
||||||
|
|
||||||
|
context = pick_acceleration(
|
||||||
|
args.ffmpeg,
|
||||||
|
report,
|
||||||
|
video=video,
|
||||||
|
codec=codec,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.note and args.mode != "probe":
|
||||||
|
report.info(f"encoder: {context.encoder} — {context.note}")
|
||||||
|
|
||||||
|
return args.func(args, context, report)
|
||||||
|
|
||||||
|
|
||||||
|
def _supplied_options(argv: list[str]) -> dict[str, str]:
|
||||||
|
"""Map ``--flag`` to its value for flags present on the command line.
|
||||||
|
|
||||||
|
Retained for tests and external callers; ``main`` no longer needs it now that
|
||||||
|
the encoder options are parsed by argparse rather than sniffed out of argv.
|
||||||
|
"""
|
||||||
|
supplied: dict[str, str] = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
token = argv[i]
|
||||||
|
if token.startswith("--") and "=" in token:
|
||||||
|
flag, _, value = token.partition("=")
|
||||||
|
supplied[flag] = value
|
||||||
|
elif token.startswith("--"):
|
||||||
|
if i + 1 < len(argv):
|
||||||
|
supplied[token] = argv[i + 1]
|
||||||
|
i += 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return supplied
|
||||||
|
|
||||||
|
|
||||||
|
def _decide(flag: str, supplied: dict[str, str], namespace_value, fallback):
|
||||||
|
"""Prefer a flag supplied on the command line, then the namespace, then default."""
|
||||||
|
if flag in supplied:
|
||||||
|
return supplied[flag]
|
||||||
|
|
||||||
|
return namespace_value if namespace_value is not None else fallback
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Detect which video encoders this machine can actually use.
|
||||||
|
|
||||||
|
Probes ffmpeg for hardware and software encoders and decides which are usable
|
||||||
|
here, then emits either a human-readable report or a shell ``export`` block.
|
||||||
|
|
||||||
|
The reference machine has no NVIDIA GPU, so ``*_nvenc`` encoders exist in the
|
||||||
|
ffmpeg build but cannot run. Detecting that up front is cheaper than discovering
|
||||||
|
it in the middle of a render.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 scripts/hwprobe.py # human-readable report
|
||||||
|
python3 scripts/hwprobe.py --json # machine-readable
|
||||||
|
python3 scripts/hwprobe.py --shell # eval-able export block
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _common import ( # type: ignore[import-not-found]
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
add_ffmpeg_argument,
|
||||||
|
find_ffmpeg,
|
||||||
|
main_wrapper,
|
||||||
|
report as default_report,
|
||||||
|
run,
|
||||||
|
text_table,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - exercised via `python3 -m scripts.hwprobe`
|
||||||
|
from ._common import (
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
add_ffmpeg_argument,
|
||||||
|
find_ffmpeg,
|
||||||
|
main_wrapper,
|
||||||
|
report as default_report,
|
||||||
|
run,
|
||||||
|
text_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Candidate tables
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: Hardware encoder families we know how to enable, keyed by ffmpeg suffix.
|
||||||
|
HW_FAMILIES = ("nvenc", "vaapi", "qsv", "amf", "v4l2m2m", "vulkan", "videotoolbox")
|
||||||
|
|
||||||
|
#: The encoders worth offering as a "best available" pick, in preference order.
|
||||||
|
#: Hardware first, then software fallbacks that always exist in a full build.
|
||||||
|
ENCODER_PREFERENCE = (
|
||||||
|
"h264_nvenc",
|
||||||
|
"hevc_nvenc",
|
||||||
|
"av1_nvenc",
|
||||||
|
"h264_qsv",
|
||||||
|
"hevc_qsv",
|
||||||
|
"h264_vaapi",
|
||||||
|
"hevc_vaapi",
|
||||||
|
"av1_vaapi",
|
||||||
|
"h264_amf",
|
||||||
|
"hevc_amf",
|
||||||
|
"h264_v4l2m2m",
|
||||||
|
"hevc_v4l2m2m",
|
||||||
|
"hevc_vulkan",
|
||||||
|
"libx264",
|
||||||
|
"libx265",
|
||||||
|
"libsvtav1",
|
||||||
|
"libvpx-vp9",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: vaapi/vulkan/qsv devices we look for, in preference order.
|
||||||
|
DEVICE_CANDIDATES = (
|
||||||
|
"/dev/dri/renderD128",
|
||||||
|
"/dev/dri/renderD129",
|
||||||
|
"/dev/dri/card0",
|
||||||
|
"/dev/dri/card1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Encoder:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def family(self) -> str:
|
||||||
|
for family in HW_FAMILIES:
|
||||||
|
if self.name.endswith(f"_{family}"):
|
||||||
|
return family
|
||||||
|
|
||||||
|
return "software"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_hardware(self) -> bool:
|
||||||
|
return self.family != "software"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProbeResult:
|
||||||
|
ffmpeg: str
|
||||||
|
ffmpeg_version: str
|
||||||
|
encoders: list[Encoder] = field(default_factory=list)
|
||||||
|
hwaccels: list[str] = field(default_factory=list)
|
||||||
|
devices: dict[str, bool] = field(default_factory=dict)
|
||||||
|
tested: dict[str, dict] = field(default_factory=dict)
|
||||||
|
#: Encoder families that are compiled in but have no usable device.
|
||||||
|
blocked: dict[str, str] = field(default_factory=dict)
|
||||||
|
recommended: str | None = None
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def encryptors(self) -> list[Encoder]:
|
||||||
|
return [e for e in self.encoders if e.is_hardware]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_encoders(output: str) -> list[Encoder]:
|
||||||
|
"""Parse ``ffmpeg -encoders`` output into video encoders only."""
|
||||||
|
encoders: list[Encoder] = []
|
||||||
|
|
||||||
|
for line in output.splitlines():
|
||||||
|
match = re.match(r"^\s*([VAS][\w.]{5})\s+(\S+)\s+(.*\S)\s*$", line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
flags, name, description = match.groups()
|
||||||
|
if not flags.startswith("V"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
encoders.append(Encoder(name=name, description=description))
|
||||||
|
|
||||||
|
return encoders
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hwaccels(output: str) -> list[str]:
|
||||||
|
"""Parse ``ffmpeg -hwaccels`` output."""
|
||||||
|
methods: list[str] = []
|
||||||
|
seen_header = False
|
||||||
|
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.lower().startswith("hardware acceleration"):
|
||||||
|
seen_header = True
|
||||||
|
continue
|
||||||
|
if seen_header:
|
||||||
|
methods.append(line)
|
||||||
|
|
||||||
|
return methods
|
||||||
|
|
||||||
|
|
||||||
|
def probe_devices() -> dict[str, bool]:
|
||||||
|
"""Check which DRM devices exist *and* are readable by this process."""
|
||||||
|
found: dict[str, bool] = {}
|
||||||
|
|
||||||
|
for device in DEVICE_CANDIDATES:
|
||||||
|
if not os.path.exists(device):
|
||||||
|
continue
|
||||||
|
|
||||||
|
found[device] = os.access(device, os.R_OK | os.W_OK)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def probe_device_driver(device: str) -> str | None:
|
||||||
|
"""Read the kernel driver name backing a DRM node, if sysfs is readable."""
|
||||||
|
node = os.path.basename(device)
|
||||||
|
uevent = f"/sys/class/drm/{node}/device/uevent"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(uevent, "r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
content = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = re.search(r"^DRIVER=(\S+)", content, re.MULTILINE)
|
||||||
|
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def missing_render_group(devices: dict[str, bool]) -> bool:
|
||||||
|
"""True when a DRM device exists but this process cannot open it."""
|
||||||
|
return any(not ok for ok in devices.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoder(ffmpeg: str, encoder: str, report: Report, device: str | None = None) -> tuple[bool, str]:
|
||||||
|
"""Encode two throwaway frames with *encoder*. Returns (worked, detail)."""
|
||||||
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error"]
|
||||||
|
|
||||||
|
if encoder.endswith("_vaapi"):
|
||||||
|
if not device:
|
||||||
|
return False, "no vaapi device available"
|
||||||
|
cmd += [
|
||||||
|
"-init_hw_device", f"vaapi=va:{device}",
|
||||||
|
"-filter_hw_device", "va",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "color=c=black:s=256x256:r=30:d=1",
|
||||||
|
"-vf", "format=nv12,hwupload",
|
||||||
|
]
|
||||||
|
elif encoder.endswith("_vulkan"):
|
||||||
|
cmd += ["-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1"]
|
||||||
|
else:
|
||||||
|
cmd += ["-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1"]
|
||||||
|
|
||||||
|
cmd += ["-c:v", encoder, "-frames:v", "2", "-f", "null", "-"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "timed out after 60s"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "ffmpeg binary missing"
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
stderr = (result.stderr or "").strip().splitlines()
|
||||||
|
|
||||||
|
return False, stderr[-1] if stderr else f"exit status {result.returncode}"
|
||||||
|
|
||||||
|
|
||||||
|
def probe(ffmpeg: str, report: Report, *, test: bool = True) -> ProbeResult:
|
||||||
|
"""Gather everything we can learn about this machine's encoders."""
|
||||||
|
|
||||||
|
def capture(args: list[str]) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[ffmpeg, "-hide_banner", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise Die(f"could not run {ffmpeg}: {exc}") from exc
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
banner = capture(["-version"])
|
||||||
|
version = banner.splitlines()[0] if banner else "unknown"
|
||||||
|
|
||||||
|
result = ProbeResult(
|
||||||
|
ffmpeg=ffmpeg,
|
||||||
|
ffmpeg_version=version,
|
||||||
|
encoders=parse_encoders(capture(["-encoders"])),
|
||||||
|
hwaccels=parse_hwaccels(capture(["-hwaccels"])),
|
||||||
|
devices=probe_devices(),
|
||||||
|
)
|
||||||
|
|
||||||
|
usable_device = next((d for d, ok in result.devices.items() if ok), None)
|
||||||
|
blocked_families: dict[str, str] = {}
|
||||||
|
|
||||||
|
if usable_device:
|
||||||
|
driver = probe_device_driver(usable_device)
|
||||||
|
if driver:
|
||||||
|
result.notes.append(f"{usable_device} is driven by {driver}")
|
||||||
|
elif result.devices:
|
||||||
|
blocked_families["vaapi"] = "DRM device present but not openable"
|
||||||
|
blocked_families["vulkan"] = "DRM device present but not openable"
|
||||||
|
else:
|
||||||
|
blocked_families["vaapi"] = "no DRM render device found"
|
||||||
|
|
||||||
|
if not test:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for encoder in result.encoders:
|
||||||
|
if not encoder.is_hardware and encoder.name not in ("libx264",):
|
||||||
|
continue
|
||||||
|
if encoder.family in blocked_families:
|
||||||
|
result.tested[encoder.name] = {
|
||||||
|
"ok": False,
|
||||||
|
"detail": blocked_families[encoder.family],
|
||||||
|
"tested": False,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if encoder.family in ("qsv", "amf", "videotoolbox", "v4l2m2m") and not result.devices:
|
||||||
|
result.tested[encoder.name] = {
|
||||||
|
"ok": False,
|
||||||
|
"detail": "no matching device",
|
||||||
|
"tested": False,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok, detail = test_encoder(ffmpeg, encoder.name, report, usable_device)
|
||||||
|
result.tested[encoder.name] = {"ok": ok, "detail": detail, "tested": True}
|
||||||
|
|
||||||
|
for family, reason in blocked_families.items():
|
||||||
|
if any(e.family == family for e in result.encoders):
|
||||||
|
result.blocked[family] = reason
|
||||||
|
|
||||||
|
for name in ENCODER_PREFERENCE:
|
||||||
|
entry = result.tested.get(name)
|
||||||
|
if entry and entry["ok"]:
|
||||||
|
result.recommended = name
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Output
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def render_report(result: ProbeResult) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"ffmpeg {result.ffmpeg_version}")
|
||||||
|
lines.append(f"binary {result.ffmpeg}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("DRM devices")
|
||||||
|
if result.devices:
|
||||||
|
for device in sorted(result.devices):
|
||||||
|
ok = result.devices[device]
|
||||||
|
driver = probe_device_driver(device) or "?"
|
||||||
|
state = "readable" if ok else "NOT READABLE"
|
||||||
|
lines.append(f" {device.ljust(20)} {driver.ljust(8)} {state}")
|
||||||
|
else:
|
||||||
|
lines.append(" none found — hardware encoding is unavailable")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"hwaccels {', '.join(result.hwaccels) or '(none)'}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
hw = result.encryptors()
|
||||||
|
sw = [e for e in result.encoders if not e.is_hardware]
|
||||||
|
|
||||||
|
lines.append(f"Hardware encoders in this build ({len(hw)})")
|
||||||
|
if hw:
|
||||||
|
rows = []
|
||||||
|
for encoder in hw:
|
||||||
|
entry = result.tested.get(encoder.name)
|
||||||
|
if entry is None:
|
||||||
|
verdict = "untested"
|
||||||
|
elif entry["ok"]:
|
||||||
|
verdict = "WORKS"
|
||||||
|
elif not entry.get("tested", True):
|
||||||
|
verdict = "unavailable"
|
||||||
|
else:
|
||||||
|
verdict = "failed"
|
||||||
|
rows.append((encoder.name, encoder.family, verdict))
|
||||||
|
lines.append(text_table(("encoder", "family", "status"), rows))
|
||||||
|
else:
|
||||||
|
lines.append(" none")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Software encoders ({len(sw)})")
|
||||||
|
notable = [e for e in sw if e.name in ("libx264", "libx265", "libsvtav1", "libvpx-vp9", "libxvid")]
|
||||||
|
lines.append(" " + (", ".join(e.name for e in notable) or "none"))
|
||||||
|
|
||||||
|
if result.blocked:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Blocked")
|
||||||
|
for family, reason in sorted(result.blocked.items()):
|
||||||
|
lines.append(f" {family}: {reason}")
|
||||||
|
|
||||||
|
if result.notes:
|
||||||
|
lines.append("")
|
||||||
|
for note in result.notes:
|
||||||
|
lines.append(f"note: {note}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
if result.recommended:
|
||||||
|
lines.append(f"recommended -c:v {result.recommended}")
|
||||||
|
else:
|
||||||
|
lines.append("recommended (nothing usable detected)")
|
||||||
|
|
||||||
|
if missing_render_group(result.devices):
|
||||||
|
lines.append("")
|
||||||
|
lines.append("hint: a DRM device exists but this user cannot open it.")
|
||||||
|
lines.append(" add the user to the 'render' group and re-login:")
|
||||||
|
lines.append(" sudo usermod -aG render,video $USER")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_shell(result: ProbeResult) -> str:
|
||||||
|
"""Export block for ``eval \"$(python3 scripts/hwprobe.py --shell)\"``."""
|
||||||
|
device = next((d for d, ok in result.devices.items() if ok), "")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# generated by scripts/hwprobe.py — eval to apply",
|
||||||
|
f"export FFMPEG={result.ffmpeg}",
|
||||||
|
]
|
||||||
|
|
||||||
|
for encoder in result.encryptors():
|
||||||
|
entry = result.tested.get(encoder.name, {})
|
||||||
|
if entry.get("ok"):
|
||||||
|
lines.append(f"export ENCODER_{encoder.family.upper()}={encoder.name}")
|
||||||
|
|
||||||
|
lines.append(f"export ENCODER={result.recommended or 'libx264'}")
|
||||||
|
|
||||||
|
if device:
|
||||||
|
lines.append(f"export HW_DEVICE={device}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="hwprobe",
|
||||||
|
description="Report which ffmpeg encoders this machine can actually use.",
|
||||||
|
epilog=(
|
||||||
|
"Handy: eval \"$(python3 scripts/hwprobe.py --shell)\" "
|
||||||
|
"to put the detected encoder in $ENCODER."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
add_common_arguments(parser)
|
||||||
|
add_ffmpeg_argument(parser)
|
||||||
|
|
||||||
|
parser.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
||||||
|
parser.add_argument("--shell", action="store_true", help="emit an eval-able export block")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-test",
|
||||||
|
action="store_true",
|
||||||
|
help="only read ffmpeg's capability lists; do not try encoding",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
@main_wrapper
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
report = Report(quiet=args.quiet)
|
||||||
|
|
||||||
|
ffmpeg = find_ffmpeg(args.ffmpeg)
|
||||||
|
result = probe(ffmpeg, report, test=not args.no_test)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
payload = asdict(result)
|
||||||
|
payload["blocked"] = result.blocked
|
||||||
|
payload["recommended"] = result.recommended
|
||||||
|
print(json.dumps(payload, indent=2))
|
||||||
|
elif args.shell:
|
||||||
|
print(render_shell(result))
|
||||||
|
else:
|
||||||
|
print(render_report(result))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"""Convert LRC lyrics to SRT subtitles.
|
||||||
|
|
||||||
|
LRC is the timestamped-lyrics format most lyric sites and players emit. This
|
||||||
|
converter handles the shapes that actually show up in the wild:
|
||||||
|
|
||||||
|
* ``[mm:ss.xx]`` and ``[mm:ss.xxx]`` timestamps, and bare ``[mm:ss]``
|
||||||
|
* several timestamps on one line (``[00:05.00][00:30.00]chorus``), which is how
|
||||||
|
repeated sections are written
|
||||||
|
* ID tags (``[ti:Title]``, ``[ar:Artist]``, ``[offset:+250]``), which are
|
||||||
|
metadata, not lyrics
|
||||||
|
* multiple timestamped lines sharing one timestamp, which SRT wants as one cue
|
||||||
|
with two subtitle lines
|
||||||
|
|
||||||
|
Cue end times are inferred from the next cue's start, so the timing is driven by
|
||||||
|
the LRC rather than invented. The final cue gets ``--last-duration`` seconds.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 scripts/lrc2srt.py song.lrc # -> song.srt
|
||||||
|
python3 scripts/lrc2srt.py song.lrc -o subs/song.srt
|
||||||
|
python3 scripts/lrc2srt.py lyrics/*.lrc --out-dir build/
|
||||||
|
python3 scripts/lrc2srt.py song.lrc --stdout # write to stdout
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _common import ( # type: ignore[import-not-found]
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
main_wrapper,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover
|
||||||
|
from ._common import (
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
main_wrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: ``[mm:ss.xx]`` / ``[mm:ss.xxx]`` / ``[mm:ss]``, anywhere in a line.
|
||||||
|
TIMESTAMP_RE = re.compile(r"\[(?P<m>\d{1,3}):(?P<s>\d{1,2})(?:[.:](?P<frac>\d{1,3}))?\]")
|
||||||
|
|
||||||
|
#: ``[ti:...]`` / ``[ar:...]`` / ``[offset:...]`` — an identifier, not a time.
|
||||||
|
TAG_RE = re.compile(r"^\[(?P<key>[A-Za-z#]+):(?P<value>.*)\]\s*$")
|
||||||
|
|
||||||
|
#: Keys whose value is a signed millisecond count, not a timestamp.
|
||||||
|
META_KEYS = {
|
||||||
|
"ti", "al", "ar", "au", "by", "re", "ve", "length", "offset", "encoding", "tool", "id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LyricLine:
|
||||||
|
timestamp: float
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LrcDocument:
|
||||||
|
lines: list[LyricLine] = field(default_factory=list)
|
||||||
|
metadata: dict[str, str] = field(default_factory=dict)
|
||||||
|
offset_ms: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str | None:
|
||||||
|
return self.metadata.get("ti")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def artist(self) -> str | None:
|
||||||
|
return self.metadata.get("ar")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(minutes: str, seconds: str, fraction: str | None) -> float:
|
||||||
|
"""Turn LRC timestamp parts into seconds.
|
||||||
|
|
||||||
|
The fractional part is centiseconds in the common case (``.30`` = 300 ms) but
|
||||||
|
milliseconds when three digits are given (``.300`` = 300 ms). The length of
|
||||||
|
the field disambiguates.
|
||||||
|
"""
|
||||||
|
total = int(minutes) * 60 + int(seconds)
|
||||||
|
|
||||||
|
if fraction:
|
||||||
|
if len(fraction) == 3:
|
||||||
|
total += int(fraction) / 1000
|
||||||
|
else:
|
||||||
|
total += int(fraction) / 100
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lrc(content: str) -> LrcDocument:
|
||||||
|
"""Parse LRC text into a document, sorted by timestamp."""
|
||||||
|
if content.startswith("\ufeff"):
|
||||||
|
content = content[1:]
|
||||||
|
|
||||||
|
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
document = LrcDocument()
|
||||||
|
|
||||||
|
for raw in content.split("\n"):
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Metadata tags first: [ti:...] looks a lot like [00:12] to a loose regex.
|
||||||
|
tag = TAG_RE.match(line)
|
||||||
|
if tag and tag.group("key").lower() in META_KEYS:
|
||||||
|
key = tag.group("key").lower()
|
||||||
|
value = tag.group("value").strip()
|
||||||
|
if key == "offset":
|
||||||
|
try:
|
||||||
|
document.offset_ms = int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
document.metadata[key] = value
|
||||||
|
continue
|
||||||
|
|
||||||
|
stamps = list(TIMESTAMP_RE.finditer(line))
|
||||||
|
if not stamps:
|
||||||
|
# An untimed line. Attach it to the previous cue as a continuation
|
||||||
|
# line rather than discarding the lyric entirely.
|
||||||
|
if document.lines:
|
||||||
|
previous = document.lines[-1]
|
||||||
|
previous.text = f"{previous.text}\n{line}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = TIMESTAMP_RE.sub("", line).strip()
|
||||||
|
|
||||||
|
for stamp in stamps:
|
||||||
|
document.lines.append(
|
||||||
|
LyricLine(
|
||||||
|
timestamp=parse_timestamp(
|
||||||
|
stamp.group("m"), stamp.group("s"), stamp.group("frac")
|
||||||
|
),
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if document.offset_ms:
|
||||||
|
shift = document.offset_ms / 1000
|
||||||
|
for line in document.lines:
|
||||||
|
line.timestamp = max(0.0, line.timestamp + shift)
|
||||||
|
|
||||||
|
document.lines.sort(key=lambda l: l.timestamp)
|
||||||
|
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# SRT output
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cue:
|
||||||
|
index: int
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
lines: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def format_srt_time(seconds: float) -> str:
|
||||||
|
"""Seconds to SRT ``HH:MM:SS,mmm``."""
|
||||||
|
if seconds < 0:
|
||||||
|
seconds = 0.0
|
||||||
|
|
||||||
|
total_ms = int(round(seconds * 1000))
|
||||||
|
hours, remainder = divmod(total_ms, 3_600_000)
|
||||||
|
minutes, remainder = divmod(remainder, 60_000)
|
||||||
|
secs, millis = divmod(remainder, 1000)
|
||||||
|
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_cues(
|
||||||
|
lines: list[LyricLine],
|
||||||
|
*,
|
||||||
|
last_duration: float = 5.0,
|
||||||
|
min_duration: float = 0.5,
|
||||||
|
) -> list[Cue]:
|
||||||
|
"""Group simultaneous lines into cues and infer each cue's end time.
|
||||||
|
|
||||||
|
Lines sharing one timestamp become a single cue with several subtitle lines —
|
||||||
|
the format singers actually expect for a two-line phrase. A cue ends when the
|
||||||
|
*next distinct* timestamp arrives.
|
||||||
|
"""
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Collapse lines with identical timestamps into one cue.
|
||||||
|
groups: list[tuple[float, list[str]]] = []
|
||||||
|
for line in lines:
|
||||||
|
if groups and abs(groups[-1][0] - line.timestamp) < 1e-6:
|
||||||
|
groups[-1][1].append(line.text)
|
||||||
|
else:
|
||||||
|
groups.append((line.timestamp, [line.text]))
|
||||||
|
|
||||||
|
cues: list[Cue] = []
|
||||||
|
for i, (start, text_lines) in enumerate(groups):
|
||||||
|
if i + 1 < len(groups):
|
||||||
|
end = groups[i + 1][0]
|
||||||
|
if end - start < min_duration:
|
||||||
|
end = start + min_duration
|
||||||
|
else:
|
||||||
|
end = start + last_duration
|
||||||
|
|
||||||
|
cues.append(Cue(index=i + 1, start=start, end=end, lines=text_lines))
|
||||||
|
|
||||||
|
return cues
|
||||||
|
|
||||||
|
|
||||||
|
def render_srt(cues: list[Cue]) -> str:
|
||||||
|
blocks = []
|
||||||
|
for cue in cues:
|
||||||
|
body = "\n".join(cue.lines)
|
||||||
|
blocks.append(
|
||||||
|
f"{cue.index}\n{format_srt_time(cue.start)} --> {format_srt_time(cue.end)}\n{body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n\n".join(blocks) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="lrc2srt",
|
||||||
|
description="Convert LRC lyrics to SRT subtitles.",
|
||||||
|
epilog="Multiple timestamps on one line are expanded; ID tags are ignored.",
|
||||||
|
)
|
||||||
|
add_common_arguments(parser)
|
||||||
|
|
||||||
|
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="LRC file(s) to convert")
|
||||||
|
parser.add_argument("-o", "--output", metavar="FILE", help="output path (single input only)")
|
||||||
|
parser.add_argument("--out-dir", metavar="DIR", help="write every output into this directory")
|
||||||
|
parser.add_argument("--stdout", action="store_true", help="write the SRT to stdout, no file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--last-duration",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="how long to hold the final line (default: 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-duration",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="shortest cue allowed (default: 0.5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--title",
|
||||||
|
help="override the SRT's implicit title (used for reporting only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def convert(src: str, args: argparse.Namespace, report: Report) -> tuple[int, str | None]:
|
||||||
|
try:
|
||||||
|
with open(src, "r", encoding="utf-8-sig", errors="replace") as handle:
|
||||||
|
content = handle.read()
|
||||||
|
except OSError as exc:
|
||||||
|
raise Die(f"cannot read {src}: {exc.strerror}") from exc
|
||||||
|
|
||||||
|
document = parse_lrc(content)
|
||||||
|
|
||||||
|
if not document.lines:
|
||||||
|
raise Die(f"{src}: no timestamped lyrics found — is this an LRC file?")
|
||||||
|
|
||||||
|
cues = build_cues(
|
||||||
|
document.lines,
|
||||||
|
last_duration=args.last_duration,
|
||||||
|
min_duration=args.min_duration,
|
||||||
|
)
|
||||||
|
output = render_srt(cues)
|
||||||
|
|
||||||
|
if args.stdout:
|
||||||
|
sys.stdout.write(output)
|
||||||
|
return len(cues), None
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
dest = args.output
|
||||||
|
elif args.out_dir:
|
||||||
|
dest = os.path.join(args.out_dir, os.path.splitext(os.path.basename(src))[0] + ".srt")
|
||||||
|
else:
|
||||||
|
dest = os.path.splitext(src)[0] + ".srt"
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
report.info(f"[dry-run] would write {dest} ({len(cues)} cues)")
|
||||||
|
return len(cues), dest
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
|
||||||
|
with open(dest, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(output)
|
||||||
|
|
||||||
|
label = document.title or os.path.basename(src)
|
||||||
|
report.ok(f"{label} -> {dest} ({len(cues)} cues)")
|
||||||
|
|
||||||
|
if document.artist:
|
||||||
|
report.verbose(f"artist: {document.artist}")
|
||||||
|
if document.offset_ms:
|
||||||
|
report.verbose(f"applied LRC offset of {document.offset_ms:+d} ms")
|
||||||
|
|
||||||
|
return len(cues), dest
|
||||||
|
|
||||||
|
|
||||||
|
@main_wrapper
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
report = Report(quiet=args.quiet)
|
||||||
|
|
||||||
|
if args.output and len(args.inputs) > 1:
|
||||||
|
raise Die("--output takes a single input; use --out-dir for batches")
|
||||||
|
if args.stdout and len(args.inputs) > 1:
|
||||||
|
raise Die("--stdout takes a single input")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
converted = 0
|
||||||
|
for src in args.inputs:
|
||||||
|
if not os.path.isfile(src):
|
||||||
|
raise Die(f"no such file: {src}")
|
||||||
|
|
||||||
|
cues, _dest = convert(src, args, report)
|
||||||
|
total += cues
|
||||||
|
converted += 1
|
||||||
|
|
||||||
|
if converted > 1:
|
||||||
|
report.info(f"{total} cues across {converted} file(s)")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Convert an SRT subtitle file to ASS (Advanced SubStation Alpha).
|
||||||
|
|
||||||
|
Stdlib only. Handles the constructs that actually appear in song subtitles:
|
||||||
|
multi-line cues, ``{\\an8}``-style override blocks passed through untouched,
|
||||||
|
and the SRT quirks (CRLF, BOM, cue numbers, blank-line separators).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 scripts/srt2ass.py song.srt song.ass
|
||||||
|
python3 scripts/srt2ass.py lyrics/*.srt --out-dir build/ # batch
|
||||||
|
python3 scripts/srt2ass.py song.srt --style-font "Noto Sans" --style-size 64
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _common import ( # type: ignore[import-not-found]
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
glob_files,
|
||||||
|
main_wrapper,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover
|
||||||
|
from ._common import (
|
||||||
|
Die,
|
||||||
|
Report,
|
||||||
|
add_common_arguments,
|
||||||
|
glob_files,
|
||||||
|
main_wrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Timestamps
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_SRT_TIME_RE = re.compile(
|
||||||
|
r"(?P<h>\d{1,2}):(?P<m>\d{2}):(?P<s>\d{2})[,.](?P<ms>\d{1,3})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt_time(stamp: str) -> float:
|
||||||
|
"""``00:01:02,500`` (or ``00:01:02.5``) to seconds."""
|
||||||
|
match = _SRT_TIME_RE.search(stamp.strip())
|
||||||
|
if not match:
|
||||||
|
raise Die(f"unparseable SRT timestamp: {stamp!r}")
|
||||||
|
|
||||||
|
ms = match.group("ms").ljust(3, "0")[:3]
|
||||||
|
|
||||||
|
return (
|
||||||
|
int(match.group("h")) * 3600
|
||||||
|
+ int(match.group("m")) * 60
|
||||||
|
+ int(match.group("s"))
|
||||||
|
+ int(ms) / 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_ass_time(seconds: float) -> str:
|
||||||
|
"""Seconds to ASS ``H:MM:SS.cc`` (centiseconds, single-digit hour)."""
|
||||||
|
if seconds < 0:
|
||||||
|
seconds = 0.0
|
||||||
|
|
||||||
|
total_cs = int(round(seconds * 100))
|
||||||
|
hours, remainder = divmod(total_cs, 360_000)
|
||||||
|
minutes, remainder = divmod(remainder, 6_000)
|
||||||
|
secs, centis = divmod(remainder, 100)
|
||||||
|
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cue:
|
||||||
|
index: int
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
lines: list[str]
|
||||||
|
|
||||||
|
def text(self, line_sep: str = r"\N") -> str:
|
||||||
|
return line_sep.join(self.lines)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_bom(text: str) -> str:
|
||||||
|
return text[1:] if text.startswith("\ufeff") else text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt(content: str) -> list[Cue]:
|
||||||
|
"""Parse SRT text into cues. Tolerates CRLF, BOM, and missing cue numbers."""
|
||||||
|
content = strip_bom(content).replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
blocks = re.split(r"\n\s*\n", content)
|
||||||
|
|
||||||
|
cues: list[Cue] = []
|
||||||
|
auto_index = 0
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
lines = block.strip("\n").split("\n")
|
||||||
|
if not lines or not any(line.strip() for line in lines):
|
||||||
|
continue
|
||||||
|
|
||||||
|
index: int | None = None
|
||||||
|
if lines[0].strip().isdigit():
|
||||||
|
index = int(lines[0].strip())
|
||||||
|
lines = lines[1:]
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "-->" not in lines[0]:
|
||||||
|
# Not a cue (stray header, WEBVTT marker, ...). Skip it quietly.
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_raw, _, end_raw = lines[0].partition("-->")
|
||||||
|
start = parse_srt_time(start_raw)
|
||||||
|
end = parse_srt_time(end_raw)
|
||||||
|
|
||||||
|
body = [line for line in lines[1:] if line.strip()]
|
||||||
|
if not body:
|
||||||
|
# A cue with a timestamp but no text: pure gap in the lyrics. Emitting
|
||||||
|
# it would produce an empty subtitle block, so drop it — the previous
|
||||||
|
# cue simply holds until the next one.
|
||||||
|
continue
|
||||||
|
|
||||||
|
auto_index += 1
|
||||||
|
cues.append(Cue(index=index or auto_index, start=start, end=end, lines=body))
|
||||||
|
|
||||||
|
return cues
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# ASS output
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
#: ASS uses US-ASCII-with-code-page-1252 in practice; karaoke/Japanese needs 1.
|
||||||
|
ASS_CODEPAGES = {
|
||||||
|
"ascii": 0,
|
||||||
|
"latin1": 0,
|
||||||
|
"cp1252": 0,
|
||||||
|
"shift_jis": 1,
|
||||||
|
"sjis": 1,
|
||||||
|
"gbk": 134,
|
||||||
|
"utf-8": 1,
|
||||||
|
"utf8": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ASS_HEADER = """[Script Info]
|
||||||
|
; Generated by scripts/srt2ass.py — edit the source SRT, not this file.
|
||||||
|
Title: {title}
|
||||||
|
ScriptType: v4.00+
|
||||||
|
WrapStyle: {wrap_style}
|
||||||
|
ScaledBorderAndShadow: yes
|
||||||
|
YCbCr Matrix: TV.709
|
||||||
|
PlayResX: {width}
|
||||||
|
PlayResY: {height}
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Default,{font},{size},{primary},{secondary},{outline_colour},{back},{bold},{italic},0,0,100,100,{spacing},0,1,{border},{shadow},{alignment},{margin_l},{margin_r},{margin_v},{encoding}
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssStyle:
|
||||||
|
"""The subset of the Default style we expose on the CLI."""
|
||||||
|
|
||||||
|
font: str = "Arial"
|
||||||
|
size: int = 48
|
||||||
|
primary: str = "&H00FFFFFF" # white, fully opaque
|
||||||
|
secondary: str = "&H000000FF"
|
||||||
|
outline_colour: str = "&H00000000"
|
||||||
|
back: str = "&H00000000"
|
||||||
|
bold: int = -1
|
||||||
|
italic: int = 0
|
||||||
|
spacing: float = 0.0
|
||||||
|
border: float = 2.0
|
||||||
|
shadow: float = 1.0
|
||||||
|
alignment: int = 2 # 2 = bottom centre
|
||||||
|
margin_l: int = 40
|
||||||
|
margin_r: int = 40
|
||||||
|
margin_v: int = 60
|
||||||
|
encoding: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
def render_ass(
|
||||||
|
cues: list[Cue],
|
||||||
|
*,
|
||||||
|
style: AssStyle,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
title: str,
|
||||||
|
wrap_style: int = 0,
|
||||||
|
) -> str:
|
||||||
|
header = ASS_HEADER.format(
|
||||||
|
title=title.replace("\n", " ") or "Untitled",
|
||||||
|
wrap_style=wrap_style,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
font=style.font,
|
||||||
|
size=style.size,
|
||||||
|
primary=style.primary,
|
||||||
|
secondary=style.secondary,
|
||||||
|
outline_colour=style.outline_colour,
|
||||||
|
back=style.back,
|
||||||
|
bold=style.bold,
|
||||||
|
italic=style.italic,
|
||||||
|
spacing=style.spacing,
|
||||||
|
border=style.border,
|
||||||
|
shadow=style.shadow,
|
||||||
|
alignment=style.alignment,
|
||||||
|
margin_l=style.margin_l,
|
||||||
|
margin_r=style.margin_r,
|
||||||
|
margin_v=style.margin_v,
|
||||||
|
encoding=style.encoding,
|
||||||
|
)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for cue in cues:
|
||||||
|
text = cue.text()
|
||||||
|
# ASS treats commas as field separators in the Text column, but only the
|
||||||
|
# first 9 commas matter — ffmpeg/libass handles commas inside Text fine as
|
||||||
|
# long as the Format line lists Text last, which it does above.
|
||||||
|
events.append(
|
||||||
|
"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}".format(
|
||||||
|
start=format_ass_time(cue.start),
|
||||||
|
end=format_ass_time(cue.end),
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return header + "\n".join(events) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="srt2ass",
|
||||||
|
description="Convert SRT subtitles to ASS, ready for ffmpeg's ass= filter.",
|
||||||
|
epilog=(
|
||||||
|
"Then bake with: ffmpeg -vf \"ass=out.ass\" -i in.mp4 ... "
|
||||||
|
"(or scripts/ffmpeg_tools.py bake)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
add_common_arguments(parser)
|
||||||
|
|
||||||
|
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="SRT file(s) to convert")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
metavar="FILE",
|
||||||
|
help="output .ass path (single input only; default: alongside the input)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out-dir",
|
||||||
|
metavar="DIR",
|
||||||
|
help="write every output into this directory (created if missing)",
|
||||||
|
)
|
||||||
|
|
||||||
|
style = parser.add_argument_group("style")
|
||||||
|
style.add_argument("--style-font", default="Arial", help="font name (default: Arial)")
|
||||||
|
style.add_argument("--style-size", type=int, default=48, help="font size (default: 48)")
|
||||||
|
style.add_argument(
|
||||||
|
"--style-alignment",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
choices=range(1, 10),
|
||||||
|
metavar="N",
|
||||||
|
help="ASS numpad alignment 1-9 (default: 2, bottom centre)",
|
||||||
|
)
|
||||||
|
style.add_argument("--style-margin-v", type=int, default=60, help="vertical margin (default: 60)")
|
||||||
|
style.add_argument("--style-outline", type=float, default=2.0, help="outline width (default: 2)")
|
||||||
|
style.add_argument("--style-shadow", type=float, default=1.0, help="shadow depth (default: 1)")
|
||||||
|
style.add_argument("--style-bold", action=argparse.BooleanOptionalAction, default=True)
|
||||||
|
style.add_argument("--style-italic", action=argparse.BooleanOptionalAction, default=False)
|
||||||
|
|
||||||
|
canvas = parser.add_argument_group("canvas")
|
||||||
|
canvas.add_argument("--width", type=int, default=1920, help="PlayResX (default: 1920)")
|
||||||
|
canvas.add_argument("--height", type=int, default=1080, help="PlayResY (default: 1080)")
|
||||||
|
canvas.add_argument(
|
||||||
|
"--title",
|
||||||
|
default=None,
|
||||||
|
help="ASS Title field (default: the output filename)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def convert_one(src: str, dest: str, args: argparse.Namespace, report: Report) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
with open(src, "r", encoding="utf-8-sig", errors="replace") as handle:
|
||||||
|
content = handle.read()
|
||||||
|
except OSError as exc:
|
||||||
|
raise Die(f"cannot read {src}: {exc.strerror}") from exc
|
||||||
|
|
||||||
|
cues = parse_srt(content)
|
||||||
|
if not cues:
|
||||||
|
report.warn(f"{src}: no cues parsed — is this really an SRT file?")
|
||||||
|
|
||||||
|
style = AssStyle(
|
||||||
|
font=args.style_font,
|
||||||
|
size=args.style_size,
|
||||||
|
alignment=args.style_alignment,
|
||||||
|
margin_v=args.style_margin_v,
|
||||||
|
border=args.style_outline,
|
||||||
|
shadow=args.style_shadow,
|
||||||
|
bold=-1 if args.style_bold else 0,
|
||||||
|
italic=-1 if args.style_italic else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
title = args.title or os.path.splitext(os.path.basename(dest))[0]
|
||||||
|
document = render_ass(
|
||||||
|
cues,
|
||||||
|
style=style,
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
report.info(f"[dry-run] would write {dest} ({len(cues)} cues)")
|
||||||
|
return len(cues), 0
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
|
||||||
|
with open(dest, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(document)
|
||||||
|
|
||||||
|
report.ok(f"{src} -> {dest} ({len(cues)} cues)")
|
||||||
|
|
||||||
|
return len(cues), 0
|
||||||
|
|
||||||
|
|
||||||
|
@main_wrapper
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
report = Report(quiet=args.quiet)
|
||||||
|
|
||||||
|
if args.output and len(args.inputs) > 1:
|
||||||
|
raise Die("--output takes a single input; use --out-dir for batches")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for src in args.inputs:
|
||||||
|
if not os.path.isfile(src):
|
||||||
|
raise Die(f"no such file: {src}")
|
||||||
|
|
||||||
|
stem = os.path.splitext(os.path.basename(src))[0]
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
dest = args.output
|
||||||
|
elif args.out_dir:
|
||||||
|
dest = os.path.join(args.out_dir, stem + ".ass")
|
||||||
|
else:
|
||||||
|
dest = os.path.splitext(src)[0] + ".ass"
|
||||||
|
|
||||||
|
cues, _ = convert_one(src, dest, args, report)
|
||||||
|
total += cues
|
||||||
|
|
||||||
|
if len(args.inputs) > 1:
|
||||||
|
report.info(f"{total} cues across {len(args.inputs)} file(s)")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unified entry point for the NCMR songs-videos helper tools.
|
||||||
|
|
||||||
|
Dispatches to a sibling script, forwards all remaining arguments, and keeps the
|
||||||
|
exit status. Use whichever feels natural — this dispatcher, or the script
|
||||||
|
directly.
|
||||||
|
|
||||||
|
python3 scripts/tools.py --help
|
||||||
|
python3 scripts/tools.py hwprobe
|
||||||
|
python3 scripts/tools.py tools lrc2srt song.lrc -o song.srt
|
||||||
|
|
||||||
|
The same thing, called directly:
|
||||||
|
|
||||||
|
python3 scripts/hwprobe.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
#: name -> (module filename, one-line summary)
|
||||||
|
TOOLS: dict[str, tuple[str, str]] = {
|
||||||
|
"hwprobe": ("hwprobe.py", "which ffmpeg encoders this machine can actually use"),
|
||||||
|
"ffmpeg": ("ffmpeg_tools.py", "mux, bake subtitles, build still-image videos, strip audio"),
|
||||||
|
"lrc2srt": ("lrc2srt.py", "convert LRC lyrics to SRT"),
|
||||||
|
"srt2ass": ("srt2ass.py", "convert SRT subtitles to ASS"),
|
||||||
|
"bom": ("bom_gen.py", "generate the untracked-asset bill of materials"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ALIASES = {
|
||||||
|
"ffmpeg_tools": "ffmpeg",
|
||||||
|
"ffmpeg-tools": "ffmpeg",
|
||||||
|
"bom_gen": "bom",
|
||||||
|
"lrc": "lrc2srt",
|
||||||
|
"srt": "srt2ass",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def usage(stream=sys.stdout) -> None:
|
||||||
|
print("usage: tools.py <tool> [options] [-- <tool options>]", file=stream)
|
||||||
|
print(" tools.py --help | --list", file=stream)
|
||||||
|
print(file=stream)
|
||||||
|
print("tools:", file=stream)
|
||||||
|
width = max(len(name) for name in TOOLS)
|
||||||
|
for name, (_filename, summary) in TOOLS.items():
|
||||||
|
print(f" {name.ljust(width)} {summary}", file=stream)
|
||||||
|
print(file=stream)
|
||||||
|
print("Every tool also runs standalone: python3 scripts/<name>.py --help", file=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
|
||||||
|
if not argv or argv[0] in ("-h", "--help", "help", "list", "--list"):
|
||||||
|
usage(stream=sys.stderr if not argv else sys.stdout)
|
||||||
|
return 0 if argv else 2
|
||||||
|
|
||||||
|
name = ALIASES.get(argv[0], argv[0])
|
||||||
|
if name not in TOOLS:
|
||||||
|
print(f"tools.py: unknown tool {argv[0]!r}", file=sys.stderr)
|
||||||
|
print(file=sys.stderr)
|
||||||
|
usage(stream=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
filename, _summary = TOOLS[name]
|
||||||
|
path = os.path.join(HERE, filename)
|
||||||
|
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print(f"tools.py: {filename} is missing from {HERE}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
remaining = argv[1:]
|
||||||
|
if remaining and remaining[0] == "--":
|
||||||
|
remaining = remaining[1:]
|
||||||
|
|
||||||
|
sys.argv = [path, *remaining]
|
||||||
|
|
||||||
|
# run_path gives the child the same process, so its exit code is ours.
|
||||||
|
try:
|
||||||
|
runpy.run_path(path, run_name="__main__")
|
||||||
|
except SystemExit as exc:
|
||||||
|
code = exc.code
|
||||||
|
if code is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(code, int):
|
||||||
|
return code
|
||||||
|
print(code, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
[Script Info]
|
||||||
|
; Script generated by Aegisub 3.2.2
|
||||||
|
; http://www.aegisub.org/
|
||||||
|
Title: Default Aegisub file
|
||||||
|
ScriptType: v4.00+
|
||||||
|
WrapStyle: 0
|
||||||
|
ScaledBorderAndShadow: yes
|
||||||
|
YCbCr Matrix: PC.601
|
||||||
|
PlayResX: 1920
|
||||||
|
PlayResY: 1080
|
||||||
|
|
||||||
|
[Aegisub Project Garbage]
|
||||||
|
Audio File: 274_transect.mp4
|
||||||
|
Video File: 274_transect.mp4
|
||||||
|
Video AR Mode: 4
|
||||||
|
Video AR Value: 1.777778
|
||||||
|
Video Zoom Percent: 0.500000
|
||||||
|
Scroll Position: 74
|
||||||
|
Active Line: 82
|
||||||
|
Video Position: 8587
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||||
|
Style: rightoffset,Source Han Serif SC SemiBold,40,&H006C574D,&H000000FF,&H006E6E6E,&H00B4CBD7,-1,0,0,0,100,100,0,0,1,0,0,3,10,60,60,1
|
||||||
|
Style: main heavy,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,2,10,10,125,1
|
||||||
|
Style: happy1551_base,Inziu Iosevka Slab SC,75,&H0000D2FF,&H000000FF,&H000000C8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_zx,Inziu Iosevka Slab SC,75,&H000000FF,&H000000FF,&H0000006E,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_60,Inziu Iosevka Slab SC,75,&H00E0DDCE,&H000000FF,&H00353537,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_houye,Inziu Iosevka Slab SC,75,&H00DA7400,&H000000FF,&H00DFD1A8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_s20,Inziu Iosevka Slab SC,75,&H00B38766,&H000000FF,&H00F1F1F1,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: leftoffset,Source Han Serif SC Medium,65,&H00FFFFFF,&H000000FF,&H00343434,&H00000000,0,0,0,0,100,100,0,0,1,2,0,1,40,10,20,1
|
||||||
|
Style: main heavy - left,源ノ明朝 SemiBold,90,&H003F6CCD,&H000000FF,&H00221716,&H85AAB2B7,0,0,0,0,100,100,0,0,1,3,0,1,10,250,100,1
|
||||||
|
Style: credits,Source Han Serif SC Medium,35,&H00C2B3A9,&H000000FF,&H000D0C0E,&H00000000,0,0,0,0,100,100,0,0,1,2,0,9,0,45,30,1
|
||||||
|
Style: transparent_timesig,Noto Serif JP Medium,60,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,1,150,0,200,1
|
||||||
|
Style: 274-place,M PLUS 1 Code SemiBold,70,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,2,0,1,4,0,1,128,0,180,1
|
||||||
|
Style: 274-credits,M PLUS 1 Code,50,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,1,0,1,2,0,3,0,150,100,1
|
||||||
|
Style: 274-addr,M PLUS 1 Code Medium,50,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,4,0,1,2,0,1,128,0,262,1
|
||||||
|
Style: main heavy right,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,3,10,125,125,1
|
||||||
|
Style: credits_sponsor,源ノ角ゴシック Heavy,188,&H00FFFFFF,&H000000FF,&H000D0C0E,&H00000000,-1,0,0,0,100,100,0,0,1,4.5,4.5,5,0,0,0,1
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:14.54,0:00:19.67,274-addr,,0,0,0,,夕張郡由仁町川端
|
||||||
|
Dialogue: 0,0:00:17.89,0:00:19.67,274-place,,0,0,0,,川端橋
|
||||||
|
Dialogue: 0,0:00:19.67,0:00:25.04,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:25.04,0:00:42.01,274-addr,,0,0,0,,夕張郡栗山町滝下
|
||||||
|
Dialogue: 0,0:00:28.41,0:00:32.06,274-place,,0,0,0,,川端発電所
|
||||||
|
Dialogue: 0,0:00:32.06,0:00:35.44,274-place,,0,0,0,,滝下八幡神社
|
||||||
|
Dialogue: 0,0:00:35.44,0:00:39.84,274-place,,0,0,0,,滝ノ下信号場
|
||||||
|
Dialogue: 0,0:00:39.84,0:00:42.01,274-place,,0,0,0,,雨霧橋
|
||||||
|
Dialogue: 0,0:00:42.01,0:00:42.45,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:42.45,0:00:46.69,274-addr,,0,0,0,,夕張郡由仁町川端
|
||||||
|
Dialogue: 0,0:00:45.15,0:00:46.69,274-place,,0,0,0,,竜仙橋
|
||||||
|
Dialogue: 0,0:00:46.69,0:00:47.63,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:47.63,0:00:54.73,274-addr,,0,0,0,,夕張郡栗山町滝下
|
||||||
|
Dialogue: 0,0:00:50.08,0:00:52.88,274-place,,0,0,0,,草木橋
|
||||||
|
Dialogue: 0,0:00:52.88,0:00:54.73,274-place,,0,0,0,,滝の上こ線橋
|
||||||
|
Dialogue: 0,0:00:54.73,0:00:56.44,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:56.44,0:01:08.78,274-addr,,0,0,0,,夕張市滝ノ上
|
||||||
|
Dialogue: 0,0:00:59.95,0:01:03.47,274-place,,0,0,0,,滝の上発電所
|
||||||
|
Dialogue: 0,0:01:03.47,0:01:06.94,274-place,,0,0,0,,滝ノ上信号場
|
||||||
|
Dialogue: 0,0:01:06.94,0:01:08.78,274-place,,0,0,0,,滝ノ上神社
|
||||||
|
Dialogue: 0,0:01:08.78,0:01:10.50,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:01:10.50,0:01:45.03,274-addr,,0,0,0,,夕張市紅葉山
|
||||||
|
Dialogue: 0,0:01:12.05,0:01:13.86,274-place,,0,0,0,,十三里信号場
|
||||||
|
Dialogue: 0,0:01:13.86,0:01:16.44,274-place,,0,0,0,,十三里橋
|
||||||
|
Dialogue: 0,0:01:16.44,0:01:18.78,274-place,,0,0,0,,紅葉山駐車帯
|
||||||
|
Dialogue: 0,0:01:18.78,0:01:21.06,274-place,,0,0,0,,瑞穂橋
|
||||||
|
Dialogue: 0,0:01:21.06,0:01:24.44,274-place,,0,0,0,,セブンイレブン夕張紅葉山店
|
||||||
|
Dialogue: 0,0:01:24.44,0:01:27.06,274-place,,0,0,0,,新夕張駅
|
||||||
|
Dialogue: 0,0:01:27.06,0:01:30.02,274-place,,0,0,0,,紅葉山郵便局
|
||||||
|
Dialogue: 0,0:01:30.02,0:01:32.30,274-place,,0,0,0,,紅葉山神社
|
||||||
|
Dialogue: 0,0:01:32.30,0:01:35.24,274-place,,0,0,0,,セイコーマートもみじやま店
|
||||||
|
Dialogue: 0,0:01:35.24,0:01:38.48,274-place,,0,0,0,,紅楓橋
|
||||||
|
Dialogue: 0,0:01:38.48,0:01:40.25,274-place,,0,0,0,,ポン久留喜橋
|
||||||
|
Dialogue: 0,0:01:40.25,0:01:42.44,274-place,,0,0,0,,久留喜橋
|
||||||
|
Dialogue: 0,0:01:42.44,0:01:43.79,274-place,,0,0,0,,楓橋
|
||||||
|
Dialogue: 0,0:01:43.79,0:01:45.03,274-place,,0,0,0,,桂橋
|
||||||
|
Dialogue: 0,0:01:45.03,0:01:45.77,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:01:45.77,0:02:00.74,274-addr,,0,0,0,,夕張市楓
|
||||||
|
Dialogue: 0,0:01:49.12,0:01:50.76,274-place,,0,0,0,,千紫橋
|
||||||
|
Dialogue: 0,0:01:50.76,0:01:52.51,274-place,,0,0,0,,楓駐車帯
|
||||||
|
Dialogue: 0,0:01:52.51,0:01:53.80,274-place,,0,0,0,,万紅橋
|
||||||
|
Dialogue: 0,0:01:53.80,0:01:55.54,274-place,,0,0,0,,登川神社
|
||||||
|
Dialogue: 0,0:01:55.54,0:01:57.32,274-place,,0,0,0,,楓信号場
|
||||||
|
Dialogue: 0,0:01:57.32,0:01:59.00,274-place,,0,0,0,,登川橋
|
||||||
|
Dialogue: 0,0:01:59.00,0:02:00.74,274-place,,0,0,0,,登川郵便局
|
||||||
|
Dialogue: 0,0:02:00.74,0:02:01.26,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:02:01.26,0:02:06.62,274-addr,,0,0,0,,夕張市登川
|
||||||
|
Dialogue: 0,0:02:02.93,0:02:05.23,274-place,,0,0,0,,花園橋
|
||||||
|
Dialogue: 0,0:02:05.23,0:02:06.62,274-place,,0,0,0,,登川トンネル
|
||||||
|
Dialogue: 0,0:02:06.62,0:03:30.80,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:03:30.80,0:03:39.41,274-addr,,0,0,0,,勇払郡むかわ町穂別長和
|
||||||
|
Dialogue: 0,0:03:34.55,0:03:36.77,274-place,,0,0,0,,オマナイ橋
|
||||||
|
Dialogue: 0,0:03:36.77,0:03:39.41,274-place,,0,0,0,,桂木橋
|
||||||
|
Dialogue: 0,0:03:39.41,0:03:41.13,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:03:41.13,0:04:05.59,274-addr,,0,0,0,,勇払郡むかわ町穂別稲里
|
||||||
|
Dialogue: 0,0:03:44.62,0:03:46.67,274-place,,0,0,0,,大和橋
|
||||||
|
Dialogue: 0,0:03:46.67,0:03:48.38,274-place,,0,0,0,,豊進橋
|
||||||
|
Dialogue: 0,0:03:48.38,0:03:51.61,274-place,,0,0,0,,豊進神社
|
||||||
|
Dialogue: 0,0:03:51.61,0:03:53.14,274-place,,0,0,0,,豊里橋
|
||||||
|
Dialogue: 0,0:03:53.14,0:03:55.52,274-place,,0,0,0,,稲豊橋
|
||||||
|
Dialogue: 0,0:03:55.52,0:03:57.78,274-place,,0,0,0,,稲里橋
|
||||||
|
Dialogue: 0,0:03:57.78,0:04:01.08,274-place,,0,0,0,,稲里簡易郵便局
|
||||||
|
Dialogue: 0,0:04:01.08,0:04:03.50,274-place,,0,0,0,,佐主橋
|
||||||
|
Dialogue: 0,0:04:03.50,0:04:05.59,274-place,,0,0,0,,穂別ダム神社
|
||||||
|
Dialogue: 0,0:04:05.59,0:04:05.96,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:04:05.96,0:04:25.27,274-addr,,0,0,0,,勇払郡むかわ町穂別長和
|
||||||
|
Dialogue: 0,0:04:10.08,0:04:13.02,274-place,,0,0,0,,長和トンネル
|
||||||
|
Dialogue: 0,0:04:13.02,0:04:15.39,274-place,,0,0,0,,穂別大橋
|
||||||
|
Dialogue: 0,0:04:15.39,0:04:18.81,274-place,,0,0,0,,山水橋
|
||||||
|
Dialogue: 0,0:04:18.81,0:04:23.20,274-place,,0,0,0,,沼の沢橋
|
||||||
|
Dialogue: 0,0:04:23.20,0:04:25.27,274-place,,0,0,0,,稲里トンネル
|
||||||
|
Dialogue: 0,0:04:25.27,0:04:26.00,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:04:26.00,0:04:55.52,274-addr,,0,0,0,,勇払郡むかわ町穂別福山
|
||||||
|
Dialogue: 0,0:04:29.74,0:04:31.50,274-place,,0,0,0,,ペンケ橋
|
||||||
|
Dialogue: 0,0:04:31.50,0:04:33.62,274-place,,0,0,0,,下福山橋
|
||||||
|
Dialogue: 0,0:04:33.62,0:04:35.02,274-place,,0,0,0,,オロロップ橋
|
||||||
|
Dialogue: 0,0:04:35.02,0:04:37.07,274-place,,0,0,0,,福山橋
|
||||||
|
Dialogue: 0,0:04:37.07,0:04:38.70,274-place,,0,0,0,,福山神社
|
||||||
|
Dialogue: 0,0:04:38.70,0:04:41.02,274-place,,0,0,0,,福山大橋
|
||||||
|
Dialogue: 0,0:04:41.02,0:04:43.29,274-place,,0,0,0,,千坂橋
|
||||||
|
Dialogue: 0,0:04:43.29,0:04:45.12,274-place,,0,0,0,,オコタン橋
|
||||||
|
Dialogue: 0,0:04:45.12,0:04:46.22,274-place,,0,0,0,,モトツトンネル
|
||||||
|
Dialogue: 0,0:04:46.22,0:04:48.98,274-place,,0,0,0,,モトツ橋 / モトツシェルター
|
||||||
|
Dialogue: 0,0:04:48.98,0:04:51.39,274-place,,0,0,0,,福山トンネル
|
||||||
|
Dialogue: 0,0:04:51.39,0:04:53.61,274-place,,0,0,0,,福穂橋
|
||||||
|
Dialogue: 0,0:04:53.61,0:04:55.52,274-place,,0,0,0,,穂高トンネル
|
||||||
|
Dialogue: 0,0:04:55.52,0:05:22.60,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:05:22.60,0:05:50.73,274-addr,,0,0,0,,勇払郡占冠村中央
|
||||||
|
Dialogue: 0,0:05:25.76,0:05:28.88,274-place,,0,0,0,,穂高橋
|
||||||
|
Dialogue: 0,0:05:28.88,0:05:31.52,274-place,,0,0,0,,高翠橋
|
||||||
|
Dialogue: 0,0:05:31.52,0:05:34.95,274-place,,0,0,0,,占翠橋
|
||||||
|
Dialogue: 0,0:05:34.95,0:05:38.08,274-place,,0,0,0,,名石駐車場
|
||||||
|
Dialogue: 0,0:05:38.08,0:05:40.99,274-place,,0,0,0,,名石橋
|
||||||
|
Dialogue: 0,0:05:40.99,0:05:43.96,274-place,,0,0,0,,赤岩大橋
|
||||||
|
Dialogue: 0,0:05:43.96,0:05:48.62,274-place,,0,0,0,,渓流大橋
|
||||||
|
Dialogue: 0,0:05:48.62,0:05:50.73,274-place,,0,0,0,,日高トンネル
|
||||||
|
Dialogue: 0,0:05:50.73,0:07:11.39,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:11.39,0:07:16.38,274-addr,,0,0,0,,沙流郡日高町日高
|
||||||
|
Dialogue: 0,0:07:14.14,0:07:16.38,274-place,,0,0,0,,日夕橋
|
||||||
|
Dialogue: 0,0:07:16.38,0:07:28.85,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:28.85,0:07:34.09,274-addr,,0,0,0,,沙流郡日高町宮下町
|
||||||
|
Dialogue: 0,0:07:31.68,0:07:34.09,274-place,,0,0,0,,日高神社
|
||||||
|
Dialogue: 0,0:07:34.09,0:07:42.90,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:42.90,0:07:55.11,274-addr,,0,0,0,,沙流郡日高町本町東
|
||||||
|
Dialogue: 0,0:07:45.51,0:07:49.88,274-place,,0,0,0,,新川橋
|
||||||
|
Dialogue: 0,0:07:49.88,0:07:52.76,274-place,,0,0,0,,セイコーマート日高道の駅店
|
||||||
|
Dialogue: 0,0:07:52.76,0:07:55.11,274-place,,0,0,0,,日高郵便局
|
||||||
|
Dialogue: 0,0:07:55.11,0:07:56.69,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:56.69,0:08:02.15,274-addr,,0,0,0,,沙流郡日高町千栄
|
||||||
|
Dialogue: 0,0:07:59.36,0:08:02.15,274-place,,0,0,0,,北電日高発電所
|
||||||
|
Dialogue: 0,0:08:02.15,0:08:03.90,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:08:03.90,0:08:09.44,274-addr,,0,0,0,,沙流郡日高町日高
|
||||||
|
Dialogue: 0,0:08:06.38,0:08:09.44,274-place,,0,0,0,,右左府発電所
|
||||||
|
Dialogue: 0,0:08:09.44,0:08:10.93,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:08:10.93,0:08:12.66,274-credits,,0,0,0,,{\b1}東に続く{\b0}
|
||||||
|
Dialogue: 0,0:08:12.66,0:08:17.66,274-place,,0,0,0,,
|
||||||
|
Dialogue: 0,0:08:24.92,0:08:31.98,274-credits,,0,0,0,,{\b1}'274'{\b0}\Na song about (one stretch of) national route 274
|
||||||
|
Dialogue: 0,0:08:31.98,0:08:38.97,274-credits,,0,0,0,,music by Tomaš Orika / data aggregation by Mikkeli Matlock\Nvisualiser by Joroz Tosci / powered by Claude Opus 5
|
||||||
|
Dialogue: 0,0:08:38.97,0:08:46.00,274-credits,,0,0,0,,voiced by Yuzuki Yukari Shizuku (A.I.VOICE 2)
|
||||||
|
Dialogue: 0,0:08:46.00,0:08:52.98,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\Nroute01.com\Nhgf03030.a.la9.jp
|
||||||
|
Dialogue: 0,0:08:52.98,0:09:00.00,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\N全国道路施設点検データベース\N国土交通省北海道開発局 道路施設の老朽化対策\N国土数値情報 河川データセット
|
||||||
|
Dialogue: 0,0:09:00.00,0:09:06.98,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\N日本郵便 郵便番号データ\N国土地理院 逆ジオコーディング API
|
||||||
|
Dialogue: 0,0:09:06.98,0:09:14.00,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\NOpenStreetMap contributors\NGoogle Maps\NWikipedia
|
||||||
|
Dialogue: 0,0:09:14.00,0:09:20.96,274-credits,,0,0,0,,2026 northwich committee of magic researches
|
||||||
|
Dialogue: 0,0:09:20.96,0:09:22.98,274-credits,,0,0,0,,see you in Yuubari
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
[Script Info]
|
||||||
|
; Script generated by Aegisub 3.2.2
|
||||||
|
; http://www.aegisub.org/
|
||||||
|
Title: Default Aegisub file
|
||||||
|
ScriptType: v4.00+
|
||||||
|
WrapStyle: 0
|
||||||
|
ScaledBorderAndShadow: yes
|
||||||
|
YCbCr Matrix: TV.601
|
||||||
|
PlayResX: 1920
|
||||||
|
PlayResY: 1080
|
||||||
|
|
||||||
|
[Aegisub Project Garbage]
|
||||||
|
Audio File: 274_white.mp4
|
||||||
|
Video File: 274_white.mp4
|
||||||
|
Video AR Mode: 4
|
||||||
|
Video AR Value: 1.777778
|
||||||
|
Video Zoom Percent: 0.500000
|
||||||
|
Scroll Position: 54
|
||||||
|
Active Line: 66
|
||||||
|
Video Position: 7588
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||||
|
Style: rightoffset,Source Han Serif SC SemiBold,40,&H006C574D,&H000000FF,&H006E6E6E,&H00B4CBD7,-1,0,0,0,100,100,0,0,1,0,0,3,10,60,60,1
|
||||||
|
Style: main heavy,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,2,10,10,125,1
|
||||||
|
Style: happy1551_base,Inziu Iosevka Slab SC,75,&H0000D2FF,&H000000FF,&H000000C8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_zx,Inziu Iosevka Slab SC,75,&H000000FF,&H000000FF,&H0000006E,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_60,Inziu Iosevka Slab SC,75,&H00E0DDCE,&H000000FF,&H00353537,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_houye,Inziu Iosevka Slab SC,75,&H00DA7400,&H000000FF,&H00DFD1A8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: happy1551_s20,Inziu Iosevka Slab SC,75,&H00B38766,&H000000FF,&H00F1F1F1,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
|
||||||
|
Style: leftoffset,Source Han Serif SC Medium,65,&H00FFFFFF,&H000000FF,&H00343434,&H00000000,0,0,0,0,100,100,0,0,1,2,0,1,40,10,20,1
|
||||||
|
Style: main heavy - left,源ノ明朝 SemiBold,90,&H003F6CCD,&H000000FF,&H00221716,&H85AAB2B7,0,0,0,0,100,100,0,0,1,3,0,1,10,250,100,1
|
||||||
|
Style: credits,Source Han Serif SC Medium,35,&H00C2B3A9,&H000000FF,&H000D0C0E,&H00000000,0,0,0,0,100,100,0,0,1,2,0,9,0,45,30,1
|
||||||
|
Style: transparent_timesig,Noto Serif JP Medium,60,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,1,150,0,200,1
|
||||||
|
Style: transparent_lyrics_en,Noto Serif JP Medium,70,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,0,0,1,192,0,320,1
|
||||||
|
Style: transparent_lyrics_jp,Noto Serif JP Medium,50,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,3,0,150,100,1
|
||||||
|
Style: transparent_title,Noto Serif JP SemiBold,50,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,0,0,1,192,0,395,1
|
||||||
|
Style: main heavy right,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,3,10,125,125,1
|
||||||
|
Style: credits_sponsor,源ノ角ゴシック Heavy,188,&H00FFFFFF,&H000000FF,&H000D0C0E,&H00000000,-1,0,0,0,100,100,0,0,1,4.5,4.5,5,0,0,0,1
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:14.54,0:00:19.67,transparent_title,,0,0,0,,夕張郡由仁町川端
|
||||||
|
Dialogue: 0,0:00:17.89,0:00:19.67,transparent_lyrics_en,,0,0,0,,川端橋
|
||||||
|
Dialogue: 0,0:00:19.67,0:00:25.04,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:25.04,0:00:42.01,transparent_title,,0,0,0,,夕張郡栗山町滝下
|
||||||
|
Dialogue: 0,0:00:28.41,0:00:32.06,transparent_lyrics_en,,0,0,0,,川端発電所
|
||||||
|
Dialogue: 0,0:00:32.06,0:00:35.44,transparent_lyrics_en,,0,0,0,,滝下八幡神社
|
||||||
|
Dialogue: 0,0:00:35.44,0:00:39.84,transparent_lyrics_en,,0,0,0,,滝ノ下信号場
|
||||||
|
Dialogue: 0,0:00:39.84,0:00:42.01,transparent_lyrics_en,,0,0,0,,雨霧橋
|
||||||
|
Dialogue: 0,0:00:42.01,0:00:42.45,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:42.45,0:00:46.69,transparent_title,,0,0,0,,夕張郡由仁町川端
|
||||||
|
Dialogue: 0,0:00:45.15,0:00:46.69,transparent_lyrics_en,,0,0,0,,竜仙橋
|
||||||
|
Dialogue: 0,0:00:46.69,0:00:47.63,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:47.63,0:00:54.73,transparent_title,,0,0,0,,夕張郡栗山町滝下
|
||||||
|
Dialogue: 0,0:00:50.08,0:00:52.88,transparent_lyrics_en,,0,0,0,,草木橋
|
||||||
|
Dialogue: 0,0:00:52.88,0:00:54.73,transparent_lyrics_en,,0,0,0,,滝の上こ線橋
|
||||||
|
Dialogue: 0,0:00:54.73,0:00:56.44,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:00:56.44,0:01:08.78,transparent_title,,0,0,0,,夕張市滝ノ上
|
||||||
|
Dialogue: 0,0:00:59.95,0:01:03.47,transparent_lyrics_en,,0,0,0,,滝の上発電所
|
||||||
|
Dialogue: 0,0:01:03.47,0:01:06.94,transparent_lyrics_en,,0,0,0,,滝ノ上信号場
|
||||||
|
Dialogue: 0,0:01:06.94,0:01:08.78,transparent_lyrics_en,,0,0,0,,滝ノ上神社
|
||||||
|
Dialogue: 0,0:01:08.78,0:01:10.50,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:01:10.50,0:01:45.03,transparent_title,,0,0,0,,夕張市紅葉山
|
||||||
|
Dialogue: 0,0:01:12.05,0:01:13.86,transparent_lyrics_en,,0,0,0,,十三里信号場
|
||||||
|
Dialogue: 0,0:01:13.86,0:01:16.44,transparent_lyrics_en,,0,0,0,,十三里橋
|
||||||
|
Dialogue: 0,0:01:16.44,0:01:18.78,transparent_lyrics_en,,0,0,0,,紅葉山駐車帯
|
||||||
|
Dialogue: 0,0:01:18.78,0:01:21.06,transparent_lyrics_en,,0,0,0,,瑞穂橋
|
||||||
|
Dialogue: 0,0:01:21.06,0:01:24.44,transparent_lyrics_en,,0,0,0,,セブンイレブン夕張紅葉山店
|
||||||
|
Dialogue: 0,0:01:24.44,0:01:27.06,transparent_lyrics_en,,0,0,0,,新夕張駅
|
||||||
|
Dialogue: 0,0:01:27.06,0:01:30.02,transparent_lyrics_en,,0,0,0,,紅葉山郵便局
|
||||||
|
Dialogue: 0,0:01:30.02,0:01:32.30,transparent_lyrics_en,,0,0,0,,紅葉山神社
|
||||||
|
Dialogue: 0,0:01:32.30,0:01:35.24,transparent_lyrics_en,,0,0,0,,セイコーマートもみじやま店
|
||||||
|
Dialogue: 0,0:01:35.24,0:01:38.48,transparent_lyrics_en,,0,0,0,,紅楓橋
|
||||||
|
Dialogue: 0,0:01:38.48,0:01:40.25,transparent_lyrics_en,,0,0,0,,ポン久留喜橋
|
||||||
|
Dialogue: 0,0:01:40.25,0:01:42.44,transparent_lyrics_en,,0,0,0,,久留喜橋
|
||||||
|
Dialogue: 0,0:01:42.44,0:01:43.79,transparent_lyrics_en,,0,0,0,,楓橋
|
||||||
|
Dialogue: 0,0:01:43.79,0:01:45.03,transparent_lyrics_en,,0,0,0,,桂橋
|
||||||
|
Dialogue: 0,0:01:45.03,0:01:45.77,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:01:45.77,0:02:00.74,transparent_title,,0,0,0,,夕張市楓
|
||||||
|
Dialogue: 0,0:01:49.12,0:01:50.76,transparent_lyrics_en,,0,0,0,,千紫橋
|
||||||
|
Dialogue: 0,0:01:50.76,0:01:52.51,transparent_lyrics_en,,0,0,0,,楓駐車帯
|
||||||
|
Dialogue: 0,0:01:52.51,0:01:53.80,transparent_lyrics_en,,0,0,0,,万紅橋
|
||||||
|
Dialogue: 0,0:01:53.80,0:01:55.54,transparent_lyrics_en,,0,0,0,,登川神社
|
||||||
|
Dialogue: 0,0:01:55.54,0:01:57.32,transparent_lyrics_en,,0,0,0,,楓信号場
|
||||||
|
Dialogue: 0,0:01:57.32,0:01:59.00,transparent_lyrics_en,,0,0,0,,登川橋
|
||||||
|
Dialogue: 0,0:01:59.00,0:02:00.74,transparent_lyrics_en,,0,0,0,,登川郵便局
|
||||||
|
Dialogue: 0,0:02:00.74,0:02:01.26,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:02:01.26,0:02:06.62,transparent_title,,0,0,0,,夕張市登川
|
||||||
|
Dialogue: 0,0:02:02.93,0:02:05.23,transparent_lyrics_en,,0,0,0,,花園橋
|
||||||
|
Dialogue: 0,0:02:05.23,0:02:06.62,transparent_lyrics_en,,0,0,0,,登川トンネル
|
||||||
|
Dialogue: 0,0:02:06.62,0:03:30.80,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:03:30.80,0:03:39.41,transparent_title,,0,0,0,,勇払郡むかわ町穂別長和
|
||||||
|
Dialogue: 0,0:03:34.55,0:03:36.77,transparent_lyrics_en,,0,0,0,,オマナイ橋
|
||||||
|
Dialogue: 0,0:03:36.77,0:03:39.41,transparent_lyrics_en,,0,0,0,,桂木橋
|
||||||
|
Dialogue: 0,0:03:39.41,0:03:41.13,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:03:41.13,0:04:05.59,transparent_title,,0,0,0,,勇払郡むかわ町穂別稲里
|
||||||
|
Dialogue: 0,0:03:44.62,0:03:46.67,transparent_lyrics_en,,0,0,0,,大和橋
|
||||||
|
Dialogue: 0,0:03:46.67,0:03:48.38,transparent_lyrics_en,,0,0,0,,豊進橋
|
||||||
|
Dialogue: 0,0:03:48.38,0:03:51.61,transparent_lyrics_en,,0,0,0,,豊進神社
|
||||||
|
Dialogue: 0,0:03:51.61,0:03:53.14,transparent_lyrics_en,,0,0,0,,豊里橋
|
||||||
|
Dialogue: 0,0:03:53.14,0:03:55.52,transparent_lyrics_en,,0,0,0,,稲豊橋
|
||||||
|
Dialogue: 0,0:03:55.52,0:03:57.78,transparent_lyrics_en,,0,0,0,,稲里橋
|
||||||
|
Dialogue: 0,0:03:57.78,0:04:01.08,transparent_lyrics_en,,0,0,0,,稲里簡易郵便局
|
||||||
|
Dialogue: 0,0:04:01.08,0:04:03.50,transparent_lyrics_en,,0,0,0,,佐主橋
|
||||||
|
Dialogue: 0,0:04:03.50,0:04:05.59,transparent_lyrics_en,,0,0,0,,穂別ダム神社
|
||||||
|
Dialogue: 0,0:04:05.59,0:04:05.96,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:04:05.96,0:04:25.27,transparent_title,,0,0,0,,勇払郡むかわ町穂別長和
|
||||||
|
Dialogue: 0,0:04:10.08,0:04:13.02,transparent_lyrics_en,,0,0,0,,長和トンネル
|
||||||
|
Dialogue: 0,0:04:13.02,0:04:15.39,transparent_lyrics_en,,0,0,0,,穂別大橋
|
||||||
|
Dialogue: 0,0:04:15.39,0:04:18.81,transparent_lyrics_en,,0,0,0,,山水橋
|
||||||
|
Dialogue: 0,0:04:18.81,0:04:23.20,transparent_lyrics_en,,0,0,0,,沼の沢橋
|
||||||
|
Dialogue: 0,0:04:23.20,0:04:25.27,transparent_lyrics_en,,0,0,0,,稲里トンネル
|
||||||
|
Dialogue: 0,0:04:25.27,0:04:26.00,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:04:26.00,0:04:55.52,transparent_title,,0,0,0,,勇払郡むかわ町穂別福山
|
||||||
|
Dialogue: 0,0:04:29.74,0:04:31.50,transparent_lyrics_en,,0,0,0,,ペンケ橋
|
||||||
|
Dialogue: 0,0:04:31.50,0:04:33.62,transparent_lyrics_en,,0,0,0,,下福山橋
|
||||||
|
Dialogue: 0,0:04:33.62,0:04:35.02,transparent_lyrics_en,,0,0,0,,オロロップ橋
|
||||||
|
Dialogue: 0,0:04:35.02,0:04:37.07,transparent_lyrics_en,,0,0,0,,福山橋
|
||||||
|
Dialogue: 0,0:04:37.07,0:04:38.70,transparent_lyrics_en,,0,0,0,,福山神社
|
||||||
|
Dialogue: 0,0:04:38.70,0:04:41.02,transparent_lyrics_en,,0,0,0,,福山大橋
|
||||||
|
Dialogue: 0,0:04:41.02,0:04:43.29,transparent_lyrics_en,,0,0,0,,千坂橋
|
||||||
|
Dialogue: 0,0:04:43.29,0:04:45.12,transparent_lyrics_en,,0,0,0,,オコタン橋
|
||||||
|
Dialogue: 0,0:04:45.12,0:04:46.22,transparent_lyrics_en,,0,0,0,,モトツトンネル
|
||||||
|
Dialogue: 0,0:04:46.22,0:04:48.98,transparent_lyrics_en,,0,0,0,,モトツ橋にモトツシェルター
|
||||||
|
Dialogue: 0,0:04:48.98,0:04:51.39,transparent_lyrics_en,,0,0,0,,福山トンネル
|
||||||
|
Dialogue: 0,0:04:51.39,0:04:53.61,transparent_lyrics_en,,0,0,0,,福穂橋
|
||||||
|
Dialogue: 0,0:04:53.61,0:04:55.52,transparent_lyrics_en,,0,0,0,,穂高トンネル
|
||||||
|
Dialogue: 0,0:04:55.52,0:05:22.60,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:05:22.60,0:05:50.73,transparent_title,,0,0,0,,勇払郡占冠村中央
|
||||||
|
Dialogue: 0,0:05:25.76,0:05:28.88,transparent_lyrics_en,,0,0,0,,穂高橋
|
||||||
|
Dialogue: 0,0:05:28.88,0:05:31.52,transparent_lyrics_en,,0,0,0,,高翠橋
|
||||||
|
Dialogue: 0,0:05:31.52,0:05:34.95,transparent_lyrics_en,,0,0,0,,占翠橋
|
||||||
|
Dialogue: 0,0:05:34.95,0:05:38.08,transparent_lyrics_en,,0,0,0,,名石駐車場
|
||||||
|
Dialogue: 0,0:05:38.08,0:05:40.99,transparent_lyrics_en,,0,0,0,,名石橋
|
||||||
|
Dialogue: 0,0:05:40.99,0:05:43.96,transparent_lyrics_en,,0,0,0,,赤岩大橋
|
||||||
|
Dialogue: 0,0:05:43.96,0:05:48.62,transparent_lyrics_en,,0,0,0,,渓流大橋
|
||||||
|
Dialogue: 0,0:05:48.62,0:05:50.73,transparent_lyrics_en,,0,0,0,,日高トンネル
|
||||||
|
Dialogue: 0,0:05:50.73,0:07:11.39,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:11.39,0:07:16.38,transparent_title,,0,0,0,,沙流郡日高町日高
|
||||||
|
Dialogue: 0,0:07:14.14,0:07:16.38,transparent_lyrics_en,,0,0,0,,日夕橋
|
||||||
|
Dialogue: 0,0:07:16.38,0:07:28.85,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:28.85,0:07:34.09,transparent_title,,0,0,0,,沙流郡日高町宮下町
|
||||||
|
Dialogue: 0,0:07:31.68,0:07:34.09,transparent_lyrics_en,,0,0,0,,日高神社
|
||||||
|
Dialogue: 0,0:07:34.09,0:07:42.90,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:42.90,0:07:55.11,transparent_title,,0,0,0,,沙流郡日高町本町東
|
||||||
|
Dialogue: 0,0:07:45.51,0:07:49.88,transparent_lyrics_en,,0,0,0,,新川橋
|
||||||
|
Dialogue: 0,0:07:49.88,0:07:52.76,transparent_lyrics_en,,0,0,0,,セイコーマート日高道の駅店
|
||||||
|
Dialogue: 0,0:07:52.76,0:07:55.11,transparent_lyrics_en,,0,0,0,,日高郵便局
|
||||||
|
Dialogue: 0,0:07:55.11,0:07:56.69,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:07:56.69,0:08:02.15,transparent_title,,0,0,0,,沙流郡日高町千栄
|
||||||
|
Dialogue: 0,0:07:59.36,0:08:02.15,transparent_lyrics_en,,0,0,0,,北電日高発電所
|
||||||
|
Dialogue: 0,0:08:02.15,0:08:03.90,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:08:03.90,0:08:09.44,transparent_title,,0,0,0,,沙流郡日高町日高
|
||||||
|
Dialogue: 0,0:08:06.38,0:08:09.44,transparent_lyrics_en,,0,0,0,,右左府発電所
|
||||||
|
Dialogue: 0,0:08:09.44,0:08:10.93,transparent_lyrics_en,,0,0,0,,
|
||||||
|
Dialogue: 0,0:08:10.93,0:08:12.66,transparent_lyrics_jp,,0,0,0,,東に続く
|
||||||
|
Dialogue: 0,0:08:12.66,0:08:17.66,transparent_lyrics_en,,0,0,0,,
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
[ti:274]
|
||||||
|
[ar:Northwich committee of magic researches]
|
||||||
|
[al:Shirakaba Express Service]
|
||||||
|
|
||||||
|
|
||||||
|
[00:14.54]夕張郡由仁町川端
|
||||||
|
[00:17.89]川端橋
|
||||||
|
[00:19.67]
|
||||||
|
[00:25.04]夕張郡栗山町滝下
|
||||||
|
[00:28.41]川端発電所
|
||||||
|
[00:32.06]滝下八幡神社
|
||||||
|
[00:35.44]滝ノ下信号場
|
||||||
|
[00:39.84]雨霧橋
|
||||||
|
[00:42.01]
|
||||||
|
[00:42.45]夕張郡由仁町川端
|
||||||
|
[00:45.15]竜仙橋
|
||||||
|
[00:46.69]
|
||||||
|
[00:47.63]夕張郡栗山町滝下
|
||||||
|
[00:50.08]草木橋
|
||||||
|
[00:52.88]滝の上こ線橋
|
||||||
|
[00:54.73]
|
||||||
|
[00:56.44]夕張市滝ノ上
|
||||||
|
[00:59.95]滝の上発電所
|
||||||
|
[01:03.47]滝ノ上信号場
|
||||||
|
[01:06.94]滝ノ上神社
|
||||||
|
[01:08.78]
|
||||||
|
[01:10.50]夕張市紅葉山
|
||||||
|
[01:12.05]十三里信号場
|
||||||
|
[01:13.86]十三里橋
|
||||||
|
[01:16.44]紅葉山駐車帯
|
||||||
|
[01:18.78]瑞穂橋
|
||||||
|
[01:21.06]セブンイレブン夕張紅葉山店
|
||||||
|
[01:24.44]新夕張駅
|
||||||
|
[01:27.06]紅葉山郵便局
|
||||||
|
[01:30.02]紅葉山神社
|
||||||
|
[01:32.30]セイコーマートもみじやま店
|
||||||
|
[01:35.24]紅楓橋
|
||||||
|
[01:38.48]ポン久留喜橋
|
||||||
|
[01:40.25]久留喜橋
|
||||||
|
[01:42.44]楓橋
|
||||||
|
[01:43.79]桂橋
|
||||||
|
[01:45.03]
|
||||||
|
[01:45.77]夕張市楓
|
||||||
|
[01:49.12]千紫橋
|
||||||
|
[01:50.76]楓駐車帯
|
||||||
|
[01:52.51]万紅橋
|
||||||
|
[01:53.80]登川神社
|
||||||
|
[01:55.54]楓信号場
|
||||||
|
[01:57.32]登川橋
|
||||||
|
[01:59.00]登川郵便局
|
||||||
|
[02:00.74]
|
||||||
|
[02:01.26]夕張市登川
|
||||||
|
[02:02.93]花園橋
|
||||||
|
[02:05.23]登川トンネル
|
||||||
|
[02:06.62]
|
||||||
|
[03:30.80]勇払郡むかわ町穂別長和
|
||||||
|
[03:34.55]オマナイ橋
|
||||||
|
[03:36.77]桂木橋
|
||||||
|
[03:39.41]
|
||||||
|
[03:41.13]勇払郡むかわ町穂別稲里
|
||||||
|
[03:44.62]大和橋
|
||||||
|
[03:46.67]豊進橋
|
||||||
|
[03:48.38]豊進神社
|
||||||
|
[03:51.61]豊里橋
|
||||||
|
[03:53.14]稲豊橋
|
||||||
|
[03:55.52]稲里橋
|
||||||
|
[03:57.78]稲里簡易郵便局
|
||||||
|
[04:01.08]佐主橋
|
||||||
|
[04:03.50]穂別ダム神社
|
||||||
|
[04:05.59]
|
||||||
|
[04:05.96]勇払郡むかわ町穂別長和
|
||||||
|
[04:10.08]長和トンネル
|
||||||
|
[04:13.02]穂別大橋
|
||||||
|
[04:15.39]山水橋
|
||||||
|
[04:18.81]沼の沢橋
|
||||||
|
[04:23.20]稲里トンネル
|
||||||
|
[04:25.27]
|
||||||
|
[04:26.00]勇払郡むかわ町穂別福山
|
||||||
|
[04:29.74]ペンケ橋
|
||||||
|
[04:31.50]下福山橋
|
||||||
|
[04:33.62]オロロップ橋
|
||||||
|
[04:35.02]福山橋
|
||||||
|
[04:37.07]福山神社
|
||||||
|
[04:38.70]福山大橋
|
||||||
|
[04:41.02]千坂橋
|
||||||
|
[04:43.29]オコタン橋
|
||||||
|
[04:45.12]モトツトンネル
|
||||||
|
[04:46.22]モトツ橋にモトツシェルター
|
||||||
|
[04:48.98]福山トンネル
|
||||||
|
[04:51.39]福穂橋
|
||||||
|
[04:53.61]穂高トンネル
|
||||||
|
[04:55.52]
|
||||||
|
[05:22.60]勇払郡占冠村中央
|
||||||
|
[05:25.76]穂高橋
|
||||||
|
[05:28.88]高翠橋
|
||||||
|
[05:31.52]占翠橋
|
||||||
|
[05:34.95]名石駐車場
|
||||||
|
[05:38.08]名石橋
|
||||||
|
[05:40.99]赤岩大橋
|
||||||
|
[05:43.96]渓流大橋
|
||||||
|
[05:48.62]日高トンネル
|
||||||
|
[05:50.73]
|
||||||
|
[07:11.39]沙流郡日高町日高
|
||||||
|
[07:14.14]日夕橋
|
||||||
|
[07:16.38]
|
||||||
|
[07:28.85]沙流郡日高町宮下町
|
||||||
|
[07:31.68]日高神社
|
||||||
|
[07:34.09]
|
||||||
|
[07:42.90]沙流郡日高町本町東
|
||||||
|
[07:45.51]新川橋
|
||||||
|
[07:49.88]セイコーマート日高道の駅店
|
||||||
|
[07:52.76]日高郵便局
|
||||||
|
[07:55.11]
|
||||||
|
[07:56.69]沙流郡日高町千栄
|
||||||
|
[07:59.36]北電日高発電所
|
||||||
|
[08:02.15]
|
||||||
|
[08:03.90]沙流郡日高町日高
|
||||||
|
[08:06.38]右左府発電所
|
||||||
|
[08:09.44]
|
||||||
|
[08:10.93]東に続く
|
||||||
|
[08:12.66]
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
1
|
||||||
|
00:00:14,540 --> 00:00:17,890
|
||||||
|
夕張郡由仁町川端
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:17,890 --> 00:00:19,670
|
||||||
|
川端橋
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:19,670 --> 00:00:25,040
|
||||||
|
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:25,040 --> 00:00:28,410
|
||||||
|
夕張郡栗山町滝下
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:28,410 --> 00:00:32,060
|
||||||
|
川端発電所
|
||||||
|
|
||||||
|
6
|
||||||
|
00:00:32,060 --> 00:00:35,440
|
||||||
|
滝下八幡神社
|
||||||
|
|
||||||
|
7
|
||||||
|
00:00:35,440 --> 00:00:39,840
|
||||||
|
滝ノ下信号場
|
||||||
|
|
||||||
|
8
|
||||||
|
00:00:39,840 --> 00:00:42,010
|
||||||
|
雨霧橋
|
||||||
|
|
||||||
|
9
|
||||||
|
00:00:42,010 --> 00:00:42,510
|
||||||
|
|
||||||
|
|
||||||
|
10
|
||||||
|
00:00:42,450 --> 00:00:45,150
|
||||||
|
夕張郡由仁町川端
|
||||||
|
|
||||||
|
11
|
||||||
|
00:00:45,150 --> 00:00:46,690
|
||||||
|
竜仙橋
|
||||||
|
|
||||||
|
12
|
||||||
|
00:00:46,690 --> 00:00:47,630
|
||||||
|
|
||||||
|
|
||||||
|
13
|
||||||
|
00:00:47,630 --> 00:00:50,080
|
||||||
|
夕張郡栗山町滝下
|
||||||
|
|
||||||
|
14
|
||||||
|
00:00:50,080 --> 00:00:52,880
|
||||||
|
草木橋
|
||||||
|
|
||||||
|
15
|
||||||
|
00:00:52,880 --> 00:00:54,730
|
||||||
|
滝の上こ線橋
|
||||||
|
|
||||||
|
16
|
||||||
|
00:00:54,730 --> 00:00:56,440
|
||||||
|
|
||||||
|
|
||||||
|
17
|
||||||
|
00:00:56,440 --> 00:00:59,950
|
||||||
|
夕張市滝ノ上
|
||||||
|
|
||||||
|
18
|
||||||
|
00:00:59,950 --> 00:01:03,470
|
||||||
|
滝の上発電所
|
||||||
|
|
||||||
|
19
|
||||||
|
00:01:03,470 --> 00:01:06,940
|
||||||
|
滝ノ上信号場
|
||||||
|
|
||||||
|
20
|
||||||
|
00:01:06,940 --> 00:01:08,780
|
||||||
|
滝ノ上神社
|
||||||
|
|
||||||
|
21
|
||||||
|
00:01:08,780 --> 00:01:10,500
|
||||||
|
|
||||||
|
|
||||||
|
22
|
||||||
|
00:01:10,500 --> 00:01:12,050
|
||||||
|
夕張市紅葉山
|
||||||
|
|
||||||
|
23
|
||||||
|
00:01:12,050 --> 00:01:13,860
|
||||||
|
十三里信号場
|
||||||
|
|
||||||
|
24
|
||||||
|
00:01:13,860 --> 00:01:16,440
|
||||||
|
十三里橋
|
||||||
|
|
||||||
|
25
|
||||||
|
00:01:16,440 --> 00:01:18,780
|
||||||
|
紅葉山駐車帯
|
||||||
|
|
||||||
|
26
|
||||||
|
00:01:18,780 --> 00:01:21,060
|
||||||
|
瑞穂橋
|
||||||
|
|
||||||
|
27
|
||||||
|
00:01:21,060 --> 00:01:24,440
|
||||||
|
セブンイレブン夕張紅葉山店
|
||||||
|
|
||||||
|
28
|
||||||
|
00:01:24,440 --> 00:01:27,060
|
||||||
|
新夕張駅
|
||||||
|
|
||||||
|
29
|
||||||
|
00:01:27,060 --> 00:01:30,020
|
||||||
|
紅葉山郵便局
|
||||||
|
|
||||||
|
30
|
||||||
|
00:01:30,020 --> 00:01:32,300
|
||||||
|
紅葉山神社
|
||||||
|
|
||||||
|
31
|
||||||
|
00:01:32,300 --> 00:01:35,240
|
||||||
|
セイコーマートもみじやま店
|
||||||
|
|
||||||
|
32
|
||||||
|
00:01:35,240 --> 00:01:38,480
|
||||||
|
紅楓橋
|
||||||
|
|
||||||
|
33
|
||||||
|
00:01:38,480 --> 00:01:40,250
|
||||||
|
ポン久留喜橋
|
||||||
|
|
||||||
|
34
|
||||||
|
00:01:40,250 --> 00:01:42,440
|
||||||
|
久留喜橋
|
||||||
|
|
||||||
|
35
|
||||||
|
00:01:42,440 --> 00:01:43,790
|
||||||
|
楓橋
|
||||||
|
|
||||||
|
36
|
||||||
|
00:01:43,790 --> 00:01:45,030
|
||||||
|
桂橋
|
||||||
|
|
||||||
|
37
|
||||||
|
00:01:45,030 --> 00:01:45,770
|
||||||
|
|
||||||
|
|
||||||
|
38
|
||||||
|
00:01:45,770 --> 00:01:49,120
|
||||||
|
夕張市楓
|
||||||
|
|
||||||
|
39
|
||||||
|
00:01:49,120 --> 00:01:50,760
|
||||||
|
千紫橋
|
||||||
|
|
||||||
|
40
|
||||||
|
00:01:50,760 --> 00:01:52,510
|
||||||
|
楓駐車帯
|
||||||
|
|
||||||
|
41
|
||||||
|
00:01:52,510 --> 00:01:53,800
|
||||||
|
万紅橋
|
||||||
|
|
||||||
|
42
|
||||||
|
00:01:53,800 --> 00:01:55,540
|
||||||
|
登川神社
|
||||||
|
|
||||||
|
43
|
||||||
|
00:01:55,540 --> 00:01:57,320
|
||||||
|
楓信号場
|
||||||
|
|
||||||
|
44
|
||||||
|
00:01:57,320 --> 00:01:59,000
|
||||||
|
登川橋
|
||||||
|
|
||||||
|
45
|
||||||
|
00:01:59,000 --> 00:02:00,740
|
||||||
|
登川郵便局
|
||||||
|
|
||||||
|
46
|
||||||
|
00:02:00,740 --> 00:02:01,260
|
||||||
|
|
||||||
|
|
||||||
|
47
|
||||||
|
00:02:01,260 --> 00:02:02,930
|
||||||
|
夕張市登川
|
||||||
|
|
||||||
|
48
|
||||||
|
00:02:02,930 --> 00:02:05,230
|
||||||
|
花園橋
|
||||||
|
|
||||||
|
49
|
||||||
|
00:02:05,230 --> 00:02:06,620
|
||||||
|
登川トンネル
|
||||||
|
|
||||||
|
50
|
||||||
|
00:02:06,620 --> 00:03:30,800
|
||||||
|
|
||||||
|
|
||||||
|
51
|
||||||
|
00:03:30,800 --> 00:03:34,550
|
||||||
|
勇払郡むかわ町穂別長和
|
||||||
|
|
||||||
|
52
|
||||||
|
00:03:34,550 --> 00:03:36,770
|
||||||
|
オマナイ橋
|
||||||
|
|
||||||
|
53
|
||||||
|
00:03:36,770 --> 00:03:39,410
|
||||||
|
桂木橋
|
||||||
|
|
||||||
|
54
|
||||||
|
00:03:39,410 --> 00:03:41,130
|
||||||
|
|
||||||
|
|
||||||
|
55
|
||||||
|
00:03:41,130 --> 00:03:44,620
|
||||||
|
勇払郡むかわ町穂別稲里
|
||||||
|
|
||||||
|
56
|
||||||
|
00:03:44,620 --> 00:03:46,670
|
||||||
|
大和橋
|
||||||
|
|
||||||
|
57
|
||||||
|
00:03:46,670 --> 00:03:48,380
|
||||||
|
豊進橋
|
||||||
|
|
||||||
|
58
|
||||||
|
00:03:48,380 --> 00:03:51,610
|
||||||
|
豊進神社
|
||||||
|
|
||||||
|
59
|
||||||
|
00:03:51,610 --> 00:03:53,140
|
||||||
|
豊里橋
|
||||||
|
|
||||||
|
60
|
||||||
|
00:03:53,140 --> 00:03:55,520
|
||||||
|
稲豊橋
|
||||||
|
|
||||||
|
61
|
||||||
|
00:03:55,520 --> 00:03:57,780
|
||||||
|
稲里橋
|
||||||
|
|
||||||
|
62
|
||||||
|
00:03:57,780 --> 00:04:01,080
|
||||||
|
稲里簡易郵便局
|
||||||
|
|
||||||
|
63
|
||||||
|
00:04:01,080 --> 00:04:03,500
|
||||||
|
佐主橋
|
||||||
|
|
||||||
|
64
|
||||||
|
00:04:03,500 --> 00:04:05,590
|
||||||
|
穂別ダム神社
|
||||||
|
|
||||||
|
65
|
||||||
|
00:04:05,590 --> 00:04:06,090
|
||||||
|
|
||||||
|
|
||||||
|
66
|
||||||
|
00:04:05,960 --> 00:04:10,080
|
||||||
|
勇払郡むかわ町穂別長和
|
||||||
|
|
||||||
|
67
|
||||||
|
00:04:10,080 --> 00:04:13,020
|
||||||
|
長和トンネル
|
||||||
|
|
||||||
|
68
|
||||||
|
00:04:13,020 --> 00:04:15,390
|
||||||
|
穂別大橋
|
||||||
|
|
||||||
|
69
|
||||||
|
00:04:15,390 --> 00:04:18,810
|
||||||
|
山水橋
|
||||||
|
|
||||||
|
70
|
||||||
|
00:04:18,810 --> 00:04:23,200
|
||||||
|
沼の沢橋
|
||||||
|
|
||||||
|
71
|
||||||
|
00:04:23,200 --> 00:04:25,270
|
||||||
|
稲里トンネル
|
||||||
|
|
||||||
|
72
|
||||||
|
00:04:25,270 --> 00:04:26,000
|
||||||
|
|
||||||
|
|
||||||
|
73
|
||||||
|
00:04:26,000 --> 00:04:29,740
|
||||||
|
勇払郡むかわ町穂別福山
|
||||||
|
|
||||||
|
74
|
||||||
|
00:04:29,740 --> 00:04:31,500
|
||||||
|
ペンケ橋
|
||||||
|
|
||||||
|
75
|
||||||
|
00:04:31,500 --> 00:04:33,620
|
||||||
|
下福山橋
|
||||||
|
|
||||||
|
76
|
||||||
|
00:04:33,620 --> 00:04:35,020
|
||||||
|
オロロップ橋
|
||||||
|
|
||||||
|
77
|
||||||
|
00:04:35,020 --> 00:04:37,070
|
||||||
|
福山橋
|
||||||
|
|
||||||
|
78
|
||||||
|
00:04:37,070 --> 00:04:38,700
|
||||||
|
福山神社
|
||||||
|
|
||||||
|
79
|
||||||
|
00:04:38,700 --> 00:04:41,020
|
||||||
|
福山大橋
|
||||||
|
|
||||||
|
80
|
||||||
|
00:04:41,020 --> 00:04:43,290
|
||||||
|
千坂橋
|
||||||
|
|
||||||
|
81
|
||||||
|
00:04:43,290 --> 00:04:45,120
|
||||||
|
オコタン橋
|
||||||
|
|
||||||
|
82
|
||||||
|
00:04:45,120 --> 00:04:46,220
|
||||||
|
モトツトンネル
|
||||||
|
|
||||||
|
83
|
||||||
|
00:04:46,220 --> 00:04:48,980
|
||||||
|
モトツ橋にモトツシェルター
|
||||||
|
|
||||||
|
84
|
||||||
|
00:04:48,980 --> 00:04:51,390
|
||||||
|
福山トンネル
|
||||||
|
|
||||||
|
85
|
||||||
|
00:04:51,390 --> 00:04:53,610
|
||||||
|
福穂橋
|
||||||
|
|
||||||
|
86
|
||||||
|
00:04:53,610 --> 00:04:55,520
|
||||||
|
穂高トンネル
|
||||||
|
|
||||||
|
87
|
||||||
|
00:04:55,520 --> 00:05:22,600
|
||||||
|
|
||||||
|
|
||||||
|
88
|
||||||
|
00:05:22,600 --> 00:05:25,760
|
||||||
|
勇払郡占冠村中央
|
||||||
|
|
||||||
|
89
|
||||||
|
00:05:25,760 --> 00:05:28,880
|
||||||
|
穂高橋
|
||||||
|
|
||||||
|
90
|
||||||
|
00:05:28,880 --> 00:05:31,520
|
||||||
|
高翠橋
|
||||||
|
|
||||||
|
91
|
||||||
|
00:05:31,520 --> 00:05:34,950
|
||||||
|
占翠橋
|
||||||
|
|
||||||
|
92
|
||||||
|
00:05:34,950 --> 00:05:38,080
|
||||||
|
名石駐車場
|
||||||
|
|
||||||
|
93
|
||||||
|
00:05:38,080 --> 00:05:40,990
|
||||||
|
名石橋
|
||||||
|
|
||||||
|
94
|
||||||
|
00:05:40,990 --> 00:05:43,960
|
||||||
|
赤岩大橋
|
||||||
|
|
||||||
|
95
|
||||||
|
00:05:43,960 --> 00:05:48,620
|
||||||
|
渓流大橋
|
||||||
|
|
||||||
|
96
|
||||||
|
00:05:48,620 --> 00:05:50,730
|
||||||
|
日高トンネル
|
||||||
|
|
||||||
|
97
|
||||||
|
00:05:50,730 --> 00:07:11,390
|
||||||
|
|
||||||
|
|
||||||
|
98
|
||||||
|
00:07:11,390 --> 00:07:14,140
|
||||||
|
沙流郡日高町日高
|
||||||
|
|
||||||
|
99
|
||||||
|
00:07:14,140 --> 00:07:16,380
|
||||||
|
日夕橋
|
||||||
|
|
||||||
|
100
|
||||||
|
00:07:16,380 --> 00:07:28,850
|
||||||
|
|
||||||
|
|
||||||
|
101
|
||||||
|
00:07:28,850 --> 00:07:31,680
|
||||||
|
沙流郡日高町宮下町
|
||||||
|
|
||||||
|
102
|
||||||
|
00:07:31,680 --> 00:07:34,090
|
||||||
|
日高神社
|
||||||
|
|
||||||
|
103
|
||||||
|
00:07:34,090 --> 00:07:42,900
|
||||||
|
|
||||||
|
|
||||||
|
104
|
||||||
|
00:07:42,900 --> 00:07:45,510
|
||||||
|
沙流郡日高町本町東
|
||||||
|
|
||||||
|
105
|
||||||
|
00:07:45,510 --> 00:07:49,880
|
||||||
|
新川橋
|
||||||
|
|
||||||
|
106
|
||||||
|
00:07:49,880 --> 00:07:52,760
|
||||||
|
セイコーマート日高道の駅店
|
||||||
|
|
||||||
|
107
|
||||||
|
00:07:52,760 --> 00:07:55,110
|
||||||
|
日高郵便局
|
||||||
|
|
||||||
|
108
|
||||||
|
00:07:55,110 --> 00:07:56,690
|
||||||
|
|
||||||
|
|
||||||
|
109
|
||||||
|
00:07:56,690 --> 00:07:59,360
|
||||||
|
沙流郡日高町千栄
|
||||||
|
|
||||||
|
110
|
||||||
|
00:07:59,360 --> 00:08:02,150
|
||||||
|
北電日高発電所
|
||||||
|
|
||||||
|
111
|
||||||
|
00:08:02,150 --> 00:08:03,900
|
||||||
|
|
||||||
|
|
||||||
|
112
|
||||||
|
00:08:03,900 --> 00:08:06,380
|
||||||
|
沙流郡日高町日高
|
||||||
|
|
||||||
|
113
|
||||||
|
00:08:06,380 --> 00:08:09,440
|
||||||
|
右左府発電所
|
||||||
|
|
||||||
|
114
|
||||||
|
00:08:09,440 --> 00:08:10,930
|
||||||
|
|
||||||
|
|
||||||
|
115
|
||||||
|
00:08:10,930 --> 00:08:12,660
|
||||||
|
東に続く
|
||||||
|
|
||||||
|
116
|
||||||
|
00:08:12,660 --> 00:08:17,660
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Bill of materials — 274
|
||||||
|
|
||||||
|
9 file(s), 472.1 MiB. Generated by `scripts/bom_gen.py`.
|
||||||
|
|
||||||
|
## audio
|
||||||
|
|
||||||
|
file size hash description
|
||||||
|
------------------------ --------- -------------------------- ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
`audio/274_release1.wav` 226.8 MiB `4287774b506177d282b8a035` Release master of "274" (2026-09-16), superseded by 274_release2.wav — stereo, 48 kHz, 32-bit float PCM, 10:19 (619.27 s). Peak -0.4 dBFS, mean -12.4 dBFS.
|
||||||
|
`audio/274_release2.wav` 226.8 MiB `834aad4988f387dd9871d099` Release master of "274" (updated 2026-09-17, replaces 274_release1.wav; used by the white/lyrics renders and Song274Transect) — stereo, 48 kHz, 32-bit float PCM, 10:19 (619.27 s). Float peak +0.25 dBFS (1.0297; 25 samples beyond full scale), mean -13.1 dBFS.
|
||||||
|
|
||||||
|
## images
|
||||||
|
|
||||||
|
file size hash description
|
||||||
|
------------------ -------- -------------------------- ------------------------------------------------------------------------------------------------------
|
||||||
|
`images/white.png` 11.1 KiB `717ddffe599554fb2dcdf855` Background plate: plain pure-white (#FFFFFF) 1920×1080 RGB image, used as the still for the PV render.
|
||||||
|
|
||||||
|
## fonts
|
||||||
|
|
||||||
|
file size hash description
|
||||||
|
-------------------------------- ------- -------------------------- ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
`fonts/MPLUS1Code-Bold.ttf` 1.6 MiB `2bb7c37e098719928fc47383` M PLUS 1 Code Bold (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass for {\b1} runs in 274-credits.
|
||||||
|
`fonts/MPLUS1Code-Medium.ttf` 1.6 MiB `0c9342dd910f7daaea1d40ac` M PLUS 1 Code Medium (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-addr.
|
||||||
|
`fonts/MPLUS1Code-Regular.ttf` 1.6 MiB `2f8308951632e89da5e3bcee` M PLUS 1 Code Regular (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-credits.
|
||||||
|
`fonts/MPLUS1Code-SemiBold.ttf` 1.6 MiB `8d5d9c9d3e427a99b2b4e25e` M PLUS 1 Code SemiBold (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-place.
|
||||||
|
`fonts/NotoSerifJP-Medium.otf` 6.0 MiB `b3aa1bf0e4aeb96623aeaab1` Noto Serif JP Medium (SubsetOTF/JP, v2.003, SIL OFL 1.1) from notofonts/noto-cjk release Serif2.003 (12_NotoSerifJP.zip). Used by 274.ass styles transparent_lyrics_en, transparent_lyrics_jp.
|
||||||
|
`fonts/NotoSerifJP-SemiBold.otf` 6.0 MiB `81863c254d5833df489433c4` Noto Serif JP SemiBold (SubsetOTF/JP, v2.003, SIL OFL 1.1) from notofonts/noto-cjk release Serif2.003 (12_NotoSerifJP.zip). Used by 274.ass style transparent_title.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"frm": 36.5,
|
||||||
|
"to": 38.25,
|
||||||
|
"code": "01427",
|
||||||
|
"city": "夕張郡由仁町",
|
||||||
|
"lv": "川端",
|
||||||
|
"kana": "かわばた"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 38.5,
|
||||||
|
"to": 42.0,
|
||||||
|
"code": "01429",
|
||||||
|
"city": "夕張郡栗山町",
|
||||||
|
"lv": "字滝下",
|
||||||
|
"kana": "たきした"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 42.25,
|
||||||
|
"to": 43.0,
|
||||||
|
"code": "01427",
|
||||||
|
"city": "夕張郡由仁町",
|
||||||
|
"lv": "川端",
|
||||||
|
"kana": "かわばた"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 43.25,
|
||||||
|
"to": 44.0,
|
||||||
|
"code": "01429",
|
||||||
|
"city": "夕張郡栗山町",
|
||||||
|
"lv": "字滝下",
|
||||||
|
"kana": "たきした"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 44.25,
|
||||||
|
"to": 48.25,
|
||||||
|
"code": "01209",
|
||||||
|
"city": "夕張市",
|
||||||
|
"lv": "滝ノ上",
|
||||||
|
"kana": "たきのうえ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 48.5,
|
||||||
|
"to": 55.5,
|
||||||
|
"code": "01209",
|
||||||
|
"city": "夕張市",
|
||||||
|
"lv": "紅葉山",
|
||||||
|
"kana": "もみじやま"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 55.75,
|
||||||
|
"to": 57.75,
|
||||||
|
"code": "01209",
|
||||||
|
"city": "夕張市",
|
||||||
|
"lv": "楓",
|
||||||
|
"kana": "かえで"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 58.0,
|
||||||
|
"to": 61.0,
|
||||||
|
"code": "01209",
|
||||||
|
"city": "夕張市",
|
||||||
|
"lv": "登川",
|
||||||
|
"kana": "のぼりかわ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 61.25,
|
||||||
|
"to": 62.75,
|
||||||
|
"code": "01586",
|
||||||
|
"city": "勇払郡むかわ町",
|
||||||
|
"lv": "穂別長和",
|
||||||
|
"kana": "ほべつおさわ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 63.0,
|
||||||
|
"to": 72.0,
|
||||||
|
"code": "01586",
|
||||||
|
"city": "勇払郡むかわ町",
|
||||||
|
"lv": "穂別稲里",
|
||||||
|
"kana": "ほべついなさと"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 72.25,
|
||||||
|
"to": 76.0,
|
||||||
|
"code": "01586",
|
||||||
|
"city": "勇払郡むかわ町",
|
||||||
|
"lv": "穂別長和",
|
||||||
|
"kana": "ほべつおさわ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 76.25,
|
||||||
|
"to": 88.75,
|
||||||
|
"code": "01586",
|
||||||
|
"city": "勇払郡むかわ町",
|
||||||
|
"lv": "穂別福山",
|
||||||
|
"kana": "ほべつふくやま"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 89.0,
|
||||||
|
"to": 96.75,
|
||||||
|
"code": "01463",
|
||||||
|
"city": "勇払郡占冠村",
|
||||||
|
"lv": "字中央",
|
||||||
|
"kana": "ちゅうおう"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 97.0,
|
||||||
|
"to": 101.75,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "字日高",
|
||||||
|
"kana": "ひだか(1く)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 102.0,
|
||||||
|
"to": 102.5,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "新町三丁目",
|
||||||
|
"kana": "しんまち"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 102.75,
|
||||||
|
"to": 102.75,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "新町一丁目",
|
||||||
|
"kana": "しんまち"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 103.0,
|
||||||
|
"to": 103.0,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "栄町東一丁目",
|
||||||
|
"kana": "さかえまちひがし"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 103.25,
|
||||||
|
"to": 103.5,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "本町東一丁目",
|
||||||
|
"kana": "ほんちょうひがし"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 103.75,
|
||||||
|
"to": 103.75,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "本町東二丁目",
|
||||||
|
"kana": "ほんちょうひがし"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 104.0,
|
||||||
|
"to": 104.0,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "松風町一丁目",
|
||||||
|
"kana": "まつかぜちょう"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 104.25,
|
||||||
|
"to": 104.25,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "山手町一丁目",
|
||||||
|
"kana": "やまてちょう"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 104.5,
|
||||||
|
"to": 107.0,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "字日高",
|
||||||
|
"kana": "ひだか(1く)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frm": 107.25,
|
||||||
|
"to": 107.75,
|
||||||
|
"code": "01601",
|
||||||
|
"city": "沙流郡日高町",
|
||||||
|
"lv": "字千栄",
|
||||||
|
"kana": "ちさか"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
km,name,object,kana,romaji,kind,offset_m,lat,lon,length_m,built,grade,oaza,address,address_kana,location
|
||||||
|
37.27,川端,川端,かわばた,Kawabata,place (neighbourhood),32,42.91538,141.89568,,,,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,
|
||||||
|
38.41,川端,川端橋,かわばたはし,Kawabata hashi,bridge,0,42.91715,141.90896,255,1991,Ⅰ,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,由仁町
|
||||||
|
39.80,川端,北海道企業局 川端発電所,,,power station,451,42.91197,141.91642,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
39.80,川端,川端ダム,,,dam,523,42.91140,141.91590,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
40.16,滝下,滝下八幡神社,たきしたはちまんじんじゃ,Takishitahachiman jinja,Shinto shrine,98,42.91183,141.92472,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
40.57,滝ノ下,滝ノ下信号場,たきのした,Takinoshita,place (locality),400,42.90630,141.92320,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
41.02,クオーベツ,クオーベツダム,くおーぺつ,Kuoopetsu,dam,1798,42.89310,141.93874,,,,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,
|
||||||
|
41.02,滝下川端,滝下川端連絡線,,,power line,75,42.90857,141.93273,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
41.02,砂金,砂金川,,,watercourse,58,42.90889,141.93222,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,石狩川水系
|
||||||
|
41.86,雨霧橋,雨霧橋,あまぎりはし,Amagiri hashi,bridge,0,42.91003,141.94197,280.7,1974,Ⅱ,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,栗山町
|
||||||
|
43.03,竜仙橋,竜仙橋,りゅうせんばし,Ryuusen bashi,bridge,0,42.91604,141.95220,178,1972,Ⅱ,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,由仁町
|
||||||
|
43.07,滝下,北海道企業局 滝下発電所,たきのしたはつでんしょ,Takinoshita hatsudensho,power station,210,42.91801,141.95218,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
43.30,滝下,滝下,たきした,Takishita,place (neighbourhood),1636,42.93155,141.95583,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
|
||||||
|
43.64,草木,草木川,そうもく,Soumoku,watercourse,84,42.91577,141.96022,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,石狩川水系
|
||||||
|
44.00,草木,草木橋,そうもくはし,Soumoku hashi,bridge,0,42.91449,141.96321,24.1,1979,Ⅲ,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,栗山町
|
||||||
|
44.00,滝の上,滝の上こ線橋,たきのうえこせんきょう,Takinoue kosenkyou,bridge,0,42.91449,141.96321,47.6,1975,Ⅲ,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,夕張市
|
||||||
|
44.00,草木舞,草木舞沢川,そうもくまいさわ,Soumokumaisawa,watercourse,21,42.91436,141.96339,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,石狩川水系
|
||||||
|
44.62,滝の上,北海道企業局 滝の上発電所,,,power station,142,42.91152,141.97021,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
|
||||||
|
44.62,滝の上,滝の上公園,,,park,69,42.91225,141.97000,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
|
||||||
|
45.21,カミ,カミの沢川,,,watercourse,40,42.91059,141.97704,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,石狩川水系
|
||||||
|
45.21,滝の上,滝ノ上信号場,たきのうえしんごうじょう,Takinoue shingoujou,railway signal post,43,42.91000,141.97642,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
|
||||||
|
45.29,滝の上,滝ノ上神社,たきのうえじんじゃ,Takinoue jinja,Shinto shrine,100,42.90979,141.97873,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
|
||||||
|
47.66,滝の上,滝ノ上,たきのうえ,Takinoue,place (neighbourhood),52,42.90347,142.00470,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
|
||||||
|
48.18,チョポツナイ,チョポツナイ川,ちょぽつない,Chopotsunai,watercourse,63,42.90575,142.01006,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,石狩川水系
|
||||||
|
49.71,十三里,十三里信号場,とみさとしんごうじょう,Tomisato shingoujou,place (locality),39,42.91482,142.02439,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
50.28,カライケ,カライケの沢川,からいけ,Karaike,watercourse,132,42.91674,142.03132,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
|
||||||
|
50.28,十三里,十三里橋,とみさとはし,Tomisato hashi,bridge,0,42.91772,142.03043,122,1997,Ⅰ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
50.52,紅葉山,紅葉山駐車帯,もみじやまちゅうしゃたい,Momijiyama chuushatai,parking,41,42.91840,142.03331,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
52.11,瑞穂,瑞穂橋,みずほはし,Mizuho hashi,bridge,0,42.93244,142.03548,155,1968,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
52.11,夕張,夕張川,,,watercourse,16,42.93257,142.03538,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
|
||||||
|
52.47,セブン-イレブン,セブンイレブン夕張紅葉山店,せぶんいれぶんゆうばりもみじやまてん,Sebun'irebun'yuubarimomijiyama ten,convenience store,73,42.93507,142.03683,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
52.54,新夕張,新夕張駅,しんゆうばり,Shin'yuubari,railway station,173,42.93670,142.03648,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
52.61,紅葉山,紅葉山郵便局,もみじやまゆうびんきょく,Momijiyama yuubinkyoku,post office,54,42.93642,142.03925,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
52.85,紅葉山,紅葉山神社,もみじやまじんじゃ,Momijiyama jinja,Shinto shrine,67,42.93788,142.03956,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
52.99,セイコーマート,セイコーマートもみじやま店,せいこーまーともみじやまてん,Seikoomaatomomijiyama ten,convenience store,31,42.93910,142.04167,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
|
||||||
|
53.13,紅楓,紅楓橋,こうふうばし,Koufuu bashi,bridge,0,42.93855,142.04317,102.5,1958,Ⅱ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
53.69,久留喜,ポン久留喜橋,ぽんくるきはし,Ponkuruki hashi,bridge,0,42.93697,142.04976,12.7,1984,Ⅰ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
53.69,久留喜,ポンクルキ川,,,watercourse,16,42.93695,142.04995,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
|
||||||
|
53.69,久留喜,久留喜橋,くるきはし,Kuruki hashi,bridge,0,42.93697,142.04976,34,1962,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
55.14,シイタルキ,シイタルキ川,,,watercourse,143,42.92706,142.06277,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
|
||||||
|
55.41,楓,楓橋,かえでばし,Kaede bashi,bridge,0,42.92957,142.06562,32,1968,Ⅲ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
55.60,桂,桂橋,かつらはし,Katsura hashi,bridge,0,42.93026,142.06775,48.3,1987,Ⅰ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
|
||||||
|
56.60,千紫,千紫橋,せんしばし,Senshi bashi,bridge,0,42.93414,142.07860,31.4,1988,Ⅰ,楓,夕張市楓,ゆうばりしかえで,夕張市
|
||||||
|
56.88,楓,楓,かえで,Kaede,place (neighbourhood),711,42.94002,142.08407,,,,楓,夕張市楓,ゆうばりしかえで,
|
||||||
|
57.08,楓,楓駐車帯,かえでちゅうしゃたい,Kaede chuushatai,parking,39,42.93352,142.08412,,,,楓,夕張市楓,ゆうばりしかえで,
|
||||||
|
57.25,万紅,万紅橋,ばんこうばし,Bankou bashi,bridge,0,42.93274,142.08627,27.6,1988,Ⅰ,楓,夕張市楓,ゆうばりしかえで,夕張市
|
||||||
|
57.44,登川,登川神社,のぼりかわじんじゃ,Noborikawa jinja,Shinto shrine,164,42.93395,142.08819,,,,楓,夕張市楓,ゆうばりしかえで,
|
||||||
|
57.76,楓,楓信号場,かえでしんごうじょう,Kaede shingoujou,railway signal post,47,42.93157,142.09230,,,,楓,夕張市楓,ゆうばりしかえで,
|
||||||
|
57.84,登川,登川橋,のぼりかわばし,Noborikawa bashi,bridge,0,42.93167,142.09327,21,1988,Ⅰ,楓,夕張市楓,ゆうばりしかえで,夕張市
|
||||||
|
57.84,登川,登川郵便局,のぼりかわゆうびんきょく,Noborikawa yuubinkyoku,post office,21,42.93185,142.09335,,,,楓,夕張市楓,ゆうばりしかえで,
|
||||||
|
58.23,花園,花園橋,はなぞのばし,Hanazono bashi,bridge,0,42.92922,142.09647,58.6,1971,Ⅱ,登川,夕張市登川,ゆうばりしのぼりかわ,夕張市
|
||||||
|
58.26,ホルカクルキ,ホルカクルキ川,,,watercourse,9,42.92920,142.09685,,,,登川,夕張市登川,ゆうばりしのぼりかわ,石狩川水系
|
||||||
|
58.56,滝沢川,滝沢川,,,watercourse,52,42.92962,142.10029,,,,登川,夕張市登川,ゆうばりしのぼりかわ,石狩川水系
|
||||||
|
59.54,松竹沢,松竹沢川,,,watercourse,58,42.93047,142.11198,,,,登川,夕張市登川,ゆうばりしのぼりかわ,石狩川水系
|
||||||
|
59.66,登川,登川,のぼりかわ,Noborikawa,place (neighbourhood),1208,42.93831,142.12137,,,,登川,夕張市登川,ゆうばりしのぼりかわ,
|
||||||
|
60.29,登川,登川トンネル,のぼりかわとんねる,Noborikawa tonneru,tunnel,0,42.92478,142.11599,348,1978,,登川,夕張市登川,ゆうばりしのぼりかわ,夕張市
|
||||||
|
62.36,オマナイ,オマナイ橋,おまないはし,Omanai hashi,bridge,0,42.90892,142.12482,174.2,1988,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
62.94,桂木,桂木橋,かつらぎはし,Katsuragi hashi,bridge,0,42.90434,142.12726,129.6,1986,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
64.44,大和,大和橋,やまとはし,Yamato hashi,bridge,0,42.89157,142.12305,16,1967,Ⅲ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
|
||||||
|
66.69,豊進,豊進橋,ほうしんばし,Houshin bashi,bridge,0,42.87371,142.13048,16,1967,Ⅲ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
|
||||||
|
66.78,豊進,豊進神社,ほうしんじんじゃ,Houshin jinja,Shinto shrine,49,42.87318,142.13135,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
|
||||||
|
66.83,穂別,穂別キャンプ場,,,campsite,66,42.87222,142.13061,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
|
||||||
|
66.86,石山,石山の沢川,,,watercourse,161,42.87125,142.13037,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,鵡川水系
|
||||||
|
67.05,狩勝,狩勝幹線,,,power line,414,42.86806,142.13398,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
|
||||||
|
67.48,豊里,豊里橋,とよさとはし,Toyosato hashi,bridge,0,42.87311,142.13854,27,1966,Ⅰ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
|
||||||
|
67.60,稲豊,稲豊橋,いなとよはし,Inatoyo hashi,bridge,0,42.87313,142.14000,27,1966,Ⅰ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
|
||||||
|
67.62,サヌシュッペ,サヌシュッペ川,,,watercourse,5,42.87304,142.14032,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,鵡川水系
|
||||||
|
68.63,稲里,稲里橋,いなさとはし,Inasato hashi,bridge,0,42.87144,142.15164,31,1988,Ⅱ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
|
||||||
|
69.29,稲里,稲里簡易郵便局,いなさとかんいゆうびんきょく,Inasatokan'i yuubinkyoku,post office,161,42.86734,142.15774,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
|
||||||
|
69.48,佐主,佐主橋,さぬしばし,Sanushi bashi,bridge,0,42.86894,142.16085,85,1973,Ⅲ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
|
||||||
|
70.22,穂別,穂別川,,,watercourse,32,42.87408,142.16642,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,鵡川水系
|
||||||
|
71.86,穂別,穂別ダム神社,ほべつだむじんじゃ,Hobetsudamu jinja,Shinto shrine,18,42.88475,142.17737,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
|
||||||
|
71.90,穂別,穂別ダム,,,dam,166,42.88502,142.17937,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
|
||||||
|
72.02,長和,長和トンネル,おさわとんねる,Osawa tonneru,tunnel,0,42.88619,142.17748,281,1983,,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
72.29,穂別,穂別大橋,ほべつおおはし,Hobetsu oohashi,bridge,0,42.88820,142.17918,506.2,1979,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
72.90,山水,山水橋,さんすいばし,Sansui bashi,bridge,0,42.89187,142.18476,53.5,1979,Ⅰ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
72.93,高西,高西の沢川,,,watercourse,9,42.89195,142.18498,,,,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,鵡川水系
|
||||||
|
74.31,沼の沢,沼の沢橋,ぬまのさわはし,Numanosawa hashi,bridge,0,42.88830,142.20015,188,1983,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
75.28,稲里,稲里トンネル,いなさととんねる,Inasato tonneru,tunnel,0,42.88343,142.20951,1441,1985,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
|
||||||
|
76.97,ペンケ橋,ペンケ橋,ぺんけばし,Penke bashi,bridge,0,42.87802,142.22782,120.7,1985,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
77.05,オロロップ,オロロップ沢川,,,watercourse,15,42.87849,142.22885,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
|
||||||
|
78.03,下福山,下福山橋,しもふくやまはし,Shimofukuyama hashi,bridge,0,42.88572,142.23487,129.9,1982,Ⅰ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
78.31,オロロップ,オロロップ橋,おろろっぷはし,Ororoppu hashi,bridge,0,42.88721,142.23759,205.9,1983,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
79.11,福山,福山橋,ふくやまはし,Fukuyama hashi,bridge,0,42.89249,142.24390,134.7,1984,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
79.67,福山,福山神社,ふくやまじんじゃ,Fukuyama jinja,Shinto shrine,184,42.89114,142.25175,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
|
||||||
|
79.81,福山第1ゲート,福山第1ゲート,,,gate,71,42.89003,142.25118,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
|
||||||
|
79.86,福山第2ゲート,福山第2ゲート,,,gate,47,42.88890,142.25072,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
|
||||||
|
80.01,穂別福山 (穂別 + 福山),穂別福山,ほべつふくやま,Hobetsufukuyama,place (neighbourhood),172,42.88942,142.25385,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
|
||||||
|
80.24,鵡川,鵡川,,,watercourse,68,42.88740,142.25421,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
|
||||||
|
80.24,福山,福山大橋,ふくやまおおはし,Fukuyama oohashi,bridge,0,42.88678,142.25416,160,1968,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
85.40,千坂,千坂橋,ちさかはし,Chisaka hashi,bridge,0,42.88901,142.29725,138.7,1975,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
85.87,オコタン,オコタン橋,おこたんばし,Okotan bashi,bridge,0,42.89179,142.30059,338,1985,Ⅰ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
86.16,パンケモトツ,パンケモトツ沢川,,,watercourse,10,42.89278,142.30394,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
|
||||||
|
86.24,モトツ,モトツトンネル,もとつとんねる,Mototsu tonneru,tunnel,0,42.89321,142.30463,924,1991,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
87.12,モトツ,モトツ橋,もとつばし,Mototsu bashi,bridge,0,42.89931,142.31156,62,1988,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
87.12,モトツ,モトツシェルター,もとつしぇるたー,Mototsu sherutaa,shed,0,42.89931,142.31156,62,1990,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
87.21,福山,福山トンネル,,,tunnel,0,42.89988,142.31229,839,1991,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
88.04,ペンケモトツ,ペンケモトツ沢川,,,watercourse,26,42.90332,142.32161,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
|
||||||
|
88.04,福穂,福穂橋,ふくすいはし,Fukusui hashi,bridge,0,42.90322,142.32132,210,1990,Ⅰ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
88.25,穂高,穂高トンネル,ほだかとんねる,Hodaka tonneru,tunnel,0,42.90398,142.32368,1991,1991,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
|
||||||
|
90.58,穂高,穂高橋,ほだかはし,Hodaka hashi,bridge,0,42.90958,142.35128,140.6,1988,Ⅱ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
90.90,高翠,高翠橋,こうすいばし,Kousui bashi,bridge,0,42.90889,142.35485,114,1988,Ⅰ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
91.14,占翠,占翠橋,せんすいばし,Sensui bashi,bridge,0,42.90869,142.35768,167,1989,Ⅰ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
93.58,名石,名石駐車場,めいせきちゅうしゃじょう,Meiseki chuushajou,parking,32,42.90980,142.36986,,,,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,
|
||||||
|
93.67,名石,名石橋,めいせきばし,Meiseki bashi,bridge,0,42.91029,142.37052,39.6,1970,Ⅰ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
93.73,ニセイパオマナイ,ニセイパオマナイ川,,,watercourse,30,42.91045,142.37087,,,,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,鵡川水系
|
||||||
|
94.35,赤岩,赤岩大橋,あかいわおおはし,Akaiwa oohashi,bridge,0,42.91317,142.37731,157.4,1970,Ⅱ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
95.37,一の沢川,一の沢川,,,watercourse,44,42.91532,142.38713,,,,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,鵡川水系
|
||||||
|
95.37,渓流,渓流大橋,けいりゅうおおはし,Keiryuu oohashi,bridge,0,42.91502,142.38677,153.7,1974,Ⅲ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
96.43,日高,日高トンネル,ひだかとんねる,Hidaka tonneru,tunnel,0,42.91588,142.39911,730,1972,Ⅲ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
|
||||||
|
99.93,右の沢川,右の沢川,,,watercourse,145,42.89623,142.41771,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
|
||||||
|
99.98,ホロカワウシャップ,ホロカワウシャップ川,,,watercourse,79,42.89623,142.41942,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
|
||||||
|
100.60,日夕,日夕橋,にちゆうばし,Nichiyuu bashi,bridge,0,42.89710,142.42716,30,1968,Ⅰ,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,日高町
|
||||||
|
100.66,パンケウシャップ,パンケウシャップ川,,,watercourse,21,42.89691,142.42766,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
|
||||||
|
100.90,日高,日高,,,place (neighbourhood),373,42.89635,142.43448,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,
|
||||||
|
102.18,新町,新町三丁目,しんまち3ちょうめ,Shinmachi 3-choume,place (neighbourhood),13,42.88427,142.43237,,,,新町,沙流郡日高町新町,さるぐんひだかちょうしんまち,
|
||||||
|
102.36,日高,日高神社,ひだかじんじゃ,Hidaka jinja,Shinto shrine,221,42.88427,142.43553,,,,宮下町,沙流郡日高町宮下町,さるぐんひだかちょうみやしたちょう,
|
||||||
|
102.72,kanamoto,kanamoto,かなもと,Kanamoto,shop,42,42.88086,142.43687,,,,宮下町,沙流郡日高町宮下町,さるぐんひだかちょうみやしたちょう,
|
||||||
|
103.12,新川,新川橋,しんかわはし,Shinkawa hashi,bridge,0,42.87791,142.44001,3.6,1993,Ⅰ,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,日高町
|
||||||
|
103.12,新川,新川,にいかわ,Niikawa,watercourse,23,42.87811,142.43992,,,,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,沙流川水系
|
||||||
|
103.29,日高,日高郵便局,ひだかゆうびんきょく,Hidaka yuubinkyoku,post office,85,42.87748,142.44195,,,,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,
|
||||||
|
103.29,セイコーマート,セイコーマート日高道の駅店,せいこーまーとひだかみちのえきてん,Seikoomaatohidakamichinoeki ten,convenience store,85,42.87734,142.44216,,,,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,
|
||||||
|
104.39,ウシャップ,ペンケウシャップ川,,,watercourse,15,42.88146,142.45211,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
|
||||||
|
104.52,日高,北海道電力 日高発電所,ひだかはつでんしょ,Hidaka hatsudensho,power station,666,42.87724,142.45788,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,
|
||||||
|
105.67,沙流川,沙流川,,,watercourse,272,42.88829,142.46766,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
|
||||||
|
105.99,ユウケシュオマナイ,パンケユウケシュオマナイ川,,,watercourse,181,42.88869,142.46778,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
|
||||||
|
106.22,右左府,右左府発電所,うさっぷはつでんしょ,Usappu hatsudensho,power station,244,42.89314,142.46755,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,
|
||||||
|
106.22,右左府,右左府変電所,,,substation,245,42.89280,142.46713,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,
|
||||||
|
106.47,オワイタカ,オワイタカ沢川,,,watercourse,52,42.89254,142.47167,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
|
||||||
|
107.29,滝の沢川,滝の沢川,,,watercourse,18,42.89575,142.48126,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
|
||||||
|
107.83,ルウケシオマナイ,ルウケシオマナイ川,,,watercourse,134,42.89400,142.48842,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -0,0 +1,99 @@
|
|||||||
|
start,stop,start_s,stop_s,sung,object,kind,km,lat,lon,km_from,km_to
|
||||||
|
00:14.54,00:17.89,14.54,17.89,夕張郡由仁町川端,夕張郡由仁町川端,district,38.41,42.91715,141.90896,38.41,38.41
|
||||||
|
00:17.89,00:19.67,17.89,19.67,川端橋,川端橋,bridge,38.41,42.91715,141.90896,,
|
||||||
|
00:25.04,00:28.41,25.04,28.41,夕張郡栗山町滝下,夕張郡栗山町滝下,district,39.80,42.91197,141.91642,39.80,41.86
|
||||||
|
00:28.41,00:32.06,28.41,32.06,川端発電所,北海道企業局 川端発電所,power station,39.80,42.91197,141.91642,,
|
||||||
|
00:32.06,00:35.44,32.06,35.44,滝下八幡神社,滝下八幡神社,Shinto shrine,40.16,42.91183,141.92472,,
|
||||||
|
00:35.44,00:39.84,35.44,39.84,滝ノ下信号場,滝ノ下信号場,place (locality),40.57,42.90630,141.92320,,
|
||||||
|
00:39.84,00:42.01,39.84,42.01,雨霧橋,雨霧橋,bridge,41.86,42.91003,141.94197,,
|
||||||
|
00:42.45,00:45.15,42.45,45.15,夕張郡由仁町川端,夕張郡由仁町川端,district,43.03,42.91604,141.95220,43.03,43.03
|
||||||
|
00:45.15,00:46.69,45.15,46.69,竜仙橋,竜仙橋,bridge,43.03,42.91604,141.95220,,
|
||||||
|
00:47.63,00:50.08,47.63,50.08,夕張郡栗山町滝下,夕張郡栗山町滝下,district,44.00,42.91449,141.96321,44.00,44.00
|
||||||
|
00:50.08,00:52.88,50.08,52.88,草木橋,草木橋,bridge,44.00,42.91449,141.96321,,
|
||||||
|
00:52.88,00:54.73,52.88,54.73,滝の上こ線橋,滝の上こ線橋,bridge,44.00,42.91449,141.96321,,
|
||||||
|
00:56.44,00:59.95,56.44,59.95,夕張市滝ノ上,夕張市滝ノ上,district,44.62,42.91152,141.97021,44.62,45.29
|
||||||
|
00:59.95,01:03.47,59.95,63.47,滝の上発電所,北海道企業局 滝の上発電所,power station,44.62,42.91152,141.97021,,
|
||||||
|
01:03.47,01:06.94,63.47,66.94,滝ノ上信号場,滝ノ上信号場,railway signal post,45.21,42.91000,141.97642,,
|
||||||
|
01:06.94,01:08.78,66.94,68.78,滝ノ上神社,滝ノ上神社,Shinto shrine,45.29,42.90979,141.97873,,
|
||||||
|
01:10.50,01:12.05,70.50,72.05,夕張市紅葉山,夕張市紅葉山,district,49.71,42.91482,142.02439,49.71,55.60
|
||||||
|
01:12.05,01:13.86,72.05,73.86,十三里信号場,十三里信号場,place (locality),49.71,42.91482,142.02439,,
|
||||||
|
01:13.86,01:16.44,73.86,76.44,十三里橋,十三里橋,bridge,50.28,42.91772,142.03043,,
|
||||||
|
01:16.44,01:18.78,76.44,78.78,紅葉山駐車帯,紅葉山駐車帯,parking,50.52,42.91840,142.03331,,
|
||||||
|
01:18.78,01:21.06,78.78,81.06,瑞穂橋,瑞穂橋,bridge,52.11,42.93244,142.03548,,
|
||||||
|
01:21.06,01:24.44,81.06,84.44,セブンイレブン夕張紅葉山店,セブンイレブン夕張紅葉山店,convenience store,52.47,42.93507,142.03683,,
|
||||||
|
01:24.44,01:27.06,84.44,87.06,新夕張駅,新夕張駅,railway station,52.54,42.93670,142.03648,,
|
||||||
|
01:27.06,01:30.02,87.06,90.02,紅葉山郵便局,紅葉山郵便局,post office,52.61,42.93642,142.03925,,
|
||||||
|
01:30.02,01:32.30,90.02,92.30,紅葉山神社,紅葉山神社,Shinto shrine,52.85,42.93788,142.03956,,
|
||||||
|
01:32.30,01:35.24,92.30,95.24,セイコーマートもみじやま店,セイコーマートもみじやま店,convenience store,52.99,42.93910,142.04167,,
|
||||||
|
01:35.24,01:38.48,95.24,98.48,紅楓橋,紅楓橋,bridge,53.13,42.93855,142.04317,,
|
||||||
|
01:38.48,01:40.25,98.48,100.25,ポン久留喜橋,ポン久留喜橋,bridge,53.69,42.93697,142.04976,,
|
||||||
|
01:40.25,01:42.44,100.25,102.44,久留喜橋,久留喜橋,bridge,53.69,42.93697,142.04976,,
|
||||||
|
01:42.44,01:43.79,102.44,103.79,楓橋,楓橋,bridge,55.41,42.92957,142.06562,,
|
||||||
|
01:43.79,01:45.03,103.79,105.03,桂橋,桂橋,bridge,55.60,42.93026,142.06775,,
|
||||||
|
01:45.77,01:49.12,105.77,109.12,夕張市楓,夕張市楓,district,56.60,42.93414,142.07860,56.60,57.84
|
||||||
|
01:49.12,01:50.76,109.12,110.76,千紫橋,千紫橋,bridge,56.60,42.93414,142.07860,,
|
||||||
|
01:50.76,01:52.51,110.76,112.51,楓駐車帯,楓駐車帯,parking,57.08,42.93352,142.08412,,
|
||||||
|
01:52.51,01:53.80,112.51,113.80,万紅橋,万紅橋,bridge,57.25,42.93274,142.08627,,
|
||||||
|
01:53.80,01:55.54,113.80,115.54,登川神社,登川神社,Shinto shrine,57.44,42.93395,142.08819,,
|
||||||
|
01:55.54,01:57.32,115.54,117.32,楓信号場,楓信号場,railway signal post,57.76,42.93157,142.09230,,
|
||||||
|
01:57.32,01:59.00,117.32,119.00,登川橋,登川橋,bridge,57.84,42.93167,142.09327,,
|
||||||
|
01:59.00,02:00.74,119.00,120.74,登川郵便局,登川郵便局,post office,57.84,42.93185,142.09335,,
|
||||||
|
02:01.26,02:02.93,121.26,122.93,夕張市登川,夕張市登川,district,58.23,42.92922,142.09647,58.23,60.29
|
||||||
|
02:02.93,02:05.23,122.93,125.23,花園橋,花園橋,bridge,58.23,42.92922,142.09647,,
|
||||||
|
02:05.23,02:06.62,125.23,126.62,登川トンネル,登川トンネル,tunnel,60.29,42.92478,142.11599,,
|
||||||
|
03:30.80,03:34.55,210.80,214.55,勇払郡むかわ町穂別長和,勇払郡むかわ町穂別長和,district,62.36,42.90892,142.12482,62.36,62.94
|
||||||
|
03:34.55,03:36.77,214.55,216.77,オマナイ橋,オマナイ橋,bridge,62.36,42.90892,142.12482,,
|
||||||
|
03:36.77,03:39.41,216.77,219.41,桂木橋,桂木橋,bridge,62.94,42.90434,142.12726,,
|
||||||
|
03:41.13,03:44.62,221.13,224.62,勇払郡むかわ町穂別稲里,勇払郡むかわ町穂別稲里,district,64.44,42.89157,142.12305,64.44,71.86
|
||||||
|
03:44.62,03:46.67,224.62,226.67,大和橋,大和橋,bridge,64.44,42.89157,142.12305,,
|
||||||
|
03:46.67,03:48.38,226.67,228.38,豊進橋,豊進橋,bridge,66.69,42.87371,142.13048,,
|
||||||
|
03:48.38,03:51.61,228.38,231.61,豊進神社,豊進神社,Shinto shrine,66.78,42.87318,142.13135,,
|
||||||
|
03:51.61,03:53.14,231.61,233.14,豊里橋,豊里橋,bridge,67.48,42.87311,142.13854,,
|
||||||
|
03:53.14,03:55.52,233.14,235.52,稲豊橋,稲豊橋,bridge,67.60,42.87313,142.14000,,
|
||||||
|
03:55.52,03:57.78,235.52,237.78,稲里橋,稲里橋,bridge,68.63,42.87144,142.15164,,
|
||||||
|
03:57.78,04:01.08,237.78,241.08,稲里簡易郵便局,稲里簡易郵便局,post office,69.29,42.86734,142.15774,,
|
||||||
|
04:01.08,04:03.50,241.08,243.50,佐主橋,佐主橋,bridge,69.48,42.86894,142.16085,,
|
||||||
|
04:03.50,04:05.59,243.50,245.59,穂別ダム神社,穂別ダム神社,Shinto shrine,71.86,42.88475,142.17737,,
|
||||||
|
04:05.96,04:10.08,245.96,250.08,勇払郡むかわ町穂別長和,勇払郡むかわ町穂別長和,district,72.02,42.88619,142.17748,72.02,75.28
|
||||||
|
04:10.08,04:13.02,250.08,253.02,長和トンネル,長和トンネル,tunnel,72.02,42.88619,142.17748,,
|
||||||
|
04:13.02,04:15.39,253.02,255.39,穂別大橋,穂別大橋,bridge,72.29,42.88820,142.17918,,
|
||||||
|
04:15.39,04:18.81,255.39,258.81,山水橋,山水橋,bridge,72.90,42.89187,142.18476,,
|
||||||
|
04:18.81,04:23.20,258.81,263.20,沼の沢橋,沼の沢橋,bridge,74.31,42.88830,142.20015,,
|
||||||
|
04:23.20,04:25.27,263.20,265.27,稲里トンネル,稲里トンネル,tunnel,75.28,42.88343,142.20951,,
|
||||||
|
04:26.00,04:29.74,266.00,269.74,勇払郡むかわ町穂別福山,勇払郡むかわ町穂別福山,district,76.97,42.87802,142.22782,76.97,88.25
|
||||||
|
04:29.74,04:31.50,269.74,271.50,ペンケ橋,ペンケ橋,bridge,76.97,42.87802,142.22782,,
|
||||||
|
04:31.50,04:33.62,271.50,273.62,下福山橋,下福山橋,bridge,78.03,42.88572,142.23487,,
|
||||||
|
04:33.62,04:35.02,273.62,275.02,オロロップ橋,オロロップ橋,bridge,78.31,42.88721,142.23759,,
|
||||||
|
04:35.02,04:37.07,275.02,277.07,福山橋,福山橋,bridge,79.11,42.89249,142.24390,,
|
||||||
|
04:37.07,04:38.70,277.07,278.70,福山神社,福山神社,Shinto shrine,79.67,42.89114,142.25175,,
|
||||||
|
04:38.70,04:41.02,278.70,281.02,福山大橋,福山大橋,bridge,80.24,42.88678,142.25416,,
|
||||||
|
04:41.02,04:43.29,281.02,283.29,千坂橋,千坂橋,bridge,85.40,42.88901,142.29725,,
|
||||||
|
04:43.29,04:45.12,283.29,285.12,オコタン橋,オコタン橋,bridge,85.87,42.89179,142.30059,,
|
||||||
|
04:45.12,04:46.22,285.12,286.22,モトツトンネル,モトツトンネル,tunnel,86.24,42.89321,142.30463,,
|
||||||
|
04:46.22,04:48.98,286.22,288.98,モトツ橋にモトツシェルター,モトツ橋,bridge,87.12,42.89931,142.31156,,
|
||||||
|
04:46.22,04:48.98,286.22,288.98,モトツ橋にモトツシェルター,モトツシェルター,shed,87.12,42.89931,142.31156,,
|
||||||
|
04:48.98,04:51.39,288.98,291.39,福山トンネル,福山トンネル,tunnel,87.21,42.89988,142.31229,,
|
||||||
|
04:51.39,04:53.61,291.39,293.61,福穂橋,福穂橋,bridge,88.04,42.90322,142.32132,,
|
||||||
|
04:53.61,04:55.52,293.61,295.52,穂高トンネル,穂高トンネル,tunnel,88.25,42.90398,142.32368,,
|
||||||
|
05:22.60,05:25.76,322.60,325.76,勇払郡占冠村中央,勇払郡占冠村中央,district,90.58,42.90958,142.35128,90.58,96.43
|
||||||
|
05:25.76,05:28.88,325.76,328.88,穂高橋,穂高橋,bridge,90.58,42.90958,142.35128,,
|
||||||
|
05:28.88,05:31.52,328.88,331.52,高翠橋,高翠橋,bridge,90.90,42.90889,142.35485,,
|
||||||
|
05:31.52,05:34.95,331.52,334.95,占翠橋,占翠橋,bridge,91.14,42.90869,142.35768,,
|
||||||
|
05:34.95,05:38.08,334.95,338.08,名石駐車場,名石駐車場,parking,93.58,42.90980,142.36986,,
|
||||||
|
05:38.08,05:40.99,338.08,340.99,名石橋,名石橋,bridge,93.67,42.91029,142.37052,,
|
||||||
|
05:40.99,05:43.96,340.99,343.96,赤岩大橋,赤岩大橋,bridge,94.35,42.91317,142.37731,,
|
||||||
|
05:43.96,05:48.62,343.96,348.62,渓流大橋,渓流大橋,bridge,95.37,42.91502,142.38677,,
|
||||||
|
05:48.62,05:50.73,348.62,350.73,日高トンネル,日高トンネル,tunnel,96.43,42.91588,142.39911,,
|
||||||
|
07:11.39,07:14.14,431.39,434.14,沙流郡日高町日高,沙流郡日高町日高,district,100.60,42.89710,142.42716,100.60,100.60
|
||||||
|
07:14.14,07:16.38,434.14,436.38,日夕橋,日夕橋,bridge,100.60,42.89710,142.42716,,
|
||||||
|
07:28.85,07:31.68,448.85,451.68,沙流郡日高町宮下町,沙流郡日高町宮下町,district,102.36,42.88427,142.43553,102.36,102.36
|
||||||
|
07:31.68,07:34.09,451.68,454.09,日高神社,日高神社,Shinto shrine,102.36,42.88427,142.43553,,
|
||||||
|
07:42.90,07:45.51,462.90,465.51,沙流郡日高町本町東,沙流郡日高町本町東,district,103.12,42.87791,142.44001,103.12,103.29
|
||||||
|
07:45.51,07:49.88,465.51,469.88,新川橋,新川橋,bridge,103.12,42.87791,142.44001,,
|
||||||
|
07:49.88,07:52.76,469.88,472.76,セイコーマート日高道の駅店,セイコーマート日高道の駅店,convenience store,103.29,42.87734,142.44216,,
|
||||||
|
07:52.76,07:55.11,472.76,475.11,日高郵便局,日高郵便局,post office,103.29,42.87748,142.44195,,
|
||||||
|
07:56.69,07:59.36,476.69,479.36,沙流郡日高町千栄,沙流郡日高町千栄,district,104.52,42.87724,142.45788,104.52,104.52
|
||||||
|
07:59.36,08:02.15,479.36,482.15,北電日高発電所,北海道電力 日高発電所,power station,104.52,42.87724,142.45788,,
|
||||||
|
08:03.90,08:06.38,483.90,486.38,沙流郡日高町日高,沙流郡日高町日高,district,106.22,42.89314,142.46755,106.22,106.22
|
||||||
|
08:06.38,08:09.44,486.38,489.44,右左府発電所,右左府発電所,power station,106.22,42.89314,142.46755,,
|
||||||
|
08:10.93,08:12.66,490.93,492.66,東に続く,,closing,,,,,
|
||||||
|
@@ -0,0 +1 @@
|
|||||||
|
[[108.557,42.89528,142.49683],[108.603,42.89539,142.49736],[108.649,42.89556,142.49787],[108.695,42.89571,142.49842],[108.736,42.89578,142.4989],[108.794,42.89581,142.49962],[108.84,42.89571,142.50016],[108.946,42.89545,142.50142],[109.173,42.89489,142.5041],[109.371,42.89434,142.5064],[109.456,42.8942,142.50743],[109.52,42.89412,142.5082],[110.111,42.89351,142.51541],[110.297,42.89332,142.51768],[110.457,42.89317,142.51963],[110.482,42.89315,142.51995],[110.575,42.89305,142.52107],[110.738,42.89288,142.52306],[111.028,42.89258,142.5266],[111.056,42.89255,142.52694],[111.318,42.89229,142.53014],[111.333,42.89227,142.53031],[111.358,42.89224,142.53062],[111.359,42.89224,142.53064],[111.498,42.89212,142.53233],[111.527,42.89209,142.53269],[111.596,42.89201,142.53353],[111.671,42.89194,142.53444],[111.814,42.89181,142.53619],[111.842,42.89182,142.53653],[111.864,42.89177,142.5368],[111.958,42.89168,142.53794],[112.065,42.89155,142.53924],[112.09,42.89153,142.53954],[112.111,42.89153,142.53981],[112.138,42.89155,142.54013],[112.164,42.89159,142.54045],[112.196,42.89167,142.54083],[112.333,42.89204,142.54243],[112.688,42.89295,142.5466],[112.973,42.89375,142.54994],[113.048,42.89392,142.55082],[113.143,42.89417,142.55194],[113.202,42.89435,142.55262],[113.23,42.89444,142.55295],[113.27,42.89459,142.55338],[113.312,42.89478,142.55384],[113.395,42.8952,142.55467],[113.435,42.8954,142.55508],[113.492,42.89569,142.55566],[113.793,42.89718,142.55874],[113.829,42.89736,142.55911],[113.851,42.89747,142.55933],[113.987,42.89814,142.56073]]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[[30.456,42.94898,141.83538],[30.485,42.94902,141.83573],[31.021,42.94975,141.84224],[31.152,42.94995,141.84383],[31.305,42.95011,141.8457],[31.317,42.95013,141.84585],[31.385,42.95022,141.84667],[31.396,42.95024,141.8468],[31.405,42.95025,141.84692],[31.543,42.95043,141.84859],[31.633,42.95055,141.84968],[31.656,42.95058,141.84996],[31.663,42.95059,141.85004],[31.719,42.95067,141.85073],[31.953,42.95099,141.85357],[31.986,42.95103,141.85397],[32.022,42.95104,141.85441],[32.046,42.95104,141.8547],[32.061,42.95102,141.85489],[32.082,42.951,141.85514],[32.102,42.95095,141.85538],[32.124,42.95088,141.85564],[32.148,42.95079,141.8559],[32.17,42.95069,141.85614],[32.192,42.95059,141.85636],[32.214,42.95046,141.85658],[32.249,42.95025,141.8569],[32.278,42.95007,141.85715],[32.304,42.9499,141.85737],[32.315,42.94983,141.85746],[32.398,42.94927,141.85815],[32.462,42.94884,141.85865],[32.474,42.94875,141.85875],[32.607,42.94783,141.85978],[32.639,42.9476,141.86004],[32.72,42.94705,141.86067],[32.929,42.94561,141.86234],[32.957,42.94542,141.86256],[33.001,42.94512,141.86292],[33.138,42.94418,141.86399],[33.305,42.94303,141.8653],[33.489,42.94176,141.86678],[33.731,42.94012,141.86873],[33.99,42.93834,141.87077],[34.132,42.93738,141.87193],[34.161,42.93717,141.87214],[34.308,42.93617,141.87331],[34.584,42.93429,141.87554],[34.753,42.93312,141.87686],[35.006,42.9314,141.87888],[35.192,42.93012,141.88036],[35.335,42.92915,141.8815],[35.443,42.92841,141.88237],[35.527,42.92783,141.88304]]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,74 @@
|
|||||||
|
# Route data — 274
|
||||||
|
|
||||||
|
Copied from `novoyuuparosk-wiki/ncmr-songs/wip/274/`, which is where these are
|
||||||
|
generated and edited. **Treat everything here as read-only**: change it upstream
|
||||||
|
and re-copy, or the two will drift.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `274-lyrics-index.csv` | One row per sung line, resolved to its roster entry, with start/stop timing and coordinates. The file the video syncs to. |
|
||||||
|
| `274-lookup.csv` | The full roster — every named object on the stretch, sung or not. The page draws all of these; the index only covers the ones that get sung. |
|
||||||
|
| `274-route.json` | The road itself: `[km, lat, lon]` per point, for drawing the line. |
|
||||||
|
| `274-route-west.json` | A short run of R274 before the western cutoff, same format — km 30.46 to 35.53, ending exactly on the first point of `274-route.json`. The road arriving before 川端橋, in symmetry with the east stub. Also in the GeoJSON as `kind: route-west`. |
|
||||||
|
| `274-route-east.json` | A short continuation past the eastern cutoff — km 108.56 to 113.99, overlapping the main shape by 0.18 km so the join is seamless. For `東に続く`: the road visibly carrying on after the last named thing. Also in the GeoJSON as `kind: route-east`. |
|
||||||
|
| `274-districts.json` | The 大字 bands with entry/exit km — the strip across the top of the transect page. |
|
||||||
|
| `274-lookup.geojson` | Route line, one point per roster entry, and the bands as geometryless features. For anything that wants GeoJSON directly. |
|
||||||
|
| `274-transect.html` | The interactive page the video is based on. Self-contained; opens from disk. |
|
||||||
|
|
||||||
|
## Using the index
|
||||||
|
|
||||||
|
`start` / `stop` are `mm:ss.xx`; `start_s` / `stop_s` are the same in seconds.
|
||||||
|
`stop` is the next timestamp in the LRC, blank spacer lines included, so every
|
||||||
|
line has one and the spans butt up against each other without gaps.
|
||||||
|
|
||||||
|
Join to `274-lookup.csv` on `object`, which is unique across the roster. The
|
||||||
|
`sung` column is the line as it appears in the lyric — the three power stations
|
||||||
|
are sung without their operator prefix, so `sung` and `object` differ for those.
|
||||||
|
|
||||||
|
Two row types are not ordinary nodes:
|
||||||
|
|
||||||
|
- **`kind = district`** — the address headers. These have no single point; they
|
||||||
|
carry the first coordinate of the run they introduce, plus `km_from`/`km_to`
|
||||||
|
spanning it.
|
||||||
|
- **`kind = closing`** — `東に続く`, which is text rather than an object. No
|
||||||
|
coordinates.
|
||||||
|
|
||||||
|
**モトツ橋にモトツシェルター emits two rows** sharing one timing and one
|
||||||
|
coordinate. It is one structure to drive through — a bridge roofed by a shelter,
|
||||||
|
between モトツトンネル and 福山トンネル — and two separately inspected facilities
|
||||||
|
in the national registers. Light both or either; they are the same place.
|
||||||
|
|
||||||
|
## Caveats that matter for the visuals
|
||||||
|
|
||||||
|
**All three route shapes are single clean polylines.** Where R274 has a central
|
||||||
|
reservation — the divided climbs after 福山大橋 and through 占冠 — OpenStreetMap
|
||||||
|
draws each carriageway as its own way. The shapes follow one spine through those
|
||||||
|
sections: point spacing adds up to exactly the km span, and km strictly increases
|
||||||
|
along every file, so the line can be drawn point to point without sideways spikes.
|
||||||
|
|
||||||
|
**`km` is not an official kilopost.** It is measured along the OpenStreetMap
|
||||||
|
route geometry from a datum near 上野幌, because the route relation is broken
|
||||||
|
across the divided carriageway in 札幌. Official KP is roughly this plus 14 km,
|
||||||
|
unverified. Ordering, spacing and geometry are all internally consistent; only
|
||||||
|
the absolute labels would be wrong if shown as real KP.
|
||||||
|
|
||||||
|
**`offset_m` is the straight-line distance from the object to the road.** Some
|
||||||
|
things sung are well off the carriageway — 日高神社 is 221 m away, クオーベツダム
|
||||||
|
1798 m. If the camera follows the road, an object's own coordinate is not always
|
||||||
|
where the camera should point.
|
||||||
|
|
||||||
|
**The lyric is monotonic by km end to end** — no backward jumps — so a camera
|
||||||
|
driven by `start_s` moves forward continuously from km 38.41 to 106.22.
|
||||||
|
|
||||||
|
**The lyric ends before the music does.** Lines run 14.54 s to 492.66 s (8:12),
|
||||||
|
while the release master is 619.27 s (10:19). Roughly two minutes of music play
|
||||||
|
past 東に続く.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
How the roster was built, what was verified by hand, and what is derived rather
|
||||||
|
than measured: see `274-dev.md` in the source directory. Several entries exist
|
||||||
|
only because they were found on Google Maps and are absent from OpenStreetMap
|
||||||
|
entirely — ten of them, including four of the eight shrines.
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { MyComposition } from "./Composition";
|
import { MyComposition } from "./Composition";
|
||||||
|
import { Song274Transect } from "./song-274/Composition";
|
||||||
|
|
||||||
export const RemotionRoot: React.FC = () => {
|
export const RemotionRoot: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MyComposition />
|
<MyComposition />
|
||||||
|
<Song274Transect />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// lrc→srt converter — minimal, no external dependencies
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// node lrc2srt.ts <input.lrc> [output.srt]
|
|
||||||
//
|
|
||||||
// If output is omitted, writes to stdout.
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
function lrcTimeToSeconds(line: string): number | null {
|
|
||||||
const match = line.match(/\[(\d{1,2}):(\d{2})\.(\d{2,3})\]/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const minutes = parseInt(match[1], 10);
|
|
||||||
const seconds = parseInt(match[2], 10);
|
|
||||||
const centis = match[3].padEnd(3, "0").substring(0, 3);
|
|
||||||
const ms = parseInt(centis, 10);
|
|
||||||
|
|
||||||
return minutes * 60 + seconds + ms / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function secondsToSrtTime(t: number): string {
|
|
||||||
const hours = Math.floor(t / 3600);
|
|
||||||
const minutes = Math.floor((t % 3600) / 60);
|
|
||||||
const secs = Math.floor(t % 60);
|
|
||||||
const ms = Math.round((t - Math.floor(t)) * 1000);
|
|
||||||
|
|
||||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 1) {
|
|
||||||
console.error("Usage: lrc2srt.ts <input.lrc> [output.srt]");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = fs.readFileSync(args[0], "utf-8");
|
|
||||||
const lines = input.split(/\r?\n/);
|
|
||||||
const entries: { start: number; end: number; text: string }[] = [];
|
|
||||||
let currentStart: number | null = null;
|
|
||||||
let currentText: string | null = null;
|
|
||||||
|
|
||||||
for (const raw of lines) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
const timeMatch = trimmed.match(/\[(\d{1,2}):(\d{2})\.(\d{2,3})\]/);
|
|
||||||
const text = trimmed.replace(/\[\d{1,2}:\d{2}\.\d{2,3}\]/g, "").trim();
|
|
||||||
|
|
||||||
if (timeMatch && !currentStart) {
|
|
||||||
currentStart = lrcTimeToSeconds(raw);
|
|
||||||
currentText = text;
|
|
||||||
} else if (currentStart && currentText) {
|
|
||||||
// If there's a time tag, the current entry ends here
|
|
||||||
entries.push({ start: currentStart, end: currentStart + 1, text: currentText });
|
|
||||||
currentStart = lrcTimeToSeconds(raw);
|
|
||||||
currentText = text;
|
|
||||||
} else if (currentStart && !currentText) {
|
|
||||||
currentText = text;
|
|
||||||
} else if (!currentStart) {
|
|
||||||
// line without time tag, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStart && currentText) {
|
|
||||||
entries.push({ start: currentStart, end: currentStart + 1, text: currentText });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix end times: each entry ends when the next one starts
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
|
||||||
if (i + 1 < entries.length) {
|
|
||||||
entries[i].end = entries[i + 1].start;
|
|
||||||
} else {
|
|
||||||
entries[i].end = entries[i].start + 5; // default 5s for last line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const srtBlocks = entries.map((e, idx) => {
|
|
||||||
return `${idx + 1}\n${secondsToSrtTime(e.start)} --> ${secondsToSrtTime(e.end)}\n${e.text}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = srtBlocks.join("\n\n") + "\n";
|
|
||||||
const outPath = args[1];
|
|
||||||
if (outPath) {
|
|
||||||
fs.writeFileSync(outPath, output, "utf-8");
|
|
||||||
} else {
|
|
||||||
process.stdout.write(output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CalculateMetadataFunction, Composition } from "remotion";
|
||||||
|
import { loadTransectData } from "./data";
|
||||||
|
import { darkPalette } from "./palette";
|
||||||
|
import { Transect, type TransectProps } from "./Transect";
|
||||||
|
|
||||||
|
const FPS = 60;
|
||||||
|
/** Length of song-274/audio/274_release2.wav (see BOM.md). */
|
||||||
|
const AUDIO_DURATION_S = 619.270083;
|
||||||
|
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<TransectProps> = async ({
|
||||||
|
props,
|
||||||
|
}) => ({
|
||||||
|
props: { ...props, data: await loadTransectData() },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Song274Transect: React.FC = () => (
|
||||||
|
<Composition
|
||||||
|
id="Song274Transect"
|
||||||
|
component={Transect}
|
||||||
|
durationInFrames={Math.ceil(AUDIO_DURATION_S * FPS)}
|
||||||
|
fps={FPS}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
defaultProps={{
|
||||||
|
palette: darkPalette,
|
||||||
|
audio: "audio/274_release2.wav",
|
||||||
|
data: null,
|
||||||
|
}}
|
||||||
|
calculateMetadata={calculateMetadata}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
Html5Audio,
|
||||||
|
staticFile,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
} from "remotion";
|
||||||
|
import {
|
||||||
|
blendViews,
|
||||||
|
followView,
|
||||||
|
narrationParts,
|
||||||
|
overviewLeadKm,
|
||||||
|
overviewView,
|
||||||
|
overviewWeight,
|
||||||
|
smoothedFocus,
|
||||||
|
toCamera,
|
||||||
|
type Frame,
|
||||||
|
} from "./camera";
|
||||||
|
import type { Cue, RosterItem, TransectData } from "./data";
|
||||||
|
import { blackOpacity } from "./fade";
|
||||||
|
import {
|
||||||
|
extendBounds,
|
||||||
|
GROUND,
|
||||||
|
nodePositions,
|
||||||
|
prefixBounds,
|
||||||
|
projectGround,
|
||||||
|
routePath,
|
||||||
|
} from "./layout";
|
||||||
|
import { useWindowedAudioData } from "@remotion/media-utils";
|
||||||
|
import { isAwake, mixLooks, nodeLookAt, spectrumLook } from "./nodeAnimation";
|
||||||
|
import {
|
||||||
|
AUDIO_WINDOW_S,
|
||||||
|
audioWindowFrame,
|
||||||
|
bandLevels,
|
||||||
|
shuffledBandFrequencies,
|
||||||
|
spectrumWeight,
|
||||||
|
} from "./spectrum";
|
||||||
|
import type { TransectPalette } from "./palette";
|
||||||
|
import {
|
||||||
|
coveredRoute,
|
||||||
|
monotoneCurve,
|
||||||
|
paintableRoute,
|
||||||
|
progressKeyframes,
|
||||||
|
routeAtKm,
|
||||||
|
} from "./progress";
|
||||||
|
|
||||||
|
export type TransectProps = {
|
||||||
|
palette: TransectPalette;
|
||||||
|
/** Path under the public dir (song-274/). */
|
||||||
|
audio: string;
|
||||||
|
/** Filled in by calculateMetadata from song-274/route/. */
|
||||||
|
data: TransectData | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Road stroke widths in map units; they grow with zoom like everything else. */
|
||||||
|
const ROAD_AHEAD_WIDTH = 1.4;
|
||||||
|
const ROAD_COVERED_WIDTH = 2.4;
|
||||||
|
|
||||||
|
/** Each object's cues in time order; most nodes are sung exactly once. */
|
||||||
|
const cuesByObject = (cues: Cue[]): Map<string, Cue[]> => {
|
||||||
|
const map = new Map<string, Cue[]>();
|
||||||
|
for (const cue of cues) {
|
||||||
|
for (const object of cue.objects) {
|
||||||
|
const list = map.get(object) ?? [];
|
||||||
|
list.push(cue);
|
||||||
|
map.set(object, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The cue that governs an object at `t`: the latest one to have started. */
|
||||||
|
const currentCue = (list: Cue[] | undefined, t: number): Cue | undefined => {
|
||||||
|
let current: Cue | undefined;
|
||||||
|
for (const cue of list ?? []) {
|
||||||
|
if (cue.startS <= t) {
|
||||||
|
current = cue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, width, height, durationInFrames } = useVideoConfig();
|
||||||
|
const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]);
|
||||||
|
// The main road plus the east continuation. The paint runs into the latter
|
||||||
|
// after 東に続く; the camera and overview stay on the main road, so that run
|
||||||
|
// leaves the screen.
|
||||||
|
const paintRoute = useMemo(() => (data ? paintableRoute(data) : []), [data]);
|
||||||
|
// The dimmed road ahead includes the east continuation, so it shows all along.
|
||||||
|
const fullRoad = useMemo(
|
||||||
|
() => (projection && paintRoute.length > 1 ? routePath(paintRoute, projection) : ""),
|
||||||
|
[paintRoute, projection],
|
||||||
|
);
|
||||||
|
const bounds = useMemo(
|
||||||
|
() => (data && projection ? prefixBounds(data.route, projection) : []),
|
||||||
|
[data, projection],
|
||||||
|
);
|
||||||
|
const kmAt = useMemo(() => (data ? monotoneCurve(progressKeyframes(data)) : null), [data]);
|
||||||
|
const westRoad = useMemo(
|
||||||
|
() =>
|
||||||
|
data && projection && data.routeWest.length > 1
|
||||||
|
? routePath(data.routeWest, projection)
|
||||||
|
: "",
|
||||||
|
[data, projection],
|
||||||
|
);
|
||||||
|
const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
|
||||||
|
const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [data]);
|
||||||
|
// Nodes at identical coordinates are spread slightly apart along the road.
|
||||||
|
const positions = useMemo(
|
||||||
|
() => (data && projection ? nodePositions(data.items, data.route, projection) : null),
|
||||||
|
[data, projection],
|
||||||
|
);
|
||||||
|
// Each node gets one log-spaced band, dealt out in a seeded shuffle.
|
||||||
|
const frequencies = useMemo(
|
||||||
|
() => shuffledBandFrequencies(data ? data.items.length : 0),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||||
|
src: staticFile(audio),
|
||||||
|
frame: audioWindowFrame(frame, fps),
|
||||||
|
fps,
|
||||||
|
windowInSeconds: AUDIO_WINDOW_S,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data || !projection || !kmAt || !positions) {
|
||||||
|
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = frame / fps;
|
||||||
|
const fadeToBlack = blackOpacity(t, (durationInFrames - 1) / fps);
|
||||||
|
const headAt = (time: number) => {
|
||||||
|
const km = kmAt(time);
|
||||||
|
const pos = routeAtKm(data.route, km);
|
||||||
|
return { km, index: pos.index, x: projection.x(pos.lon), y: projection.y(pos.lat) };
|
||||||
|
};
|
||||||
|
const head = headAt(t);
|
||||||
|
const routeStartKm = data.route[0][0];
|
||||||
|
const routeEndKm = data.route[data.route.length - 1][0];
|
||||||
|
const lead = routeAtKm(
|
||||||
|
data.route,
|
||||||
|
Math.min(routeEndKm, head.km + overviewLeadKm(head.km - routeStartKm)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view: Frame = {
|
||||||
|
worldWidth: GROUND.width,
|
||||||
|
worldHeight: projection.height,
|
||||||
|
frameWidth: width,
|
||||||
|
frameHeight: height,
|
||||||
|
};
|
||||||
|
const camera = toCamera(
|
||||||
|
blendViews(
|
||||||
|
followView(smoothedFocus(headAt, t)),
|
||||||
|
overviewView(
|
||||||
|
extendBounds(bounds[lead.index], projection.x(lead.lon), projection.y(lead.lat)),
|
||||||
|
view,
|
||||||
|
),
|
||||||
|
overviewWeight(parts, t),
|
||||||
|
),
|
||||||
|
view,
|
||||||
|
);
|
||||||
|
|
||||||
|
// In the outro the nodes hand over from their lyric looks to the spectrum.
|
||||||
|
const spectrum = spectrumWeight(t);
|
||||||
|
const levels =
|
||||||
|
spectrum > 0 && audioData
|
||||||
|
? bandLevels({ audioData, dataOffsetInSeconds, frame, fps, frequencies })
|
||||||
|
: null;
|
||||||
|
const nodes = data.items.map((item: RosterItem, i) => {
|
||||||
|
const lyricLook = nodeLookAt(currentCue(cueMap.get(item.object), t), t);
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
look: levels ? mixLooks(lyricLook, spectrumLook(levels[i]), spectrum) : lyricLook,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const draw = ({ item, look }: (typeof nodes)[number]) => (
|
||||||
|
<circle
|
||||||
|
key={item.object}
|
||||||
|
cx={positions.get(item.object)?.x ?? projection.x(item.lon)}
|
||||||
|
cy={positions.get(item.object)?.y ?? projection.y(item.lat)}
|
||||||
|
r={look.radius}
|
||||||
|
fill={palette[item.family]}
|
||||||
|
opacity={look.opacity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: palette.background }}>
|
||||||
|
<Html5Audio src={staticFile(audio)} />
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
<g transform={`translate(${camera.originX} ${camera.originY}) scale(${camera.scale})`}>
|
||||||
|
<path
|
||||||
|
d={fullRoad}
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.roadAhead}
|
||||||
|
strokeWidth={ROAD_AHEAD_WIDTH}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* The road arriving from the west: already travelled, so always painted. */}
|
||||||
|
{westRoad ? (
|
||||||
|
<path
|
||||||
|
d={westRoad}
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.roadCovered}
|
||||||
|
strokeWidth={ROAD_COVERED_WIDTH}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<path
|
||||||
|
d={routePath(coveredRoute(paintRoute, head.km), projection)}
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.roadCovered}
|
||||||
|
strokeWidth={ROAD_COVERED_WIDTH}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Animating nodes last so they sit on top of their neighbours. */}
|
||||||
|
{nodes.filter((n) => !isAwake(n.look)).map(draw)}
|
||||||
|
{nodes.filter((n) => isAwake(n.look)).map(draw)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{/* Fade from and to black at the two ends (fade.ts). */}
|
||||||
|
{fadeToBlack > 0 ? (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#000000", opacity: fadeToBlack }} />
|
||||||
|
) : null}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Bounds } from "./layout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the map sits on screen.
|
||||||
|
*
|
||||||
|
* The song narrates the road in parts separated by long instrumental breaks.
|
||||||
|
* During a part the camera holds a fixed close zoom and follows the head of the
|
||||||
|
* covered road. When a part ends it pulls back to frame everything covered so
|
||||||
|
* far, and just before the next part it closes in on the head again, arriving
|
||||||
|
* as the first line starts. After the last part it pulls back for good, which
|
||||||
|
* by then frames the whole route.
|
||||||
|
*
|
||||||
|
* Views are described in map units ({zoom, centre}); `toCamera` turns one into
|
||||||
|
* the scale and origin the drawing uses: screen = map unit × scale + origin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zoom while following, relative to the whole route fitting the frame width. */
|
||||||
|
export const FOLLOW_ZOOM = 4;
|
||||||
|
/** Lines closer together than this belong to the same narration part. */
|
||||||
|
export const PART_GAP_S = 20;
|
||||||
|
/** Pull-back after a part ends, and close-in before the next one starts. */
|
||||||
|
export const ZOOM_OUT_S = 3;
|
||||||
|
export const ZOOM_IN_S = 3;
|
||||||
|
/**
|
||||||
|
* The camera follows a Gaussian-weighted average of the head's position, so a
|
||||||
|
* road zigzagging under a nearly straight run of objects does not shake it.
|
||||||
|
* This is the standard deviation, in seconds; the window spans ±3 of these.
|
||||||
|
*/
|
||||||
|
export const FOLLOW_SMOOTH_S = 0.6;
|
||||||
|
/**
|
||||||
|
* How often that window is sampled, in seconds. Independent of fps: finer
|
||||||
|
* sampling costs more per frame and changes the average only slightly.
|
||||||
|
*/
|
||||||
|
export const FOLLOW_SAMPLE_S = 1 / 30;
|
||||||
|
/**
|
||||||
|
* An overview frames the covered road plus some road ahead of the head, so the
|
||||||
|
* head does not sit against the frame edge: this fraction of the distance
|
||||||
|
* covered so far, but never less than the minimum.
|
||||||
|
*/
|
||||||
|
export const OVERVIEW_LEAD = 0.25;
|
||||||
|
export const OVERVIEW_LEAD_MIN_KM = 3;
|
||||||
|
/** Frame pixels kept clear around an overview. */
|
||||||
|
export const MARGIN_X = 80;
|
||||||
|
export const MARGIN_Y = 80;
|
||||||
|
|
||||||
|
export type Span = { startS: number; endS: number };
|
||||||
|
export type Point = { x: number; y: number };
|
||||||
|
export type View = { zoom: number; centreX: number; centreY: number };
|
||||||
|
export type Camera = { scale: number; originX: number; originY: number };
|
||||||
|
|
||||||
|
export type Frame = {
|
||||||
|
worldWidth: number;
|
||||||
|
worldHeight: number;
|
||||||
|
frameWidth: number;
|
||||||
|
frameHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
|
const easeInOutCubic = (p: number) =>
|
||||||
|
p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||||
|
|
||||||
|
/** Merge lyric lines into narration parts wherever the gap is short. */
|
||||||
|
export const narrationParts = (lines: { startS: number; stopS: number }[]): Span[] => {
|
||||||
|
const sorted = lines.slice().sort((a, b) => a.startS - b.startS);
|
||||||
|
const parts: Span[] = [];
|
||||||
|
for (const line of sorted) {
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
if (last && line.startS - last.endS <= PART_GAP_S) {
|
||||||
|
last.endS = Math.max(last.endS, line.stopS);
|
||||||
|
} else {
|
||||||
|
parts.push({ startS: line.startS, endS: line.stopS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0 while following the head, 1 while showing everything covered so far.
|
||||||
|
* The intro before the first part counts as following.
|
||||||
|
*/
|
||||||
|
export const overviewWeight = (parts: Span[], t: number): number => {
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
if (t < part.startS) {
|
||||||
|
if (i === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const out = clamp01((t - parts[i - 1].endS) / ZOOM_OUT_S);
|
||||||
|
const into = clamp01((part.startS - t) / ZOOM_IN_S);
|
||||||
|
return easeInOutCubic(Math.min(out, into));
|
||||||
|
}
|
||||||
|
if (t <= part.endS) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
return lastPart ? easeInOutCubic(clamp01((t - lastPart.endS) / ZOOM_OUT_S)) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The head's position averaged over a Gaussian window around `t`. */
|
||||||
|
export const smoothedFocus = (focusAt: (t: number) => Point, t: number): Point => {
|
||||||
|
const step = FOLLOW_SAMPLE_S;
|
||||||
|
const reach = 3 * FOLLOW_SMOOTH_S;
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
|
let sumW = 0;
|
||||||
|
for (let dt = -reach; dt <= reach + 1e-9; dt += step) {
|
||||||
|
const w = Math.exp(-(dt * dt) / (2 * FOLLOW_SMOOTH_S * FOLLOW_SMOOTH_S));
|
||||||
|
const p = focusAt(t + dt);
|
||||||
|
sumX += p.x * w;
|
||||||
|
sumY += p.y * w;
|
||||||
|
sumW += w;
|
||||||
|
}
|
||||||
|
return { x: sumX / sumW, y: sumY / sumW };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitScale = (frame: Frame) => (frame.frameWidth - 2 * MARGIN_X) / frame.worldWidth;
|
||||||
|
|
||||||
|
/** How much road beyond the head (km) an overview includes. */
|
||||||
|
export const overviewLeadKm = (coveredKm: number): number =>
|
||||||
|
Math.max(OVERVIEW_LEAD_MIN_KM, coveredKm * OVERVIEW_LEAD);
|
||||||
|
|
||||||
|
export const followView = (focus: Point): View => ({
|
||||||
|
zoom: FOLLOW_ZOOM,
|
||||||
|
centreX: focus.x,
|
||||||
|
centreY: focus.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Frame `bounds` with margins, never closer than following nor wider than the full map. */
|
||||||
|
export const overviewView = (bounds: Bounds, frame: Frame): View => {
|
||||||
|
const base = fitScale(frame);
|
||||||
|
const width = Math.max(bounds.maxX - bounds.minX, 1e-6);
|
||||||
|
const height = Math.max(bounds.maxY - bounds.minY, 1e-6);
|
||||||
|
const zoom = Math.min(
|
||||||
|
(frame.frameWidth - 2 * MARGIN_X) / (width * base),
|
||||||
|
(frame.frameHeight - 2 * MARGIN_Y) / (height * base),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
zoom: Math.min(FOLLOW_ZOOM, Math.max(1, zoom)),
|
||||||
|
centreX: (bounds.minX + bounds.maxX) / 2,
|
||||||
|
centreY: (bounds.minY + bounds.maxY) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Blend two views; zoom on a log scale so zooming in and out feel even. */
|
||||||
|
export const blendViews = (a: View, b: View, w: number): View => ({
|
||||||
|
zoom: Math.exp(Math.log(a.zoom) + (Math.log(b.zoom) - Math.log(a.zoom)) * w),
|
||||||
|
centreX: a.centreX + (b.centreX - a.centreX) * w,
|
||||||
|
centreY: a.centreY + (b.centreY - a.centreY) * w,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centre on `focus`, but never scroll past the map's ends: once the map is
|
||||||
|
* smaller than the view, centre the map itself.
|
||||||
|
*/
|
||||||
|
const clampAxis = (focus: number, worldSize: number, halfView: number, scale: number) => {
|
||||||
|
const low = halfView / scale;
|
||||||
|
const high = worldSize - halfView / scale;
|
||||||
|
return low >= high ? worldSize / 2 : Math.min(high, Math.max(low, focus));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toCamera = (view: View, frame: Frame): Camera => {
|
||||||
|
const scale = fitScale(frame) * view.zoom;
|
||||||
|
const centreX = clampAxis(view.centreX, frame.worldWidth, frame.frameWidth / 2 - MARGIN_X, scale);
|
||||||
|
const centreY = clampAxis(view.centreY, frame.worldHeight, frame.frameHeight / 2, scale);
|
||||||
|
return {
|
||||||
|
scale,
|
||||||
|
originX: frame.frameWidth / 2 - centreX * scale,
|
||||||
|
originY: frame.frameHeight / 2 - centreY * scale,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { staticFile } from "remotion";
|
||||||
|
import type { FeatureFamily } from "./palette";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the route data straight from `song-274/route/` (the public dir), so the
|
||||||
|
* video never holds its own copy that could drift from the wiki. See that
|
||||||
|
* folder's README for what each file is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RosterItem = {
|
||||||
|
object: string;
|
||||||
|
kind: string;
|
||||||
|
family: FeatureFamily;
|
||||||
|
/** Distance along the road; the nearest road point for off-road objects. */
|
||||||
|
km: number;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `[km, lat, lon]` along the road, in order of km. */
|
||||||
|
export type RoutePoint = [number, number, number];
|
||||||
|
|
||||||
|
/** One sung line: the roster entries it lights, where they are, and when. */
|
||||||
|
export type Cue = {
|
||||||
|
startS: number;
|
||||||
|
stopS: number;
|
||||||
|
/** Distance along the road; the nearest road point for off-road objects. */
|
||||||
|
km: number;
|
||||||
|
objects: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransectData = {
|
||||||
|
route: RoutePoint[];
|
||||||
|
/**
|
||||||
|
* The road carrying on past the eastern cutoff (274-route-east.json), painted
|
||||||
|
* only after 東に続く. Empty if the file is absent.
|
||||||
|
*/
|
||||||
|
routeEast: RoutePoint[];
|
||||||
|
/**
|
||||||
|
* The road arriving before the western cutoff (274-route-west.json), always
|
||||||
|
* drawn as covered. Empty if the file is absent.
|
||||||
|
*/
|
||||||
|
routeWest: RoutePoint[];
|
||||||
|
/** When 東に続く (the closing line) starts, or null if the index has none. */
|
||||||
|
closingStartS: number | null;
|
||||||
|
/** Only the entries the song names; nothing else is drawn. */
|
||||||
|
items: RosterItem[];
|
||||||
|
cues: Cue[];
|
||||||
|
/** Every lyric line, districts and 東に続く included: when words are sung. */
|
||||||
|
lines: { startS: number; stopS: number }[];
|
||||||
|
/** When the last lyric line (東に続く included) ends. */
|
||||||
|
lyricsEndS: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's famOf() grouping, plus shrines and railway. Railway goes by name as
|
||||||
|
* well as kind: some 信号場 are in the roster as `place (locality)`.
|
||||||
|
*/
|
||||||
|
const familyOf = (kind: string, object: string): FeatureFamily => {
|
||||||
|
if (
|
||||||
|
kind === "railway station" ||
|
||||||
|
kind === "railway signal post" ||
|
||||||
|
/信号場$/.test(object)
|
||||||
|
) {
|
||||||
|
return "railway";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
kind === "bridge" ||
|
||||||
|
kind === "tunnel" ||
|
||||||
|
kind === "shed" ||
|
||||||
|
kind === "watercourse"
|
||||||
|
) {
|
||||||
|
return kind;
|
||||||
|
} else if (
|
||||||
|
kind === "Shinto shrine"
|
||||||
|
) {
|
||||||
|
return "shrine";
|
||||||
|
}
|
||||||
|
return kind.indexOf("place") === 0 ? "place" : "other";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** RFC 4180 CSV into header-keyed rows. Quoted fields may hold commas. */
|
||||||
|
const parseCsv = (text: string): Record<string, string>[] => {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||||
|
|
||||||
|
for (let i = 0; i < src.length; i++) {
|
||||||
|
const c = src[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (c !== '"') {
|
||||||
|
field += c;
|
||||||
|
} else if (src[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
quoted = false;
|
||||||
|
}
|
||||||
|
} else if (c === '"') {
|
||||||
|
quoted = true;
|
||||||
|
} else if (c === ",") {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
} else if (c === "\n" || c === "\r") {
|
||||||
|
if (c === "\r" && src[i + 1] === "\n") {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = "";
|
||||||
|
} else {
|
||||||
|
field += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field !== "" || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [header, ...body] = rows.filter((r) => r.length > 1 || r[0] !== "");
|
||||||
|
return body.map((r) => {
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
header.forEach((key, j) => {
|
||||||
|
record[key] = r[j] === undefined ? "" : r[j];
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchText = async (path: string): Promise<string> => {
|
||||||
|
const res = await fetch(staticFile(path));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Could not load ${path}: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Like fetchText, but a missing file (404) is not an error: returns null. */
|
||||||
|
const fetchOptionalText = async (path: string): Promise<string | null> => {
|
||||||
|
const res = await fetch(staticFile(path));
|
||||||
|
if (res.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Could not load ${path}: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadTransectData = async (): Promise<TransectData> => {
|
||||||
|
const [routeJson, routeEastJson, routeWestJson, lookupCsv, indexCsv] = await Promise.all([
|
||||||
|
fetchText("route/274-route.json"),
|
||||||
|
fetchOptionalText("route/274-route-east.json"),
|
||||||
|
fetchOptionalText("route/274-route-west.json"),
|
||||||
|
fetchText("route/274-lookup.csv"),
|
||||||
|
fetchText("route/274-lyrics-index.csv"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roster = new Map<string, RosterItem>();
|
||||||
|
for (const r of parseCsv(lookupCsv)) {
|
||||||
|
roster.set(r.object, {
|
||||||
|
object: r.object,
|
||||||
|
kind: r.kind,
|
||||||
|
family: familyOf(r.kind, r.object),
|
||||||
|
km: Number(r.km),
|
||||||
|
lat: Number(r.lat),
|
||||||
|
lon: Number(r.lon),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: RosterItem[] = [];
|
||||||
|
const cues: Cue[] = [];
|
||||||
|
const lines: TransectData["lines"] = [];
|
||||||
|
let lyricsEndS = 0;
|
||||||
|
let closingStartS: number | null = null;
|
||||||
|
for (const r of parseCsv(indexCsv)) {
|
||||||
|
lyricsEndS = Math.max(lyricsEndS, Number(r.stop_s));
|
||||||
|
lines.push({ startS: Number(r.start_s), stopS: Number(r.stop_s) });
|
||||||
|
if (r.kind === "closing") {
|
||||||
|
closingStartS = Number(r.start_s);
|
||||||
|
}
|
||||||
|
// Address headers and the closing line have no single node to light.
|
||||||
|
if (r.kind === "district" || r.kind === "closing") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const item = roster.get(r.object);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Lyrics index names ${r.object}, which is not in the roster`);
|
||||||
|
}
|
||||||
|
items.push(item);
|
||||||
|
|
||||||
|
const startS = Number(r.start_s);
|
||||||
|
const stopS = Number(r.stop_s);
|
||||||
|
const last = cues[cues.length - 1];
|
||||||
|
// モトツ橋にモトツシェルター emits two rows sharing one timing: light both.
|
||||||
|
if (last && last.startS === startS && last.stopS === stopS) {
|
||||||
|
last.objects.push(r.object);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cues.push({ startS, stopS, km: Number(r.km), objects: [r.object] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
route: JSON.parse(routeJson) as RoutePoint[],
|
||||||
|
routeEast: routeEastJson ? (JSON.parse(routeEastJson) as RoutePoint[]) : [],
|
||||||
|
routeWest: routeWestJson ? (JSON.parse(routeWestJson) as RoutePoint[]) : [],
|
||||||
|
closingStartS,
|
||||||
|
items,
|
||||||
|
cues,
|
||||||
|
lines,
|
||||||
|
lyricsEndS,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Fade from and to a completely black screen at the two ends of the video: a
|
||||||
|
* black layer over everything, fully opaque on the first and last frames.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Seconds of complete black at start */
|
||||||
|
export const FADE_IN_PRE = 0.4;
|
||||||
|
/** Seconds of complete black at end */
|
||||||
|
export const FADE_OUT_POST = 0.4;
|
||||||
|
/** Seconds from black to the picture at the start. 0 disables the fade-in. */
|
||||||
|
export const FADE_IN_S = 0.3;
|
||||||
|
/** Seconds from the picture to black at the end. 0 disables the fade-out. */
|
||||||
|
export const FADE_OUT_S = 0.2;
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity of the black layer at time `t`.
|
||||||
|
* @param lastFrameS time of the video's last frame, (durationInFrames - 1) / fps,
|
||||||
|
* so that frame is fully black rather than nearly so
|
||||||
|
*/
|
||||||
|
export const blackOpacity = (t: number, lastFrameS: number): number => {
|
||||||
|
const fadeIn = (t < FADE_IN_PRE) ? 1 : (FADE_IN_S > 0 ? 1 - clamp01((t - FADE_IN_PRE) / FADE_IN_S) : 0);
|
||||||
|
// Black from here to the last frame; the fade-out ramp ends exactly here.
|
||||||
|
const blackFromS = lastFrameS - FADE_OUT_POST;
|
||||||
|
const fadeOut = (t >= blackFromS) ? 1 : (FADE_OUT_S > 0 ? clamp01((t - (blackFromS - FADE_OUT_S)) / FADE_OUT_S) : 0);
|
||||||
|
return Math.max(fadeIn, fadeOut);
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { RosterItem, RoutePoint } from "./data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Ground — true shape" projection of `274-transect.html`, in the page's
|
||||||
|
* own units: an equirectangular plot with longitude shrunk by cos(lat), so
|
||||||
|
* distances read true east–west and north–south. camera.ts decides how these
|
||||||
|
* units land on screen.
|
||||||
|
*/
|
||||||
|
export const GROUND = {
|
||||||
|
width: 1200,
|
||||||
|
pad: 34,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Projection = {
|
||||||
|
height: number;
|
||||||
|
x: (lon: number) => number;
|
||||||
|
y: (lat: number) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectGround = (route: RoutePoint[]): Projection => {
|
||||||
|
const lats = route.map((p) => p[1]);
|
||||||
|
const lons = route.map((p) => p[2]);
|
||||||
|
const la0 = Math.min(...lats);
|
||||||
|
const la1 = Math.max(...lats);
|
||||||
|
const lo0 = Math.min(...lons);
|
||||||
|
const lo1 = Math.max(...lons);
|
||||||
|
|
||||||
|
const kx = Math.cos((((la0 + la1) / 2) * Math.PI) / 180);
|
||||||
|
const sx = (lo1 - lo0) * kx;
|
||||||
|
const sy = la1 - la0;
|
||||||
|
const iw = GROUND.width - 2 * GROUND.pad;
|
||||||
|
const ih = (iw * sy) / sx;
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: ih + 2 * GROUND.pad,
|
||||||
|
x: (lon) => GROUND.pad + (((lon - lo0) * kx) / sx) * iw,
|
||||||
|
y: (lat) => GROUND.pad + ih - ((lat - la0) / sy) * ih,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance, in map units, between nodes that share exactly the same roster
|
||||||
|
* coordinates (草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, モトツ橋 /
|
||||||
|
* モトツシェルター): small, so they read as a doubled dot on the road. Grows with
|
||||||
|
* zoom like the nodes themselves. (1 map unit ≈ 44 m on the ground here.)
|
||||||
|
*/
|
||||||
|
export const COINCIDENT_SPREAD = 1.5;
|
||||||
|
|
||||||
|
export type NodePosition = { x: number; y: number };
|
||||||
|
|
||||||
|
/** Direction of the road at `km`, as a unit vector in map units. */
|
||||||
|
const roadDirection = (route: RoutePoint[], km: number, proj: Projection) => {
|
||||||
|
const at = (k: number) => {
|
||||||
|
let i = 1;
|
||||||
|
while (i < route.length - 1 && route[i][0] < k) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const [k0, lat0, lon0] = route[i - 1];
|
||||||
|
const [k1, lat1, lon1] = route[i];
|
||||||
|
const p = k1 === k0 ? 0 : Math.min(1, Math.max(0, (k - k0) / (k1 - k0)));
|
||||||
|
return { x: proj.x(lon0 + (lon1 - lon0) * p), y: proj.y(lat0 + (lat1 - lat0) * p) };
|
||||||
|
};
|
||||||
|
const a = at(km - 0.1);
|
||||||
|
const b = at(km + 0.1);
|
||||||
|
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
return length > 0 ? { x: (b.x - a.x) / length, y: (b.y - a.y) / length } : { x: 1, y: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each node is drawn, by object name. Usually its roster coordinate; nodes
|
||||||
|
* sharing exactly the same coordinate are spread COINCIDENT_SPREAD apart along
|
||||||
|
* the road there, in road order, centred on the shared point. All such pairs are
|
||||||
|
* on-road structures, so they stay on the road. Keep the spread small: at 6,
|
||||||
|
* モトツシェルター landed on 福山トンネル, only 90 m further on.
|
||||||
|
*/
|
||||||
|
export const nodePositions = (
|
||||||
|
items: RosterItem[],
|
||||||
|
route: RoutePoint[],
|
||||||
|
proj: Projection,
|
||||||
|
): Map<string, NodePosition> => {
|
||||||
|
const groups = new Map<string, RosterItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.lat},${item.lon}`;
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(item);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Map<string, NodePosition>();
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const x = proj.x(group[0].lon);
|
||||||
|
const y = proj.y(group[0].lat);
|
||||||
|
if (group.length === 1) {
|
||||||
|
positions.set(group[0].object, { x, y });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ordered = group.slice().sort((a, b) => a.km - b.km);
|
||||||
|
const dir = roadDirection(route, ordered[0].km, proj);
|
||||||
|
ordered.forEach((item, i) => {
|
||||||
|
const offset = (i - (ordered.length - 1) / 2) * COINCIDENT_SPREAD;
|
||||||
|
positions.set(item.object, { x: x + dir.x * offset, y: y + dir.y * offset });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
|
||||||
|
/** Bounds of the projected route from its start up to each point, inclusive. */
|
||||||
|
export const prefixBounds = (route: RoutePoint[], proj: Projection): Bounds[] => {
|
||||||
|
const out: Bounds[] = [];
|
||||||
|
for (const p of route) {
|
||||||
|
const x = proj.x(p[2]);
|
||||||
|
const y = proj.y(p[1]);
|
||||||
|
const prev = out[out.length - 1];
|
||||||
|
out.push(prev ? extendBounds(prev, x, y) : { minX: x, minY: y, maxX: x, maxY: y });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extendBounds = (b: Bounds, x: number, y: number): Bounds => ({
|
||||||
|
minX: Math.min(b.minX, x),
|
||||||
|
minY: Math.min(b.minY, y),
|
||||||
|
maxX: Math.max(b.maxX, x),
|
||||||
|
maxY: Math.max(b.maxY, y),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const routePath = (points: RoutePoint[], proj: Projection): string =>
|
||||||
|
points
|
||||||
|
.map(
|
||||||
|
(p, i) =>
|
||||||
|
`${i ? "L" : "M"}${proj.x(p[2]).toFixed(2)} ${proj.y(p[1]).toFixed(2)}`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Cue } from "./data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one node looks at one instant. The animation functions below produce
|
||||||
|
* these, and Transect.tsx only draws them, so adding a property here (a colour,
|
||||||
|
* a stroke, a glow) is the only change the drawing needs to follow.
|
||||||
|
*/
|
||||||
|
export type NodeLook = {
|
||||||
|
radius: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node that is not being sung. Radius is in map units (the page's pixels),
|
||||||
|
* so nodes grow with the camera's zoom. (The page itself uses 3 at 0.85.)
|
||||||
|
*/
|
||||||
|
export const RESTING: NodeLook = { radius: 2.5, opacity: 0.9 };
|
||||||
|
/** A node while its line is sung: the page's selected mark. */
|
||||||
|
export const LIT: NodeLook = { radius: 7, opacity: 1 };
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
|
/** Slightly slower lighting up and switching off. Relatively slower decay. */
|
||||||
|
const LIGHT_UP_S = 0.12;
|
||||||
|
const SWITCH_OFF_S = 0.5;
|
||||||
|
|
||||||
|
|
||||||
|
/** Blend two looks; p = 0 is `from`, p = 1 is `to`. */
|
||||||
|
export const mixLooks = (from: NodeLook, to: NodeLook, p: number): NodeLook => ({
|
||||||
|
radius: from.radius + (to.radius - from.radius) * p,
|
||||||
|
opacity: from.opacity + (to.opacity - from.opacity) * p,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node being switched on.
|
||||||
|
* @param elapsedS seconds since its line started (0 at the lyric timestamp)
|
||||||
|
* @param from the look it starts from
|
||||||
|
*/
|
||||||
|
export const lightUp = (elapsedS: number, from: NodeLook = RESTING): NodeLook =>
|
||||||
|
mixLooks(from, LIT, clamp01(elapsedS / LIGHT_UP_S));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node being switched off.
|
||||||
|
* @param elapsedS seconds since its line ended (0 at the next lyric timestamp)
|
||||||
|
* @param from the look it had when it was switched off
|
||||||
|
*/
|
||||||
|
export const switchOff = (elapsedS: number, from: NodeLook = LIT): NodeLook =>
|
||||||
|
mixLooks(from, RESTING, clamp01(elapsedS / SWITCH_OFF_S));
|
||||||
|
|
||||||
|
/** Longest a node holds fully lit, after lighting up, before it switches off. */
|
||||||
|
export const MAX_LIT_S = 0.9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a cue's nodes start switching off: when the line ends, or once they have
|
||||||
|
* lit up and held for MAX_LIT_S, whichever comes first, so a long line does not
|
||||||
|
* keep a node lit for too long.
|
||||||
|
*/
|
||||||
|
export const switchOffAt = (cue: Cue): number =>
|
||||||
|
Math.min(cue.stopS, cue.startS + LIGHT_UP_S + MAX_LIT_S);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The look of a node at time `t`, given the cue that lights it (or none).
|
||||||
|
* Before the line: resting. Then lighting up until switchOffAt(). After that:
|
||||||
|
* switching off, starting from however far the light-up got, so a short line
|
||||||
|
* never jumps.
|
||||||
|
*/
|
||||||
|
export const nodeLookAt = (cue: Cue | undefined, t: number): NodeLook => {
|
||||||
|
if (!cue || t < cue.startS) {
|
||||||
|
return RESTING;
|
||||||
|
}
|
||||||
|
const offS = switchOffAt(cue);
|
||||||
|
if (t < offS) {
|
||||||
|
return lightUp(t - cue.startS);
|
||||||
|
}
|
||||||
|
return switchOff(t - offS, lightUp(offS - cue.startS));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A node at full amplitude in the outro spectrum (see spectrum.ts). */
|
||||||
|
export const SPECTRUM_PEAK: NodeLook = { radius: 9, opacity: 1 };
|
||||||
|
|
||||||
|
/** level scaling helper */
|
||||||
|
export const levelExponential = (level: number, exponent: number = 2.0): number =>
|
||||||
|
Math.pow(clamp01(level), exponent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node in the outro spectrum.
|
||||||
|
* @param level its band's level, 0 (silence: resting) to 1 (SPECTRUM_PEAK)
|
||||||
|
*/
|
||||||
|
export const spectrumLook = (level: number): NodeLook =>
|
||||||
|
mixLooks(RESTING, SPECTRUM_PEAK, clamp01(levelExponential(level)));
|
||||||
|
|
||||||
|
/** Whether a look is anything other than resting (drawn on top if so). */
|
||||||
|
export const isAwake = (look: NodeLook): boolean =>
|
||||||
|
look.radius !== RESTING.radius || look.opacity !== RESTING.opacity;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Every colour the 274 transect video draws, named by what it is for rather than
|
||||||
|
* what it looks like. Change values here, or override `palette` in the Studio's
|
||||||
|
* input props, without touching the drawing code.
|
||||||
|
*
|
||||||
|
* The dark palette is the video's own, grown from the dark palette of
|
||||||
|
* `song-274/route/274-transect.html`; comments name the page's CSS variable each
|
||||||
|
* token corresponds to. The light palette is the page's, extended with the
|
||||||
|
* tokens the video added (shrine, railway).
|
||||||
|
*/
|
||||||
|
export type TransectPalette = {
|
||||||
|
/** Frame background. */
|
||||||
|
background: string;
|
||||||
|
/** The whole road, before the song has covered it. */
|
||||||
|
roadAhead: string;
|
||||||
|
/** The road the song has covered so far. */
|
||||||
|
roadCovered: string;
|
||||||
|
/** Node fill per feature family, as on the page's legend. */
|
||||||
|
bridge: string;
|
||||||
|
tunnel: string;
|
||||||
|
shed: string;
|
||||||
|
watercourse: string;
|
||||||
|
place: string;
|
||||||
|
/** Stations and every 信号場, whatever their roster kind. */
|
||||||
|
railway: string;
|
||||||
|
shrine: string;
|
||||||
|
other: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureFamily =
|
||||||
|
| "bridge"
|
||||||
|
| "tunnel"
|
||||||
|
| "shed"
|
||||||
|
| "watercourse"
|
||||||
|
| "place"
|
||||||
|
| "railway"
|
||||||
|
| "shrine"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export const darkPalette: TransectPalette = {
|
||||||
|
background: "#28323d", // --paper
|
||||||
|
roadAhead: "#49565f", // --rule
|
||||||
|
roadCovered: "#fdb021", // --road
|
||||||
|
bridge: "#9be9de", // --bridge
|
||||||
|
tunnel: "#a6ccc2", // --tunnel
|
||||||
|
shed: "#eba9eb", // --shed
|
||||||
|
watercourse: "#5FB6D6", // --water
|
||||||
|
place: "#68ad7f", // --place
|
||||||
|
railway: "#68ad7f", // --railway
|
||||||
|
shrine: "#b94141", // --shrine
|
||||||
|
other: "#8b9ab8", // --util
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightPalette: TransectPalette = {
|
||||||
|
background: "#E7ECEE",
|
||||||
|
roadAhead: "#C0CCD2",
|
||||||
|
roadCovered: "#2E4A5A",
|
||||||
|
bridge: "#116B62",
|
||||||
|
tunnel: "#3A4650",
|
||||||
|
shed: "#7A5AA6",
|
||||||
|
watercourse: "#2F7E9E",
|
||||||
|
place: "#8A6D1F",
|
||||||
|
railway: "#1F6E43",
|
||||||
|
shrine: "#9B2C2C",
|
||||||
|
other: "#5B6B75",
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { RoutePoint, TransectData } from "./data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far along the road (km) the song has got at any moment: the head of the
|
||||||
|
* "covered so far" paint, and what the camera follows.
|
||||||
|
*
|
||||||
|
* The head is pinned to each sung object's km at the instant its line starts,
|
||||||
|
* and glides between those pins on a monotone cubic curve: it passes through
|
||||||
|
* every pin exactly, never runs backwards, and changes speed smoothly rather
|
||||||
|
* than lurching at each object.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Keyframe = { t: number; km: number };
|
||||||
|
|
||||||
|
/** How long the paint takes to run the east continuation once 東に続く starts. */
|
||||||
|
export const EAST_RUN_S = 75.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins, in time order: the start of the route at 0 s and each sung line's
|
||||||
|
* object. Then the end of the main road as 東に続く starts, and the end of the
|
||||||
|
* east continuation EAST_RUN_S later. Without a closing line or an east
|
||||||
|
* continuation, the main road simply ends as the last lyric line ends.
|
||||||
|
*/
|
||||||
|
export const progressKeyframes = (data: TransectData): Keyframe[] => {
|
||||||
|
const { route, routeEast, closingStartS, cues, lyricsEndS } = data;
|
||||||
|
const keys: Keyframe[] = [{ t: 0, km: route[0][0] }];
|
||||||
|
// Keep time strictly increasing and km never decreasing.
|
||||||
|
const pin = (t: number, km: number) => {
|
||||||
|
const last = keys[keys.length - 1];
|
||||||
|
if (t <= last.t) {
|
||||||
|
last.km = Math.max(last.km, km);
|
||||||
|
} else {
|
||||||
|
keys.push({ t, km: Math.max(last.km, km) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const cue of cues) {
|
||||||
|
pin(cue.startS, cue.km);
|
||||||
|
}
|
||||||
|
const mainEndKm = route[route.length - 1][0];
|
||||||
|
if (closingStartS !== null && routeEast.length > 0) {
|
||||||
|
pin(closingStartS, mainEndKm);
|
||||||
|
pin(closingStartS + EAST_RUN_S, routeEast[routeEast.length - 1][0]);
|
||||||
|
} else {
|
||||||
|
pin(lyricsEndS, mainEndKm);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The road the paint can travel: the main route, then the east continuation
|
||||||
|
* from where it passes the main route's end (the two overlap slightly).
|
||||||
|
*/
|
||||||
|
export const paintableRoute = (data: TransectData): RoutePoint[] => {
|
||||||
|
const mainEndKm = data.route[data.route.length - 1][0];
|
||||||
|
return data.route.concat(data.routeEast.filter((p) => p[0] > mainEndKm));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fritsch–Carlson monotone cubic Hermite interpolation through `keys`.
|
||||||
|
* Returns km as a function of time, held flat before the first and after the
|
||||||
|
* last keyframe.
|
||||||
|
*/
|
||||||
|
export const monotoneCurve = (keys: Keyframe[]): ((t: number) => number) => {
|
||||||
|
const n = keys.length;
|
||||||
|
if (n === 1) {
|
||||||
|
return () => keys[0].km;
|
||||||
|
}
|
||||||
|
const secant: number[] = [];
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
secant.push((keys[i + 1].km - keys[i].km) / (keys[i + 1].t - keys[i].t));
|
||||||
|
}
|
||||||
|
const tangent: number[] = [secant[0]];
|
||||||
|
for (let i = 1; i < n - 1; i++) {
|
||||||
|
tangent.push(secant[i - 1] * secant[i] <= 0 ? 0 : (secant[i - 1] + secant[i]) / 2);
|
||||||
|
}
|
||||||
|
tangent.push(secant[n - 2]);
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
if (secant[i] === 0) {
|
||||||
|
tangent[i] = 0;
|
||||||
|
tangent[i + 1] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const a = tangent[i] / secant[i];
|
||||||
|
const b = tangent[i + 1] / secant[i];
|
||||||
|
const h = a * a + b * b;
|
||||||
|
if (h > 9) {
|
||||||
|
const tau = 3 / Math.sqrt(h);
|
||||||
|
tangent[i] = tau * a * secant[i];
|
||||||
|
tangent[i + 1] = tau * b * secant[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (t) => {
|
||||||
|
if (t <= keys[0].t) {
|
||||||
|
return keys[0].km;
|
||||||
|
}
|
||||||
|
if (t >= keys[n - 1].t) {
|
||||||
|
return keys[n - 1].km;
|
||||||
|
}
|
||||||
|
let i = 0;
|
||||||
|
while (keys[i + 1].t < t) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const h = keys[i + 1].t - keys[i].t;
|
||||||
|
const s = (t - keys[i].t) / h;
|
||||||
|
const s2 = s * s;
|
||||||
|
const s3 = s2 * s;
|
||||||
|
return (
|
||||||
|
(2 * s3 - 3 * s2 + 1) * keys[i].km +
|
||||||
|
(s3 - 2 * s2 + s) * h * tangent[i] +
|
||||||
|
(-2 * s3 + 3 * s2) * keys[i + 1].km +
|
||||||
|
(s3 - s2) * h * tangent[i + 1]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoutePosition = {
|
||||||
|
/** Last route point at or before this km. */
|
||||||
|
index: number;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The point on the road at `km`, interpolated between route points. */
|
||||||
|
export const routeAtKm = (route: RoutePoint[], km: number): RoutePosition => {
|
||||||
|
const lastIndex = route.length - 1;
|
||||||
|
if (km <= route[0][0]) {
|
||||||
|
return { index: 0, lat: route[0][1], lon: route[0][2] };
|
||||||
|
}
|
||||||
|
if (km >= route[lastIndex][0]) {
|
||||||
|
return { index: lastIndex, lat: route[lastIndex][1], lon: route[lastIndex][2] };
|
||||||
|
}
|
||||||
|
let low = 0;
|
||||||
|
let high = lastIndex;
|
||||||
|
while (high - low > 1) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (route[mid][0] <= km) {
|
||||||
|
low = mid;
|
||||||
|
} else {
|
||||||
|
high = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [k0, lat0, lon0] = route[low];
|
||||||
|
const [k1, lat1, lon1] = route[high];
|
||||||
|
const p = k1 === k0 ? 0 : (km - k0) / (k1 - k0);
|
||||||
|
return { index: low, lat: lat0 + (lat1 - lat0) * p, lon: lon0 + (lon1 - lon0) * p };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The covered stretch of road: every route point up to the head, then the head. */
|
||||||
|
export const coveredRoute = (route: RoutePoint[], km: number): RoutePoint[] => {
|
||||||
|
const head = routeAtKm(route, km);
|
||||||
|
return [...route.slice(0, head.index + 1), [km, head.lat, head.lon]];
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { visualizeAudio, type MediaUtilsAudioData } from "@remotion/media-utils";
|
||||||
|
import { random } from "remotion";
|
||||||
|
import { secondsToFrames } from "./time";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outro spectrum: from SPECTRUM_START_S the nodes stop following the lyric
|
||||||
|
* and act as a spectrum analyser. The log-spaced frequency bands are dealt out
|
||||||
|
* to the nodes in a seeded shuffle (see BAND_SEED), with no relation to where
|
||||||
|
* a node is on the road; each node's size follows its band's amplitude.
|
||||||
|
*
|
||||||
|
* Everything here is a pure function of the frame, so Studio previews and
|
||||||
|
* renders agree. Starting values were calibrated on the outro of
|
||||||
|
* 274_release2.wav (8:20 to the end, left channel).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** When the spectrum takes over from the lyric-driven looks. */
|
||||||
|
export const SPECTRUM_START_S = 504.4;
|
||||||
|
/** How long the hand-over from lyric looks to spectrum looks takes. */
|
||||||
|
export const SPECTRUM_FADE_S = 1.5;
|
||||||
|
|
||||||
|
/** Band centres are log-spaced across this range, one band per node. */
|
||||||
|
export const FREQ_LOW_HZ = 40;
|
||||||
|
export const FREQ_HIGH_HZ = 12000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed for the node-to-band shuffle. Any string or number; change it to deal a
|
||||||
|
* different mapping. Seeded (Remotion's random()) rather than Math.random(), so
|
||||||
|
* every render tab and the Studio agree on which node gets which band.
|
||||||
|
*/
|
||||||
|
export const BAND_SEED = "274-spectrum";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw amplitude → level (0–1): (raw × GAIN × (f / FREQ_LOW_HZ)^TREBLE_TILT)^CURVE.
|
||||||
|
* High bands are far quieter than bass, so TREBLE_TILT lifts them; CURVE below 1
|
||||||
|
* lifts quiet passages so more than the loudest bands move.
|
||||||
|
*
|
||||||
|
* On the release2 outro these give a median level of 0.35 (10–90% range
|
||||||
|
* 0.18–0.67), with nodes pinned at full size 0.6% of the time. GAIN 10 pinned
|
||||||
|
* them 11% of the time with the bass nodes nearly always full; CURVE 0.8 with
|
||||||
|
* GAIN 8 is livelier (0.15–0.85) but pins 6%.
|
||||||
|
*/
|
||||||
|
export const GAIN = 5;
|
||||||
|
export const TREBLE_TILT = 0.5;
|
||||||
|
export const CURVE = 0.6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release: a node's level is the loudest reading over the RELEASE_WINDOW_S
|
||||||
|
* before it, sampled every RELEASE_STEP_S and multiplied by RELEASE_DECAY once
|
||||||
|
* more per step back. Hits jump up at once and fall back smoothly, without any
|
||||||
|
* state carried between frames.
|
||||||
|
*
|
||||||
|
* Sampling by time rather than by frame keeps the tail the same length, and the
|
||||||
|
* work per frame the same, whatever the fps.
|
||||||
|
*/
|
||||||
|
export const RELEASE_WINDOW_S = 0.2;
|
||||||
|
export const RELEASE_STEP_S = 1 / 30;
|
||||||
|
export const RELEASE_DECAY = 0.75;
|
||||||
|
|
||||||
|
/** FFT size for visualizeAudio: bins are (sample rate / 2) / FFT_SAMPLES wide (23.4 Hz). */
|
||||||
|
export const FFT_SAMPLES = 1024;
|
||||||
|
/** Seconds of audio useWindowedAudioData keeps loaded around the current frame. */
|
||||||
|
export const AUDIO_WINDOW_S = 10;
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
|
||||||
|
const easeInOutCubic = (p: number) =>
|
||||||
|
p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||||
|
|
||||||
|
/** 0 before the spectrum, 1 once the hand-over has finished. */
|
||||||
|
export const spectrumWeight = (t: number): number =>
|
||||||
|
easeInOutCubic(clamp01((t - SPECTRUM_START_S) / SPECTRUM_FADE_S));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame to load audio around: the real frame once the spectrum is active,
|
||||||
|
* otherwise the spectrum's start, so nothing is fetched for the rest of the song.
|
||||||
|
*/
|
||||||
|
export const audioWindowFrame = (frame: number, fps: number): number =>
|
||||||
|
Math.max(frame, secondsToFrames(SPECTRUM_START_S - SPECTRUM_FADE_S, fps));
|
||||||
|
|
||||||
|
/** `count` log-spaced band centres from FREQ_LOW_HZ to FREQ_HIGH_HZ. */
|
||||||
|
export const bandFrequencies = (count: number): number[] =>
|
||||||
|
Array.from({ length: count }, (_, i) =>
|
||||||
|
count === 1
|
||||||
|
? FREQ_LOW_HZ
|
||||||
|
: FREQ_LOW_HZ * Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, i / (count - 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One band centre per node, in node order: bandFrequencies(count) dealt out in a
|
||||||
|
* Fisher–Yates shuffle seeded by BAND_SEED.
|
||||||
|
*/
|
||||||
|
export const shuffledBandFrequencies = (count: number): number[] => {
|
||||||
|
const frequencies = bandFrequencies(count);
|
||||||
|
for (let i = frequencies.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random(`${BAND_SEED}-${i}`) * (i + 1));
|
||||||
|
const swap = frequencies[i];
|
||||||
|
frequencies[i] = frequencies[j];
|
||||||
|
frequencies[j] = swap;
|
||||||
|
}
|
||||||
|
return frequencies;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A band's raw amplitude from one spectrum: the mean over the bins between its
|
||||||
|
* geometric edges, or, for bands narrower than a bin (the bass end), the value
|
||||||
|
* interpolated at its centre, so neighbouring bass nodes still differ.
|
||||||
|
*/
|
||||||
|
const bandValue = (spectrum: number[], centreHz: number, ratio: number, binHz: number) => {
|
||||||
|
const half = Math.sqrt(ratio);
|
||||||
|
const low = centreHz / half / binHz;
|
||||||
|
const high = centreHz * half / binHz;
|
||||||
|
const first = Math.max(1, Math.ceil(low));
|
||||||
|
const last = Math.min(spectrum.length - 1, Math.floor(high));
|
||||||
|
if (last >= first) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = first; k <= last; k++) {
|
||||||
|
sum += spectrum[k];
|
||||||
|
}
|
||||||
|
return sum / (last - first + 1);
|
||||||
|
}
|
||||||
|
const pos = Math.min(spectrum.length - 1.001, Math.max(0, centreHz / binHz));
|
||||||
|
const k = Math.floor(pos);
|
||||||
|
return spectrum[k] + (spectrum[k + 1] - spectrum[k]) * (pos - k);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every band's level (0–1) at `frame`, release included. */
|
||||||
|
export const bandLevels = (args: {
|
||||||
|
audioData: MediaUtilsAudioData;
|
||||||
|
dataOffsetInSeconds: number;
|
||||||
|
frame: number;
|
||||||
|
fps: number;
|
||||||
|
frequencies: number[];
|
||||||
|
}): number[] => {
|
||||||
|
const { audioData, dataOffsetInSeconds, frame, fps, frequencies } = args;
|
||||||
|
const binHz = audioData.sampleRate / 2 / FFT_SAMPLES;
|
||||||
|
const ratio =
|
||||||
|
frequencies.length > 1 ? Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, 1 / (frequencies.length - 1)) : 2;
|
||||||
|
const levels = frequencies.map(() => 0);
|
||||||
|
|
||||||
|
const steps = Math.max(1, Math.round(RELEASE_WINDOW_S / RELEASE_STEP_S));
|
||||||
|
for (let step = 0; step < steps; step++) {
|
||||||
|
const back = secondsToFrames(step * RELEASE_STEP_S, fps);
|
||||||
|
if (frame - back < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const spectrum = visualizeAudio({
|
||||||
|
audioData,
|
||||||
|
frame: frame - back,
|
||||||
|
fps,
|
||||||
|
numberOfSamples: FFT_SAMPLES,
|
||||||
|
dataOffsetInSeconds,
|
||||||
|
optimizeFor: "accuracy",
|
||||||
|
});
|
||||||
|
const decay = Math.pow(RELEASE_DECAY, step);
|
||||||
|
frequencies.forEach((hz, i) => {
|
||||||
|
const raw = bandValue(spectrum, hz, ratio, binHz);
|
||||||
|
const level =
|
||||||
|
Math.pow(clamp01(raw * GAIN * Math.pow(hz / FREQ_LOW_HZ, TREBLE_TILT)), CURVE) * decay;
|
||||||
|
levels[i] = Math.max(levels[i], level);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return levels;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Frame-rate conversions.
|
||||||
|
*
|
||||||
|
* Every timing constant in this composition is in seconds, so the animation is
|
||||||
|
* identical at any fps. Only Remotion's own API counts frames, so convert here
|
||||||
|
* rather than writing frame counts into the constants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Seconds to a whole number of frames, rounded to the nearest. */
|
||||||
|
export const secondsToFrames = (seconds: number, fps: number): number =>
|
||||||
|
Math.round(seconds * fps);
|
||||||
|
|
||||||
|
/** A frame number back to its time in seconds. */
|
||||||
|
export const framesToSeconds = (frames: number, fps: number): number => frames / fps;
|
||||||
Reference in New Issue
Block a user