1 Commits

Author SHA1 Message Date
irene 8f860e037a feat: add BOM generator (v2 with hashes) 2026-09-09 17:53:11 +09:00
43 changed files with 263 additions and 6180 deletions
-15
View File
@@ -36,18 +36,3 @@ node_modules
dist
out
.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
View File
@@ -1,11 +0,0 @@
# 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`.
+5 -44
View File
@@ -4,32 +4,15 @@ This repo houses the code-like files for NCMR music videos. Binary assets live e
## 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
This branch holds the common scaffolding:
- **Remotion project** — entry point, root component, and a placeholder composition
- **`scripts/`** — Python toolchain (stdlib only) for subtitles, ffmpeg and asset manifests
- **`ffmpeg.sh`** — helper for baking ASS subtitles or bypassing with static image + audio
- **`src/lrc2srt.ts`** — legacy LRC → SRT subtitle converter
- **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
For each song, create a branch `<song-name>/master` (and optionally feature branches
@@ -46,8 +29,7 @@ mkdir -p <song-name>
# Place your song-specific compositions, assets manifests, etc.
```
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.
The `master` branch remains protected and mostly immutable. Song branches are independent.
## Guideline
@@ -55,35 +37,14 @@ song branches are a rehearsal for that end state, not a requirement.
- Binary input assets (audio, images, fonts, material video clips)
- Video output (`out/`, rendered files)
- `package-lock.json` — see below
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.
### 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
- `master` — minimal template (protected once locked in)
- `master` — minimal template, protected
- `<song>/master` — song-specific work
- `<song>/<feature>` — parallel features for a song
Once `master` is locked, this repo's branching rules supersede any generic git
operation conventions. Before that point, use whatever is convenient.
This repo's branching rules supersede any generic git operation conventions.
+5 -13
View File
@@ -42,25 +42,17 @@ The Studio auto-reloads on file changes. No manual refresh needed.
```
.
├── src/ # Remotion input — compiled by tsc/Remotion
├── src/
│ ├── index.ts # Entry point — calls registerRoot()
│ ├── Root.tsx # React root component
│ └── Composition.tsx # Composition definitions (placeholder)
├── 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
│ ├── Composition.tsx # Composition definitions (placeholder)
│ └── lrc2srt.ts # LRC → SRT subtitle converter
├── package.json # Remotion + React + Tailwind deps
├── 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 are defined in `src/Composition.tsx`. Each has:
+60
View File
@@ -0,0 +1,60 @@
#!/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
-1
View File
@@ -6,7 +6,6 @@
"license": "UNLICENSED",
"dependencies": {
"@remotion/cli": "4.0.522",
"@remotion/media-utils": "4.0.522",
"@remotion/tailwind-v4": "4.0.522",
"react": "19.2.3",
"react-dom": "19.2.3",
-3
View File
@@ -12,6 +12,3 @@ Config.setRspack(true);
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
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");
-217
View File
@@ -1,217 +0,0 @@
# 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`.
-340
View File
@@ -1,340 +0,0 @@
"""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
+99
View File
@@ -0,0 +1,99 @@
#!/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);
-516
View File
@@ -1,516 +0,0 @@
"""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())
-646
View File
@@ -1,646 +0,0 @@
"""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())
-460
View File
@@ -1,460 +0,0 @@
"""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())
-353
View File
@@ -1,353 +0,0 @@
"""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())
-382
View File
@@ -1,382 +0,0 @@
"""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())
-98
View File
@@ -1,98 +0,0 @@
#!/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())
-168
View File
@@ -1,168 +0,0 @@
[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
-159
View File
@@ -1,159 +0,0 @@
[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,,
-121
View File
@@ -1,121 +0,0 @@
[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]
-463
View File
@@ -1,463 +0,0 @@
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
-27
View File
@@ -1,27 +0,0 @@
# 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.
-186
View File
@@ -1,186 +0,0 @@
[
{
"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": "ちさか"
}
]
-132
View File
@@ -1,132 +0,0 @@
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,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
1 km name object kana romaji kind offset_m lat lon length_m built grade oaza address address_kana location
2 37.27 川端 川端 かわばた Kawabata place (neighbourhood) 32 42.91538 141.89568 川端 夕張郡由仁町川端 ゆうばりぐんゆにちょうかわばた
3 38.41 川端 川端橋 かわばたはし Kawabata hashi bridge 0 42.91715 141.90896 255 1991 Ⅰ 川端 夕張郡由仁町川端 ゆうばりぐんゆにちょうかわばた 由仁町
4 39.80 川端 北海道企業局 川端発電所 power station 451 42.91197 141.91642 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
5 39.80 川端 川端ダム dam 523 42.91140 141.91590 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
6 40.16 滝下 滝下八幡神社 たきしたはちまんじんじゃ Takishitahachiman jinja Shinto shrine 98 42.91183 141.92472 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
7 40.57 滝ノ下 滝ノ下信号場 たきのした Takinoshita place (locality) 400 42.90630 141.92320 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
8 41.02 クオーベツ クオーベツダム くおーぺつ Kuoopetsu dam 1798 42.89310 141.93874 川端 夕張郡由仁町川端 ゆうばりぐんゆにちょうかわばた
9 41.02 滝下川端 滝下川端連絡線 power line 75 42.90857 141.93273 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
10 41.02 砂金 砂金川 watercourse 58 42.90889 141.93222 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした 石狩川水系
11 41.86 雨霧橋 雨霧橋 あまぎりはし Amagiri hashi bridge 0 42.91003 141.94197 280.7 1974 Ⅱ 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした 栗山町
12 43.03 竜仙橋 竜仙橋 りゅうせんばし Ryuusen bashi bridge 0 42.91604 141.95220 178 1972 Ⅱ 川端 夕張郡由仁町川端 ゆうばりぐんゆにちょうかわばた 由仁町
13 43.07 滝下 北海道企業局 滝下発電所 たきのしたはつでんしょ Takinoshita hatsudensho power station 210 42.91801 141.95218 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
14 43.30 滝下 滝下 たきした Takishita place (neighbourhood) 1636 42.93155 141.95583 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした
15 43.64 草木 草木川 そうもく Soumoku watercourse 84 42.91577 141.96022 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした 石狩川水系
16 44.00 草木 草木橋 そうもくはし Soumoku hashi bridge 0 42.91449 141.96321 24.1 1979 Ⅲ 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした 栗山町
17 44.00 滝の上 滝の上こ線橋 たきのうえこせんきょう Takinoue kosenkyou bridge 0 42.91449 141.96321 47.6 1975 Ⅲ 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした 夕張市
18 44.00 草木舞 草木舞沢川 そうもくまいさわ Soumokumaisawa watercourse 21 42.91436 141.96339 滝下 夕張郡栗山町滝下 ゆうばりぐんくりやまちょうたきした 石狩川水系
19 44.62 滝の上 北海道企業局 滝の上発電所 power station 142 42.91152 141.97021 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ
20 44.62 滝の上 滝の上公園 park 69 42.91225 141.97000 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ
21 45.21 カミ カミの沢川 watercourse 40 42.91059 141.97704 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ 石狩川水系
22 45.21 滝の上 滝ノ上信号場 たきのうえしんごうじょう Takinoue shingoujou railway signal post 43 42.91000 141.97642 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ
23 45.29 滝の上 滝ノ上神社 たきのうえじんじゃ Takinoue jinja Shinto shrine 100 42.90979 141.97873 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ
24 47.66 滝の上 滝ノ上 たきのうえ Takinoue place (neighbourhood) 52 42.90347 142.00470 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ
25 48.18 チョポツナイ チョポツナイ川 ちょぽつない Chopotsunai watercourse 63 42.90575 142.01006 滝ノ上 夕張市滝ノ上 ゆうばりしたきのうえ 石狩川水系
26 49.71 十三里 十三里信号場 とみさとしんごうじょう Tomisato shingoujou place (locality) 39 42.91482 142.02439 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
27 50.28 カライケ カライケの沢川 からいけ Karaike watercourse 132 42.91674 142.03132 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 石狩川水系
28 50.28 十三里 十三里橋 とみさとはし Tomisato hashi bridge 0 42.91772 142.03043 122 1997 Ⅰ 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
29 50.52 紅葉山 紅葉山駐車帯 もみじやまちゅうしゃたい Momijiyama chuushatai parking 41 42.91840 142.03331 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
30 52.11 瑞穂 瑞穂橋 みずほはし Mizuho hashi bridge 0 42.93244 142.03548 155 1968 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
31 52.11 夕張 夕張川 watercourse 16 42.93257 142.03538 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 石狩川水系
32 52.47 セブン-イレブン セブンイレブン夕張紅葉山店 せぶんいれぶんゆうばりもみじやまてん Sebun'irebun'yuubarimomijiyama ten convenience store 73 42.93507 142.03683 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
33 52.54 新夕張 新夕張駅 しんゆうばり Shin'yuubari railway station 173 42.93670 142.03648 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
34 52.61 紅葉山 紅葉山郵便局 もみじやまゆうびんきょく Momijiyama yuubinkyoku post office 54 42.93642 142.03925 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
35 52.85 紅葉山 紅葉山神社 もみじやまじんじゃ Momijiyama jinja Shinto shrine 67 42.93788 142.03956 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
36 52.99 セイコーマート セイコーマートもみじやま店 せいこーまーともみじやまてん Seikoomaatomomijiyama ten convenience store 31 42.93910 142.04167 紅葉山 夕張市紅葉山 ゆうばりしもみじやま
37 53.13 紅楓 紅楓橋 こうふうばし Koufuu bashi bridge 0 42.93855 142.04317 102.5 1958 Ⅱ 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
38 53.69 久留喜 ポン久留喜橋 ぽんくるきはし Ponkuruki hashi bridge 0 42.93697 142.04976 12.7 1984 Ⅰ 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
39 53.69 久留喜 ポンクルキ川 watercourse 16 42.93695 142.04995 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 石狩川水系
40 53.69 久留喜 久留喜橋 くるきはし Kuruki hashi bridge 0 42.93697 142.04976 34 1962 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
41 55.14 シイタルキ シイタルキ川 watercourse 143 42.92706 142.06277 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 石狩川水系
42 55.41 楓 楓橋 かえでばし Kaede bashi bridge 0 42.92957 142.06562 32 1968 Ⅲ 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
43 55.60 桂 桂橋 かつらはし Katsura hashi bridge 0 42.93026 142.06775 48.3 1987 Ⅰ 紅葉山 夕張市紅葉山 ゆうばりしもみじやま 夕張市
44 56.60 千紫 千紫橋 せんしばし Senshi bashi bridge 0 42.93414 142.07860 31.4 1988 Ⅰ 楓 夕張市楓 ゆうばりしかえで 夕張市
45 56.88 楓 楓 かえで Kaede place (neighbourhood) 711 42.94002 142.08407 楓 夕張市楓 ゆうばりしかえで
46 57.08 楓 楓駐車帯 かえでちゅうしゃたい Kaede chuushatai parking 39 42.93352 142.08412 楓 夕張市楓 ゆうばりしかえで
47 57.25 万紅 万紅橋 ばんこうばし Bankou bashi bridge 0 42.93274 142.08627 27.6 1988 Ⅰ 楓 夕張市楓 ゆうばりしかえで 夕張市
48 57.44 登川 登川神社 のぼりかわじんじゃ Noborikawa jinja Shinto shrine 164 42.93395 142.08819 楓 夕張市楓 ゆうばりしかえで
49 57.76 楓 楓信号場 かえでしんごうじょう Kaede shingoujou railway signal post 47 42.93157 142.09230 楓 夕張市楓 ゆうばりしかえで
50 57.84 登川 登川橋 のぼりかわばし Noborikawa bashi bridge 0 42.93167 142.09327 21 1988 Ⅰ 楓 夕張市楓 ゆうばりしかえで 夕張市
51 57.84 登川 登川郵便局 のぼりかわゆうびんきょく Noborikawa yuubinkyoku post office 21 42.93185 142.09335 楓 夕張市楓 ゆうばりしかえで
52 58.23 花園 花園橋 はなぞのばし Hanazono bashi bridge 0 42.92922 142.09647 58.6 1971 Ⅱ 登川 夕張市登川 ゆうばりしのぼりかわ 夕張市
53 58.26 ホルカクルキ ホルカクルキ川 watercourse 9 42.92920 142.09685 登川 夕張市登川 ゆうばりしのぼりかわ 石狩川水系
54 58.56 滝沢川 滝沢川 watercourse 52 42.92962 142.10029 登川 夕張市登川 ゆうばりしのぼりかわ 石狩川水系
55 59.54 松竹沢 松竹沢川 watercourse 58 42.93047 142.11198 登川 夕張市登川 ゆうばりしのぼりかわ 石狩川水系
56 59.66 登川 登川 のぼりかわ Noborikawa place (neighbourhood) 1208 42.93831 142.12137 登川 夕張市登川 ゆうばりしのぼりかわ
57 60.29 登川 登川トンネル のぼりかわとんねる Noborikawa tonneru tunnel 0 42.92478 142.11599 348 1978 登川 夕張市登川 ゆうばりしのぼりかわ 夕張市
58 62.36 オマナイ オマナイ橋 おまないはし Omanai hashi bridge 0 42.90892 142.12482 174.2 1988 Ⅲ 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
59 62.94 桂木 桂木橋 かつらぎはし Katsuragi hashi bridge 0 42.90434 142.12726 129.6 1986 Ⅲ 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
60 64.44 大和 大和橋 やまとはし Yamato hashi bridge 0 42.89157 142.12305 16 1967 Ⅲ 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと むかわ町
61 66.69 豊進 豊進橋 ほうしんばし Houshin bashi bridge 0 42.87371 142.13048 16 1967 Ⅲ 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと むかわ町
62 66.78 豊進 豊進神社 ほうしんじんじゃ Houshin jinja Shinto shrine 49 42.87318 142.13135 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと
63 66.83 穂別 穂別キャンプ場 campsite 66 42.87222 142.13061 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと
64 66.86 石山 石山の沢川 watercourse 161 42.87125 142.13037 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと 鵡川水系
65 67.05 狩勝 狩勝幹線 power line 414 42.86806 142.13398 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと
66 67.48 豊里 豊里橋 とよさとはし Toyosato hashi bridge 0 42.87311 142.13854 27 1966 Ⅰ 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと むかわ町
67 67.60 稲豊 稲豊橋 いなとよはし Inatoyo hashi bridge 0 42.87313 142.14000 27 1966 Ⅰ 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと むかわ町
68 67.62 サヌシュッペ サヌシュッペ川 watercourse 5 42.87304 142.14032 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと 鵡川水系
69 68.63 稲里 稲里橋 いなさとはし Inasato hashi bridge 0 42.87144 142.15164 31 1988 Ⅱ 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと むかわ町
70 69.29 稲里 稲里簡易郵便局 いなさとかんいゆうびんきょく Inasatokan'i yuubinkyoku post office 161 42.86734 142.15774 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと
71 69.48 佐主 佐主橋 さぬしばし Sanushi bashi bridge 0 42.86894 142.16085 85 1973 Ⅲ 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと むかわ町
72 70.22 穂別 穂別川 watercourse 32 42.87408 142.16642 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと 鵡川水系
73 71.86 穂別 穂別ダム神社 ほべつだむじんじゃ Hobetsudamu jinja Shinto shrine 18 42.88475 142.17737 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと
74 71.90 穂別 穂別ダム dam 166 42.88502 142.17937 穂別稲里 勇払郡むかわ町穂別稲里 ゆうふつぐんむかわちょうほべついなさと
75 72.02 長和 長和トンネル おさわとんねる Osawa tonneru tunnel 0 42.88619 142.17748 281 1983 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
76 72.29 穂別 穂別大橋 ほべつおおはし Hobetsu oohashi bridge 0 42.88820 142.17918 506.2 1979 Ⅲ 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
77 72.90 山水 山水橋 さんすいばし Sansui bashi bridge 0 42.89187 142.18476 53.5 1979 Ⅰ 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
78 72.93 高西 高西の沢川 watercourse 9 42.89195 142.18498 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ 鵡川水系
79 74.31 沼の沢 沼の沢橋 ぬまのさわはし Numanosawa hashi bridge 0 42.88830 142.20015 188 1983 Ⅲ 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
80 75.28 稲里 稲里トンネル いなさととんねる Inasato tonneru tunnel 0 42.88343 142.20951 1441 1985 Ⅲ 穂別長和 勇払郡むかわ町穂別長和 ゆうふつぐんむかわちょうほべつおさわ むかわ町
81 76.97 ペンケ橋 ペンケ橋 ぺんけばし Penke bashi bridge 0 42.87802 142.22782 120.7 1985 Ⅲ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
82 77.05 オロロップ オロロップ沢川 watercourse 15 42.87849 142.22885 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま 鵡川水系
83 78.03 下福山 下福山橋 しもふくやまはし Shimofukuyama hashi bridge 0 42.88572 142.23487 129.9 1982 Ⅰ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
84 78.31 オロロップ オロロップ橋 おろろっぷはし Ororoppu hashi bridge 0 42.88721 142.23759 205.9 1983 Ⅱ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
85 79.11 福山 福山橋 ふくやまはし Fukuyama hashi bridge 0 42.89249 142.24390 134.7 1984 Ⅲ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
86 79.67 福山 福山神社 ふくやまじんじゃ Fukuyama jinja Shinto shrine 184 42.89114 142.25175 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま
87 79.81 福山第1ゲート 福山第1ゲート gate 71 42.89003 142.25118 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま
88 79.86 福山第2ゲート 福山第2ゲート gate 47 42.88890 142.25072 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま
89 80.01 穂別福山 (穂別 + 福山) 穂別福山 ほべつふくやま Hobetsufukuyama place (neighbourhood) 172 42.88942 142.25385 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま
90 80.24 鵡川 鵡川 watercourse 68 42.88740 142.25421 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま 鵡川水系
91 80.24 福山 福山大橋 ふくやまおおはし Fukuyama oohashi bridge 0 42.88678 142.25416 160 1968 Ⅲ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
92 85.40 千坂 千坂橋 ちさかはし Chisaka hashi bridge 0 42.88901 142.29725 138.7 1975 Ⅱ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
93 85.87 オコタン オコタン橋 おこたんばし Okotan bashi bridge 0 42.89179 142.30059 338 1985 Ⅰ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
94 86.16 パンケモトツ パンケモトツ沢川 watercourse 10 42.89278 142.30394 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま 鵡川水系
95 86.24 モトツ モトツトンネル もとつとんねる Mototsu tonneru tunnel 0 42.89321 142.30463 924 1991 Ⅱ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
96 87.12 モトツ モトツ橋 もとつばし Mototsu bashi bridge 0 42.89931 142.31156 62 1988 Ⅱ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
97 87.12 モトツ モトツシェルター もとつしぇるたー Mototsu sherutaa shed 0 42.89931 142.31156 62 1990 Ⅲ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
98 87.21 福山 福山トンネル tunnel 0 42.89988 142.31229 839 1991 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
99 88.04 ペンケモトツ ペンケモトツ沢川 watercourse 26 42.90332 142.32161 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま 鵡川水系
100 88.04 福穂 福穂橋 ふくすいはし Fukusui hashi bridge 0 42.90322 142.32132 210 1990 Ⅰ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
101 88.25 穂高 穂高トンネル ほだかとんねる Hodaka tonneru tunnel 0 42.90398 142.32368 1991 1991 Ⅱ 穂別福山 勇払郡むかわ町穂別福山 ゆうふつぐんむかわちょうほべつふくやま むかわ町
102 90.58 穂高 穂高橋 ほだかはし Hodaka hashi bridge 0 42.90958 142.35128 140.6 1988 Ⅱ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
103 90.90 高翠 高翠橋 こうすいばし Kousui bashi bridge 0 42.90889 142.35485 114 1988 Ⅰ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
104 91.14 占翠 占翠橋 せんすいばし Sensui bashi bridge 0 42.90869 142.35768 167 1989 Ⅰ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
105 93.58 名石 名石駐車場 めいせきちゅうしゃじょう Meiseki chuushajou parking 32 42.90980 142.36986 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう
106 93.67 名石 名石橋 めいせきばし Meiseki bashi bridge 0 42.91029 142.37052 39.6 1970 Ⅰ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
107 93.73 ニセイパオマナイ ニセイパオマナイ川 watercourse 30 42.91045 142.37087 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 鵡川水系
108 94.35 赤岩 赤岩大橋 あかいわおおはし Akaiwa oohashi bridge 0 42.91317 142.37731 157.4 1970 Ⅱ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
109 95.37 一の沢川 一の沢川 watercourse 44 42.91532 142.38713 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 鵡川水系
110 95.37 渓流 渓流大橋 けいりゅうおおはし Keiryuu oohashi bridge 0 42.91502 142.38677 153.7 1974 Ⅲ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
111 96.43 日高 日高トンネル ひだかとんねる Hidaka tonneru tunnel 0 42.91588 142.39911 730 1972 Ⅲ 中央 勇払郡占冠村中央 ゆうふつぐんしむかっぷむらちゅうおう 占冠村
112 99.93 右の沢川 右の沢川 watercourse 145 42.89623 142.41771 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 沙流川水系
113 99.98 ホロカワウシャップ ホロカワウシャップ川 watercourse 79 42.89623 142.41942 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 沙流川水系
114 100.60 日夕 日夕橋 にちゆうばし Nichiyuu bashi bridge 0 42.89710 142.42716 30 1968 Ⅰ 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 日高町
115 100.66 パンケウシャップ パンケウシャップ川 watercourse 21 42.89691 142.42766 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 沙流川水系
116 100.90 日高 日高 place (neighbourhood) 373 42.89635 142.43448 日高 沙流郡日高町日高 さるぐんひだかちょうひだか
117 102.18 新町 新町三丁目 しんまち3ちょうめ Shinmachi 3-choume place (neighbourhood) 13 42.88427 142.43237 新町 沙流郡日高町新町 さるぐんひだかちょうしんまち
118 102.36 日高 日高神社 ひだかじんじゃ Hidaka jinja Shinto shrine 221 42.88427 142.43553 宮下町 沙流郡日高町宮下町 さるぐんひだかちょうみやしたちょう
119 102.72 kanamoto kanamoto かなもと Kanamoto shop 42 42.88086 142.43687 宮下町 沙流郡日高町宮下町 さるぐんひだかちょうみやしたちょう
120 103.12 新川 新川橋 しんかわはし Shinkawa hashi bridge 0 42.87791 142.44001 3.6 1993 Ⅰ 本町東 沙流郡日高町本町東 さるぐんひだかちょうほんちょうひがし 日高町
121 103.12 新川 新川 にいかわ Niikawa watercourse 23 42.87811 142.43992 本町東 沙流郡日高町本町東 さるぐんひだかちょうほんちょうひがし 沙流川水系
122 103.29 日高 日高郵便局 ひだかゆうびんきょく Hidaka yuubinkyoku post office 85 42.87748 142.44195 本町東 沙流郡日高町本町東 さるぐんひだかちょうほんちょうひがし
123 103.29 セイコーマート セイコーマート日高道の駅店 せいこーまーとひだかみちのえきてん Seikoomaatohidakamichinoeki ten convenience store 85 42.87734 142.44216 本町東 沙流郡日高町本町東 さるぐんひだかちょうほんちょうひがし
124 104.39 ウシャップ ペンケウシャップ川 watercourse 15 42.88146 142.45211 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 沙流川水系
125 104.52 日高 北海道電力 日高発電所 ひだかはつでんしょ Hidaka hatsudensho power station 666 42.87724 142.45788 千栄 沙流郡日高町千栄 さるぐんひだかちょうちさか
126 105.67 沙流川 沙流川 watercourse 272 42.88829 142.46766 千栄 沙流郡日高町千栄 さるぐんひだかちょうちさか 沙流川水系
127 105.99 ユウケシュオマナイ パンケユウケシュオマナイ川 watercourse 181 42.88869 142.46778 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 沙流川水系
128 106.22 右左府 右左府発電所 うさっぷはつでんしょ Usappu hatsudensho power station 244 42.89314 142.46755 日高 沙流郡日高町日高 さるぐんひだかちょうひだか
129 106.22 右左府 右左府変電所 substation 245 42.89280 142.46713 日高 沙流郡日高町日高 さるぐんひだかちょうひだか
130 106.47 オワイタカ オワイタカ沢川 watercourse 52 42.89254 142.47167 日高 沙流郡日高町日高 さるぐんひだかちょうひだか 沙流川水系
131 107.29 滝の沢川 滝の沢川 watercourse 18 42.89575 142.48126 千栄 沙流郡日高町千栄 さるぐんひだかちょうちさか 沙流川水系
132 107.83 ルウケシオマナイ ルウケシオマナイ川 watercourse 134 42.89400 142.48842 千栄 沙流郡日高町千栄 さるぐんひだかちょうちさか 沙流川水系
File diff suppressed because one or more lines are too long
-99
View File
@@ -1,99 +0,0 @@
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,,,,,
1 start stop start_s stop_s sung object kind km lat lon km_from km_to
2 00:14.54 00:17.89 14.54 17.89 夕張郡由仁町川端 夕張郡由仁町川端 district 38.41 42.91715 141.90896 38.41 38.41
3 00:17.89 00:19.67 17.89 19.67 川端橋 川端橋 bridge 38.41 42.91715 141.90896
4 00:25.04 00:28.41 25.04 28.41 夕張郡栗山町滝下 夕張郡栗山町滝下 district 39.80 42.91197 141.91642 39.80 41.86
5 00:28.41 00:32.06 28.41 32.06 川端発電所 北海道企業局 川端発電所 power station 39.80 42.91197 141.91642
6 00:32.06 00:35.44 32.06 35.44 滝下八幡神社 滝下八幡神社 Shinto shrine 40.16 42.91183 141.92472
7 00:35.44 00:39.84 35.44 39.84 滝ノ下信号場 滝ノ下信号場 place (locality) 40.57 42.90630 141.92320
8 00:39.84 00:42.01 39.84 42.01 雨霧橋 雨霧橋 bridge 41.86 42.91003 141.94197
9 00:42.45 00:45.15 42.45 45.15 夕張郡由仁町川端 夕張郡由仁町川端 district 43.03 42.91604 141.95220 43.03 43.03
10 00:45.15 00:46.69 45.15 46.69 竜仙橋 竜仙橋 bridge 43.03 42.91604 141.95220
11 00:47.63 00:50.08 47.63 50.08 夕張郡栗山町滝下 夕張郡栗山町滝下 district 44.00 42.91449 141.96321 44.00 44.00
12 00:50.08 00:52.88 50.08 52.88 草木橋 草木橋 bridge 44.00 42.91449 141.96321
13 00:52.88 00:54.73 52.88 54.73 滝の上こ線橋 滝の上こ線橋 bridge 44.00 42.91449 141.96321
14 00:56.44 00:59.95 56.44 59.95 夕張市滝ノ上 夕張市滝ノ上 district 44.62 42.91152 141.97021 44.62 45.29
15 00:59.95 01:03.47 59.95 63.47 滝の上発電所 北海道企業局 滝の上発電所 power station 44.62 42.91152 141.97021
16 01:03.47 01:06.94 63.47 66.94 滝ノ上信号場 滝ノ上信号場 railway signal post 45.21 42.91000 141.97642
17 01:06.94 01:08.78 66.94 68.78 滝ノ上神社 滝ノ上神社 Shinto shrine 45.29 42.90979 141.97873
18 01:10.50 01:12.05 70.50 72.05 夕張市紅葉山 夕張市紅葉山 district 49.71 42.91482 142.02439 49.71 55.60
19 01:12.05 01:13.86 72.05 73.86 十三里信号場 十三里信号場 place (locality) 49.71 42.91482 142.02439
20 01:13.86 01:16.44 73.86 76.44 十三里橋 十三里橋 bridge 50.28 42.91772 142.03043
21 01:16.44 01:18.78 76.44 78.78 紅葉山駐車帯 紅葉山駐車帯 parking 50.52 42.91840 142.03331
22 01:18.78 01:21.06 78.78 81.06 瑞穂橋 瑞穂橋 bridge 52.11 42.93244 142.03548
23 01:21.06 01:24.44 81.06 84.44 セブンイレブン夕張紅葉山店 セブンイレブン夕張紅葉山店 convenience store 52.47 42.93507 142.03683
24 01:24.44 01:27.06 84.44 87.06 新夕張駅 新夕張駅 railway station 52.54 42.93670 142.03648
25 01:27.06 01:30.02 87.06 90.02 紅葉山郵便局 紅葉山郵便局 post office 52.61 42.93642 142.03925
26 01:30.02 01:32.30 90.02 92.30 紅葉山神社 紅葉山神社 Shinto shrine 52.85 42.93788 142.03956
27 01:32.30 01:35.24 92.30 95.24 セイコーマートもみじやま店 セイコーマートもみじやま店 convenience store 52.99 42.93910 142.04167
28 01:35.24 01:38.48 95.24 98.48 紅楓橋 紅楓橋 bridge 53.13 42.93855 142.04317
29 01:38.48 01:40.25 98.48 100.25 ポン久留喜橋 ポン久留喜橋 bridge 53.69 42.93697 142.04976
30 01:40.25 01:42.44 100.25 102.44 久留喜橋 久留喜橋 bridge 53.69 42.93697 142.04976
31 01:42.44 01:43.79 102.44 103.79 楓橋 楓橋 bridge 55.41 42.92957 142.06562
32 01:43.79 01:45.03 103.79 105.03 桂橋 桂橋 bridge 55.60 42.93026 142.06775
33 01:45.77 01:49.12 105.77 109.12 夕張市楓 夕張市楓 district 56.60 42.93414 142.07860 56.60 57.84
34 01:49.12 01:50.76 109.12 110.76 千紫橋 千紫橋 bridge 56.60 42.93414 142.07860
35 01:50.76 01:52.51 110.76 112.51 楓駐車帯 楓駐車帯 parking 57.08 42.93352 142.08412
36 01:52.51 01:53.80 112.51 113.80 万紅橋 万紅橋 bridge 57.25 42.93274 142.08627
37 01:53.80 01:55.54 113.80 115.54 登川神社 登川神社 Shinto shrine 57.44 42.93395 142.08819
38 01:55.54 01:57.32 115.54 117.32 楓信号場 楓信号場 railway signal post 57.76 42.93157 142.09230
39 01:57.32 01:59.00 117.32 119.00 登川橋 登川橋 bridge 57.84 42.93167 142.09327
40 01:59.00 02:00.74 119.00 120.74 登川郵便局 登川郵便局 post office 57.84 42.93185 142.09335
41 02:01.26 02:02.93 121.26 122.93 夕張市登川 夕張市登川 district 58.23 42.92922 142.09647 58.23 60.29
42 02:02.93 02:05.23 122.93 125.23 花園橋 花園橋 bridge 58.23 42.92922 142.09647
43 02:05.23 02:06.62 125.23 126.62 登川トンネル 登川トンネル tunnel 60.29 42.92478 142.11599
44 03:30.80 03:34.55 210.80 214.55 勇払郡むかわ町穂別長和 勇払郡むかわ町穂別長和 district 62.36 42.90892 142.12482 62.36 62.94
45 03:34.55 03:36.77 214.55 216.77 オマナイ橋 オマナイ橋 bridge 62.36 42.90892 142.12482
46 03:36.77 03:39.41 216.77 219.41 桂木橋 桂木橋 bridge 62.94 42.90434 142.12726
47 03:41.13 03:44.62 221.13 224.62 勇払郡むかわ町穂別稲里 勇払郡むかわ町穂別稲里 district 64.44 42.89157 142.12305 64.44 71.86
48 03:44.62 03:46.67 224.62 226.67 大和橋 大和橋 bridge 64.44 42.89157 142.12305
49 03:46.67 03:48.38 226.67 228.38 豊進橋 豊進橋 bridge 66.69 42.87371 142.13048
50 03:48.38 03:51.61 228.38 231.61 豊進神社 豊進神社 Shinto shrine 66.78 42.87318 142.13135
51 03:51.61 03:53.14 231.61 233.14 豊里橋 豊里橋 bridge 67.48 42.87311 142.13854
52 03:53.14 03:55.52 233.14 235.52 稲豊橋 稲豊橋 bridge 67.60 42.87313 142.14000
53 03:55.52 03:57.78 235.52 237.78 稲里橋 稲里橋 bridge 68.63 42.87144 142.15164
54 03:57.78 04:01.08 237.78 241.08 稲里簡易郵便局 稲里簡易郵便局 post office 69.29 42.86734 142.15774
55 04:01.08 04:03.50 241.08 243.50 佐主橋 佐主橋 bridge 69.48 42.86894 142.16085
56 04:03.50 04:05.59 243.50 245.59 穂別ダム神社 穂別ダム神社 Shinto shrine 71.86 42.88475 142.17737
57 04:05.96 04:10.08 245.96 250.08 勇払郡むかわ町穂別長和 勇払郡むかわ町穂別長和 district 72.02 42.88619 142.17748 72.02 75.28
58 04:10.08 04:13.02 250.08 253.02 長和トンネル 長和トンネル tunnel 72.02 42.88619 142.17748
59 04:13.02 04:15.39 253.02 255.39 穂別大橋 穂別大橋 bridge 72.29 42.88820 142.17918
60 04:15.39 04:18.81 255.39 258.81 山水橋 山水橋 bridge 72.90 42.89187 142.18476
61 04:18.81 04:23.20 258.81 263.20 沼の沢橋 沼の沢橋 bridge 74.31 42.88830 142.20015
62 04:23.20 04:25.27 263.20 265.27 稲里トンネル 稲里トンネル tunnel 75.28 42.88343 142.20951
63 04:26.00 04:29.74 266.00 269.74 勇払郡むかわ町穂別福山 勇払郡むかわ町穂別福山 district 76.97 42.87802 142.22782 76.97 88.25
64 04:29.74 04:31.50 269.74 271.50 ペンケ橋 ペンケ橋 bridge 76.97 42.87802 142.22782
65 04:31.50 04:33.62 271.50 273.62 下福山橋 下福山橋 bridge 78.03 42.88572 142.23487
66 04:33.62 04:35.02 273.62 275.02 オロロップ橋 オロロップ橋 bridge 78.31 42.88721 142.23759
67 04:35.02 04:37.07 275.02 277.07 福山橋 福山橋 bridge 79.11 42.89249 142.24390
68 04:37.07 04:38.70 277.07 278.70 福山神社 福山神社 Shinto shrine 79.67 42.89114 142.25175
69 04:38.70 04:41.02 278.70 281.02 福山大橋 福山大橋 bridge 80.24 42.88678 142.25416
70 04:41.02 04:43.29 281.02 283.29 千坂橋 千坂橋 bridge 85.40 42.88901 142.29725
71 04:43.29 04:45.12 283.29 285.12 オコタン橋 オコタン橋 bridge 85.87 42.89179 142.30059
72 04:45.12 04:46.22 285.12 286.22 モトツトンネル モトツトンネル tunnel 86.24 42.89321 142.30463
73 04:46.22 04:48.98 286.22 288.98 モトツ橋にモトツシェルター モトツ橋 bridge 87.12 42.89931 142.31156
74 04:46.22 04:48.98 286.22 288.98 モトツ橋にモトツシェルター モトツシェルター shed 87.12 42.89931 142.31156
75 04:48.98 04:51.39 288.98 291.39 福山トンネル 福山トンネル tunnel 87.21 42.89988 142.31229
76 04:51.39 04:53.61 291.39 293.61 福穂橋 福穂橋 bridge 88.04 42.90322 142.32132
77 04:53.61 04:55.52 293.61 295.52 穂高トンネル 穂高トンネル tunnel 88.25 42.90398 142.32368
78 05:22.60 05:25.76 322.60 325.76 勇払郡占冠村中央 勇払郡占冠村中央 district 90.58 42.90958 142.35128 90.58 96.43
79 05:25.76 05:28.88 325.76 328.88 穂高橋 穂高橋 bridge 90.58 42.90958 142.35128
80 05:28.88 05:31.52 328.88 331.52 高翠橋 高翠橋 bridge 90.90 42.90889 142.35485
81 05:31.52 05:34.95 331.52 334.95 占翠橋 占翠橋 bridge 91.14 42.90869 142.35768
82 05:34.95 05:38.08 334.95 338.08 名石駐車場 名石駐車場 parking 93.58 42.90980 142.36986
83 05:38.08 05:40.99 338.08 340.99 名石橋 名石橋 bridge 93.67 42.91029 142.37052
84 05:40.99 05:43.96 340.99 343.96 赤岩大橋 赤岩大橋 bridge 94.35 42.91317 142.37731
85 05:43.96 05:48.62 343.96 348.62 渓流大橋 渓流大橋 bridge 95.37 42.91502 142.38677
86 05:48.62 05:50.73 348.62 350.73 日高トンネル 日高トンネル tunnel 96.43 42.91588 142.39911
87 07:11.39 07:14.14 431.39 434.14 沙流郡日高町日高 沙流郡日高町日高 district 100.60 42.89710 142.42716 100.60 100.60
88 07:14.14 07:16.38 434.14 436.38 日夕橋 日夕橋 bridge 100.60 42.89710 142.42716
89 07:28.85 07:31.68 448.85 451.68 沙流郡日高町宮下町 沙流郡日高町宮下町 district 102.36 42.88427 142.43553 102.36 102.36
90 07:31.68 07:34.09 451.68 454.09 日高神社 日高神社 Shinto shrine 102.36 42.88427 142.43553
91 07:42.90 07:45.51 462.90 465.51 沙流郡日高町本町東 沙流郡日高町本町東 district 103.12 42.87791 142.44001 103.12 103.29
92 07:45.51 07:49.88 465.51 469.88 新川橋 新川橋 bridge 103.12 42.87791 142.44001
93 07:49.88 07:52.76 469.88 472.76 セイコーマート日高道の駅店 セイコーマート日高道の駅店 convenience store 103.29 42.87734 142.44216
94 07:52.76 07:55.11 472.76 475.11 日高郵便局 日高郵便局 post office 103.29 42.87748 142.44195
95 07:56.69 07:59.36 476.69 479.36 沙流郡日高町千栄 沙流郡日高町千栄 district 104.52 42.87724 142.45788 104.52 104.52
96 07:59.36 08:02.15 479.36 482.15 北電日高発電所 北海道電力 日高発電所 power station 104.52 42.87724 142.45788
97 08:03.90 08:06.38 483.90 486.38 沙流郡日高町日高 沙流郡日高町日高 district 106.22 42.89314 142.46755 106.22 106.22
98 08:06.38 08:09.44 486.38 489.44 右左府発電所 右左府発電所 power station 106.22 42.89314 142.46755
99 08:10.93 08:12.66 490.93 492.66 東に続く closing
-1
View File
@@ -1 +0,0 @@
[[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]]
-1
View File
@@ -1 +0,0 @@
[[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
-74
View File
@@ -1,74 +0,0 @@
# 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.
-2
View File
@@ -1,12 +1,10 @@
import "./index.css";
import { MyComposition } from "./Composition";
import { Song274Transect } from "./song-274/Composition";
export const RemotionRoot: React.FC = () => {
return (
<>
<MyComposition />
<Song274Transect />
</>
);
};
+94
View File
@@ -0,0 +1,94 @@
#!/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();
-32
View File
@@ -1,32 +0,0 @@
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}
/>
);
-232
View File
@@ -1,232 +0,0 @@
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>
);
};
-172
View File
@@ -1,172 +0,0 @@
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,
};
};
-216
View File
@@ -1,216 +0,0 @@
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,
};
};
-28
View File
@@ -1,28 +0,0 @@
/**
* 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);
};
-134
View File
@@ -1,134 +0,0 @@
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("");
-94
View File
@@ -1,94 +0,0 @@
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;
-66
View File
@@ -1,66 +0,0 @@
/**
* 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",
};
-154
View File
@@ -1,154 +0,0 @@
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]];
};
-163
View File
@@ -1,163 +0,0 @@
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;
};
-14
View File
@@ -1,14 +0,0 @@
/**
* 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;