26 Commits

Author SHA1 Message Date
mikkeli e3c843d65c feat(274): name the モトツ pair with a slash in the transect subtitles
モトツ橋にモトツシェルター -> モトツ橋 / モトツシェルター, matching the two nodes the
visualiser lights side by side on the road. The rest of the diff is Aegisub's
saved cursor position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:15 +09:00
mikkeli 530238a328 feat(274): render at 60 fps, with all timing in seconds
The composition is now 60 fps (37,157 frames). Everything was already written
against time in seconds except the spectrum's release, which counted frames and
so would have shortened from 0.2 s to 0.1 s at 60 fps.

- spectrum.ts: RELEASE_FRAMES -> RELEASE_WINDOW_S (0.2 s) sampled every
  RELEASE_STEP_S (1/30 s), RELEASE_DECAY per step. At 30, 60 and 120 fps this
  reads the same instants (0, 33.3, 66.7, 100, 133.3, 166.7 ms back) with the
  same weights, so the tail keeps its length and the work per frame stays
  constant instead of growing with the frame rate.
- time.ts: secondsToFrames / framesToSeconds, the one place the two meet —
  only Remotion's API counts frames.
- camera.ts: the smoothing window's hardcoded 1/30 is now FOLLOW_SAMPLE_S,
  a sampling interval in seconds rather than a frame count.
- Composition.tsx: FPS 30 -> 60; the duration still follows the audio length.

Changing FPS is now the only edit needed to try another frame rate.

Verified: eslint + tsc clean; the 60 fps export reports 60/1 and 37,157 frames,
with audio 0.00 ms off at 1:45 and 9:00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:15 +09:00
mikkeli d7b3824e1e fix(274): end the fade-out where the black hold starts
FADE_OUT_POST holds full black over the last stretch of the video, but the ramp
still aimed to finish at lastFrameS - FADE_OUT_S, so it completed inside the
hold and the two overlapped. The ramp now runs from
lastFrameS - FADE_OUT_POST - FADE_OUT_S to lastFrameS - FADE_OUT_POST, giving
FADE_OUT_S of fade followed by FADE_OUT_POST of black.

Verified: with 0.4 / 0.3 / 0.2 / 0.4 the picture is clear from 0.70 s to
618.67 s, and the rendered file is pure black for the first and last 13 frames
at 30 fps (25 at 60).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:13 +09:00
mikkeli 2dfd52e33d style(274): retune tunnel, shrine and other colours
Dark palette, chosen while previewing in the Studio:
- tunnel #b6c5c1 -> #a6ccc2
- shrine #a05050 -> #b94141
- other #a7b7d6 -> #8b9ab8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-18 02:00:29 +09:00
mikkeli fde2aee5d1 feat(274): subtitles for the transect visualiser
- 274-transect.ass: Aegisub subtitles for baking into the final transect video
  (274_transect.mp4) — addresses (274-addr), places (274-place) and the
  closing credits (274-credits), all in M PLUS 1 Code.
- BOM.md: the four static M PLUS 1 Code weights it needs (Regular, Medium,
  SemiBold, Bold; SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8. Static per-
  weight files rather than Google Fonts' variable TTF, so libass matches the
  styles' full names exactly.

Verified: a libass test bake resolves every style to the real files — Medium
for 274-addr, SemiBold for 274-place, Regular and Bold (for {\b1}) for
274-credits — with no fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-18 02:00:29 +09:00
mikkeli e23772af17 feat(274): fade the transect from and to black
The visualiser now starts and ends on a completely black screen: a black layer
over everything whose opacity comes from fade.ts.

- FADE_IN_PRE (0.4 s): hold on full black at the very start.
- FADE_IN_S (0.3 s): then fade from black to the picture.
- FADE_OUT_S (0.2 s): fade to black at the end, reaching full black on the last
  frame ((durationInFrames - 1) / fps), not a frame after it.

Verified: eslint + tsc clean; stills show the first and last frames are pure
RGB (0, 0, 0) and brightness ramps as timed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-18 02:00:29 +09:00
mikkeli 2731d9d332 Merge pull request '274: transect composition, spectrum outro, and lyric fixes it exposed' (#1) from song274/transect into song274/master
Reviewed-on: #1
2026-09-17 01:36:53 +00:00
mikkeli 10949dfa18 style(274): retune the dark transect palette
Colour choices for the dark palette, made while previewing in the Studio:
- roadCovered #9dcde4 -> #fdb021 (the covered road now reads amber)
- bridge #79d1c5 -> #9be9de
- place / railway #129252 -> #68ad7f
- shrine #b32c2c -> #a05050
- other #ff9900 -> #a7b7d6

The light palette is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:32:20 +09:00
mikkeli 561c930620 fix(274): separate nodes that share exact coordinates
Three pairs of sung nodes have identical roster coordinates, so one circle fully
eclipsed the other: 草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, and
モトツ橋 / モトツシェルター (a bridge roofed by a shelter). Editing the lyrics
index's lat/lon could not fix it: the video takes positions from the roster
(274-lookup.csv), joined on object.

- layout.ts: nodePositions() groups nodes by exact coordinate and spreads each
  group COINCIDENT_SPREAD (1.5 map units, about 35 m) apart along the road's
  direction there, in road order, centred on the shared point. All such pairs
  are on-road structures, so they stay on the road (within 0.06 units) and
  read as a doubled dot; partial overlap is intended, full eclipse is not.
  Along rather than across the road, and small: at 6 units モトツシェルター
  landed on 福山トンネル, 90 m further on.
- data.ts: RosterItem carries the roster's km, for road order and direction.
- Transect.tsx: draws nodes at those positions.

Verified: eslint + tsc clean on this commit's tree; close-ups at モトツ, resting
and lit, show both nodes on the road in order after モトツトンネル.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:32:19 +09:00
mikkeli 04963c929a feat(274): spectrum outro for the transect
From SPECTRUM_START_S the nodes stop following the lyric and behave as a
spectrum analyser: every sung node is given one log-spaced frequency band
(40 Hz–12 kHz), dealt out in a seeded shuffle, and sizes up and down with that
band's amplitude.

- spectrum.ts: band maths and every tunable. Levels come from
  @remotion/media-utils visualizeAudio on audio loaded by
  useWindowedAudioData (10 s windows, so the 227 MB WAV is never decoded
  whole; before the outro the window stays parked at its start). Bands average
  the FFT bins they span, or interpolate for bands narrower than a bin, so
  neighbouring bass nodes never move in lockstep. Level =
  (raw × GAIN × (f/40 Hz)^TREBLE_TILT)^CURVE with a release that looks back
  RELEASE_FRAMES frames at RELEASE_DECAY per frame. Defaults were calibrated
  on the release2 outro (nodes pinned at full size 0.6% of the time). The
  node→band mapping uses Remotion's seeded random() (BAND_SEED), not
  Math.random(), so render tabs and the Studio agree.
- nodeAnimation.ts: SPECTRUM_PEAK and spectrumLook(level), alongside lightUp
  and switchOff, plus a levelExponential shaping curve.
- Transect.tsx: hands each node over from its lyric look to its spectrum look
  over SPECTRUM_FADE_S.
- package.json: declare @remotion/media-utils 4.0.522 (already installed as a
  dependency of the CLI; now pinned alongside the other Remotion packages).

Verified: eslint + tsc clean on this commit's tree; outro stills show nodes
sized by band level, so the windowed audio loads during renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:31:58 +09:00
mikkeli fac282bcbc feat(274): show the east stub ahead of time; slow its paint run
- Transect.tsx: the dimmed "road ahead" now includes the east continuation
  (274-route-east.json), so it is visible all along instead of appearing only
  once the paint reaches it after 東に続く.
- progress.ts: EAST_RUN_S 4 -> 75. The paint used to dash along the east stub
  (5.25 km in 4 s, about 1.3 km/s, after 0.55 km/s for the leg before it); it now
  takes 75 s from 東に続く, well into the outro.

Verified: eslint + tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 09:56:16 +09:00
mikkeli c965fbf5fe feat(274): route data for the transect; lyric fixes the transect exposed
Building the transect visualisation (Song274Transect) is what exposed problems
in the lyric and its index: syncing nodes to the lyric showed 紅葉山橋 lighting
up although it is not on this road (it is on R452), 下福山橋 and オロロップ橋
sitting at the wrong km, and lines timed against the wrong stretch. They were
fixed upstream in the wiki (ncmr-songs 7af0f48, "274: drop 紅葉山橋, fix
オロロップ橋 and 下福山橋, re-timed lyric"), and the lyric deliverables here
follow.

274.lrc — byte-identical copy of the wiki LRC at 7af0f48: 紅葉山橋 removed;
  紅楓橋 through 楓信号場 re-timed (1:35.24–1:55.54) with 夕張市楓 at 1:45.77;
  登川トンネル at 2:05.23 and its section end at 2:06.62; 稲里トンネル's section
  end at 4:25.27. 下福山橋 and オロロップ橋 keep their timing; their km were
  corrected in the roster and index.
274.srt — regenerated with scripts/lrc2srt.py, 116 cues.
274.ass — events follow the LRC: district headers as transparent_title spanning
  their section, 東に続く as transparent_lyrics_jp, spacers as empty events, the
  final line held 5 s. The Aegisub header and all 17 styles are untouched.
  117 events -> 116.

route/ — copied from novoyuuparosk-wiki/ncmr-songs wip/274, where it is
generated; read-only here, and its README says so. src/song-274/data.ts loads
274-route.json, 274-lookup.csv and 274-lyrics-index.csv, plus the two approach
stubs: 274-route-west.json (always drawn as covered) and 274-route-east.json
(painted after 東に続く). Also carries the district bands, the GeoJSON and the
reference page. The route shapes are single clean polylines: no
second-carriageway points in the divided sections, km strictly increasing.

Verified: all 116 ASS events and SRT cues match the LRC's order, text and
timestamps; every sung node in the index joins to the roster at matching km.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 09:48:51 +09:00
mikkeli 5ad82b1b98 chore(274): record the release2 master in the BOM
274_release2.wav (2026-09-17) supersedes 274_release1.wav and is the master
used by the white/lyrics renders and Song274Transect. Same length, 10:19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:44:46 +09:00
mikkeli bc39455cfe feat(274): transect composition synced to the lyric
Song274Transect draws Route 274 in its true shape (the "Ground" view of
song-274/route/274-transect.html) and plays it along with the song.

- data.ts: reads song-274/route/ at load time (lyrics index, roster, route and
  the optional east/west stubs); nothing is copied into the code. Only sung
  nodes are shown; districts and 東に続く light nothing.
- progress.ts: a "covered so far" paint over the dimmed road. Its head is pinned
  to each sung object's km as that line starts and glides between pins on a
  monotone cubic, so it never runs backwards. After 東に続く it runs out along
  the east stub; the west stub is always painted.
- camera.ts: fixed close zoom during each narration part (lines under 20 s
  apart), following a Gaussian-smoothed head; pull back to frame the covered
  road plus some road ahead during breaks, and to the full map after the lyric.
- nodeAnimation.ts: lightUp / switchOff as reusable functions over a NodeLook,
  with long lines switching off after MAX_LIT_S.
- palette.ts: every colour as a named token (dark default, light alongside),
  including shrine and railway families.

remotion.config.ts sets song-274/ as the public dir so staticFile() reaches the
song's audio/ and route/ in place.

Verified: eslint + tsc clean; stills checked across parts, breaks and the ending.
Render with --muted and add audio with ffmpeg: Remotion's own AAC lands 42.75 ms
late.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 09:40:53 +09:00
mikkeli 7da9a6df25 feat(274): add lyrics, subtitles and asset BOM
Text-based deliverables for the 274 video, synced to the lyrics source of
truth in the wiki repo (ncmr-songs wip/274/274.lrc at c7e2494, "put the lyric
back into road order").

- 274.lrc: byte-identical copy of the wiki LRC.
- 274.srt: generated from it with scripts/lrc2srt.py (117 cues).
- 274.ass: hand-styled in Aegisub; every event re-timed to the LRC, with each
  address shown from its own timestamp until its section's last place ends.
- BOM.md: untracked assets with BLAKE2b hashes — release audio
  274_release1.wav, the white background plate, and the Noto Serif JP
  Medium/SemiBold fonts the ASS uses.

Verified: all 117 ASS events and SRT cues match the LRC's order, text and
timestamps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-16 19:22:50 +09:00
irene be81469c7c chore: ignore package-lock.json, and record why
Work happens on both Windows and Linux, so a committed lockfile would do harm
rather than good here:

- Every dependency in package.json is pinned exactly (no ^ or ~), so a lockfile
  has no floating ranges to guard. A plain `npm install` resolves identically
  without it.
- The lock records platform-specific optional binaries for whichever OS built it
  (@rollup/rollup-linux-x64-gnu vs -win32-x64-msvc, for instance). Committed from
  one platform it is wrong on the other, and `npm ci` refuses rather than
  adapting, since reproducing the recorded tree exactly is its entire purpose.

That is a strict mechanism pointed at the wrong target: it would break installs
across machines while guaranteeing versions that were already exact. Cloning is
rare here, so the reproducibility upside is small and the cross-platform cost is
immediate.

Both .gitignore and README now state the reasoning, including when to revisit (a
dependency loosened to a range, or transitive drift exact pinning cannot control).
Silently ignoring it would have invited the same question again.

Verified: `git check-ignore -v package-lock.json` matches, and a regenerated
lockfile leaves `git status` clean.
2026-09-10 23:11:25 +09:00
irene 4bc2346480 docs: note what the archive tag is for
Records why archive/feat-bom-generator exists and what was in it, so a future
reader does not have to reconstruct the reasoning from the branch name. Also
notes that archive tags need an explicit push.
2026-09-10 18:41:01 +09:00
irene 2c6e227c82 feat(bom): add --hash, and fix the BOM marker breaking YAML
`feat/bom-generator` carried one commit that never landed on master: file
content hashes in the BOM ("v2 with hashes"). The Python rewrite dropped that
capability, so port it forward rather than losing it when the branch goes.

- `--hash` records a short BLAKE2b digest per file. Opt-in, because it reads
  every asset in full and that is wasted work for a plain inventory.
- Hashes are streamed in chunks with `hashlib.blake2b`, not `md5` via haveged
  imports, and not whole-file reads — these assets run to hundreds of megabytes.
- YAML gains `hash:` after `size:`; Markdown gains a hash column only when
  hashes were requested, so the common table stays readable.

Fix a pre-existing bug found while testing: the BOM marker was written as
`; generated by ...`, but `;` is not a YAML comment, so every generated BOM
failed to parse with "mapping values are not allowed here". Now `#`. Verified by
re-parsing output from both the PyYAML path and the built-in fallback writer.

Sizes alone cannot tell you whether an asset changed; a same-length re-render
keeps the byte count identical. That is the gap the hash closes.
2026-09-10 18:37:18 +09:00
irene afe397da3f refactor: draw the src/scripts boundary, drop dead files
Make each directory answerable by path alone: src/ is what the Remotion build
compiles, scripts/ is what you run by hand.

Removed:
- `ffmpeg.sh` — never worked; every parameter was assigned `$1`, so bake/bypass
  were broken from the start. Superseded by `scripts/ffmpeg_tools.py bake|still`.
- `src/lrc2srt.ts` — the TypeScript port; `scripts/lrc2srt.py` is the script that
  was actually maintained, and it now handles the multi-timestamp cases the TS
  version did.
- `scripts/bom-gen.ts` — superseded by `scripts/bom_gen.py`.
- `scripts/ffmpegusage.txt` — legacy command notebook. Every recipe in it is now
  reproduced by `ffmpeg_tools.py`, including the `-map 0:v:0 -map 1:a:0 -c:v copy`
  mux and the FLAC `-compression_level 12` case, so it has no unique content left.

Also drops the `bom-gen` alias from the dispatcher (dash-case was TypeScript
naming) and documents the boundary in README.md and REMOTION-QUICKSTART.md.

`npm run lint` (eslint src && tsc) passes with src/ reduced to Remotion input.
2026-09-10 18:18:04 +09:00
irene a1fbd64f87 docs: note master is not locked in yet
The repo is still being assembled as a templated workspace, so the branch
conventions are the intended end state rather than a current rule. Recording
that in the README so the 'master is protected and immutable' line is not read
as an active restriction while the template is still taking shape.
2026-09-10 17:50:59 +09:00
irene 68c12caef9 feat(scripts): unified Python toolchain, stdlib only
Replaces the scattered TypeScript/shell helpers with one Python toolchain that
runs as plain files (`python3 scripts/tools.py <tool> ...`) with no third-party
dependencies.

New tools
- `_common.py`     shared plumbing (Report, run, ffmpeg discovery, argparse ergonomics)
- `hwprobe.py`     what this machine can actually encode (software + hardware)
- `srt2ass.py`     SRT -> ASS, the missing step between lrc2srt and baking
- `ffmpeg_tools.py` mux / bake / still / strip / probe, with hardware encode
- `bom_gen.py`     BOM generator, ported from scripts/bom-gen.ts
- `tools.py`       dispatcher so any tool is one command away
- `README.md`      usage for all of the above

Rewritten
- `lrc2srt.py` now handles multiple timestamps per line, timestamp-less cue lines
  and blank/comment lines, and survives malformed input. The legacy version
  emitted an empty cue plus a stray cue for every line.

Hardware encoding
This host is AMD (Strix Halo, VCN 4.0.5), so nvenc in the legacy notes is
reference only. `hwprobe.py` probes and reports what genuinely works here:
VA-API via /dev/dri/renderD128 gives h264_vaapi, hevc_vaapi and av1_vaapi.
`ffmpeg_tools.py --video auto` picks the hardware encoder when the render node
is usable and falls back to libx264/libx265 when it is not.

Two bugs found and fixed while wiring this up:
- the VA-API capability probe fed frames straight to the encoder without
  `format=nv12,hwupload`, so ffmpeg failed with "Function not implemented" and
  the toolchain silently fell back to software on a machine where hardware
  works. VA-API encoders only accept GPU-resident frames; the probe now uploads.
- `bake --video copy` only failed when the copied stream happened to be VA-API.
  Burning subtitles always changes pixels, so it now refuses `copy` outright
  with a clear message.

Also makes -q/-n usable on either side of the mode word, and ignores
__pycache__ in .gitignore.
2026-09-10 17:47:02 +09:00
mikkeli 51b878486f added legacy scripts as reference 2026-09-10 07:41:07 +00:00
irene 78e8a7decf merge: feat/bom-generator into master 2026-09-09 17:53:11 +09:00
irene 475783556f feat: add BOM generator script 2026-09-09 17:50:46 +09:00
irene a7823f1747 docs: add port pinning option to quickstart 2026-09-09 17:46:12 +09:00
irene 18de75ee28 docs: note port auto-increment in quickstart 2026-09-09 17:45:42 +09:00
42 changed files with 6185 additions and 165 deletions
+15
View File
@@ -36,3 +36,18 @@ 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
@@ -0,0 +1,11 @@
# Archive tags
Tags whose only purpose is to keep a commit reachable after the branch that
carried it was deleted. Nothing here is active code.
| Tag | Kept because |
| --- | --- |
| `archive/feat-bom-generator` | The branch held one commit (`8f860e0`, "BOM generator v2 with hashes") that never landed on `master`. The capability — a content hash per asset — was ported forward to `scripts/bom_gen.py --hash` in `2c6e227`, and the TypeScript file it patched (`scripts/bom-gen.ts`) was deleted in `afe397d`. The tag remains so the original commit is reachable if the port is ever questioned. |
Tags are pushed explicitly (`git push origin archive/<name>`); they are not
picked up by a plain `git push`.
+44 -5
View File
@@ -4,15 +4,32 @@ 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
- **`ffmpeg.sh`** — helper for baking ASS subtitles or bypassing with static image + audio
- **`src/lrc2srt.ts`** — legacy LRC → SRT subtitle converter
- **`scripts/`** — Python toolchain (stdlib only) for subtitles, ffmpeg and asset manifests
- **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
@@ -29,7 +46,8 @@ mkdir -p <song-name>
# Place your song-specific compositions, assets manifests, etc.
```
The `master` branch remains protected and mostly immutable. Song branches are independent.
Once `master` is locked in it remains protected and mostly immutable. Until then,
song branches are a rehearsal for that end state, not a requirement.
## Guideline
@@ -37,14 +55,35 @@ The `master` branch remains protected and mostly immutable. Song branches are in
- Binary input assets (audio, images, fonts, material video clips)
- 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
- `master` — minimal template (protected once locked in)
- `<song>/master` — song-specific work
- `<song>/<feature>` — parallel features for a song
This repo's branching rules supersede any generic git operation conventions.
Once `master` is locked, this repo's branching rules supersede any generic git
operation conventions. Before that point, use whatever is convenient.
+18 -6
View File
@@ -12,7 +12,11 @@ npm install # install dependencies
npm run dev # start Remotion Studio
```
Remotion Studio opens at **http://localhost:3000** in your browser.
Remotion Studio opens at **http://localhost:3000** in your browser. If 3000 is already in use, Remotion automatically bumps to 3001, 3002, etc. — watch the terminal output for the actual port number. You can also pin a specific port:
```bash
npx remotion studio --port=3001
```
## Commands
@@ -38,17 +42,25 @@ The Studio auto-reloads on file changes. No manual refresh needed.
```
.
├── src/
├── src/ # Remotion input — compiled by tsc/Remotion
│ ├── index.ts # Entry point — calls registerRoot()
│ ├── Root.tsx # React root component
│ ├── Composition.tsx # Composition definitions (placeholder)
│ └── lrc2srt.ts # LRC → SRT subtitle converter
│ └── 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
├── package.json # Remotion + React + Tailwind deps
├── remotion.config.ts # CLI config (rspack, jpeg output format)
├── tsconfig.json # TypeScript (strict, ES2018)
└── ffmpeg.sh # Helper for ASS subtitle baking
└── tsconfig.json # TypeScript (strict, ES2018)
```
`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
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# ffmpeg helper — bake ASS subtitles into video, or bypass with static image + audio
#
# Usage:
# ffmpeg.sh bake <video> <audio> <subtitles.ass> <output>
# ffmpeg.sh bypass <image> <audio> <output>
#
# Prerequisites: ffmpeg must be on $PATH.
set -euo pipefail
usage() {
echo "Usage:"
echo " $0 bake <video> <audio> <subtitles.ass> <output>"
echo " $0 bypass <image> <audio> <output>"
exit 1
}
[ $# -eq 4 ] || usage
MODE="$1"
shift
case "$MODE" in
bake)
VIDEO="$1"
AUDIO="$1"
ASS="$1"
OUTPUT="$1"
ffmpeg -y \
-i "$VIDEO" \
-i "$AUDIO" \
-vf "ass=$ASS" \
-c:v libx264 \
-c:a aac \
-shortest \
"$OUTPUT"
;;
bypass)
IMAGE="$1"
AUDIO="$1"
OUTPUT="$1"
ffmpeg -y \
-loop 1 \
-i "$IMAGE" \
-i "$AUDIO" \
-c:v libx264 \
-tune stillimage \
-c:a aac \
-shortest \
"$OUTPUT"
;;
*)
usage
;;
esac
+1
View File
@@ -6,6 +6,7 @@
"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,3 +12,6 @@ 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
@@ -0,0 +1,217 @@
# NCMR songs-videos — tools
Helper scripts for building NCMR song videos: converting lyrics to subtitles,
burning them in, muxing audio, and working out what this machine can encode.
Everything here is **Python standard library only**. No virtualenv, no install
step, no dependencies to break in six months. Run them straight from the repo:
```
python3 scripts/tools.py --help
```
---
## Quick start
```sh
cd ~/dev/mikkeli/ncmr-songs-videos
# 1. What can this machine actually encode?
python3 scripts/tools.py hwprobe
# 2. Lyrics -> subtitles
python3 scripts/tools.py lrc2srt song.lrc -o song.srt
python3 scripts/tools.py srt2ass song.srt -o song.ass
# 3. Audio + still image -> a video (the "PV" case)
python3 scripts/tools.py ffmpeg still cover.png song.flac -o pv.mkv
# 4. Burn the subtitles in, copy the audio through untouched
python3 scripts/tools.py ffmpeg bake pv.mkv song.ass -o final.mp4 --format flac
# 5. Record what untracked assets went in
python3 scripts/tools.py bom --dir .
```
Every subcommand takes `--help`. Every subcommand accepts `--dry-run` (`-n`) to
print the work it would do without doing it, which is the fastest way to see
what ffmpeg command you are about to run.
---
## The tools
| Tool | Purpose |
| --- | --- |
| `tools.py` | Dispatcher. `python3 scripts/tools.py <tool> [args...]`. |
| `hwprobe.py` | Report which encoders this machine can really use, and why not. |
| `lrc2srt.py` | LRC lyrics → SRT subtitles. |
| `srt2ass.py` | SRT → ASS, for burn-in via ffmpeg's `ass` filter. |
| `ffmpeg_tools.py` | `mux`, `bake`, `still`, `strip`, `probe` — the ffmpeg wrappers. |
| `bom_gen.py` | Write a `BOM.yaml` (or `BOM.md`) of a song's untracked assets. |
| `_common.py` | Shared helpers. Not a command; imported by the rest. |
Each script also runs standalone (`python3 scripts/hwprobe.py`) and can be
imported as a module. The dispatcher is a convenience, not a requirement.
---
## Hardware encoding
This machine is an **AMD Strix Halo** (Radeon 8060S). There is no NVIDIA card,
so `nvenc` is unavailable here. What does work is **VA-API** via the Mesa
`radeonsi` driver:
| Codec | Encoder | Status |
| --- | --- | --- |
| H.264 | `h264_vaapi` | works |
| HEVC | `hevc_vaapi` | works |
| AV1 | `av1_vaapi` | works |
| H.264/HEVC | `libx264`/`libx265` | software fallback, always available |
`ffmpeg_tools.py` picks a hardware encoder automatically when one is usable and
falls back to `libx264` when it is not, so the same command works on a machine
without a usable GPU. Override it when you want to:
```sh
--video auto # default: hardware if possible, else software
--video x264 # force software H.264
--video x265 # force software HEVC
--video copy # no re-encode at all
--codec hevc # which hardware codec family to try with --video auto
--device PATH # VA-API render node (default /dev/dri/renderD128)
```
### If hardware encoding stops working
The usual cause is permissions on the DRM render node. `/dev/dri/renderD128` is
owned by `root:render`, so the user needs to be in the `render` group:
```sh
getent group render # who is in it
python3 scripts/tools.py hwprobe # says plainly whether the device opens
```
Group membership only applies to *new* logins. If `id` shows `render` but
`hwprobe` still reports the device as unreadable, the running process is holding
stale credentials — log out and back in, or start the process under the group
explicitly:
```sh
sg render -c 'python3 scripts/hwprobe.py'
```
`hwprobe` never guesses. It opens the device, runs a two-frame test encode
through each candidate encoder, and reports what actually worked.
```sh
python3 scripts/tools.py hwprobe --shell # export-friendly KEY=value output
```
---
## Subtitles
LRC is the timestamped-lyrics format lyric sites emit; SRT is what most players
read; ASS is what you need to burn styled text into a video with ffmpeg.
```sh
python3 scripts/tools.py lrc2srt song.lrc -o song.srt --shift -0.5
```
`lrc2srt` handles the messy real-world cases: `[mm:ss]`, `[mm:ss.xx]` and
`[mm:ss.xxx]` timestamps, several timestamps on one line (repeated choruses —
each becomes its own cue), `[ar:]`/`[ti:]` metadata tags, and blank lines inside
a cue. The final cue is held for a few seconds rather than dropped; adjust with
`--tail`. Use `--shift` to nudge everything earlier or later if the lyrics drift
against the audio.
```sh
python3 scripts/tools.py srt2ass song.srt -o song.ass --play-res 1920x1080
```
`srt2ass` converts to ASS and gives ffmpeg something it can burn in. `--play-res`
sets the script resolution used for font scaling; match it to your video so text
sizes come out as designed. `--dry-run` prints the ASS to stdout for inspection.
---
## ffmpeg wrappers
Long ffmpeg command lines are easy to get subtly wrong and impossible to
remember. These wrap the five the project actually needs.
```sh
# Attach audio to a video-only file; video stream copied, never re-encoded.
python3 scripts/tools.py ffmpeg mux render.mp4 song.flac -o pv.mkv --format flac
# Burn subtitles into a video.
python3 scripts/tools.py ffmpeg bake pv.mkv song.ass -o final.mp4 --format flac
# One still image + audio -> a video.
python3 scripts/tools.py ffmpeg still cover.png song.flac -o pv.mkv --fps 60
# Pull the audio back out as MP3.
python3 scripts/tools.py ffmpeg strip final.mp4 -o final.mp3
# What is actually in this file?
python3 scripts/tools.py ffmpeg probe final.mp4
```
Notes on the choices baked in:
- **`mux` uses `-c:v copy`.** The Remotion render is already encoded; re-encoding
it to add audio costs quality and time for nothing. Containers are picked from
the output extension.
- **`--format` presets** are `aac`, `mp3`, `flac`, `opus` and `copy`. `flac` is
the default because these are archival masters; use `aac`/`opus` for anything
you are actually shipping. `--bitrate` applies to the lossy ones.
- **`-shortest`** is set where a still image is looped, so the video ends with
the audio instead of running on for ever.
- **`-movflags +faststart`** is set for MP4 output, so the file starts playing
before it has fully downloaded.
For anything these do not cover, use `--dry-run` to print the generated command,
copy it, and edit it — the wrapper is a convenience, not a cage. `hwprobe.py`
reports what this host can actually encode, so you can tell at a glance whether a
recipe will need a hardware-specific encoder.
---
## Bill of materials
Binary assets are git-ignored, so a clone does not tell you what went into a
song. `bom_gen.py` walks the asset directories and records it:
```sh
python3 scripts/tools.py bom --dir . --format yaml
```
It writes `BOM.yaml` listing every untracked file under `audio/`, `images/`,
`fonts/` and `videos/` with its size, plus whatever description you give it. Use
`--format md` for Markdown, `--format json` for machine-readable output, and
`-y` to skip the interactive description prompt. If PyYAML is available it is
used for clean output; otherwise a small built-in writer produces the same
schema. Neither is required.
Add `--hash` to record a short content hash per file:
```sh
python3 scripts/tools.py bom --dir . --hash
```
That is how you answer "has this asset changed since the BOM was written?" —
sizes stay identical when you re-render a take at the same length, but the hash
will not. Hashing reads every asset in full, so it is off by default; when you
only want the inventory, leave it out.
---
## Conventions
- `master` is the shared template. It is intended to freeze once the workspace is
finalised; until then, tooling work can land on it directly. Per-song work goes
on `<song>/master`, and features on `<song>/<feature>`. See `README.md`.
- Binary assets stay out of git; the BOM is how you know what was used.
- Keep new tools in `scripts/`, stdlib only, with a `--dry-run` and `--help`.
+340
View File
@@ -0,0 +1,340 @@
"""NCMR songs-videos helper toolchain — Python, stdlib only.
Shared plumbing for the repo's helper scripts. Every script in this package is
runnable as a plain file (``python3 scripts/bom_gen.py``) and as a module
(``python3 -m scripts.bom_gen``); both work because this package is imported
either way without needing its parent directory on ``sys.path``.
Nothing outside the standard library is imported anywhere in this package.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Callable, Iterable, Sequence
__all__ = [
"Die",
"VERSION",
"Report",
"find_ffmpeg",
"glob_files",
"human_size",
"report",
"run",
"subparsers_metavar",
"text_table",
]
VERSION = "0.2.0"
class Die(Exception):
"""Fatal, user-facing error. Caught in ``main()`` and printed to stderr."""
# --------------------------------------------------------------------------- #
# Console output
# --------------------------------------------------------------------------- #
def _colour_enabled(stream=None) -> bool:
stream = stream or sys.stdout
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("FORCE_COLOR"):
return True
return hasattr(stream, "isatty") and stream.isatty()
def _style(text: str, code: str) -> str:
if not _colour_enabled():
return text
return f"\033[{code}m{text}\033[0m"
@dataclass
class Report:
"""Prints user-facing output, with optional ANSI colour when on a TTY."""
quiet: bool = False
def _emit(self, message: str, *, err: bool = False) -> None:
stream = sys.stderr if err else sys.stdout
print(message, file=stream)
def info(self, message: str = "") -> None:
"""Ordinary progress output."""
self._emit(message)
def ok(self, message: str) -> None:
self._emit(_style(f"✓ {message}", "32"))
def warn(self, message: str) -> None:
self._emit(_style(f"warning: {message}", "33"), err=True)
def error(self, message: str) -> None:
self._emit(_style(f"error: {message}", "31"), err=True)
def verbose(self, message: str) -> None:
if not self.quiet:
self._emit(_style(f" {message}", "2"))
#: Default reporter used when a caller doesn't supply one.
report = Report()
# --------------------------------------------------------------------------- #
# Formatting helpers
# --------------------------------------------------------------------------- #
_SIZE_UNITS = ("B", "KiB", "MiB", "GiB", "TiB")
# Characters that make a key unquoted-safe in YAML and legible in a table.
_SAFE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
def human_size(num_bytes: int) -> str:
"""Format a byte count as ``1.5 MiB`` / ``12 B``."""
value = float(num_bytes)
for unit in _SIZE_UNITS[:-1]:
if value < 1024:
break
value /= 1024
else:
unit = _SIZE_UNITS[-1]
if unit == "B":
return f"{int(value)} B"
return f"{value:.1f} {unit}"
def text_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str:
"""Render a simple whitespace-aligned table (no boxes, pipe-friendly)."""
body = [[str(cell) for cell in row] for row in rows]
widths = [len(str(h)) for h in headers]
for row in body:
for i, cell in enumerate(row):
if i < len(widths):
widths[i] = max(widths[i], len(cell))
def line(cells: Sequence[str]) -> str:
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)).rstrip()
out = [line([str(h) for h in headers])]
out.append(" ".join("-" * w for w in widths).rstrip())
for row in body:
out.append(line(row))
return "\n".join(out)
def glob_files(directory: str, suffix: str) -> list[str]:
"""Sorted list of files directly inside *directory* whose name ends in *suffix*.
Returns an empty list when the directory does not exist, so callers can treat
"no such folder" and "folder is empty" identically.
"""
try:
entries = os.listdir(directory)
except (FileNotFoundError, NotADirectoryError):
return []
suffix = suffix.lower()
found = [
os.path.join(directory, name)
for name in entries
if name.lower().endswith(suffix) and os.path.isfile(os.path.join(directory, name))
]
return sorted(found)
# --------------------------------------------------------------------------- #
# Subprocess plumbing
# --------------------------------------------------------------------------- #
def run(
cmd: Sequence[str],
*,
report: Report | None = None,
check: bool = True,
cwd: str | None = None,
dry_run: bool = False,
) -> subprocess.CompletedProcess:
"""Run *cmd* with a shell-free argument list, echoing it first.
``report`` is the reporter used for the echo; the helper deliberately takes a
reporter argument rather than closing over the module-level default so that a
``--quiet`` CLI cannot accidentally echo commands.
"""
log = report or globals()["report"]
printable = " ".join(shlex_quote(part) for part in cmd)
if dry_run:
log.info(_style(f"[dry-run] {printable}", "36"))
return subprocess.CompletedProcess(list(cmd), 0)
log.info(_style(f"$ {printable}", "2"))
# ffmpeg prints its whole build configuration as a banner unless told not to.
# Keep it quiet by default, and keep stderr so a failure can be explained.
full_cmd = list(cmd)
if os.path.basename(full_cmd[0]) in ("ffmpeg", "ffprobe"):
full_cmd[1:1] = ["-hide_banner"]
try:
result = subprocess.run(full_cmd, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError as exc:
raise Die(f"command not found: {cmd[0]}") from exc
if result.stdout:
sys.stdout.write(result.stdout)
if result.stderr:
# ffmpeg writes progress to stderr; pass it through so the user still sees
# the encode happen, but drop the wall of banner text.
sys.stderr.write(result.stderr)
if check and result.returncode != 0:
detail = _last_meaningful_line(result.stderr)
suffix = f": {detail}" if detail else ""
raise Die(f"{os.path.basename(cmd[0])} failed (exit {result.returncode}){suffix}")
return result
def _last_meaningful_line(text: str | None) -> str:
"""Pick the most useful line out of ffmpeg's stderr to show in an error."""
if not text:
return ""
for line in reversed(text.strip().splitlines()):
line = line.strip()
if not line:
continue
# Progress lines start with 'frame=' and overwrite each other.
if line.startswith(("frame=", "size=")) or "speed=" in line:
continue
return line
return ""
def shlex_quote(part: str) -> str:
"""Quote one argv element for display. Local copy keeps the module stdlib-only."""
if not part:
return "''"
if re.fullmatch(r"[A-Za-z0-9_@%+=:,./\-]+", part):
return part
return "'" + part.replace("'", "'\"'\"'") + "'"
def find_ffmpeg(override: str | None = None) -> str:
"""Locate the ``ffmpeg`` binary, or raise :class:`Die` with a useful message."""
if override:
if os.path.sep in override:
if not os.access(override, os.X_OK):
raise Die(f"--ffmpeg {override} is not an executable file")
return override
return override # a bare name; let the OS resolve it on $PATH
found = shutil.which("ffmpeg")
if not found:
raise Die("ffmpeg not found on $PATH — install it or pass --ffmpeg /path/to/ffmpeg")
return found
# --------------------------------------------------------------------------- #
# argparse ergonomics
# --------------------------------------------------------------------------- #
def subparsers_metavar(parser: argparse.ArgumentParser, name: str = "mode") -> None:
"""Stop argparse from printing an unreadable ``{a,b,c}`` in help output.
Call *after* every subparser has been registered.
"""
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
action.metavar = name
action.choices = None # type: ignore[assignment] # keep choices working
def add_common_arguments(parser: argparse.ArgumentParser, *, suppress_defaults: bool = False) -> None:
"""Switches every tool in the toolbox understands.
``suppress_defaults`` is used when the same switches are also registered on
subparsers: it stops the subparser copy from stamping its default over a value
the user gave before the mode word.
"""
default_suppress = argparse.SUPPRESS if suppress_defaults else False
parser.add_argument(
"-q",
"--quiet",
action="store_true",
default=default_suppress,
help="suppress progress output (errors still go to stderr)",
)
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
default=default_suppress,
help="print the work that would happen without doing it",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION}",
)
def add_ffmpeg_argument(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--ffmpeg",
metavar="PATH",
default=None,
help="ffmpeg binary to use (default: whichever is on $PATH)",
)
@dataclass
class ReportMixin:
"""Mixin for subcommand classes that need a reporter and a dry-run flag."""
report: Report = field(default_factory=Report)
dry_run: bool = False
def main_wrapper(fn: Callable[[], int | None]) -> Callable[[], int]:
"""Decorator turning ``Die`` exceptions into a clean exit status."""
def wrapper() -> int:
try:
return fn() or 0
except Die as exc:
report.error(str(exc))
return 1
except KeyboardInterrupt:
report.error("interrupted")
return 130
return wrapper
+516
View File
@@ -0,0 +1,516 @@
"""Generate a bill of materials for a song's untracked binary assets.
The repo's guideline says every song keeps a ``BOM.md`` (or ``BOM.yaml``)
listing the untracked input assets and explaining what each file is. This walks
the conventional asset directories, prompts for a description per file, and
writes the result.
Stdlib only: the YAML is emitted by hand. If PyYAML happens to be installed it is
used to *validate* the output, but it is never required.
Usage::
python3 scripts/bom_gen.py # walk ./assets, write BOM.yaml
python3 scripts/bom_gen.py path/to/song # walk a song directory
python3 scripts/bom_gen.py --format md # write BOM.md instead
python3 scripts/bom_gen.py --descriptions d.toml # non-interactive
python3 scripts/bom_gen.py --describe-one audio/song.flac
"""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import sys
from dataclasses import dataclass, field
if __package__ in (None, ""):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import ( # type: ignore[import-not-found]
Die,
Report,
add_common_arguments,
human_size,
main_wrapper,
text_table,
)
else: # pragma: no cover
from ._common import (
Die,
Report,
add_common_arguments,
human_size,
main_wrapper,
text_table,
)
#: Directories that hold untracked input assets, with the role we assume.
ASSET_DIRS: tuple[tuple[str, str], ...] = (
("audio", "Song audio: final mix, instrumental, stems"),
("images", "Cover art, stills, background plates"),
("fonts", "Typefaces used by the subtitles or overlays"),
("videos", "Material video clips"),
)
#: File extensions that are worth flagging as (probably) not-yet-described.
_DESCRIPTION_PLACEHOLDER = "<describe this file>"
_BOM_MARKER = "# generated by scripts/bom_gen.py"
# --------------------------------------------------------------------------- #
# YAML emission (hand-rolled, stdlib only)
# --------------------------------------------------------------------------- #
#: Characters that force a value to be quoted in YAML.
_NEEDS_QUOTES = re.compile(r"""[:\{\}\[\],&*#?|<>=!%@`"']|^\s|\s$|^$|^[-?]""")
def yaml_scalar(value: str) -> str:
"""Quote *value* for YAML only when it needs it."""
if value.isdigit():
return f'"{value}"' # keep hashes/filenames that look numeric as strings
if _NEEDS_QUOTES.search(value):
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return value
def yaml_key(value: str) -> str:
"""Quote a key that would otherwise be ambiguous (spaces, colons, leading -)."""
if _NEEDS_QUOTES.search(value) or value.startswith("-") or value != value.strip():
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return value
# --------------------------------------------------------------------------- #
# Asset discovery
# --------------------------------------------------------------------------- #
@dataclass
class Asset:
path: str # relative to the BOM root, POSIX separators
size: int
description: str = ""
digest: str = "" # short content hash, empty when --hash was not requested
extra: dict[str, str] = field(default_factory=dict)
@property
def directory(self) -> str:
return os.path.dirname(self.path).split("/")[0] or "."
def file_digest(full: str, *, length: int = 12, chunk: int = 1 << 20) -> str:
"""Short BLAKE2b content hash, streamed so large assets do not land in RAM.
Assets here are audio and video files that can run to hundreds of megabytes,
so this reads in chunks rather than slurping the whole file. BLAKE2b is in
the standard library and is faster than md5 with no collision worries.
``length`` bytes of digest = 3 * length hex characters.
"""
h = hashlib.blake2b(digest_size=length)
with open(full, "rb") as fh:
while bytes_read := fh.read(chunk):
h.update(bytes_read)
return h.hexdigest()
def walk_assets(
root: str,
dirs: tuple[tuple[str, str], ...] = ASSET_DIRS,
*,
hashes: bool = False,
) -> list[Asset]:
"""Collect files under each asset directory, sorted by path.
Hashes are opt-in: they cost a full read of every asset, which is wasted
work when you only want the inventory.
"""
assets: list[Asset] = []
for name, _role in dirs:
base = os.path.join(root, name)
if not os.path.isdir(base):
continue
for dirpath, dirnames, filenames in os.walk(base):
dirnames.sort()
for filename in sorted(filenames):
if filename.startswith("."):
continue
full = os.path.join(dirpath, filename)
if not os.path.isfile(full):
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
assets.append(
Asset(
path=rel,
size=os.path.getsize(full),
digest=file_digest(full) if hashes else "",
)
)
return assets
def load_descriptions(path: str) -> dict[str, str]:
"""Read a descriptions file: simple ``path = description`` or ``path<tab>desc``.
A deliberately forgiving format so descriptions can also be pasted in from a
spreadsheet. ``#`` starts a comment.
"""
try:
with open(path, "r", encoding="utf-8") as handle:
raw = handle.read()
except OSError as exc:
raise Die(f"cannot read --descriptions {path}: {exc.strerror}") from exc
mapping: dict[str, str] = {}
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
elif "\t" in line:
key, _, value = line.partition("\t")
else:
continue
mapping[key.strip().lstrip("./")] = value.strip()
return mapping
# --------------------------------------------------------------------------- #
# Rendering
# --------------------------------------------------------------------------- #
def render_yaml(root: str, assets: list[Asset], *, title: str | None, by_dir: bool) -> str:
total = sum(a.size for a in assets)
lines = [
"---",
_BOM_MARKER,
f"root: {yaml_scalar(os.path.basename(os.path.abspath(root)) or root)}",
]
if title:
lines.append(f"title: {yaml_scalar(title)}")
lines.append(f"total_files: {len(assets)}")
lines.append(f"total_size: {total}")
lines.append("")
if by_dir:
for dirname, role in ASSET_DIRS:
grouping = [a for a in assets if a.directory == dirname]
if not grouping:
continue
subtotal = sum(a.size for a in grouping)
lines.append(f"{yaml_key(dirname)}:")
lines.append(f" role: {yaml_scalar(role)}")
lines.append(f" count: {len(grouping)}")
lines.append(f" size: {subtotal}")
lines.append(" files:")
for asset in grouping:
lines.append(f" {yaml_key(asset.path)}:")
lines.append(f" size: {asset.size}")
if asset.digest:
lines.append(f" hash: {asset.digest}")
lines.append(f" description: {yaml_scalar(asset.description)}")
lines.append("")
else:
lines.append("files:")
for asset in assets:
lines.append(f" {yaml_key(asset.path)}:")
lines.append(f" size: {asset.size}")
if asset.digest:
lines.append(f" hash: {asset.digest}")
lines.append(f" description: {yaml_scalar(asset.description)}")
# Anything under an unrecognised top-level directory still deserves a mention.
others = [a for a in assets if a.directory not in {d for d, _ in ASSET_DIRS}]
if others:
lines.append("")
lines.append("unclassified:")
for asset in others:
lines.append(f" {yaml_key(asset.path)}:")
lines.append(f" size: {asset.size}")
if asset.digest:
lines.append(f" hash: {asset.digest}")
lines.append(f" description: {yaml_scalar(asset.description)}")
lines.append("...")
return "\n".join(lines) + "\n"
def render_markdown(root: str, assets: list[Asset], *, title: str | None, by_dir: bool) -> str:
total = sum(a.size for a in assets)
heading = title or os.path.basename(os.path.abspath(root)) or "Untracked assets"
lines = [
f"# Bill of materials — {heading}",
"",
f"{len(assets)} file(s), {human_size(total)}. Generated by `scripts/bom_gen.py`.",
"",
]
groups: list[tuple[str, list[Asset]]] = []
if by_dir:
for dirname, _role in ASSET_DIRS:
grouping = [a for a in assets if a.directory == dirname]
if grouping:
groups.append((dirname, grouping))
others = [a for a in assets if a.directory not in {d for d, _ in ASSET_DIRS}]
if others:
groups.append(("unclassified", others))
else:
groups.append(("files", assets))
for group_name, grouping in groups:
lines.append(f"## {group_name}")
lines.append("")
# Only carry a hash column when hashes were asked for, so the common
# inventory stays narrow enough to read.
with_hash = any(asset.digest for asset in grouping)
rows = [
(
f"`{asset.path}`",
human_size(asset.size),
*( (f"`{asset.digest}`",) if with_hash else () ),
asset.description or f"_{_DESCRIPTION_PLACEHOLDER}_",
)
for asset in grouping
]
headers = ("file", "size", "hash", "description") if with_hash else ("file", "size", "description")
lines.append(text_table(headers, rows))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def validate_yaml(document: str, report: Report) -> None:
"""If PyYAML is importable, use it to confirm the emitted document parses."""
try:
import yaml # type: ignore[import-not-found]
except ImportError:
return
try:
yaml.safe_load(document)
except Exception as exc: # noqa: BLE001 - report whatever PyYAML says
report.warn(f"PyYAML could not parse the generated document: {exc}")
# --------------------------------------------------------------------------- #
# Interactive prompts
# --------------------------------------------------------------------------- #
def prompt_for_descriptions(assets: list[Asset], report: Report) -> None:
"""Ask for a description per asset. EOF ends prompting and keeps what we have."""
if not sys.stdin.isatty():
report.warn(
"stdin is not a terminal — skipping prompts; "
"descriptions left blank (use --descriptions FILE to supply them)"
)
return
total = len(assets)
report.info(f"Describing {total} file(s). Blank line accepts the current value.\n")
report.info("Press Ctrl-C or Ctrl-D to stop early; the BOM is still written.\n")
try:
for i, asset in enumerate(assets, start=1):
report.info(f"[{i}/{total}] {asset.path} ({human_size(asset.size)})")
try:
answer = input(" description: ").strip()
except EOFError:
report.info("")
report.warn("end of input — remaining descriptions left blank")
return
if answer:
asset.description = answer
except KeyboardInterrupt:
report.info("")
report.warn("interrupted — writing the BOM with the descriptions gathered so far")
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="bom_gen",
description="Generate a bill of materials for a song's untracked binary assets.",
)
add_common_arguments(parser)
parser.add_argument(
"root",
nargs="?",
default=".",
help="directory holding audio/, images/, fonts/, videos/ (default: .)",
)
parser.add_argument(
"-o",
"--output",
help="output path (default: BOM.yaml or BOM.md in the root directory)",
)
parser.add_argument(
"--format",
default="yaml",
choices=("yaml", "md"),
help="output format (default: yaml)",
)
parser.add_argument(
"--descriptions",
metavar="FILE",
help="read 'path = description' lines from FILE instead of prompting",
)
parser.add_argument(
"--no-prompt",
action="store_true",
help="never prompt; write the inventory with blank descriptions",
)
parser.add_argument(
"--flat",
action="store_true",
help="list files without grouping them by asset directory",
)
parser.add_argument(
"--hash",
dest="hashes",
action="store_true",
help="record a short content hash per file (reads every asset; slower)",
)
parser.add_argument(
"--title",
help="song title, recorded in the output header",
)
parser.add_argument(
"--describe-one",
metavar="PATH",
help="update the description of a single file in an existing BOM",
)
return parser
@main_wrapper
def main() -> int:
args = build_parser().parse_args()
report = Report(quiet=args.quiet)
root = os.path.abspath(args.root)
if not os.path.isdir(root):
raise Die(f"not a directory: {args.root}")
if args.describe_one:
return describe_one(root, args, report)
assets = walk_assets(root, hashes=args.hashes)
if not assets:
expected = ", ".join(f"{name}/" for name, _ in ASSET_DIRS)
report.warn(f"no assets found under {root} (looked in: {expected})")
return 1
if args.descriptions:
mapping = load_descriptions(args.descriptions)
applied = 0
for asset in assets:
if asset.path in mapping:
asset.description = mapping[asset.path]
applied += 1
missing = [a.path for a in assets if not a.description]
report.verbose(f"applied {applied} description(s) from {args.descriptions}")
if missing:
report.warn(f"{len(missing)} file(s) have no description in {args.descriptions}")
elif not args.no_prompt:
prompt_for_descriptions(assets, report)
renderer = render_markdown if args.format == "md" else render_yaml
document = renderer(root, assets, title=args.title, by_dir=not args.flat)
if args.format == "yaml":
validate_yaml(document, report)
default_name = "BOM.md" if args.format == "md" else "BOM.yaml"
output = args.output or os.path.join(root, default_name)
if args.dry_run:
report.info(document)
report.info(f"[dry-run] would write {output}")
return 0
with open(output, "w", encoding="utf-8", newline="\n") as handle:
handle.write(document)
total = sum(a.size for a in assets)
described = sum(1 for a in assets if a.description)
report.ok(f"{output} — {len(assets)} file(s), {human_size(total)}, {described} described")
if described < len(assets):
report.info(f" {len(assets) - described} file(s) still need a description")
return 0
def describe_one(root: str, args: argparse.Namespace, report: Report) -> int:
"""Set the description for a single path inside an existing BOM."""
target = args.describe_one.lstrip("./")
if not os.path.isfile(os.path.join(root, target)):
raise Die(f"no such asset: {target}")
output = args.output or os.path.join(root, "BOM.yaml" if args.format == "yaml" else "BOM.md")
description = args.title or "" # reused as the description payload for this mode
if not description:
if not sys.stdin.isatty():
raise Die("--describe-one needs a description; pass --title \"...\"")
description = input(f"description for {target}: ").strip()
if not os.path.isfile(output):
raise Die(f"no existing BOM at {output}; run without --describe-one first")
with open(output, "r", encoding="utf-8") as handle:
content = handle.read()
pattern = re.compile(
rf"(^[ \t]*{re.escape(target)}:[ \t]*\n"
rf"(?:[ \t]+\w+:.*\n)*?"
rf"[ \t]+description:[ \t]*)"
rf".*$",
re.MULTILINE,
)
updated, count = pattern.subn(rf"\g<1>{yaml_scalar(description)}", content)
if count == 0:
raise Die(f"could not find an entry for {target} in {output}")
if args.dry_run:
report.info(f"[dry-run] would set {target} description in {output}")
return 0
with open(output, "w", encoding="utf-8", newline="\n") as handle:
handle.write(updated)
report.ok(f"{target} described in {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+646
View File
@@ -0,0 +1,646 @@
"""FFmpeg front-end for NCMR song videos.
Wraps the ffmpeg invocations this project actually needs, so the long command
lines live in one place instead of in shell history and notes files.
Modes:
``mux`` attach an audio track to a video-only render, without re-encoding
the video (the Remotion output is video-only; this is step 1)
``bake`` burn an ASS subtitle file into a video (step 2)
``still`` build a video from a single image plus audio, for the no-lyrics case
``strip`` pull an MP3 out of a finished video
``probe`` print a compact stream summary
Both ``mux`` and ``bake`` can be chained with ``--then-bake``/``--then-mux`` so a
render goes to finished file in one command.
Usage::
python3 scripts/ffmpeg_tools.py probe out/pulseraw.mp4
python3 scripts/ffmpeg_tools.py mux out/pulseraw.mp4 song.wav -o raw.mkv --format flac
python3 scripts/ffmpeg_tools.py bake raw.mkv subs.ass -o final.mp4
python3 scripts/ffmpeg_tools.py still cover.png song.wav -o pv.mkv --fps 60 --format flac
python3 scripts/ffmpeg_tools.py strip final.mp4 -o song.mp3
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from dataclasses import dataclass
if __package__ in (None, ""):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import ( # type: ignore[import-not-found]
Die,
Report,
add_common_arguments,
add_ffmpeg_argument,
find_ffmpeg,
main_wrapper,
run,
text_table,
)
else: # pragma: no cover
from ._common import (
Die,
Report,
add_common_arguments,
add_ffmpeg_argument,
find_ffmpeg,
main_wrapper,
run,
text_table,
)
# --------------------------------------------------------------------------- #
# Audio/codec presets
# --------------------------------------------------------------------------- #
#: ``--format`` choices, mapping to the ffmpeg audio arguments to use.
AUDIO_FORMATS = {
# name: (extension, container args, codec args)
"aac": ("mp4", [], ["-c:a", "aac", "-b:a", "320k"]),
"mp3": ("mp4", [], ["-c:a", "libmp3lame", "-b:a", "320k"]),
"flac": ("mkv", [], ["-c:a", "flac", "-compression_level", "12"]),
"opus": ("mkv", [], ["-c:a", "libopus", "-b:a", "192k"]),
"copy": (None, [], ["-c:a", "copy"]),
}
#: ``--video`` choices. ``auto`` picks a hardware encoder when one works.
VIDEO_ENCODERS = {
"auto": None,
"x264": ["-c:v", "libx264"],
"x265": ["-c:v", "libx265"],
"copy": ["-c:v", "copy"],
}
VAAPI_ENCODERS = {
"h264": ["-c:v", "h264_vaapi"],
"hevc": ["-c:v", "hevc_vaapi"],
"av1": ["-c:v", "av1_vaapi"],
}
#: Software fallback quality knob, applied only for libx264/libx265.
X264_QUALITY = ["-preset", "medium", "-crf", "18"]
DEFAULT_DEVICE = "/dev/dri/renderD128"
@dataclass
class HwContext:
"""Hardware acceleration state: which encoder to use and how to init it."""
encoder: str
device: str | None = None
is_vaapi: bool = False
note: str = ""
def input_args(self) -> list[str]:
"""Args that go before ``-i``."""
if self.is_vaapi and self.device:
return ["-init_hw_device", f"vaapi=va:{self.device}", "-filter_hw_device", "va"]
return []
def filter_suffix(self) -> str:
"""Filters appended to a user filter chain to move frames onto the GPU."""
if self.is_vaapi:
return "format=nv12,hwupload"
return ""
# --------------------------------------------------------------------------- #
# Encoder selection
# --------------------------------------------------------------------------- #
def _vaapi_device_readable(device: str) -> bool:
try:
fd = os.open(device, os.O_RDWR)
except OSError:
return False
os.close(fd)
return True
_DEVICE_CACHE: dict[str, bool] = {}
def _encoder_works(ffmpeg: str, encoder: str, extra: list[str], device_key: str = "") -> bool:
"""Try a two-frame encode. Results are memoised per process.
VA-API encoders only accept GPU-resident frames, so the probe must upload the
test pattern first. Without ``format=nv12,hwupload`` ffmpeg fails with
"Function not implemented" even though the encoder works fine — which would
make this fall back to software on a machine that has no problem at all.
"""
cache_key = f"{encoder}|{device_key}"
if cache_key in _DEVICE_CACHE:
return _DEVICE_CACHE[cache_key]
vf = "format=nv12,hwupload" if encoder.endswith("_vaapi") else None
cmd = [
ffmpeg, "-hide_banner", "-loglevel", "error", *extra,
"-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1",
]
if vf:
cmd += ["-vf", vf]
cmd += ["-c:v", encoder, "-frames:v", "2", "-f", "null", "-"]
try:
ok = subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0
except (OSError, subprocess.TimeoutExpired):
ok = False
_DEVICE_CACHE[cache_key] = ok
return ok
#: Hardware encoders worth trying for each codec family, in preference order.
HW_ENCODER_CHAIN: dict[str, tuple[tuple[str, str], ...]] = {
# codec: ((encoder, init-family), ...)
"h264": (("h264_vaapi", "vaapi"), ("h264_nvenc", "nvenc"), ("h264_qsv", "qsv")),
"hevc": (("hevc_vaapi", "vaapi"), ("hevc_nvenc", "nvenc"), ("hevc_qsv", "qsv")),
"av1": (("av1_vaapi", "vaapi"), ("av1_nvenc", "nvenc"), ("av1_qsv", "qsv")),
}
def pick_acceleration(
ffmpeg: str,
report: Report,
*,
video: str = "auto",
codec: str = "h264",
device: str | None = None,
allow_software: bool = True,
) -> HwContext:
"""Decide how to encode, honouring an explicit ``--video`` choice."""
device = device or os.environ.get("HW_DEVICE") or DEFAULT_DEVICE
if video == "copy":
return HwContext(encoder="copy")
if video == "auto":
chain = HW_ENCODER_CHAIN.get(codec)
if chain is None:
raise Die(f"unknown --codec {codec!r}")
attempted: list[str] = []
for encoder, family in chain:
if family == "vaapi":
if not _vaapi_device_readable(device):
attempted.append(f"{encoder} (unreadable {device})")
continue
init = ["-init_hw_device", f"vaapi=va:{device}", "-filter_hw_device", "va"]
if _encoder_works(ffmpeg, encoder, init, device):
return HwContext(
encoder=encoder,
device=device,
is_vaapi=True,
note="hardware (VA-API)",
)
attempted.append(f"{encoder} (device rejects it)")
else:
init = []
if _encoder_works(ffmpeg, encoder, init, family):
return HwContext(encoder=encoder, note=f"hardware ({family})")
attempted.append(f"{encoder} (unsupported)")
if not allow_software:
raise Die(
"no working hardware encoder found and software fallback is disabled; "
f"tried {', '.join(attempted)}. Diagnose with: python3 scripts/hwprobe.py"
)
report.verbose(f"no hardware encoder for {codec}: {', '.join(attempted)}")
return HwContext(encoder="libx264" if codec == "h264" else "libx265", note="software")
if video in ("x264", "x265"):
return HwContext(encoder=VIDEO_ENCODERS[video][1], note=f"software ({video})")
raise Die(f"unknown --video {video!r}")
def video_args(context: HwContext, *, quality: bool = True) -> list[str]:
"""Encoder arguments for the selected context."""
if context.encoder == "copy":
return ["-c:v", "copy"]
args = ["-c:v", context.encoder]
if context.encoder.startswith("libx") and quality:
args += X264_QUALITY
return args
def pix_fmt_args(context: HwContext) -> list[str]:
# Other pixel formats come from the VA-API upload filter; without a filter
# chain, hardware encoders still need 4:2:0 input.
if context.is_vaapi:
return []
return ["-pix_fmt", "yuv420p"]
def audio_args(fmt: str) -> tuple[list[str], str]:
if fmt not in AUDIO_FORMATS:
raise Die(f"unknown --format {fmt!r}; choose from {', '.join(AUDIO_FORMATS)}")
extension, container, codec = AUDIO_FORMATS[fmt]
return container + codec, extension or ("")
def resolve_output(output: str | None, stem: str, fmt: str, suffix: str) -> str:
"""Work out the output filename, fixing up the extension for the container."""
if output:
return output
extension = AUDIO_FORMATS[fmt][0]
if extension and not stem.lower().endswith(f".{extension}"):
return f"{stem}{suffix}.{extension}"
return f"{stem}{suffix}"
def build_filter(context: HwContext, *filters: str) -> str | None:
"""Join filter fragments, appending the VA-API upload step when needed."""
parts = [f for f in filters if f]
upload = context.filter_suffix()
if upload:
parts.append(upload)
return ",".join(parts) if parts else None
# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #
def cmd_mux(args: argparse.Namespace, context: HwContext, report: Report) -> int:
"""Attach audio to a video-only file. Video stream is stream-copied."""
codec_args, _ = audio_args(args.format)
output = resolve_output(args.output, os.path.splitext(args.input_video)[0], args.format, "_mux")
filter_chain = build_filter(context, *args.vf) if context.is_vaapi else (",".join(args.vf) or None)
video = video_args(context, quality=False) if context.is_vaapi else ["-c:v", "copy"]
cmd = [
args.ffmpeg, "-y",
*context.input_args(),
"-i", args.input_video,
"-i", args.audio,
"-map", "0:v:0", "-map", "1:a:0",
*video,
*codec_args,
]
if filter_chain:
cmd += ["-vf", filter_chain]
cmd += ["-shortest", output]
run(cmd, report=report, dry_run=args.dry_run)
if not args.dry_run:
report.ok(f"muxed -> {output}")
return 0
def cmd_bake(args: argparse.Namespace, context: HwContext, report: Report) -> int:
"""Burn ASS subtitles into the video stream."""
if not os.path.isfile(args.subtitles):
raise Die(f"no such subtitle file: {args.subtitles}")
# libass needs the filename escaped for the filtergraph; ':' and '\' and "'"
# all have meaning there.
escaped = (
args.subtitles.replace("\\", "\\\\")
.replace(":", "\\:")
.replace("'", r"\'")
.replace("[", r"\[")
.replace("]", r"\]")
.replace(",", r"\,")
)
# Burning subtitles means changing pixels, so the video stream cannot be copied.
# Fail early with a clear message instead of letting ffmpeg refuse the filter.
if context.encoder == "copy":
raise Die(
"cannot bake subtitles with --video copy: burning them in requires "
"re-encoding. Use --video auto (default) or --video x264."
)
filter_chain = build_filter(context, f"ass={escaped}", *args.vf)
output = args.output or _append_suffix(args.input_video, "_sub")
codec_args, _ = audio_args(args.format) if args.audio_format != "keep" else (["-c:a", "copy"], None)
cmd = [
args.ffmpeg, "-y",
*context.input_args(),
"-i", args.input_video,
"-vf", filter_chain or "null",
*video_args(context),
*pix_fmt_args(context),
*codec_args,
"-shortest" if args.shortest else "-map_metadata", "-1",
output,
]
run(cmd, report=report, dry_run=args.dry_run)
if not args.dry_run:
report.ok(f"subtitles baked -> {output}")
return 0
def cmd_still(args: argparse.Namespace, context: HwContext, report: Report) -> int:
"""Build a video from one image and one audio file (the no-lyrics render)."""
codec_args, _ = audio_args(args.format)
output = resolve_output(args.output, os.path.splitext(args.image)[0], args.format, "_pv")
filters = [f"fps={args.fps}", f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]
if context.is_vaapi:
filter_chain = build_filter(context, *filters)
cmd = [args.ffmpeg, "-y", "-loop", "1", "-i", args.image, "-i", args.audio, *context.input_args(), "-vf", filter_chain]
else:
cmd = [args.ffmpeg, "-y", "-loop", "1", "-i", args.image, "-i", args.audio, "-vf", ",".join(filters)]
cmd += ["-r", str(args.fps), *video_args(context), *pix_fmt_args(context), *codec_args, "-shortest", output]
run(cmd, report=report, dry_run=args.dry_run)
if not args.dry_run:
report.ok(f"still-image video -> {output}")
return 0
def cmd_strip(args: argparse.Namespace, context: HwContext | None, report: Report) -> int:
"""Extract the audio track as MP3."""
output = args.output or os.path.splitext(args.input_video)[0] + ".mp3"
cmd = [
args.ffmpeg, "-y", "-i", args.input_video, "-vn",
"-ar", str(args.sample_rate), "-ac", str(args.channels),
"-c:a", "libmp3lame", "-b:a", args.bitrate,
output,
]
run(cmd, report=report, dry_run=args.dry_run)
if not args.dry_run:
report.ok(f"audio -> {output}")
return 0
def cmd_probe(args: argparse.Namespace, context: HwContext | None, report: Report) -> int:
"""Print a compact stream summary for a media file."""
if not os.path.isfile(args.media[0]):
raise Die(f"no such file: {args.media[0]}")
cmd = [
"ffprobe" if args.ffmpeg.endswith("ffmpeg") else args.ffmpeg.replace("ffmpeg", "ffprobe"),
"-hide_banner", "-loglevel", "error",
"-show_entries", "stream=index,codec_type,codec_name,width,height,r_frame_rate,channels,sample_rate,bit_rate",
"-show_entries", "format=duration,size,format_name",
"-of", "json",
*args.media,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
except OSError as exc:
raise Die(f"could not run ffprobe: {exc}") from exc
if result.returncode != 0:
raise Die(f"ffprobe failed: {(result.stderr or '').strip()}")
data = json.loads(result.stdout or "{}")
rows = []
for stream in data.get("streams", []):
kind = stream.get("codec_type", "?")
if kind == "video":
detail = f"{stream.get('width')}x{stream.get('height')} @ {stream.get('r_frame_rate')}"
elif kind == "audio":
detail = f"{stream.get('channels')}ch @ {stream.get('sample_rate')} Hz"
else:
detail = ""
rows.append((str(stream.get("index", "")), kind, stream.get("codec_name", "?"), detail))
report.info(text_table(("stream", "type", "codec", "detail"), rows))
fmt = data.get("format", {})
duration = fmt.get("duration")
size = fmt.get("size")
if duration:
report.info(f"\ncontainer: {fmt.get('format_name', '?')}")
report.info(f"duration: {float(duration):.2f}s")
if size:
report.info(f"size: {int(size):,} bytes")
return 0
def _append_suffix(path: str, suffix: str) -> str:
stem, extension = os.path.splitext(path)
return f"{stem}{suffix}{extension}"
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def _add_video_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--video",
default=None,
choices=("auto", "x264", "x265", "copy"),
help="video encoder: auto (hardware if possible), x264, x265, or copy (default: auto)",
)
parser.add_argument(
"--codec",
default=None,
choices=("h264", "hevc", "av1"),
help="hardware codec family to use when --video auto (default: h264)",
)
parser.add_argument(
"--device",
default=None,
metavar="PATH",
help=f"VA-API render node (default: $HW_DEVICE or {DEFAULT_DEVICE})",
)
parser.add_argument(
"--vf",
action="append",
default=[],
metavar="FILTER",
help="extra video filter, repeatable (appended after the built-in chain)",
)
def _add_audio_options(parser: argparse.ArgumentParser, *, default: str = "flac") -> None:
parser.add_argument(
"-f",
"--format",
default=default,
choices=tuple(AUDIO_FORMATS),
help=f"audio format preset (default: {default})",
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ffmpeg_tools",
description="FFmpeg front-end for NCMR song videos (mux, bake, still, strip, probe).",
epilog="Run `%(prog)s hwprobe` for a separate encoder report, or see scripts/hwprobe.py.",
)
add_common_arguments(parser)
add_ffmpeg_argument(parser)
subparsers = parser.add_subparsers(dest="mode", metavar="MODE")
mux = subparsers.add_parser("mux", help="attach audio to a video-only file, video copied as-is")
add_common_arguments(mux, suppress_defaults=True)
mux.add_argument("input_video", help="video-only input (e.g. the Remotion render)")
mux.add_argument("audio", help="audio input (e.g. the exported song)")
mux.add_argument("-o", "--output", help="output path")
_add_audio_options(mux)
_add_video_options(mux)
mux.set_defaults(func=cmd_mux)
bake = subparsers.add_parser("bake", help="burn an ASS subtitle file into a video")
add_common_arguments(bake, suppress_defaults=True)
bake.add_argument("input_video", help="input video")
bake.add_argument("subtitles", help="ASS subtitle file")
bake.add_argument("-o", "--output", help="output path (default: <video>_sub.<ext>)")
_add_audio_options(bake, default="aac")
bake.add_argument(
"--audio-format",
default="keep",
choices=("keep", *AUDIO_FORMATS),
help="re-encode audio to this preset, or keep it as-is (default: keep)",
)
bake.add_argument("--shortest", action="store_true", help="stop at the shortest stream")
_add_video_options(bake)
bake.set_defaults(func=cmd_bake)
still = subparsers.add_parser("still", help="one image + audio -> video")
add_common_arguments(still, suppress_defaults=True)
still.add_argument("image", help="cover art image")
still.add_argument("audio", help="audio file")
still.add_argument("-o", "--output", help="output path")
still.add_argument("--fps", type=int, default=60, help="frame rate (default: 60)")
still.add_argument("--width", type=int, default=1920, help="target width (default: 1920)")
still.add_argument("--height", type=int, default=1080, help="target height (default: 1080)")
_add_audio_options(still)
_add_video_options(still)
still.set_defaults(func=cmd_still)
strip = subparsers.add_parser("strip", help="extract audio from a video as MP3")
add_common_arguments(strip, suppress_defaults=True)
strip.add_argument("input_video", help="input video")
strip.add_argument("-o", "--output", help="output MP3 path")
strip.add_argument("--sample-rate", type=int, default=44100, help="(default: 44100)")
strip.add_argument("--channels", type=int, default=2, help="(default: 2)")
strip.add_argument("--bitrate", default="320k", help="(default: 320k)")
strip.set_defaults(func=cmd_strip)
probe = subparsers.add_parser("probe", help="print a compact stream summary")
add_common_arguments(probe, suppress_defaults=True)
probe.add_argument("media", nargs=1, help="media file to inspect")
probe.set_defaults(func=cmd_probe)
return parser
@main_wrapper
def main() -> int:
parser = build_parser()
args = parser.parse_args()
report = Report(quiet=args.quiet)
if not getattr(args, "func", None):
parser.print_help()
return 2
args.ffmpeg = find_ffmpeg(args.ffmpeg)
# strip and probe never encode video, so they have no --video/--codec/--device
# and no acceleration context to work out.
if not hasattr(args, "video"):
return args.func(args, None, report)
# --video/--codec/--device live on the subparsers, so the namespace already
# carries whatever was parsed. Supply the documented defaults here rather than
# duplicating them across every subparser.
video = args.video if args.video is not None else "auto"
codec = args.codec if args.codec is not None else "h264"
device = args.device or os.environ.get("HW_DEVICE")
context = pick_acceleration(
args.ffmpeg,
report,
video=video,
codec=codec,
device=device,
)
if context.note and args.mode != "probe":
report.info(f"encoder: {context.encoder} — {context.note}")
return args.func(args, context, report)
def _supplied_options(argv: list[str]) -> dict[str, str]:
"""Map ``--flag`` to its value for flags present on the command line.
Retained for tests and external callers; ``main`` no longer needs it now that
the encoder options are parsed by argparse rather than sniffed out of argv.
"""
supplied: dict[str, str] = {}
i = 0
while i < len(argv):
token = argv[i]
if token.startswith("--") and "=" in token:
flag, _, value = token.partition("=")
supplied[flag] = value
elif token.startswith("--"):
if i + 1 < len(argv):
supplied[token] = argv[i + 1]
i += 1
i += 1
return supplied
def _decide(flag: str, supplied: dict[str, str], namespace_value, fallback):
"""Prefer a flag supplied on the command line, then the namespace, then default."""
if flag in supplied:
return supplied[flag]
return namespace_value if namespace_value is not None else fallback
if __name__ == "__main__":
raise SystemExit(main())
+460
View File
@@ -0,0 +1,460 @@
"""Detect which video encoders this machine can actually use.
Probes ffmpeg for hardware and software encoders and decides which are usable
here, then emits either a human-readable report or a shell ``export`` block.
The reference machine has no NVIDIA GPU, so ``*_nvenc`` encoders exist in the
ffmpeg build but cannot run. Detecting that up front is cheaper than discovering
it in the middle of a render.
Usage::
python3 scripts/hwprobe.py # human-readable report
python3 scripts/hwprobe.py --json # machine-readable
python3 scripts/hwprobe.py --shell # eval-able export block
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field, asdict
if __package__ in (None, ""):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import ( # type: ignore[import-not-found]
Die,
Report,
add_common_arguments,
add_ffmpeg_argument,
find_ffmpeg,
main_wrapper,
report as default_report,
run,
text_table,
)
else: # pragma: no cover - exercised via `python3 -m scripts.hwprobe`
from ._common import (
Die,
Report,
add_common_arguments,
add_ffmpeg_argument,
find_ffmpeg,
main_wrapper,
report as default_report,
run,
text_table,
)
# --------------------------------------------------------------------------- #
# Candidate tables
# --------------------------------------------------------------------------- #
#: Hardware encoder families we know how to enable, keyed by ffmpeg suffix.
HW_FAMILIES = ("nvenc", "vaapi", "qsv", "amf", "v4l2m2m", "vulkan", "videotoolbox")
#: The encoders worth offering as a "best available" pick, in preference order.
#: Hardware first, then software fallbacks that always exist in a full build.
ENCODER_PREFERENCE = (
"h264_nvenc",
"hevc_nvenc",
"av1_nvenc",
"h264_qsv",
"hevc_qsv",
"h264_vaapi",
"hevc_vaapi",
"av1_vaapi",
"h264_amf",
"hevc_amf",
"h264_v4l2m2m",
"hevc_v4l2m2m",
"hevc_vulkan",
"libx264",
"libx265",
"libsvtav1",
"libvpx-vp9",
)
#: vaapi/vulkan/qsv devices we look for, in preference order.
DEVICE_CANDIDATES = (
"/dev/dri/renderD128",
"/dev/dri/renderD129",
"/dev/dri/card0",
"/dev/dri/card1",
)
@dataclass
class Encoder:
name: str
description: str
@property
def family(self) -> str:
for family in HW_FAMILIES:
if self.name.endswith(f"_{family}"):
return family
return "software"
@property
def is_hardware(self) -> bool:
return self.family != "software"
@dataclass
class ProbeResult:
ffmpeg: str
ffmpeg_version: str
encoders: list[Encoder] = field(default_factory=list)
hwaccels: list[str] = field(default_factory=list)
devices: dict[str, bool] = field(default_factory=dict)
tested: dict[str, dict] = field(default_factory=dict)
#: Encoder families that are compiled in but have no usable device.
blocked: dict[str, str] = field(default_factory=dict)
recommended: str | None = None
notes: list[str] = field(default_factory=list)
def encryptors(self) -> list[Encoder]:
return [e for e in self.encoders if e.is_hardware]
def parse_encoders(output: str) -> list[Encoder]:
"""Parse ``ffmpeg -encoders`` output into video encoders only."""
encoders: list[Encoder] = []
for line in output.splitlines():
match = re.match(r"^\s*([VAS][\w.]{5})\s+(\S+)\s+(.*\S)\s*$", line)
if not match:
continue
flags, name, description = match.groups()
if not flags.startswith("V"):
continue
encoders.append(Encoder(name=name, description=description))
return encoders
def parse_hwaccels(output: str) -> list[str]:
"""Parse ``ffmpeg -hwaccels`` output."""
methods: list[str] = []
seen_header = False
for line in output.splitlines():
line = line.strip()
if not line:
continue
if line.lower().startswith("hardware acceleration"):
seen_header = True
continue
if seen_header:
methods.append(line)
return methods
def probe_devices() -> dict[str, bool]:
"""Check which DRM devices exist *and* are readable by this process."""
found: dict[str, bool] = {}
for device in DEVICE_CANDIDATES:
if not os.path.exists(device):
continue
found[device] = os.access(device, os.R_OK | os.W_OK)
return found
def probe_device_driver(device: str) -> str | None:
"""Read the kernel driver name backing a DRM node, if sysfs is readable."""
node = os.path.basename(device)
uevent = f"/sys/class/drm/{node}/device/uevent"
try:
with open(uevent, "r", encoding="utf-8", errors="replace") as handle:
content = handle.read()
except OSError:
return None
match = re.search(r"^DRIVER=(\S+)", content, re.MULTILINE)
return match.group(1) if match else None
def missing_render_group(devices: dict[str, bool]) -> bool:
"""True when a DRM device exists but this process cannot open it."""
return any(not ok for ok in devices.values())
def test_encoder(ffmpeg: str, encoder: str, report: Report, device: str | None = None) -> tuple[bool, str]:
"""Encode two throwaway frames with *encoder*. Returns (worked, detail)."""
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error"]
if encoder.endswith("_vaapi"):
if not device:
return False, "no vaapi device available"
cmd += [
"-init_hw_device", f"vaapi=va:{device}",
"-filter_hw_device", "va",
"-f", "lavfi",
"-i", "color=c=black:s=256x256:r=30:d=1",
"-vf", "format=nv12,hwupload",
]
elif encoder.endswith("_vulkan"):
cmd += ["-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1"]
else:
cmd += ["-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1"]
cmd += ["-c:v", encoder, "-frames:v", "2", "-f", "null", "-"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
except subprocess.TimeoutExpired:
return False, "timed out after 60s"
except FileNotFoundError:
return False, "ffmpeg binary missing"
if result.returncode == 0:
return True, "ok"
stderr = (result.stderr or "").strip().splitlines()
return False, stderr[-1] if stderr else f"exit status {result.returncode}"
def probe(ffmpeg: str, report: Report, *, test: bool = True) -> ProbeResult:
"""Gather everything we can learn about this machine's encoders."""
def capture(args: list[str]) -> str:
try:
result = subprocess.run(
[ffmpeg, "-hide_banner", *args],
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise Die(f"could not run {ffmpeg}: {exc}") from exc
return result.stdout
banner = capture(["-version"])
version = banner.splitlines()[0] if banner else "unknown"
result = ProbeResult(
ffmpeg=ffmpeg,
ffmpeg_version=version,
encoders=parse_encoders(capture(["-encoders"])),
hwaccels=parse_hwaccels(capture(["-hwaccels"])),
devices=probe_devices(),
)
usable_device = next((d for d, ok in result.devices.items() if ok), None)
blocked_families: dict[str, str] = {}
if usable_device:
driver = probe_device_driver(usable_device)
if driver:
result.notes.append(f"{usable_device} is driven by {driver}")
elif result.devices:
blocked_families["vaapi"] = "DRM device present but not openable"
blocked_families["vulkan"] = "DRM device present but not openable"
else:
blocked_families["vaapi"] = "no DRM render device found"
if not test:
return result
for encoder in result.encoders:
if not encoder.is_hardware and encoder.name not in ("libx264",):
continue
if encoder.family in blocked_families:
result.tested[encoder.name] = {
"ok": False,
"detail": blocked_families[encoder.family],
"tested": False,
}
continue
if encoder.family in ("qsv", "amf", "videotoolbox", "v4l2m2m") and not result.devices:
result.tested[encoder.name] = {
"ok": False,
"detail": "no matching device",
"tested": False,
}
continue
ok, detail = test_encoder(ffmpeg, encoder.name, report, usable_device)
result.tested[encoder.name] = {"ok": ok, "detail": detail, "tested": True}
for family, reason in blocked_families.items():
if any(e.family == family for e in result.encoders):
result.blocked[family] = reason
for name in ENCODER_PREFERENCE:
entry = result.tested.get(name)
if entry and entry["ok"]:
result.recommended = name
break
return result
# --------------------------------------------------------------------------- #
# Output
# --------------------------------------------------------------------------- #
def render_report(result: ProbeResult) -> str:
lines: list[str] = []
lines.append(f"ffmpeg {result.ffmpeg_version}")
lines.append(f"binary {result.ffmpeg}")
lines.append("")
lines.append("DRM devices")
if result.devices:
for device in sorted(result.devices):
ok = result.devices[device]
driver = probe_device_driver(device) or "?"
state = "readable" if ok else "NOT READABLE"
lines.append(f" {device.ljust(20)} {driver.ljust(8)} {state}")
else:
lines.append(" none found — hardware encoding is unavailable")
lines.append("")
lines.append(f"hwaccels {', '.join(result.hwaccels) or '(none)'}")
lines.append("")
hw = result.encryptors()
sw = [e for e in result.encoders if not e.is_hardware]
lines.append(f"Hardware encoders in this build ({len(hw)})")
if hw:
rows = []
for encoder in hw:
entry = result.tested.get(encoder.name)
if entry is None:
verdict = "untested"
elif entry["ok"]:
verdict = "WORKS"
elif not entry.get("tested", True):
verdict = "unavailable"
else:
verdict = "failed"
rows.append((encoder.name, encoder.family, verdict))
lines.append(text_table(("encoder", "family", "status"), rows))
else:
lines.append(" none")
lines.append("")
lines.append(f"Software encoders ({len(sw)})")
notable = [e for e in sw if e.name in ("libx264", "libx265", "libsvtav1", "libvpx-vp9", "libxvid")]
lines.append(" " + (", ".join(e.name for e in notable) or "none"))
if result.blocked:
lines.append("")
lines.append("Blocked")
for family, reason in sorted(result.blocked.items()):
lines.append(f" {family}: {reason}")
if result.notes:
lines.append("")
for note in result.notes:
lines.append(f"note: {note}")
lines.append("")
if result.recommended:
lines.append(f"recommended -c:v {result.recommended}")
else:
lines.append("recommended (nothing usable detected)")
if missing_render_group(result.devices):
lines.append("")
lines.append("hint: a DRM device exists but this user cannot open it.")
lines.append(" add the user to the 'render' group and re-login:")
lines.append(" sudo usermod -aG render,video $USER")
return "\n".join(lines)
def render_shell(result: ProbeResult) -> str:
"""Export block for ``eval \"$(python3 scripts/hwprobe.py --shell)\"``."""
device = next((d for d, ok in result.devices.items() if ok), "")
lines = [
"# generated by scripts/hwprobe.py — eval to apply",
f"export FFMPEG={result.ffmpeg}",
]
for encoder in result.encryptors():
entry = result.tested.get(encoder.name, {})
if entry.get("ok"):
lines.append(f"export ENCODER_{encoder.family.upper()}={encoder.name}")
lines.append(f"export ENCODER={result.recommended or 'libx264'}")
if device:
lines.append(f"export HW_DEVICE={device}")
return "\n".join(lines)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="hwprobe",
description="Report which ffmpeg encoders this machine can actually use.",
epilog=(
"Handy: eval \"$(python3 scripts/hwprobe.py --shell)\" "
"to put the detected encoder in $ENCODER."
),
)
add_common_arguments(parser)
add_ffmpeg_argument(parser)
parser.add_argument("--json", action="store_true", help="emit JSON instead of text")
parser.add_argument("--shell", action="store_true", help="emit an eval-able export block")
parser.add_argument(
"--no-test",
action="store_true",
help="only read ffmpeg's capability lists; do not try encoding",
)
return parser
@main_wrapper
def main() -> int:
args = build_parser().parse_args()
report = Report(quiet=args.quiet)
ffmpeg = find_ffmpeg(args.ffmpeg)
result = probe(ffmpeg, report, test=not args.no_test)
if args.json:
payload = asdict(result)
payload["blocked"] = result.blocked
payload["recommended"] = result.recommended
print(json.dumps(payload, indent=2))
elif args.shell:
print(render_shell(result))
else:
print(render_report(result))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+353
View File
@@ -0,0 +1,353 @@
"""Convert LRC lyrics to SRT subtitles.
LRC is the timestamped-lyrics format most lyric sites and players emit. This
converter handles the shapes that actually show up in the wild:
* ``[mm:ss.xx]`` and ``[mm:ss.xxx]`` timestamps, and bare ``[mm:ss]``
* several timestamps on one line (``[00:05.00][00:30.00]chorus``), which is how
repeated sections are written
* ID tags (``[ti:Title]``, ``[ar:Artist]``, ``[offset:+250]``), which are
metadata, not lyrics
* multiple timestamped lines sharing one timestamp, which SRT wants as one cue
with two subtitle lines
Cue end times are inferred from the next cue's start, so the timing is driven by
the LRC rather than invented. The final cue gets ``--last-duration`` seconds.
Usage::
python3 scripts/lrc2srt.py song.lrc # -> song.srt
python3 scripts/lrc2srt.py song.lrc -o subs/song.srt
python3 scripts/lrc2srt.py lyrics/*.lrc --out-dir build/
python3 scripts/lrc2srt.py song.lrc --stdout # write to stdout
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from dataclasses import dataclass, field
if __package__ in (None, ""):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import ( # type: ignore[import-not-found]
Die,
Report,
add_common_arguments,
main_wrapper,
)
else: # pragma: no cover
from ._common import (
Die,
Report,
add_common_arguments,
main_wrapper,
)
# --------------------------------------------------------------------------- #
# Parsing
# --------------------------------------------------------------------------- #
#: ``[mm:ss.xx]`` / ``[mm:ss.xxx]`` / ``[mm:ss]``, anywhere in a line.
TIMESTAMP_RE = re.compile(r"\[(?P<m>\d{1,3}):(?P<s>\d{1,2})(?:[.:](?P<frac>\d{1,3}))?\]")
#: ``[ti:...]`` / ``[ar:...]`` / ``[offset:...]`` — an identifier, not a time.
TAG_RE = re.compile(r"^\[(?P<key>[A-Za-z#]+):(?P<value>.*)\]\s*$")
#: Keys whose value is a signed millisecond count, not a timestamp.
META_KEYS = {
"ti", "al", "ar", "au", "by", "re", "ve", "length", "offset", "encoding", "tool", "id",
}
@dataclass
class LyricLine:
timestamp: float
text: str
@dataclass
class LrcDocument:
lines: list[LyricLine] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
offset_ms: int = 0
@property
def title(self) -> str | None:
return self.metadata.get("ti")
@property
def artist(self) -> str | None:
return self.metadata.get("ar")
def parse_timestamp(minutes: str, seconds: str, fraction: str | None) -> float:
"""Turn LRC timestamp parts into seconds.
The fractional part is centiseconds in the common case (``.30`` = 300 ms) but
milliseconds when three digits are given (``.300`` = 300 ms). The length of
the field disambiguates.
"""
total = int(minutes) * 60 + int(seconds)
if fraction:
if len(fraction) == 3:
total += int(fraction) / 1000
else:
total += int(fraction) / 100
return total
def parse_lrc(content: str) -> LrcDocument:
"""Parse LRC text into a document, sorted by timestamp."""
if content.startswith("\ufeff"):
content = content[1:]
content = content.replace("\r\n", "\n").replace("\r", "\n")
document = LrcDocument()
for raw in content.split("\n"):
line = raw.strip()
if not line:
continue
# Metadata tags first: [ti:...] looks a lot like [00:12] to a loose regex.
tag = TAG_RE.match(line)
if tag and tag.group("key").lower() in META_KEYS:
key = tag.group("key").lower()
value = tag.group("value").strip()
if key == "offset":
try:
document.offset_ms = int(value)
except ValueError:
pass
else:
document.metadata[key] = value
continue
stamps = list(TIMESTAMP_RE.finditer(line))
if not stamps:
# An untimed line. Attach it to the previous cue as a continuation
# line rather than discarding the lyric entirely.
if document.lines:
previous = document.lines[-1]
previous.text = f"{previous.text}\n{line}"
continue
text = TIMESTAMP_RE.sub("", line).strip()
for stamp in stamps:
document.lines.append(
LyricLine(
timestamp=parse_timestamp(
stamp.group("m"), stamp.group("s"), stamp.group("frac")
),
text=text,
)
)
if document.offset_ms:
shift = document.offset_ms / 1000
for line in document.lines:
line.timestamp = max(0.0, line.timestamp + shift)
document.lines.sort(key=lambda l: l.timestamp)
return document
# --------------------------------------------------------------------------- #
# SRT output
# --------------------------------------------------------------------------- #
@dataclass
class Cue:
index: int
start: float
end: float
lines: list[str]
def format_srt_time(seconds: float) -> str:
"""Seconds to SRT ``HH:MM:SS,mmm``."""
if seconds < 0:
seconds = 0.0
total_ms = int(round(seconds * 1000))
hours, remainder = divmod(total_ms, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
secs, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def build_cues(
lines: list[LyricLine],
*,
last_duration: float = 5.0,
min_duration: float = 0.5,
) -> list[Cue]:
"""Group simultaneous lines into cues and infer each cue's end time.
Lines sharing one timestamp become a single cue with several subtitle lines —
the format singers actually expect for a two-line phrase. A cue ends when the
*next distinct* timestamp arrives.
"""
if not lines:
return []
# Collapse lines with identical timestamps into one cue.
groups: list[tuple[float, list[str]]] = []
for line in lines:
if groups and abs(groups[-1][0] - line.timestamp) < 1e-6:
groups[-1][1].append(line.text)
else:
groups.append((line.timestamp, [line.text]))
cues: list[Cue] = []
for i, (start, text_lines) in enumerate(groups):
if i + 1 < len(groups):
end = groups[i + 1][0]
if end - start < min_duration:
end = start + min_duration
else:
end = start + last_duration
cues.append(Cue(index=i + 1, start=start, end=end, lines=text_lines))
return cues
def render_srt(cues: list[Cue]) -> str:
blocks = []
for cue in cues:
body = "\n".join(cue.lines)
blocks.append(
f"{cue.index}\n{format_srt_time(cue.start)} --> {format_srt_time(cue.end)}\n{body}"
)
return "\n\n".join(blocks) + "\n"
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="lrc2srt",
description="Convert LRC lyrics to SRT subtitles.",
epilog="Multiple timestamps on one line are expanded; ID tags are ignored.",
)
add_common_arguments(parser)
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="LRC file(s) to convert")
parser.add_argument("-o", "--output", metavar="FILE", help="output path (single input only)")
parser.add_argument("--out-dir", metavar="DIR", help="write every output into this directory")
parser.add_argument("--stdout", action="store_true", help="write the SRT to stdout, no file")
parser.add_argument(
"--last-duration",
type=float,
default=5.0,
metavar="SECONDS",
help="how long to hold the final line (default: 5)",
)
parser.add_argument(
"--min-duration",
type=float,
default=0.5,
metavar="SECONDS",
help="shortest cue allowed (default: 0.5)",
)
parser.add_argument(
"--title",
help="override the SRT's implicit title (used for reporting only)",
)
return parser
def convert(src: str, args: argparse.Namespace, report: Report) -> tuple[int, str | None]:
try:
with open(src, "r", encoding="utf-8-sig", errors="replace") as handle:
content = handle.read()
except OSError as exc:
raise Die(f"cannot read {src}: {exc.strerror}") from exc
document = parse_lrc(content)
if not document.lines:
raise Die(f"{src}: no timestamped lyrics found — is this an LRC file?")
cues = build_cues(
document.lines,
last_duration=args.last_duration,
min_duration=args.min_duration,
)
output = render_srt(cues)
if args.stdout:
sys.stdout.write(output)
return len(cues), None
if args.output:
dest = args.output
elif args.out_dir:
dest = os.path.join(args.out_dir, os.path.splitext(os.path.basename(src))[0] + ".srt")
else:
dest = os.path.splitext(src)[0] + ".srt"
if args.dry_run:
report.info(f"[dry-run] would write {dest} ({len(cues)} cues)")
return len(cues), dest
os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
with open(dest, "w", encoding="utf-8", newline="\n") as handle:
handle.write(output)
label = document.title or os.path.basename(src)
report.ok(f"{label} -> {dest} ({len(cues)} cues)")
if document.artist:
report.verbose(f"artist: {document.artist}")
if document.offset_ms:
report.verbose(f"applied LRC offset of {document.offset_ms:+d} ms")
return len(cues), dest
@main_wrapper
def main() -> int:
args = build_parser().parse_args()
report = Report(quiet=args.quiet)
if args.output and len(args.inputs) > 1:
raise Die("--output takes a single input; use --out-dir for batches")
if args.stdout and len(args.inputs) > 1:
raise Die("--stdout takes a single input")
total = 0
converted = 0
for src in args.inputs:
if not os.path.isfile(src):
raise Die(f"no such file: {src}")
cues, _dest = convert(src, args, report)
total += cues
converted += 1
if converted > 1:
report.info(f"{total} cues across {converted} file(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+382
View File
@@ -0,0 +1,382 @@
"""Convert an SRT subtitle file to ASS (Advanced SubStation Alpha).
Stdlib only. Handles the constructs that actually appear in song subtitles:
multi-line cues, ``{\\an8}``-style override blocks passed through untouched,
and the SRT quirks (CRLF, BOM, cue numbers, blank-line separators).
Usage::
python3 scripts/srt2ass.py song.srt song.ass
python3 scripts/srt2ass.py lyrics/*.srt --out-dir build/ # batch
python3 scripts/srt2ass.py song.srt --style-font "Noto Sans" --style-size 64
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from dataclasses import dataclass
if __package__ in (None, ""):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import ( # type: ignore[import-not-found]
Die,
Report,
add_common_arguments,
glob_files,
main_wrapper,
)
else: # pragma: no cover
from ._common import (
Die,
Report,
add_common_arguments,
glob_files,
main_wrapper,
)
# --------------------------------------------------------------------------- #
# Timestamps
# --------------------------------------------------------------------------- #
_SRT_TIME_RE = re.compile(
r"(?P<h>\d{1,2}):(?P<m>\d{2}):(?P<s>\d{2})[,.](?P<ms>\d{1,3})"
)
def parse_srt_time(stamp: str) -> float:
"""``00:01:02,500`` (or ``00:01:02.5``) to seconds."""
match = _SRT_TIME_RE.search(stamp.strip())
if not match:
raise Die(f"unparseable SRT timestamp: {stamp!r}")
ms = match.group("ms").ljust(3, "0")[:3]
return (
int(match.group("h")) * 3600
+ int(match.group("m")) * 60
+ int(match.group("s"))
+ int(ms) / 1000
)
def format_ass_time(seconds: float) -> str:
"""Seconds to ASS ``H:MM:SS.cc`` (centiseconds, single-digit hour)."""
if seconds < 0:
seconds = 0.0
total_cs = int(round(seconds * 100))
hours, remainder = divmod(total_cs, 360_000)
minutes, remainder = divmod(remainder, 6_000)
secs, centis = divmod(remainder, 100)
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
# --------------------------------------------------------------------------- #
# Parsing
# --------------------------------------------------------------------------- #
@dataclass
class Cue:
index: int
start: float
end: float
lines: list[str]
def text(self, line_sep: str = r"\N") -> str:
return line_sep.join(self.lines)
def strip_bom(text: str) -> str:
return text[1:] if text.startswith("\ufeff") else text
def parse_srt(content: str) -> list[Cue]:
"""Parse SRT text into cues. Tolerates CRLF, BOM, and missing cue numbers."""
content = strip_bom(content).replace("\r\n", "\n").replace("\r", "\n")
blocks = re.split(r"\n\s*\n", content)
cues: list[Cue] = []
auto_index = 0
for block in blocks:
lines = block.strip("\n").split("\n")
if not lines or not any(line.strip() for line in lines):
continue
index: int | None = None
if lines[0].strip().isdigit():
index = int(lines[0].strip())
lines = lines[1:]
if not lines:
continue
if "-->" not in lines[0]:
# Not a cue (stray header, WEBVTT marker, ...). Skip it quietly.
continue
start_raw, _, end_raw = lines[0].partition("-->")
start = parse_srt_time(start_raw)
end = parse_srt_time(end_raw)
body = [line for line in lines[1:] if line.strip()]
if not body:
# A cue with a timestamp but no text: pure gap in the lyrics. Emitting
# it would produce an empty subtitle block, so drop it — the previous
# cue simply holds until the next one.
continue
auto_index += 1
cues.append(Cue(index=index or auto_index, start=start, end=end, lines=body))
return cues
# --------------------------------------------------------------------------- #
# ASS output
# --------------------------------------------------------------------------- #
#: ASS uses US-ASCII-with-code-page-1252 in practice; karaoke/Japanese needs 1.
ASS_CODEPAGES = {
"ascii": 0,
"latin1": 0,
"cp1252": 0,
"shift_jis": 1,
"sjis": 1,
"gbk": 134,
"utf-8": 1,
"utf8": 1,
}
ASS_HEADER = """[Script Info]
; Generated by scripts/srt2ass.py — edit the source SRT, not this file.
Title: {title}
ScriptType: v4.00+
WrapStyle: {wrap_style}
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.709
PlayResX: {width}
PlayResY: {height}
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,{font},{size},{primary},{secondary},{outline_colour},{back},{bold},{italic},0,0,100,100,{spacing},0,1,{border},{shadow},{alignment},{margin_l},{margin_r},{margin_v},{encoding}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
@dataclass
class AssStyle:
"""The subset of the Default style we expose on the CLI."""
font: str = "Arial"
size: int = 48
primary: str = "&H00FFFFFF" # white, fully opaque
secondary: str = "&H000000FF"
outline_colour: str = "&H00000000"
back: str = "&H00000000"
bold: int = -1
italic: int = 0
spacing: float = 0.0
border: float = 2.0
shadow: float = 1.0
alignment: int = 2 # 2 = bottom centre
margin_l: int = 40
margin_r: int = 40
margin_v: int = 60
encoding: int = 1
def render_ass(
cues: list[Cue],
*,
style: AssStyle,
width: int,
height: int,
title: str,
wrap_style: int = 0,
) -> str:
header = ASS_HEADER.format(
title=title.replace("\n", " ") or "Untitled",
wrap_style=wrap_style,
width=width,
height=height,
font=style.font,
size=style.size,
primary=style.primary,
secondary=style.secondary,
outline_colour=style.outline_colour,
back=style.back,
bold=style.bold,
italic=style.italic,
spacing=style.spacing,
border=style.border,
shadow=style.shadow,
alignment=style.alignment,
margin_l=style.margin_l,
margin_r=style.margin_r,
margin_v=style.margin_v,
encoding=style.encoding,
)
events = []
for cue in cues:
text = cue.text()
# ASS treats commas as field separators in the Text column, but only the
# first 9 commas matter — ffmpeg/libass handles commas inside Text fine as
# long as the Format line lists Text last, which it does above.
events.append(
"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}".format(
start=format_ass_time(cue.start),
end=format_ass_time(cue.end),
text=text,
)
)
return header + "\n".join(events) + "\n"
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="srt2ass",
description="Convert SRT subtitles to ASS, ready for ffmpeg's ass= filter.",
epilog=(
"Then bake with: ffmpeg -vf \"ass=out.ass\" -i in.mp4 ... "
"(or scripts/ffmpeg_tools.py bake)"
),
)
add_common_arguments(parser)
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="SRT file(s) to convert")
parser.add_argument(
"-o",
"--output",
metavar="FILE",
help="output .ass path (single input only; default: alongside the input)",
)
parser.add_argument(
"--out-dir",
metavar="DIR",
help="write every output into this directory (created if missing)",
)
style = parser.add_argument_group("style")
style.add_argument("--style-font", default="Arial", help="font name (default: Arial)")
style.add_argument("--style-size", type=int, default=48, help="font size (default: 48)")
style.add_argument(
"--style-alignment",
type=int,
default=2,
choices=range(1, 10),
metavar="N",
help="ASS numpad alignment 1-9 (default: 2, bottom centre)",
)
style.add_argument("--style-margin-v", type=int, default=60, help="vertical margin (default: 60)")
style.add_argument("--style-outline", type=float, default=2.0, help="outline width (default: 2)")
style.add_argument("--style-shadow", type=float, default=1.0, help="shadow depth (default: 1)")
style.add_argument("--style-bold", action=argparse.BooleanOptionalAction, default=True)
style.add_argument("--style-italic", action=argparse.BooleanOptionalAction, default=False)
canvas = parser.add_argument_group("canvas")
canvas.add_argument("--width", type=int, default=1920, help="PlayResX (default: 1920)")
canvas.add_argument("--height", type=int, default=1080, help="PlayResY (default: 1080)")
canvas.add_argument(
"--title",
default=None,
help="ASS Title field (default: the output filename)",
)
return parser
def convert_one(src: str, dest: str, args: argparse.Namespace, report: Report) -> tuple[int, int]:
try:
with open(src, "r", encoding="utf-8-sig", errors="replace") as handle:
content = handle.read()
except OSError as exc:
raise Die(f"cannot read {src}: {exc.strerror}") from exc
cues = parse_srt(content)
if not cues:
report.warn(f"{src}: no cues parsed — is this really an SRT file?")
style = AssStyle(
font=args.style_font,
size=args.style_size,
alignment=args.style_alignment,
margin_v=args.style_margin_v,
border=args.style_outline,
shadow=args.style_shadow,
bold=-1 if args.style_bold else 0,
italic=-1 if args.style_italic else 0,
)
title = args.title or os.path.splitext(os.path.basename(dest))[0]
document = render_ass(
cues,
style=style,
width=args.width,
height=args.height,
title=title,
)
if args.dry_run:
report.info(f"[dry-run] would write {dest} ({len(cues)} cues)")
return len(cues), 0
os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
with open(dest, "w", encoding="utf-8", newline="\n") as handle:
handle.write(document)
report.ok(f"{src} -> {dest} ({len(cues)} cues)")
return len(cues), 0
@main_wrapper
def main() -> int:
args = build_parser().parse_args()
report = Report(quiet=args.quiet)
if args.output and len(args.inputs) > 1:
raise Die("--output takes a single input; use --out-dir for batches")
total = 0
for src in args.inputs:
if not os.path.isfile(src):
raise Die(f"no such file: {src}")
stem = os.path.splitext(os.path.basename(src))[0]
if args.output:
dest = args.output
elif args.out_dir:
dest = os.path.join(args.out_dir, stem + ".ass")
else:
dest = os.path.splitext(src)[0] + ".ass"
cues, _ = convert_one(src, dest, args, report)
total += cues
if len(args.inputs) > 1:
report.info(f"{total} cues across {len(args.inputs)} file(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Unified entry point for the NCMR songs-videos helper tools.
Dispatches to a sibling script, forwards all remaining arguments, and keeps the
exit status. Use whichever feels natural — this dispatcher, or the script
directly.
python3 scripts/tools.py --help
python3 scripts/tools.py hwprobe
python3 scripts/tools.py tools lrc2srt song.lrc -o song.srt
The same thing, called directly:
python3 scripts/hwprobe.py
"""
from __future__ import annotations
import os
import runpy
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
#: name -> (module filename, one-line summary)
TOOLS: dict[str, tuple[str, str]] = {
"hwprobe": ("hwprobe.py", "which ffmpeg encoders this machine can actually use"),
"ffmpeg": ("ffmpeg_tools.py", "mux, bake subtitles, build still-image videos, strip audio"),
"lrc2srt": ("lrc2srt.py", "convert LRC lyrics to SRT"),
"srt2ass": ("srt2ass.py", "convert SRT subtitles to ASS"),
"bom": ("bom_gen.py", "generate the untracked-asset bill of materials"),
}
ALIASES = {
"ffmpeg_tools": "ffmpeg",
"ffmpeg-tools": "ffmpeg",
"bom_gen": "bom",
"lrc": "lrc2srt",
"srt": "srt2ass",
}
def usage(stream=sys.stdout) -> None:
print("usage: tools.py <tool> [options] [-- <tool options>]", file=stream)
print(" tools.py --help | --list", file=stream)
print(file=stream)
print("tools:", file=stream)
width = max(len(name) for name in TOOLS)
for name, (_filename, summary) in TOOLS.items():
print(f" {name.ljust(width)} {summary}", file=stream)
print(file=stream)
print("Every tool also runs standalone: python3 scripts/<name>.py --help", file=stream)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help", "help", "list", "--list"):
usage(stream=sys.stderr if not argv else sys.stdout)
return 0 if argv else 2
name = ALIASES.get(argv[0], argv[0])
if name not in TOOLS:
print(f"tools.py: unknown tool {argv[0]!r}", file=sys.stderr)
print(file=sys.stderr)
usage(stream=sys.stderr)
return 2
filename, _summary = TOOLS[name]
path = os.path.join(HERE, filename)
if not os.path.isfile(path):
print(f"tools.py: {filename} is missing from {HERE}", file=sys.stderr)
return 1
remaining = argv[1:]
if remaining and remaining[0] == "--":
remaining = remaining[1:]
sys.argv = [path, *remaining]
# run_path gives the child the same process, so its exit code is ours.
try:
runpy.run_path(path, run_name="__main__")
except SystemExit as exc:
code = exc.code
if code is None:
return 0
if isinstance(code, int):
return code
print(code, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+168
View File
@@ -0,0 +1,168 @@
[Script Info]
; Script generated by Aegisub 3.2.2
; http://www.aegisub.org/
Title: Default Aegisub file
ScriptType: v4.00+
WrapStyle: 0
ScaledBorderAndShadow: yes
YCbCr Matrix: PC.601
PlayResX: 1920
PlayResY: 1080
[Aegisub Project Garbage]
Audio File: 274_transect.mp4
Video File: 274_transect.mp4
Video AR Mode: 4
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Scroll Position: 74
Active Line: 82
Video Position: 8587
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: rightoffset,Source Han Serif SC SemiBold,40,&H006C574D,&H000000FF,&H006E6E6E,&H00B4CBD7,-1,0,0,0,100,100,0,0,1,0,0,3,10,60,60,1
Style: main heavy,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,2,10,10,125,1
Style: happy1551_base,Inziu Iosevka Slab SC,75,&H0000D2FF,&H000000FF,&H000000C8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_zx,Inziu Iosevka Slab SC,75,&H000000FF,&H000000FF,&H0000006E,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_60,Inziu Iosevka Slab SC,75,&H00E0DDCE,&H000000FF,&H00353537,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_houye,Inziu Iosevka Slab SC,75,&H00DA7400,&H000000FF,&H00DFD1A8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_s20,Inziu Iosevka Slab SC,75,&H00B38766,&H000000FF,&H00F1F1F1,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: leftoffset,Source Han Serif SC Medium,65,&H00FFFFFF,&H000000FF,&H00343434,&H00000000,0,0,0,0,100,100,0,0,1,2,0,1,40,10,20,1
Style: main heavy - left,源ノ明朝 SemiBold,90,&H003F6CCD,&H000000FF,&H00221716,&H85AAB2B7,0,0,0,0,100,100,0,0,1,3,0,1,10,250,100,1
Style: credits,Source Han Serif SC Medium,35,&H00C2B3A9,&H000000FF,&H000D0C0E,&H00000000,0,0,0,0,100,100,0,0,1,2,0,9,0,45,30,1
Style: transparent_timesig,Noto Serif JP Medium,60,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,1,150,0,200,1
Style: 274-place,M PLUS 1 Code SemiBold,70,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,2,0,1,4,0,1,128,0,180,1
Style: 274-credits,M PLUS 1 Code,50,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,1,0,1,2,0,3,0,150,100,1
Style: 274-addr,M PLUS 1 Code Medium,50,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,4,0,1,2,0,1,128,0,262,1
Style: main heavy right,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,3,10,125,125,1
Style: credits_sponsor,源ノ角ゴシック Heavy,188,&H00FFFFFF,&H000000FF,&H000D0C0E,&H00000000,-1,0,0,0,100,100,0,0,1,4.5,4.5,5,0,0,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:14.54,0:00:19.67,274-addr,,0,0,0,,夕張郡由仁町川端
Dialogue: 0,0:00:17.89,0:00:19.67,274-place,,0,0,0,,川端橋
Dialogue: 0,0:00:19.67,0:00:25.04,274-place,,0,0,0,,
Dialogue: 0,0:00:25.04,0:00:42.01,274-addr,,0,0,0,,夕張郡栗山町滝下
Dialogue: 0,0:00:28.41,0:00:32.06,274-place,,0,0,0,,川端発電所
Dialogue: 0,0:00:32.06,0:00:35.44,274-place,,0,0,0,,滝下八幡神社
Dialogue: 0,0:00:35.44,0:00:39.84,274-place,,0,0,0,,滝ノ下信号場
Dialogue: 0,0:00:39.84,0:00:42.01,274-place,,0,0,0,,雨霧橋
Dialogue: 0,0:00:42.01,0:00:42.45,274-place,,0,0,0,,
Dialogue: 0,0:00:42.45,0:00:46.69,274-addr,,0,0,0,,夕張郡由仁町川端
Dialogue: 0,0:00:45.15,0:00:46.69,274-place,,0,0,0,,竜仙橋
Dialogue: 0,0:00:46.69,0:00:47.63,274-place,,0,0,0,,
Dialogue: 0,0:00:47.63,0:00:54.73,274-addr,,0,0,0,,夕張郡栗山町滝下
Dialogue: 0,0:00:50.08,0:00:52.88,274-place,,0,0,0,,草木橋
Dialogue: 0,0:00:52.88,0:00:54.73,274-place,,0,0,0,,滝の上こ線橋
Dialogue: 0,0:00:54.73,0:00:56.44,274-place,,0,0,0,,
Dialogue: 0,0:00:56.44,0:01:08.78,274-addr,,0,0,0,,夕張市滝ノ上
Dialogue: 0,0:00:59.95,0:01:03.47,274-place,,0,0,0,,滝の上発電所
Dialogue: 0,0:01:03.47,0:01:06.94,274-place,,0,0,0,,滝ノ上信号場
Dialogue: 0,0:01:06.94,0:01:08.78,274-place,,0,0,0,,滝ノ上神社
Dialogue: 0,0:01:08.78,0:01:10.50,274-place,,0,0,0,,
Dialogue: 0,0:01:10.50,0:01:45.03,274-addr,,0,0,0,,夕張市紅葉山
Dialogue: 0,0:01:12.05,0:01:13.86,274-place,,0,0,0,,十三里信号場
Dialogue: 0,0:01:13.86,0:01:16.44,274-place,,0,0,0,,十三里橋
Dialogue: 0,0:01:16.44,0:01:18.78,274-place,,0,0,0,,紅葉山駐車帯
Dialogue: 0,0:01:18.78,0:01:21.06,274-place,,0,0,0,,瑞穂橋
Dialogue: 0,0:01:21.06,0:01:24.44,274-place,,0,0,0,,セブンイレブン夕張紅葉山店
Dialogue: 0,0:01:24.44,0:01:27.06,274-place,,0,0,0,,新夕張駅
Dialogue: 0,0:01:27.06,0:01:30.02,274-place,,0,0,0,,紅葉山郵便局
Dialogue: 0,0:01:30.02,0:01:32.30,274-place,,0,0,0,,紅葉山神社
Dialogue: 0,0:01:32.30,0:01:35.24,274-place,,0,0,0,,セイコーマートもみじやま店
Dialogue: 0,0:01:35.24,0:01:38.48,274-place,,0,0,0,,紅楓橋
Dialogue: 0,0:01:38.48,0:01:40.25,274-place,,0,0,0,,ポン久留喜橋
Dialogue: 0,0:01:40.25,0:01:42.44,274-place,,0,0,0,,久留喜橋
Dialogue: 0,0:01:42.44,0:01:43.79,274-place,,0,0,0,,楓橋
Dialogue: 0,0:01:43.79,0:01:45.03,274-place,,0,0,0,,桂橋
Dialogue: 0,0:01:45.03,0:01:45.77,274-place,,0,0,0,,
Dialogue: 0,0:01:45.77,0:02:00.74,274-addr,,0,0,0,,夕張市楓
Dialogue: 0,0:01:49.12,0:01:50.76,274-place,,0,0,0,,千紫橋
Dialogue: 0,0:01:50.76,0:01:52.51,274-place,,0,0,0,,楓駐車帯
Dialogue: 0,0:01:52.51,0:01:53.80,274-place,,0,0,0,,万紅橋
Dialogue: 0,0:01:53.80,0:01:55.54,274-place,,0,0,0,,登川神社
Dialogue: 0,0:01:55.54,0:01:57.32,274-place,,0,0,0,,楓信号場
Dialogue: 0,0:01:57.32,0:01:59.00,274-place,,0,0,0,,登川橋
Dialogue: 0,0:01:59.00,0:02:00.74,274-place,,0,0,0,,登川郵便局
Dialogue: 0,0:02:00.74,0:02:01.26,274-place,,0,0,0,,
Dialogue: 0,0:02:01.26,0:02:06.62,274-addr,,0,0,0,,夕張市登川
Dialogue: 0,0:02:02.93,0:02:05.23,274-place,,0,0,0,,花園橋
Dialogue: 0,0:02:05.23,0:02:06.62,274-place,,0,0,0,,登川トンネル
Dialogue: 0,0:02:06.62,0:03:30.80,274-place,,0,0,0,,
Dialogue: 0,0:03:30.80,0:03:39.41,274-addr,,0,0,0,,勇払郡むかわ町穂別長和
Dialogue: 0,0:03:34.55,0:03:36.77,274-place,,0,0,0,,オマナイ橋
Dialogue: 0,0:03:36.77,0:03:39.41,274-place,,0,0,0,,桂木橋
Dialogue: 0,0:03:39.41,0:03:41.13,274-place,,0,0,0,,
Dialogue: 0,0:03:41.13,0:04:05.59,274-addr,,0,0,0,,勇払郡むかわ町穂別稲里
Dialogue: 0,0:03:44.62,0:03:46.67,274-place,,0,0,0,,大和橋
Dialogue: 0,0:03:46.67,0:03:48.38,274-place,,0,0,0,,豊進橋
Dialogue: 0,0:03:48.38,0:03:51.61,274-place,,0,0,0,,豊進神社
Dialogue: 0,0:03:51.61,0:03:53.14,274-place,,0,0,0,,豊里橋
Dialogue: 0,0:03:53.14,0:03:55.52,274-place,,0,0,0,,稲豊橋
Dialogue: 0,0:03:55.52,0:03:57.78,274-place,,0,0,0,,稲里橋
Dialogue: 0,0:03:57.78,0:04:01.08,274-place,,0,0,0,,稲里簡易郵便局
Dialogue: 0,0:04:01.08,0:04:03.50,274-place,,0,0,0,,佐主橋
Dialogue: 0,0:04:03.50,0:04:05.59,274-place,,0,0,0,,穂別ダム神社
Dialogue: 0,0:04:05.59,0:04:05.96,274-place,,0,0,0,,
Dialogue: 0,0:04:05.96,0:04:25.27,274-addr,,0,0,0,,勇払郡むかわ町穂別長和
Dialogue: 0,0:04:10.08,0:04:13.02,274-place,,0,0,0,,長和トンネル
Dialogue: 0,0:04:13.02,0:04:15.39,274-place,,0,0,0,,穂別大橋
Dialogue: 0,0:04:15.39,0:04:18.81,274-place,,0,0,0,,山水橋
Dialogue: 0,0:04:18.81,0:04:23.20,274-place,,0,0,0,,沼の沢橋
Dialogue: 0,0:04:23.20,0:04:25.27,274-place,,0,0,0,,稲里トンネル
Dialogue: 0,0:04:25.27,0:04:26.00,274-place,,0,0,0,,
Dialogue: 0,0:04:26.00,0:04:55.52,274-addr,,0,0,0,,勇払郡むかわ町穂別福山
Dialogue: 0,0:04:29.74,0:04:31.50,274-place,,0,0,0,,ペンケ橋
Dialogue: 0,0:04:31.50,0:04:33.62,274-place,,0,0,0,,下福山橋
Dialogue: 0,0:04:33.62,0:04:35.02,274-place,,0,0,0,,オロロップ橋
Dialogue: 0,0:04:35.02,0:04:37.07,274-place,,0,0,0,,福山橋
Dialogue: 0,0:04:37.07,0:04:38.70,274-place,,0,0,0,,福山神社
Dialogue: 0,0:04:38.70,0:04:41.02,274-place,,0,0,0,,福山大橋
Dialogue: 0,0:04:41.02,0:04:43.29,274-place,,0,0,0,,千坂橋
Dialogue: 0,0:04:43.29,0:04:45.12,274-place,,0,0,0,,オコタン橋
Dialogue: 0,0:04:45.12,0:04:46.22,274-place,,0,0,0,,モトツトンネル
Dialogue: 0,0:04:46.22,0:04:48.98,274-place,,0,0,0,,モトツ橋 / モトツシェルター
Dialogue: 0,0:04:48.98,0:04:51.39,274-place,,0,0,0,,福山トンネル
Dialogue: 0,0:04:51.39,0:04:53.61,274-place,,0,0,0,,福穂橋
Dialogue: 0,0:04:53.61,0:04:55.52,274-place,,0,0,0,,穂高トンネル
Dialogue: 0,0:04:55.52,0:05:22.60,274-place,,0,0,0,,
Dialogue: 0,0:05:22.60,0:05:50.73,274-addr,,0,0,0,,勇払郡占冠村中央
Dialogue: 0,0:05:25.76,0:05:28.88,274-place,,0,0,0,,穂高橋
Dialogue: 0,0:05:28.88,0:05:31.52,274-place,,0,0,0,,高翠橋
Dialogue: 0,0:05:31.52,0:05:34.95,274-place,,0,0,0,,占翠橋
Dialogue: 0,0:05:34.95,0:05:38.08,274-place,,0,0,0,,名石駐車場
Dialogue: 0,0:05:38.08,0:05:40.99,274-place,,0,0,0,,名石橋
Dialogue: 0,0:05:40.99,0:05:43.96,274-place,,0,0,0,,赤岩大橋
Dialogue: 0,0:05:43.96,0:05:48.62,274-place,,0,0,0,,渓流大橋
Dialogue: 0,0:05:48.62,0:05:50.73,274-place,,0,0,0,,日高トンネル
Dialogue: 0,0:05:50.73,0:07:11.39,274-place,,0,0,0,,
Dialogue: 0,0:07:11.39,0:07:16.38,274-addr,,0,0,0,,沙流郡日高町日高
Dialogue: 0,0:07:14.14,0:07:16.38,274-place,,0,0,0,,日夕橋
Dialogue: 0,0:07:16.38,0:07:28.85,274-place,,0,0,0,,
Dialogue: 0,0:07:28.85,0:07:34.09,274-addr,,0,0,0,,沙流郡日高町宮下町
Dialogue: 0,0:07:31.68,0:07:34.09,274-place,,0,0,0,,日高神社
Dialogue: 0,0:07:34.09,0:07:42.90,274-place,,0,0,0,,
Dialogue: 0,0:07:42.90,0:07:55.11,274-addr,,0,0,0,,沙流郡日高町本町東
Dialogue: 0,0:07:45.51,0:07:49.88,274-place,,0,0,0,,新川橋
Dialogue: 0,0:07:49.88,0:07:52.76,274-place,,0,0,0,,セイコーマート日高道の駅店
Dialogue: 0,0:07:52.76,0:07:55.11,274-place,,0,0,0,,日高郵便局
Dialogue: 0,0:07:55.11,0:07:56.69,274-place,,0,0,0,,
Dialogue: 0,0:07:56.69,0:08:02.15,274-addr,,0,0,0,,沙流郡日高町千栄
Dialogue: 0,0:07:59.36,0:08:02.15,274-place,,0,0,0,,北電日高発電所
Dialogue: 0,0:08:02.15,0:08:03.90,274-place,,0,0,0,,
Dialogue: 0,0:08:03.90,0:08:09.44,274-addr,,0,0,0,,沙流郡日高町日高
Dialogue: 0,0:08:06.38,0:08:09.44,274-place,,0,0,0,,右左府発電所
Dialogue: 0,0:08:09.44,0:08:10.93,274-place,,0,0,0,,
Dialogue: 0,0:08:10.93,0:08:12.66,274-credits,,0,0,0,,{\b1}東に続く{\b0}
Dialogue: 0,0:08:12.66,0:08:17.66,274-place,,0,0,0,,
Dialogue: 0,0:08:24.92,0:08:31.98,274-credits,,0,0,0,,{\b1}'274'{\b0}\Na song about (one stretch of) national route 274
Dialogue: 0,0:08:31.98,0:08:38.97,274-credits,,0,0,0,,music by Tomaš Orika / data aggregation by Mikkeli Matlock\Nvisualiser by Joroz Tosci / powered by Claude Opus 5
Dialogue: 0,0:08:38.97,0:08:46.00,274-credits,,0,0,0,,voiced by Yuzuki Yukari Shizuku (A.I.VOICE 2)
Dialogue: 0,0:08:46.00,0:08:52.98,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\Nroute01.com\Nhgf03030.a.la9.jp
Dialogue: 0,0:08:52.98,0:09:00.00,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\N全国道路施設点検データベース\N国土交通省北海道開発局 道路施設の老朽化対策\N国土数値情報 河川データセット
Dialogue: 0,0:09:00.00,0:09:06.98,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\N日本郵便 郵便番号データ\N国土地理院 逆ジオコーディング API
Dialogue: 0,0:09:06.98,0:09:14.00,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\NOpenStreetMap contributors\NGoogle Maps\NWikipedia
Dialogue: 0,0:09:14.00,0:09:20.96,274-credits,,0,0,0,,2026 northwich committee of magic researches
Dialogue: 0,0:09:20.96,0:09:22.98,274-credits,,0,0,0,,see you in Yuubari
+159
View File
@@ -0,0 +1,159 @@
[Script Info]
; Script generated by Aegisub 3.2.2
; http://www.aegisub.org/
Title: Default Aegisub file
ScriptType: v4.00+
WrapStyle: 0
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.601
PlayResX: 1920
PlayResY: 1080
[Aegisub Project Garbage]
Audio File: 274_white.mp4
Video File: 274_white.mp4
Video AR Mode: 4
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Scroll Position: 54
Active Line: 66
Video Position: 7588
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: rightoffset,Source Han Serif SC SemiBold,40,&H006C574D,&H000000FF,&H006E6E6E,&H00B4CBD7,-1,0,0,0,100,100,0,0,1,0,0,3,10,60,60,1
Style: main heavy,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,2,10,10,125,1
Style: happy1551_base,Inziu Iosevka Slab SC,75,&H0000D2FF,&H000000FF,&H000000C8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_zx,Inziu Iosevka Slab SC,75,&H000000FF,&H000000FF,&H0000006E,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_60,Inziu Iosevka Slab SC,75,&H00E0DDCE,&H000000FF,&H00353537,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_houye,Inziu Iosevka Slab SC,75,&H00DA7400,&H000000FF,&H00DFD1A8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_s20,Inziu Iosevka Slab SC,75,&H00B38766,&H000000FF,&H00F1F1F1,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: leftoffset,Source Han Serif SC Medium,65,&H00FFFFFF,&H000000FF,&H00343434,&H00000000,0,0,0,0,100,100,0,0,1,2,0,1,40,10,20,1
Style: main heavy - left,源ノ明朝 SemiBold,90,&H003F6CCD,&H000000FF,&H00221716,&H85AAB2B7,0,0,0,0,100,100,0,0,1,3,0,1,10,250,100,1
Style: credits,Source Han Serif SC Medium,35,&H00C2B3A9,&H000000FF,&H000D0C0E,&H00000000,0,0,0,0,100,100,0,0,1,2,0,9,0,45,30,1
Style: transparent_timesig,Noto Serif JP Medium,60,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,1,150,0,200,1
Style: transparent_lyrics_en,Noto Serif JP Medium,70,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,0,0,1,192,0,320,1
Style: transparent_lyrics_jp,Noto Serif JP Medium,50,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,3,0,150,100,1
Style: transparent_title,Noto Serif JP SemiBold,50,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,0,0,1,192,0,395,1
Style: main heavy right,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,3,10,125,125,1
Style: credits_sponsor,源ノ角ゴシック Heavy,188,&H00FFFFFF,&H000000FF,&H000D0C0E,&H00000000,-1,0,0,0,100,100,0,0,1,4.5,4.5,5,0,0,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:14.54,0:00:19.67,transparent_title,,0,0,0,,夕張郡由仁町川端
Dialogue: 0,0:00:17.89,0:00:19.67,transparent_lyrics_en,,0,0,0,,川端橋
Dialogue: 0,0:00:19.67,0:00:25.04,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:00:25.04,0:00:42.01,transparent_title,,0,0,0,,夕張郡栗山町滝下
Dialogue: 0,0:00:28.41,0:00:32.06,transparent_lyrics_en,,0,0,0,,川端発電所
Dialogue: 0,0:00:32.06,0:00:35.44,transparent_lyrics_en,,0,0,0,,滝下八幡神社
Dialogue: 0,0:00:35.44,0:00:39.84,transparent_lyrics_en,,0,0,0,,滝ノ下信号場
Dialogue: 0,0:00:39.84,0:00:42.01,transparent_lyrics_en,,0,0,0,,雨霧橋
Dialogue: 0,0:00:42.01,0:00:42.45,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:00:42.45,0:00:46.69,transparent_title,,0,0,0,,夕張郡由仁町川端
Dialogue: 0,0:00:45.15,0:00:46.69,transparent_lyrics_en,,0,0,0,,竜仙橋
Dialogue: 0,0:00:46.69,0:00:47.63,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:00:47.63,0:00:54.73,transparent_title,,0,0,0,,夕張郡栗山町滝下
Dialogue: 0,0:00:50.08,0:00:52.88,transparent_lyrics_en,,0,0,0,,草木橋
Dialogue: 0,0:00:52.88,0:00:54.73,transparent_lyrics_en,,0,0,0,,滝の上こ線橋
Dialogue: 0,0:00:54.73,0:00:56.44,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:00:56.44,0:01:08.78,transparent_title,,0,0,0,,夕張市滝ノ上
Dialogue: 0,0:00:59.95,0:01:03.47,transparent_lyrics_en,,0,0,0,,滝の上発電所
Dialogue: 0,0:01:03.47,0:01:06.94,transparent_lyrics_en,,0,0,0,,滝ノ上信号場
Dialogue: 0,0:01:06.94,0:01:08.78,transparent_lyrics_en,,0,0,0,,滝ノ上神社
Dialogue: 0,0:01:08.78,0:01:10.50,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:01:10.50,0:01:45.03,transparent_title,,0,0,0,,夕張市紅葉山
Dialogue: 0,0:01:12.05,0:01:13.86,transparent_lyrics_en,,0,0,0,,十三里信号場
Dialogue: 0,0:01:13.86,0:01:16.44,transparent_lyrics_en,,0,0,0,,十三里橋
Dialogue: 0,0:01:16.44,0:01:18.78,transparent_lyrics_en,,0,0,0,,紅葉山駐車帯
Dialogue: 0,0:01:18.78,0:01:21.06,transparent_lyrics_en,,0,0,0,,瑞穂橋
Dialogue: 0,0:01:21.06,0:01:24.44,transparent_lyrics_en,,0,0,0,,セブンイレブン夕張紅葉山店
Dialogue: 0,0:01:24.44,0:01:27.06,transparent_lyrics_en,,0,0,0,,新夕張駅
Dialogue: 0,0:01:27.06,0:01:30.02,transparent_lyrics_en,,0,0,0,,紅葉山郵便局
Dialogue: 0,0:01:30.02,0:01:32.30,transparent_lyrics_en,,0,0,0,,紅葉山神社
Dialogue: 0,0:01:32.30,0:01:35.24,transparent_lyrics_en,,0,0,0,,セイコーマートもみじやま店
Dialogue: 0,0:01:35.24,0:01:38.48,transparent_lyrics_en,,0,0,0,,紅楓橋
Dialogue: 0,0:01:38.48,0:01:40.25,transparent_lyrics_en,,0,0,0,,ポン久留喜橋
Dialogue: 0,0:01:40.25,0:01:42.44,transparent_lyrics_en,,0,0,0,,久留喜橋
Dialogue: 0,0:01:42.44,0:01:43.79,transparent_lyrics_en,,0,0,0,,楓橋
Dialogue: 0,0:01:43.79,0:01:45.03,transparent_lyrics_en,,0,0,0,,桂橋
Dialogue: 0,0:01:45.03,0:01:45.77,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:01:45.77,0:02:00.74,transparent_title,,0,0,0,,夕張市楓
Dialogue: 0,0:01:49.12,0:01:50.76,transparent_lyrics_en,,0,0,0,,千紫橋
Dialogue: 0,0:01:50.76,0:01:52.51,transparent_lyrics_en,,0,0,0,,楓駐車帯
Dialogue: 0,0:01:52.51,0:01:53.80,transparent_lyrics_en,,0,0,0,,万紅橋
Dialogue: 0,0:01:53.80,0:01:55.54,transparent_lyrics_en,,0,0,0,,登川神社
Dialogue: 0,0:01:55.54,0:01:57.32,transparent_lyrics_en,,0,0,0,,楓信号場
Dialogue: 0,0:01:57.32,0:01:59.00,transparent_lyrics_en,,0,0,0,,登川橋
Dialogue: 0,0:01:59.00,0:02:00.74,transparent_lyrics_en,,0,0,0,,登川郵便局
Dialogue: 0,0:02:00.74,0:02:01.26,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:02:01.26,0:02:06.62,transparent_title,,0,0,0,,夕張市登川
Dialogue: 0,0:02:02.93,0:02:05.23,transparent_lyrics_en,,0,0,0,,花園橋
Dialogue: 0,0:02:05.23,0:02:06.62,transparent_lyrics_en,,0,0,0,,登川トンネル
Dialogue: 0,0:02:06.62,0:03:30.80,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:03:30.80,0:03:39.41,transparent_title,,0,0,0,,勇払郡むかわ町穂別長和
Dialogue: 0,0:03:34.55,0:03:36.77,transparent_lyrics_en,,0,0,0,,オマナイ橋
Dialogue: 0,0:03:36.77,0:03:39.41,transparent_lyrics_en,,0,0,0,,桂木橋
Dialogue: 0,0:03:39.41,0:03:41.13,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:03:41.13,0:04:05.59,transparent_title,,0,0,0,,勇払郡むかわ町穂別稲里
Dialogue: 0,0:03:44.62,0:03:46.67,transparent_lyrics_en,,0,0,0,,大和橋
Dialogue: 0,0:03:46.67,0:03:48.38,transparent_lyrics_en,,0,0,0,,豊進橋
Dialogue: 0,0:03:48.38,0:03:51.61,transparent_lyrics_en,,0,0,0,,豊進神社
Dialogue: 0,0:03:51.61,0:03:53.14,transparent_lyrics_en,,0,0,0,,豊里橋
Dialogue: 0,0:03:53.14,0:03:55.52,transparent_lyrics_en,,0,0,0,,稲豊橋
Dialogue: 0,0:03:55.52,0:03:57.78,transparent_lyrics_en,,0,0,0,,稲里橋
Dialogue: 0,0:03:57.78,0:04:01.08,transparent_lyrics_en,,0,0,0,,稲里簡易郵便局
Dialogue: 0,0:04:01.08,0:04:03.50,transparent_lyrics_en,,0,0,0,,佐主橋
Dialogue: 0,0:04:03.50,0:04:05.59,transparent_lyrics_en,,0,0,0,,穂別ダム神社
Dialogue: 0,0:04:05.59,0:04:05.96,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:04:05.96,0:04:25.27,transparent_title,,0,0,0,,勇払郡むかわ町穂別長和
Dialogue: 0,0:04:10.08,0:04:13.02,transparent_lyrics_en,,0,0,0,,長和トンネル
Dialogue: 0,0:04:13.02,0:04:15.39,transparent_lyrics_en,,0,0,0,,穂別大橋
Dialogue: 0,0:04:15.39,0:04:18.81,transparent_lyrics_en,,0,0,0,,山水橋
Dialogue: 0,0:04:18.81,0:04:23.20,transparent_lyrics_en,,0,0,0,,沼の沢橋
Dialogue: 0,0:04:23.20,0:04:25.27,transparent_lyrics_en,,0,0,0,,稲里トンネル
Dialogue: 0,0:04:25.27,0:04:26.00,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:04:26.00,0:04:55.52,transparent_title,,0,0,0,,勇払郡むかわ町穂別福山
Dialogue: 0,0:04:29.74,0:04:31.50,transparent_lyrics_en,,0,0,0,,ペンケ橋
Dialogue: 0,0:04:31.50,0:04:33.62,transparent_lyrics_en,,0,0,0,,下福山橋
Dialogue: 0,0:04:33.62,0:04:35.02,transparent_lyrics_en,,0,0,0,,オロロップ橋
Dialogue: 0,0:04:35.02,0:04:37.07,transparent_lyrics_en,,0,0,0,,福山橋
Dialogue: 0,0:04:37.07,0:04:38.70,transparent_lyrics_en,,0,0,0,,福山神社
Dialogue: 0,0:04:38.70,0:04:41.02,transparent_lyrics_en,,0,0,0,,福山大橋
Dialogue: 0,0:04:41.02,0:04:43.29,transparent_lyrics_en,,0,0,0,,千坂橋
Dialogue: 0,0:04:43.29,0:04:45.12,transparent_lyrics_en,,0,0,0,,オコタン橋
Dialogue: 0,0:04:45.12,0:04:46.22,transparent_lyrics_en,,0,0,0,,モトツトンネル
Dialogue: 0,0:04:46.22,0:04:48.98,transparent_lyrics_en,,0,0,0,,モトツ橋にモトツシェルター
Dialogue: 0,0:04:48.98,0:04:51.39,transparent_lyrics_en,,0,0,0,,福山トンネル
Dialogue: 0,0:04:51.39,0:04:53.61,transparent_lyrics_en,,0,0,0,,福穂橋
Dialogue: 0,0:04:53.61,0:04:55.52,transparent_lyrics_en,,0,0,0,,穂高トンネル
Dialogue: 0,0:04:55.52,0:05:22.60,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:05:22.60,0:05:50.73,transparent_title,,0,0,0,,勇払郡占冠村中央
Dialogue: 0,0:05:25.76,0:05:28.88,transparent_lyrics_en,,0,0,0,,穂高橋
Dialogue: 0,0:05:28.88,0:05:31.52,transparent_lyrics_en,,0,0,0,,高翠橋
Dialogue: 0,0:05:31.52,0:05:34.95,transparent_lyrics_en,,0,0,0,,占翠橋
Dialogue: 0,0:05:34.95,0:05:38.08,transparent_lyrics_en,,0,0,0,,名石駐車場
Dialogue: 0,0:05:38.08,0:05:40.99,transparent_lyrics_en,,0,0,0,,名石橋
Dialogue: 0,0:05:40.99,0:05:43.96,transparent_lyrics_en,,0,0,0,,赤岩大橋
Dialogue: 0,0:05:43.96,0:05:48.62,transparent_lyrics_en,,0,0,0,,渓流大橋
Dialogue: 0,0:05:48.62,0:05:50.73,transparent_lyrics_en,,0,0,0,,日高トンネル
Dialogue: 0,0:05:50.73,0:07:11.39,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:07:11.39,0:07:16.38,transparent_title,,0,0,0,,沙流郡日高町日高
Dialogue: 0,0:07:14.14,0:07:16.38,transparent_lyrics_en,,0,0,0,,日夕橋
Dialogue: 0,0:07:16.38,0:07:28.85,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:07:28.85,0:07:34.09,transparent_title,,0,0,0,,沙流郡日高町宮下町
Dialogue: 0,0:07:31.68,0:07:34.09,transparent_lyrics_en,,0,0,0,,日高神社
Dialogue: 0,0:07:34.09,0:07:42.90,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:07:42.90,0:07:55.11,transparent_title,,0,0,0,,沙流郡日高町本町東
Dialogue: 0,0:07:45.51,0:07:49.88,transparent_lyrics_en,,0,0,0,,新川橋
Dialogue: 0,0:07:49.88,0:07:52.76,transparent_lyrics_en,,0,0,0,,セイコーマート日高道の駅店
Dialogue: 0,0:07:52.76,0:07:55.11,transparent_lyrics_en,,0,0,0,,日高郵便局
Dialogue: 0,0:07:55.11,0:07:56.69,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:07:56.69,0:08:02.15,transparent_title,,0,0,0,,沙流郡日高町千栄
Dialogue: 0,0:07:59.36,0:08:02.15,transparent_lyrics_en,,0,0,0,,北電日高発電所
Dialogue: 0,0:08:02.15,0:08:03.90,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:08:03.90,0:08:09.44,transparent_title,,0,0,0,,沙流郡日高町日高
Dialogue: 0,0:08:06.38,0:08:09.44,transparent_lyrics_en,,0,0,0,,右左府発電所
Dialogue: 0,0:08:09.44,0:08:10.93,transparent_lyrics_en,,0,0,0,,
Dialogue: 0,0:08:10.93,0:08:12.66,transparent_lyrics_jp,,0,0,0,,東に続く
Dialogue: 0,0:08:12.66,0:08:17.66,transparent_lyrics_en,,0,0,0,,
+121
View File
@@ -0,0 +1,121 @@
[ti:274]
[ar:Northwich committee of magic researches]
[al:Shirakaba Express Service]
[00:14.54]夕張郡由仁町川端
[00:17.89]川端橋
[00:19.67]
[00:25.04]夕張郡栗山町滝下
[00:28.41]川端発電所
[00:32.06]滝下八幡神社
[00:35.44]滝ノ下信号場
[00:39.84]雨霧橋
[00:42.01]
[00:42.45]夕張郡由仁町川端
[00:45.15]竜仙橋
[00:46.69]
[00:47.63]夕張郡栗山町滝下
[00:50.08]草木橋
[00:52.88]滝の上こ線橋
[00:54.73]
[00:56.44]夕張市滝ノ上
[00:59.95]滝の上発電所
[01:03.47]滝ノ上信号場
[01:06.94]滝ノ上神社
[01:08.78]
[01:10.50]夕張市紅葉山
[01:12.05]十三里信号場
[01:13.86]十三里橋
[01:16.44]紅葉山駐車帯
[01:18.78]瑞穂橋
[01:21.06]セブンイレブン夕張紅葉山店
[01:24.44]新夕張駅
[01:27.06]紅葉山郵便局
[01:30.02]紅葉山神社
[01:32.30]セイコーマートもみじやま店
[01:35.24]紅楓橋
[01:38.48]ポン久留喜橋
[01:40.25]久留喜橋
[01:42.44]楓橋
[01:43.79]桂橋
[01:45.03]
[01:45.77]夕張市楓
[01:49.12]千紫橋
[01:50.76]楓駐車帯
[01:52.51]万紅橋
[01:53.80]登川神社
[01:55.54]楓信号場
[01:57.32]登川橋
[01:59.00]登川郵便局
[02:00.74]
[02:01.26]夕張市登川
[02:02.93]花園橋
[02:05.23]登川トンネル
[02:06.62]
[03:30.80]勇払郡むかわ町穂別長和
[03:34.55]オマナイ橋
[03:36.77]桂木橋
[03:39.41]
[03:41.13]勇払郡むかわ町穂別稲里
[03:44.62]大和橋
[03:46.67]豊進橋
[03:48.38]豊進神社
[03:51.61]豊里橋
[03:53.14]稲豊橋
[03:55.52]稲里橋
[03:57.78]稲里簡易郵便局
[04:01.08]佐主橋
[04:03.50]穂別ダム神社
[04:05.59]
[04:05.96]勇払郡むかわ町穂別長和
[04:10.08]長和トンネル
[04:13.02]穂別大橋
[04:15.39]山水橋
[04:18.81]沼の沢橋
[04:23.20]稲里トンネル
[04:25.27]
[04:26.00]勇払郡むかわ町穂別福山
[04:29.74]ペンケ橋
[04:31.50]下福山橋
[04:33.62]オロロップ橋
[04:35.02]福山橋
[04:37.07]福山神社
[04:38.70]福山大橋
[04:41.02]千坂橋
[04:43.29]オコタン橋
[04:45.12]モトツトンネル
[04:46.22]モトツ橋にモトツシェルター
[04:48.98]福山トンネル
[04:51.39]福穂橋
[04:53.61]穂高トンネル
[04:55.52]
[05:22.60]勇払郡占冠村中央
[05:25.76]穂高橋
[05:28.88]高翠橋
[05:31.52]占翠橋
[05:34.95]名石駐車場
[05:38.08]名石橋
[05:40.99]赤岩大橋
[05:43.96]渓流大橋
[05:48.62]日高トンネル
[05:50.73]
[07:11.39]沙流郡日高町日高
[07:14.14]日夕橋
[07:16.38]
[07:28.85]沙流郡日高町宮下町
[07:31.68]日高神社
[07:34.09]
[07:42.90]沙流郡日高町本町東
[07:45.51]新川橋
[07:49.88]セイコーマート日高道の駅店
[07:52.76]日高郵便局
[07:55.11]
[07:56.69]沙流郡日高町千栄
[07:59.36]北電日高発電所
[08:02.15]
[08:03.90]沙流郡日高町日高
[08:06.38]右左府発電所
[08:09.44]
[08:10.93]東に続く
[08:12.66]
+463
View File
@@ -0,0 +1,463 @@
1
00:00:14,540 --> 00:00:17,890
夕張郡由仁町川端
2
00:00:17,890 --> 00:00:19,670
川端橋
3
00:00:19,670 --> 00:00:25,040
4
00:00:25,040 --> 00:00:28,410
夕張郡栗山町滝下
5
00:00:28,410 --> 00:00:32,060
川端発電所
6
00:00:32,060 --> 00:00:35,440
滝下八幡神社
7
00:00:35,440 --> 00:00:39,840
滝ノ下信号場
8
00:00:39,840 --> 00:00:42,010
雨霧橋
9
00:00:42,010 --> 00:00:42,510
10
00:00:42,450 --> 00:00:45,150
夕張郡由仁町川端
11
00:00:45,150 --> 00:00:46,690
竜仙橋
12
00:00:46,690 --> 00:00:47,630
13
00:00:47,630 --> 00:00:50,080
夕張郡栗山町滝下
14
00:00:50,080 --> 00:00:52,880
草木橋
15
00:00:52,880 --> 00:00:54,730
滝の上こ線橋
16
00:00:54,730 --> 00:00:56,440
17
00:00:56,440 --> 00:00:59,950
夕張市滝ノ上
18
00:00:59,950 --> 00:01:03,470
滝の上発電所
19
00:01:03,470 --> 00:01:06,940
滝ノ上信号場
20
00:01:06,940 --> 00:01:08,780
滝ノ上神社
21
00:01:08,780 --> 00:01:10,500
22
00:01:10,500 --> 00:01:12,050
夕張市紅葉山
23
00:01:12,050 --> 00:01:13,860
十三里信号場
24
00:01:13,860 --> 00:01:16,440
十三里橋
25
00:01:16,440 --> 00:01:18,780
紅葉山駐車帯
26
00:01:18,780 --> 00:01:21,060
瑞穂橋
27
00:01:21,060 --> 00:01:24,440
セブンイレブン夕張紅葉山店
28
00:01:24,440 --> 00:01:27,060
新夕張駅
29
00:01:27,060 --> 00:01:30,020
紅葉山郵便局
30
00:01:30,020 --> 00:01:32,300
紅葉山神社
31
00:01:32,300 --> 00:01:35,240
セイコーマートもみじやま店
32
00:01:35,240 --> 00:01:38,480
紅楓橋
33
00:01:38,480 --> 00:01:40,250
ポン久留喜橋
34
00:01:40,250 --> 00:01:42,440
久留喜橋
35
00:01:42,440 --> 00:01:43,790
楓橋
36
00:01:43,790 --> 00:01:45,030
桂橋
37
00:01:45,030 --> 00:01:45,770
38
00:01:45,770 --> 00:01:49,120
夕張市楓
39
00:01:49,120 --> 00:01:50,760
千紫橋
40
00:01:50,760 --> 00:01:52,510
楓駐車帯
41
00:01:52,510 --> 00:01:53,800
万紅橋
42
00:01:53,800 --> 00:01:55,540
登川神社
43
00:01:55,540 --> 00:01:57,320
楓信号場
44
00:01:57,320 --> 00:01:59,000
登川橋
45
00:01:59,000 --> 00:02:00,740
登川郵便局
46
00:02:00,740 --> 00:02:01,260
47
00:02:01,260 --> 00:02:02,930
夕張市登川
48
00:02:02,930 --> 00:02:05,230
花園橋
49
00:02:05,230 --> 00:02:06,620
登川トンネル
50
00:02:06,620 --> 00:03:30,800
51
00:03:30,800 --> 00:03:34,550
勇払郡むかわ町穂別長和
52
00:03:34,550 --> 00:03:36,770
オマナイ橋
53
00:03:36,770 --> 00:03:39,410
桂木橋
54
00:03:39,410 --> 00:03:41,130
55
00:03:41,130 --> 00:03:44,620
勇払郡むかわ町穂別稲里
56
00:03:44,620 --> 00:03:46,670
大和橋
57
00:03:46,670 --> 00:03:48,380
豊進橋
58
00:03:48,380 --> 00:03:51,610
豊進神社
59
00:03:51,610 --> 00:03:53,140
豊里橋
60
00:03:53,140 --> 00:03:55,520
稲豊橋
61
00:03:55,520 --> 00:03:57,780
稲里橋
62
00:03:57,780 --> 00:04:01,080
稲里簡易郵便局
63
00:04:01,080 --> 00:04:03,500
佐主橋
64
00:04:03,500 --> 00:04:05,590
穂別ダム神社
65
00:04:05,590 --> 00:04:06,090
66
00:04:05,960 --> 00:04:10,080
勇払郡むかわ町穂別長和
67
00:04:10,080 --> 00:04:13,020
長和トンネル
68
00:04:13,020 --> 00:04:15,390
穂別大橋
69
00:04:15,390 --> 00:04:18,810
山水橋
70
00:04:18,810 --> 00:04:23,200
沼の沢橋
71
00:04:23,200 --> 00:04:25,270
稲里トンネル
72
00:04:25,270 --> 00:04:26,000
73
00:04:26,000 --> 00:04:29,740
勇払郡むかわ町穂別福山
74
00:04:29,740 --> 00:04:31,500
ペンケ橋
75
00:04:31,500 --> 00:04:33,620
下福山橋
76
00:04:33,620 --> 00:04:35,020
オロロップ橋
77
00:04:35,020 --> 00:04:37,070
福山橋
78
00:04:37,070 --> 00:04:38,700
福山神社
79
00:04:38,700 --> 00:04:41,020
福山大橋
80
00:04:41,020 --> 00:04:43,290
千坂橋
81
00:04:43,290 --> 00:04:45,120
オコタン橋
82
00:04:45,120 --> 00:04:46,220
モトツトンネル
83
00:04:46,220 --> 00:04:48,980
モトツ橋にモトツシェルター
84
00:04:48,980 --> 00:04:51,390
福山トンネル
85
00:04:51,390 --> 00:04:53,610
福穂橋
86
00:04:53,610 --> 00:04:55,520
穂高トンネル
87
00:04:55,520 --> 00:05:22,600
88
00:05:22,600 --> 00:05:25,760
勇払郡占冠村中央
89
00:05:25,760 --> 00:05:28,880
穂高橋
90
00:05:28,880 --> 00:05:31,520
高翠橋
91
00:05:31,520 --> 00:05:34,950
占翠橋
92
00:05:34,950 --> 00:05:38,080
名石駐車場
93
00:05:38,080 --> 00:05:40,990
名石橋
94
00:05:40,990 --> 00:05:43,960
赤岩大橋
95
00:05:43,960 --> 00:05:48,620
渓流大橋
96
00:05:48,620 --> 00:05:50,730
日高トンネル
97
00:05:50,730 --> 00:07:11,390
98
00:07:11,390 --> 00:07:14,140
沙流郡日高町日高
99
00:07:14,140 --> 00:07:16,380
日夕橋
100
00:07:16,380 --> 00:07:28,850
101
00:07:28,850 --> 00:07:31,680
沙流郡日高町宮下町
102
00:07:31,680 --> 00:07:34,090
日高神社
103
00:07:34,090 --> 00:07:42,900
104
00:07:42,900 --> 00:07:45,510
沙流郡日高町本町東
105
00:07:45,510 --> 00:07:49,880
新川橋
106
00:07:49,880 --> 00:07:52,760
セイコーマート日高道の駅店
107
00:07:52,760 --> 00:07:55,110
日高郵便局
108
00:07:55,110 --> 00:07:56,690
109
00:07:56,690 --> 00:07:59,360
沙流郡日高町千栄
110
00:07:59,360 --> 00:08:02,150
北電日高発電所
111
00:08:02,150 --> 00:08:03,900
112
00:08:03,900 --> 00:08:06,380
沙流郡日高町日高
113
00:08:06,380 --> 00:08:09,440
右左府発電所
114
00:08:09,440 --> 00:08:10,930
115
00:08:10,930 --> 00:08:12,660
東に続く
116
00:08:12,660 --> 00:08:17,660
+27
View File
@@ -0,0 +1,27 @@
# Bill of materials — 274
9 file(s), 472.1 MiB. Generated by `scripts/bom_gen.py`.
## audio
file size hash description
------------------------ --------- -------------------------- ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
`audio/274_release1.wav` 226.8 MiB `4287774b506177d282b8a035` Release master of "274" (2026-09-16), superseded by 274_release2.wav — stereo, 48 kHz, 32-bit float PCM, 10:19 (619.27 s). Peak -0.4 dBFS, mean -12.4 dBFS.
`audio/274_release2.wav` 226.8 MiB `834aad4988f387dd9871d099` Release master of "274" (updated 2026-09-17, replaces 274_release1.wav; used by the white/lyrics renders and Song274Transect) — stereo, 48 kHz, 32-bit float PCM, 10:19 (619.27 s). Float peak +0.25 dBFS (1.0297; 25 samples beyond full scale), mean -13.1 dBFS.
## images
file size hash description
------------------ -------- -------------------------- ------------------------------------------------------------------------------------------------------
`images/white.png` 11.1 KiB `717ddffe599554fb2dcdf855` Background plate: plain pure-white (#FFFFFF) 1920×1080 RGB image, used as the still for the PV render.
## fonts
file size hash description
-------------------------------- ------- -------------------------- ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
`fonts/MPLUS1Code-Bold.ttf` 1.6 MiB `2bb7c37e098719928fc47383` M PLUS 1 Code Bold (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass for {\b1} runs in 274-credits.
`fonts/MPLUS1Code-Medium.ttf` 1.6 MiB `0c9342dd910f7daaea1d40ac` M PLUS 1 Code Medium (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-addr.
`fonts/MPLUS1Code-Regular.ttf` 1.6 MiB `2f8308951632e89da5e3bcee` M PLUS 1 Code Regular (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-credits.
`fonts/MPLUS1Code-SemiBold.ttf` 1.6 MiB `8d5d9c9d3e427a99b2b4e25e` M PLUS 1 Code SemiBold (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-place.
`fonts/NotoSerifJP-Medium.otf` 6.0 MiB `b3aa1bf0e4aeb96623aeaab1` Noto Serif JP Medium (SubsetOTF/JP, v2.003, SIL OFL 1.1) from notofonts/noto-cjk release Serif2.003 (12_NotoSerifJP.zip). Used by 274.ass styles transparent_lyrics_en, transparent_lyrics_jp.
`fonts/NotoSerifJP-SemiBold.otf` 6.0 MiB `81863c254d5833df489433c4` Noto Serif JP SemiBold (SubsetOTF/JP, v2.003, SIL OFL 1.1) from notofonts/noto-cjk release Serif2.003 (12_NotoSerifJP.zip). Used by 274.ass style transparent_title.
+186
View File
@@ -0,0 +1,186 @@
[
{
"frm": 36.5,
"to": 38.25,
"code": "01427",
"city": "夕張郡由仁町",
"lv": "川端",
"kana": "かわばた"
},
{
"frm": 38.5,
"to": 42.0,
"code": "01429",
"city": "夕張郡栗山町",
"lv": "字滝下",
"kana": "たきした"
},
{
"frm": 42.25,
"to": 43.0,
"code": "01427",
"city": "夕張郡由仁町",
"lv": "川端",
"kana": "かわばた"
},
{
"frm": 43.25,
"to": 44.0,
"code": "01429",
"city": "夕張郡栗山町",
"lv": "字滝下",
"kana": "たきした"
},
{
"frm": 44.25,
"to": 48.25,
"code": "01209",
"city": "夕張市",
"lv": "滝ノ上",
"kana": "たきのうえ"
},
{
"frm": 48.5,
"to": 55.5,
"code": "01209",
"city": "夕張市",
"lv": "紅葉山",
"kana": "もみじやま"
},
{
"frm": 55.75,
"to": 57.75,
"code": "01209",
"city": "夕張市",
"lv": "楓",
"kana": "かえで"
},
{
"frm": 58.0,
"to": 61.0,
"code": "01209",
"city": "夕張市",
"lv": "登川",
"kana": "のぼりかわ"
},
{
"frm": 61.25,
"to": 62.75,
"code": "01586",
"city": "勇払郡むかわ町",
"lv": "穂別長和",
"kana": "ほべつおさわ"
},
{
"frm": 63.0,
"to": 72.0,
"code": "01586",
"city": "勇払郡むかわ町",
"lv": "穂別稲里",
"kana": "ほべついなさと"
},
{
"frm": 72.25,
"to": 76.0,
"code": "01586",
"city": "勇払郡むかわ町",
"lv": "穂別長和",
"kana": "ほべつおさわ"
},
{
"frm": 76.25,
"to": 88.75,
"code": "01586",
"city": "勇払郡むかわ町",
"lv": "穂別福山",
"kana": "ほべつふくやま"
},
{
"frm": 89.0,
"to": 96.75,
"code": "01463",
"city": "勇払郡占冠村",
"lv": "字中央",
"kana": "ちゅうおう"
},
{
"frm": 97.0,
"to": 101.75,
"code": "01601",
"city": "沙流郡日高町",
"lv": "字日高",
"kana": "ひだか(1く)"
},
{
"frm": 102.0,
"to": 102.5,
"code": "01601",
"city": "沙流郡日高町",
"lv": "新町三丁目",
"kana": "しんまち"
},
{
"frm": 102.75,
"to": 102.75,
"code": "01601",
"city": "沙流郡日高町",
"lv": "新町一丁目",
"kana": "しんまち"
},
{
"frm": 103.0,
"to": 103.0,
"code": "01601",
"city": "沙流郡日高町",
"lv": "栄町東一丁目",
"kana": "さかえまちひがし"
},
{
"frm": 103.25,
"to": 103.5,
"code": "01601",
"city": "沙流郡日高町",
"lv": "本町東一丁目",
"kana": "ほんちょうひがし"
},
{
"frm": 103.75,
"to": 103.75,
"code": "01601",
"city": "沙流郡日高町",
"lv": "本町東二丁目",
"kana": "ほんちょうひがし"
},
{
"frm": 104.0,
"to": 104.0,
"code": "01601",
"city": "沙流郡日高町",
"lv": "松風町一丁目",
"kana": "まつかぜちょう"
},
{
"frm": 104.25,
"to": 104.25,
"code": "01601",
"city": "沙流郡日高町",
"lv": "山手町一丁目",
"kana": "やまてちょう"
},
{
"frm": 104.5,
"to": 107.0,
"code": "01601",
"city": "沙流郡日高町",
"lv": "字日高",
"kana": "ひだか(1く)"
},
{
"frm": 107.25,
"to": 107.75,
"code": "01601",
"city": "沙流郡日高町",
"lv": "字千栄",
"kana": "ちさか"
}
]
+132
View File
@@ -0,0 +1,132 @@
km,name,object,kana,romaji,kind,offset_m,lat,lon,length_m,built,grade,oaza,address,address_kana,location
37.27,川端,川端,かわばた,Kawabata,place (neighbourhood),32,42.91538,141.89568,,,,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,
38.41,川端,川端橋,かわばたはし,Kawabata hashi,bridge,0,42.91715,141.90896,255,1991,Ⅰ,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,由仁町
39.80,川端,北海道企業局 川端発電所,,,power station,451,42.91197,141.91642,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
39.80,川端,川端ダム,,,dam,523,42.91140,141.91590,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
40.16,滝下,滝下八幡神社,たきしたはちまんじんじゃ,Takishitahachiman jinja,Shinto shrine,98,42.91183,141.92472,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
40.57,滝ノ下,滝ノ下信号場,たきのした,Takinoshita,place (locality),400,42.90630,141.92320,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
41.02,クオーベツ,クオーベツダム,くおーぺつ,Kuoopetsu,dam,1798,42.89310,141.93874,,,,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,
41.02,滝下川端,滝下川端連絡線,,,power line,75,42.90857,141.93273,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
41.02,砂金,砂金川,,,watercourse,58,42.90889,141.93222,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,石狩川水系
41.86,雨霧橋,雨霧橋,あまぎりはし,Amagiri hashi,bridge,0,42.91003,141.94197,280.7,1974,Ⅱ,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,栗山町
43.03,竜仙橋,竜仙橋,りゅうせんばし,Ryuusen bashi,bridge,0,42.91604,141.95220,178,1972,Ⅱ,川端,夕張郡由仁町川端,ゆうばりぐんゆにちょうかわばた,由仁町
43.07,滝下,北海道企業局 滝下発電所,たきのしたはつでんしょ,Takinoshita hatsudensho,power station,210,42.91801,141.95218,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
43.30,滝下,滝下,たきした,Takishita,place (neighbourhood),1636,42.93155,141.95583,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,
43.64,草木,草木川,そうもく,Soumoku,watercourse,84,42.91577,141.96022,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,石狩川水系
44.00,草木,草木橋,そうもくはし,Soumoku hashi,bridge,0,42.91449,141.96321,24.1,1979,Ⅲ,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,栗山町
44.00,滝の上,滝の上こ線橋,たきのうえこせんきょう,Takinoue kosenkyou,bridge,0,42.91449,141.96321,47.6,1975,Ⅲ,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,夕張市
44.00,草木舞,草木舞沢川,そうもくまいさわ,Soumokumaisawa,watercourse,21,42.91436,141.96339,,,,滝下,夕張郡栗山町滝下,ゆうばりぐんくりやまちょうたきした,石狩川水系
44.62,滝の上,北海道企業局 滝の上発電所,,,power station,142,42.91152,141.97021,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
44.62,滝の上,滝の上公園,,,park,69,42.91225,141.97000,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
45.21,カミ,カミの沢川,,,watercourse,40,42.91059,141.97704,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,石狩川水系
45.21,滝の上,滝ノ上信号場,たきのうえしんごうじょう,Takinoue shingoujou,railway signal post,43,42.91000,141.97642,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
45.29,滝の上,滝ノ上神社,たきのうえじんじゃ,Takinoue jinja,Shinto shrine,100,42.90979,141.97873,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
47.66,滝の上,滝ノ上,たきのうえ,Takinoue,place (neighbourhood),52,42.90347,142.00470,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,
48.18,チョポツナイ,チョポツナイ川,ちょぽつない,Chopotsunai,watercourse,63,42.90575,142.01006,,,,滝ノ上,夕張市滝ノ上,ゆうばりしたきのうえ,石狩川水系
49.71,十三里,十三里信号場,とみさとしんごうじょう,Tomisato shingoujou,place (locality),39,42.91482,142.02439,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
50.28,カライケ,カライケの沢川,からいけ,Karaike,watercourse,132,42.91674,142.03132,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
50.28,十三里,十三里橋,とみさとはし,Tomisato hashi,bridge,0,42.91772,142.03043,122,1997,Ⅰ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
50.52,紅葉山,紅葉山駐車帯,もみじやまちゅうしゃたい,Momijiyama chuushatai,parking,41,42.91840,142.03331,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
52.11,瑞穂,瑞穂橋,みずほはし,Mizuho hashi,bridge,0,42.93244,142.03548,155,1968,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
52.11,夕張,夕張川,,,watercourse,16,42.93257,142.03538,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
52.47,セブン-イレブン,セブンイレブン夕張紅葉山店,せぶんいれぶんゆうばりもみじやまてん,Sebun'irebun'yuubarimomijiyama ten,convenience store,73,42.93507,142.03683,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
52.54,新夕張,新夕張駅,しんゆうばり,Shin'yuubari,railway station,173,42.93670,142.03648,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
52.61,紅葉山,紅葉山郵便局,もみじやまゆうびんきょく,Momijiyama yuubinkyoku,post office,54,42.93642,142.03925,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
52.85,紅葉山,紅葉山神社,もみじやまじんじゃ,Momijiyama jinja,Shinto shrine,67,42.93788,142.03956,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
52.99,セイコーマート,セイコーマートもみじやま店,せいこーまーともみじやまてん,Seikoomaatomomijiyama ten,convenience store,31,42.93910,142.04167,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,
53.13,紅楓,紅楓橋,こうふうばし,Koufuu bashi,bridge,0,42.93855,142.04317,102.5,1958,Ⅱ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
53.69,久留喜,ポン久留喜橋,ぽんくるきはし,Ponkuruki hashi,bridge,0,42.93697,142.04976,12.7,1984,Ⅰ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
53.69,久留喜,ポンクルキ川,,,watercourse,16,42.93695,142.04995,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
53.69,久留喜,久留喜橋,くるきはし,Kuruki hashi,bridge,0,42.93697,142.04976,34,1962,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
55.14,シイタルキ,シイタルキ川,,,watercourse,143,42.92706,142.06277,,,,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,石狩川水系
55.41,楓,楓橋,かえでばし,Kaede bashi,bridge,0,42.92957,142.06562,32,1968,Ⅲ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
55.60,桂,桂橋,かつらはし,Katsura hashi,bridge,0,42.93026,142.06775,48.3,1987,Ⅰ,紅葉山,夕張市紅葉山,ゆうばりしもみじやま,夕張市
56.60,千紫,千紫橋,せんしばし,Senshi bashi,bridge,0,42.93414,142.07860,31.4,1988,Ⅰ,楓,夕張市楓,ゆうばりしかえで,夕張市
56.88,楓,楓,かえで,Kaede,place (neighbourhood),711,42.94002,142.08407,,,,楓,夕張市楓,ゆうばりしかえで,
57.08,楓,楓駐車帯,かえでちゅうしゃたい,Kaede chuushatai,parking,39,42.93352,142.08412,,,,楓,夕張市楓,ゆうばりしかえで,
57.25,万紅,万紅橋,ばんこうばし,Bankou bashi,bridge,0,42.93274,142.08627,27.6,1988,Ⅰ,楓,夕張市楓,ゆうばりしかえで,夕張市
57.44,登川,登川神社,のぼりかわじんじゃ,Noborikawa jinja,Shinto shrine,164,42.93395,142.08819,,,,楓,夕張市楓,ゆうばりしかえで,
57.76,楓,楓信号場,かえでしんごうじょう,Kaede shingoujou,railway signal post,47,42.93157,142.09230,,,,楓,夕張市楓,ゆうばりしかえで,
57.84,登川,登川橋,のぼりかわばし,Noborikawa bashi,bridge,0,42.93167,142.09327,21,1988,Ⅰ,楓,夕張市楓,ゆうばりしかえで,夕張市
57.84,登川,登川郵便局,のぼりかわゆうびんきょく,Noborikawa yuubinkyoku,post office,21,42.93185,142.09335,,,,楓,夕張市楓,ゆうばりしかえで,
58.23,花園,花園橋,はなぞのばし,Hanazono bashi,bridge,0,42.92922,142.09647,58.6,1971,Ⅱ,登川,夕張市登川,ゆうばりしのぼりかわ,夕張市
58.26,ホルカクルキ,ホルカクルキ川,,,watercourse,9,42.92920,142.09685,,,,登川,夕張市登川,ゆうばりしのぼりかわ,石狩川水系
58.56,滝沢川,滝沢川,,,watercourse,52,42.92962,142.10029,,,,登川,夕張市登川,ゆうばりしのぼりかわ,石狩川水系
59.54,松竹沢,松竹沢川,,,watercourse,58,42.93047,142.11198,,,,登川,夕張市登川,ゆうばりしのぼりかわ,石狩川水系
59.66,登川,登川,のぼりかわ,Noborikawa,place (neighbourhood),1208,42.93831,142.12137,,,,登川,夕張市登川,ゆうばりしのぼりかわ,
60.29,登川,登川トンネル,のぼりかわとんねる,Noborikawa tonneru,tunnel,0,42.92478,142.11599,348,1978,,登川,夕張市登川,ゆうばりしのぼりかわ,夕張市
62.36,オマナイ,オマナイ橋,おまないはし,Omanai hashi,bridge,0,42.90892,142.12482,174.2,1988,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
62.94,桂木,桂木橋,かつらぎはし,Katsuragi hashi,bridge,0,42.90434,142.12726,129.6,1986,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
64.44,大和,大和橋,やまとはし,Yamato hashi,bridge,0,42.89157,142.12305,16,1967,Ⅲ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
66.69,豊進,豊進橋,ほうしんばし,Houshin bashi,bridge,0,42.87371,142.13048,16,1967,Ⅲ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
66.78,豊進,豊進神社,ほうしんじんじゃ,Houshin jinja,Shinto shrine,49,42.87318,142.13135,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
66.83,穂別,穂別キャンプ場,,,campsite,66,42.87222,142.13061,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
66.86,石山,石山の沢川,,,watercourse,161,42.87125,142.13037,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,鵡川水系
67.05,狩勝,狩勝幹線,,,power line,414,42.86806,142.13398,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
67.48,豊里,豊里橋,とよさとはし,Toyosato hashi,bridge,0,42.87311,142.13854,27,1966,Ⅰ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
67.60,稲豊,稲豊橋,いなとよはし,Inatoyo hashi,bridge,0,42.87313,142.14000,27,1966,Ⅰ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
67.62,サヌシュッペ,サヌシュッペ川,,,watercourse,5,42.87304,142.14032,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,鵡川水系
68.63,稲里,稲里橋,いなさとはし,Inasato hashi,bridge,0,42.87144,142.15164,31,1988,Ⅱ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
69.29,稲里,稲里簡易郵便局,いなさとかんいゆうびんきょく,Inasatokan'i yuubinkyoku,post office,161,42.86734,142.15774,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
69.48,佐主,佐主橋,さぬしばし,Sanushi bashi,bridge,0,42.86894,142.16085,85,1973,Ⅲ,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,むかわ町
70.22,穂別,穂別川,,,watercourse,32,42.87408,142.16642,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,鵡川水系
71.86,穂別,穂別ダム神社,ほべつだむじんじゃ,Hobetsudamu jinja,Shinto shrine,18,42.88475,142.17737,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
71.90,穂別,穂別ダム,,,dam,166,42.88502,142.17937,,,,穂別稲里,勇払郡むかわ町穂別稲里,ゆうふつぐんむかわちょうほべついなさと,
72.02,長和,長和トンネル,おさわとんねる,Osawa tonneru,tunnel,0,42.88619,142.17748,281,1983,,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
72.29,穂別,穂別大橋,ほべつおおはし,Hobetsu oohashi,bridge,0,42.88820,142.17918,506.2,1979,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
72.90,山水,山水橋,さんすいばし,Sansui bashi,bridge,0,42.89187,142.18476,53.5,1979,Ⅰ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
72.93,高西,高西の沢川,,,watercourse,9,42.89195,142.18498,,,,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,鵡川水系
74.31,沼の沢,沼の沢橋,ぬまのさわはし,Numanosawa hashi,bridge,0,42.88830,142.20015,188,1983,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
75.28,稲里,稲里トンネル,いなさととんねる,Inasato tonneru,tunnel,0,42.88343,142.20951,1441,1985,Ⅲ,穂別長和,勇払郡むかわ町穂別長和,ゆうふつぐんむかわちょうほべつおさわ,むかわ町
76.97,ペンケ橋,ペンケ橋,ぺんけばし,Penke bashi,bridge,0,42.87802,142.22782,120.7,1985,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
77.05,オロロップ,オロロップ沢川,,,watercourse,15,42.87849,142.22885,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
78.03,下福山,下福山橋,しもふくやまはし,Shimofukuyama hashi,bridge,0,42.88572,142.23487,129.9,1982,Ⅰ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
78.31,オロロップ,オロロップ橋,おろろっぷはし,Ororoppu hashi,bridge,0,42.88721,142.23759,205.9,1983,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
79.11,福山,福山橋,ふくやまはし,Fukuyama hashi,bridge,0,42.89249,142.24390,134.7,1984,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
79.67,福山,福山神社,ふくやまじんじゃ,Fukuyama jinja,Shinto shrine,184,42.89114,142.25175,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
79.81,福山第1ゲート,福山第1ゲート,,,gate,71,42.89003,142.25118,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
79.86,福山第2ゲート,福山第2ゲート,,,gate,47,42.88890,142.25072,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
80.01,穂別福山 (穂別 + 福山),穂別福山,ほべつふくやま,Hobetsufukuyama,place (neighbourhood),172,42.88942,142.25385,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,
80.24,鵡川,鵡川,,,watercourse,68,42.88740,142.25421,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
80.24,福山,福山大橋,ふくやまおおはし,Fukuyama oohashi,bridge,0,42.88678,142.25416,160,1968,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
85.40,千坂,千坂橋,ちさかはし,Chisaka hashi,bridge,0,42.88901,142.29725,138.7,1975,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
85.87,オコタン,オコタン橋,おこたんばし,Okotan bashi,bridge,0,42.89179,142.30059,338,1985,Ⅰ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
86.16,パンケモトツ,パンケモトツ沢川,,,watercourse,10,42.89278,142.30394,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
86.24,モトツ,モトツトンネル,もとつとんねる,Mototsu tonneru,tunnel,0,42.89321,142.30463,924,1991,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
87.12,モトツ,モトツ橋,もとつばし,Mototsu bashi,bridge,0,42.89931,142.31156,62,1988,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
87.12,モトツ,モトツシェルター,もとつしぇるたー,Mototsu sherutaa,shed,0,42.89931,142.31156,62,1990,Ⅲ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
87.21,福山,福山トンネル,,,tunnel,0,42.89988,142.31229,839,1991,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
88.04,ペンケモトツ,ペンケモトツ沢川,,,watercourse,26,42.90332,142.32161,,,,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,鵡川水系
88.04,福穂,福穂橋,ふくすいはし,Fukusui hashi,bridge,0,42.90322,142.32132,210,1990,Ⅰ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
88.25,穂高,穂高トンネル,ほだかとんねる,Hodaka tonneru,tunnel,0,42.90398,142.32368,1991,1991,Ⅱ,穂別福山,勇払郡むかわ町穂別福山,ゆうふつぐんむかわちょうほべつふくやま,むかわ町
90.58,穂高,穂高橋,ほだかはし,Hodaka hashi,bridge,0,42.90958,142.35128,140.6,1988,Ⅱ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
90.90,高翠,高翠橋,こうすいばし,Kousui bashi,bridge,0,42.90889,142.35485,114,1988,Ⅰ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
91.14,占翠,占翠橋,せんすいばし,Sensui bashi,bridge,0,42.90869,142.35768,167,1989,Ⅰ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
93.58,名石,名石駐車場,めいせきちゅうしゃじょう,Meiseki chuushajou,parking,32,42.90980,142.36986,,,,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,
93.67,名石,名石橋,めいせきばし,Meiseki bashi,bridge,0,42.91029,142.37052,39.6,1970,Ⅰ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
93.73,ニセイパオマナイ,ニセイパオマナイ川,,,watercourse,30,42.91045,142.37087,,,,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,鵡川水系
94.35,赤岩,赤岩大橋,あかいわおおはし,Akaiwa oohashi,bridge,0,42.91317,142.37731,157.4,1970,Ⅱ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
95.37,一の沢川,一の沢川,,,watercourse,44,42.91532,142.38713,,,,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,鵡川水系
95.37,渓流,渓流大橋,けいりゅうおおはし,Keiryuu oohashi,bridge,0,42.91502,142.38677,153.7,1974,Ⅲ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
96.43,日高,日高トンネル,ひだかとんねる,Hidaka tonneru,tunnel,0,42.91588,142.39911,730,1972,Ⅲ,中央,勇払郡占冠村中央,ゆうふつぐんしむかっぷむらちゅうおう,占冠村
99.93,右の沢川,右の沢川,,,watercourse,145,42.89623,142.41771,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
99.98,ホロカワウシャップ,ホロカワウシャップ川,,,watercourse,79,42.89623,142.41942,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
100.60,日夕,日夕橋,にちゆうばし,Nichiyuu bashi,bridge,0,42.89710,142.42716,30,1968,Ⅰ,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,日高町
100.66,パンケウシャップ,パンケウシャップ川,,,watercourse,21,42.89691,142.42766,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
100.90,日高,日高,,,place (neighbourhood),373,42.89635,142.43448,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,
102.18,新町,新町三丁目,しんまち3ちょうめ,Shinmachi 3-choume,place (neighbourhood),13,42.88427,142.43237,,,,新町,沙流郡日高町新町,さるぐんひだかちょうしんまち,
102.36,日高,日高神社,ひだかじんじゃ,Hidaka jinja,Shinto shrine,221,42.88427,142.43553,,,,宮下町,沙流郡日高町宮下町,さるぐんひだかちょうみやしたちょう,
102.72,kanamoto,kanamoto,かなもと,Kanamoto,shop,42,42.88086,142.43687,,,,宮下町,沙流郡日高町宮下町,さるぐんひだかちょうみやしたちょう,
103.12,新川,新川橋,しんかわはし,Shinkawa hashi,bridge,0,42.87791,142.44001,3.6,1993,Ⅰ,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,日高町
103.12,新川,新川,にいかわ,Niikawa,watercourse,23,42.87811,142.43992,,,,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,沙流川水系
103.29,日高,日高郵便局,ひだかゆうびんきょく,Hidaka yuubinkyoku,post office,85,42.87748,142.44195,,,,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,
103.29,セイコーマート,セイコーマート日高道の駅店,せいこーまーとひだかみちのえきてん,Seikoomaatohidakamichinoeki ten,convenience store,85,42.87734,142.44216,,,,本町東,沙流郡日高町本町東,さるぐんひだかちょうほんちょうひがし,
104.39,ウシャップ,ペンケウシャップ川,,,watercourse,15,42.88146,142.45211,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
104.52,日高,北海道電力 日高発電所,ひだかはつでんしょ,Hidaka hatsudensho,power station,666,42.87724,142.45788,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,
105.67,沙流川,沙流川,,,watercourse,272,42.88829,142.46766,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
105.99,ユウケシュオマナイ,パンケユウケシュオマナイ川,,,watercourse,181,42.88869,142.46778,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
106.22,右左府,右左府発電所,うさっぷはつでんしょ,Usappu hatsudensho,power station,244,42.89314,142.46755,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,
106.22,右左府,右左府変電所,,,substation,245,42.89280,142.46713,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,
106.47,オワイタカ,オワイタカ沢川,,,watercourse,52,42.89254,142.47167,,,,日高,沙流郡日高町日高,さるぐんひだかちょうひだか,沙流川水系
107.29,滝の沢川,滝の沢川,,,watercourse,18,42.89575,142.48126,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
107.83,ルウケシオマナイ,ルウケシオマナイ川,,,watercourse,134,42.89400,142.48842,,,,千栄,沙流郡日高町千栄,さるぐんひだかちょうちさか,沙流川水系
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
@@ -0,0 +1,99 @@
start,stop,start_s,stop_s,sung,object,kind,km,lat,lon,km_from,km_to
00:14.54,00:17.89,14.54,17.89,夕張郡由仁町川端,夕張郡由仁町川端,district,38.41,42.91715,141.90896,38.41,38.41
00:17.89,00:19.67,17.89,19.67,川端橋,川端橋,bridge,38.41,42.91715,141.90896,,
00:25.04,00:28.41,25.04,28.41,夕張郡栗山町滝下,夕張郡栗山町滝下,district,39.80,42.91197,141.91642,39.80,41.86
00:28.41,00:32.06,28.41,32.06,川端発電所,北海道企業局 川端発電所,power station,39.80,42.91197,141.91642,,
00:32.06,00:35.44,32.06,35.44,滝下八幡神社,滝下八幡神社,Shinto shrine,40.16,42.91183,141.92472,,
00:35.44,00:39.84,35.44,39.84,滝ノ下信号場,滝ノ下信号場,place (locality),40.57,42.90630,141.92320,,
00:39.84,00:42.01,39.84,42.01,雨霧橋,雨霧橋,bridge,41.86,42.91003,141.94197,,
00:42.45,00:45.15,42.45,45.15,夕張郡由仁町川端,夕張郡由仁町川端,district,43.03,42.91604,141.95220,43.03,43.03
00:45.15,00:46.69,45.15,46.69,竜仙橋,竜仙橋,bridge,43.03,42.91604,141.95220,,
00:47.63,00:50.08,47.63,50.08,夕張郡栗山町滝下,夕張郡栗山町滝下,district,44.00,42.91449,141.96321,44.00,44.00
00:50.08,00:52.88,50.08,52.88,草木橋,草木橋,bridge,44.00,42.91449,141.96321,,
00:52.88,00:54.73,52.88,54.73,滝の上こ線橋,滝の上こ線橋,bridge,44.00,42.91449,141.96321,,
00:56.44,00:59.95,56.44,59.95,夕張市滝ノ上,夕張市滝ノ上,district,44.62,42.91152,141.97021,44.62,45.29
00:59.95,01:03.47,59.95,63.47,滝の上発電所,北海道企業局 滝の上発電所,power station,44.62,42.91152,141.97021,,
01:03.47,01:06.94,63.47,66.94,滝ノ上信号場,滝ノ上信号場,railway signal post,45.21,42.91000,141.97642,,
01:06.94,01:08.78,66.94,68.78,滝ノ上神社,滝ノ上神社,Shinto shrine,45.29,42.90979,141.97873,,
01:10.50,01:12.05,70.50,72.05,夕張市紅葉山,夕張市紅葉山,district,49.71,42.91482,142.02439,49.71,55.60
01:12.05,01:13.86,72.05,73.86,十三里信号場,十三里信号場,place (locality),49.71,42.91482,142.02439,,
01:13.86,01:16.44,73.86,76.44,十三里橋,十三里橋,bridge,50.28,42.91772,142.03043,,
01:16.44,01:18.78,76.44,78.78,紅葉山駐車帯,紅葉山駐車帯,parking,50.52,42.91840,142.03331,,
01:18.78,01:21.06,78.78,81.06,瑞穂橋,瑞穂橋,bridge,52.11,42.93244,142.03548,,
01:21.06,01:24.44,81.06,84.44,セブンイレブン夕張紅葉山店,セブンイレブン夕張紅葉山店,convenience store,52.47,42.93507,142.03683,,
01:24.44,01:27.06,84.44,87.06,新夕張駅,新夕張駅,railway station,52.54,42.93670,142.03648,,
01:27.06,01:30.02,87.06,90.02,紅葉山郵便局,紅葉山郵便局,post office,52.61,42.93642,142.03925,,
01:30.02,01:32.30,90.02,92.30,紅葉山神社,紅葉山神社,Shinto shrine,52.85,42.93788,142.03956,,
01:32.30,01:35.24,92.30,95.24,セイコーマートもみじやま店,セイコーマートもみじやま店,convenience store,52.99,42.93910,142.04167,,
01:35.24,01:38.48,95.24,98.48,紅楓橋,紅楓橋,bridge,53.13,42.93855,142.04317,,
01:38.48,01:40.25,98.48,100.25,ポン久留喜橋,ポン久留喜橋,bridge,53.69,42.93697,142.04976,,
01:40.25,01:42.44,100.25,102.44,久留喜橋,久留喜橋,bridge,53.69,42.93697,142.04976,,
01:42.44,01:43.79,102.44,103.79,楓橋,楓橋,bridge,55.41,42.92957,142.06562,,
01:43.79,01:45.03,103.79,105.03,桂橋,桂橋,bridge,55.60,42.93026,142.06775,,
01:45.77,01:49.12,105.77,109.12,夕張市楓,夕張市楓,district,56.60,42.93414,142.07860,56.60,57.84
01:49.12,01:50.76,109.12,110.76,千紫橋,千紫橋,bridge,56.60,42.93414,142.07860,,
01:50.76,01:52.51,110.76,112.51,楓駐車帯,楓駐車帯,parking,57.08,42.93352,142.08412,,
01:52.51,01:53.80,112.51,113.80,万紅橋,万紅橋,bridge,57.25,42.93274,142.08627,,
01:53.80,01:55.54,113.80,115.54,登川神社,登川神社,Shinto shrine,57.44,42.93395,142.08819,,
01:55.54,01:57.32,115.54,117.32,楓信号場,楓信号場,railway signal post,57.76,42.93157,142.09230,,
01:57.32,01:59.00,117.32,119.00,登川橋,登川橋,bridge,57.84,42.93167,142.09327,,
01:59.00,02:00.74,119.00,120.74,登川郵便局,登川郵便局,post office,57.84,42.93185,142.09335,,
02:01.26,02:02.93,121.26,122.93,夕張市登川,夕張市登川,district,58.23,42.92922,142.09647,58.23,60.29
02:02.93,02:05.23,122.93,125.23,花園橋,花園橋,bridge,58.23,42.92922,142.09647,,
02:05.23,02:06.62,125.23,126.62,登川トンネル,登川トンネル,tunnel,60.29,42.92478,142.11599,,
03:30.80,03:34.55,210.80,214.55,勇払郡むかわ町穂別長和,勇払郡むかわ町穂別長和,district,62.36,42.90892,142.12482,62.36,62.94
03:34.55,03:36.77,214.55,216.77,オマナイ橋,オマナイ橋,bridge,62.36,42.90892,142.12482,,
03:36.77,03:39.41,216.77,219.41,桂木橋,桂木橋,bridge,62.94,42.90434,142.12726,,
03:41.13,03:44.62,221.13,224.62,勇払郡むかわ町穂別稲里,勇払郡むかわ町穂別稲里,district,64.44,42.89157,142.12305,64.44,71.86
03:44.62,03:46.67,224.62,226.67,大和橋,大和橋,bridge,64.44,42.89157,142.12305,,
03:46.67,03:48.38,226.67,228.38,豊進橋,豊進橋,bridge,66.69,42.87371,142.13048,,
03:48.38,03:51.61,228.38,231.61,豊進神社,豊進神社,Shinto shrine,66.78,42.87318,142.13135,,
03:51.61,03:53.14,231.61,233.14,豊里橋,豊里橋,bridge,67.48,42.87311,142.13854,,
03:53.14,03:55.52,233.14,235.52,稲豊橋,稲豊橋,bridge,67.60,42.87313,142.14000,,
03:55.52,03:57.78,235.52,237.78,稲里橋,稲里橋,bridge,68.63,42.87144,142.15164,,
03:57.78,04:01.08,237.78,241.08,稲里簡易郵便局,稲里簡易郵便局,post office,69.29,42.86734,142.15774,,
04:01.08,04:03.50,241.08,243.50,佐主橋,佐主橋,bridge,69.48,42.86894,142.16085,,
04:03.50,04:05.59,243.50,245.59,穂別ダム神社,穂別ダム神社,Shinto shrine,71.86,42.88475,142.17737,,
04:05.96,04:10.08,245.96,250.08,勇払郡むかわ町穂別長和,勇払郡むかわ町穂別長和,district,72.02,42.88619,142.17748,72.02,75.28
04:10.08,04:13.02,250.08,253.02,長和トンネル,長和トンネル,tunnel,72.02,42.88619,142.17748,,
04:13.02,04:15.39,253.02,255.39,穂別大橋,穂別大橋,bridge,72.29,42.88820,142.17918,,
04:15.39,04:18.81,255.39,258.81,山水橋,山水橋,bridge,72.90,42.89187,142.18476,,
04:18.81,04:23.20,258.81,263.20,沼の沢橋,沼の沢橋,bridge,74.31,42.88830,142.20015,,
04:23.20,04:25.27,263.20,265.27,稲里トンネル,稲里トンネル,tunnel,75.28,42.88343,142.20951,,
04:26.00,04:29.74,266.00,269.74,勇払郡むかわ町穂別福山,勇払郡むかわ町穂別福山,district,76.97,42.87802,142.22782,76.97,88.25
04:29.74,04:31.50,269.74,271.50,ペンケ橋,ペンケ橋,bridge,76.97,42.87802,142.22782,,
04:31.50,04:33.62,271.50,273.62,下福山橋,下福山橋,bridge,78.03,42.88572,142.23487,,
04:33.62,04:35.02,273.62,275.02,オロロップ橋,オロロップ橋,bridge,78.31,42.88721,142.23759,,
04:35.02,04:37.07,275.02,277.07,福山橋,福山橋,bridge,79.11,42.89249,142.24390,,
04:37.07,04:38.70,277.07,278.70,福山神社,福山神社,Shinto shrine,79.67,42.89114,142.25175,,
04:38.70,04:41.02,278.70,281.02,福山大橋,福山大橋,bridge,80.24,42.88678,142.25416,,
04:41.02,04:43.29,281.02,283.29,千坂橋,千坂橋,bridge,85.40,42.88901,142.29725,,
04:43.29,04:45.12,283.29,285.12,オコタン橋,オコタン橋,bridge,85.87,42.89179,142.30059,,
04:45.12,04:46.22,285.12,286.22,モトツトンネル,モトツトンネル,tunnel,86.24,42.89321,142.30463,,
04:46.22,04:48.98,286.22,288.98,モトツ橋にモトツシェルター,モトツ橋,bridge,87.12,42.89931,142.31156,,
04:46.22,04:48.98,286.22,288.98,モトツ橋にモトツシェルター,モトツシェルター,shed,87.12,42.89931,142.31156,,
04:48.98,04:51.39,288.98,291.39,福山トンネル,福山トンネル,tunnel,87.21,42.89988,142.31229,,
04:51.39,04:53.61,291.39,293.61,福穂橋,福穂橋,bridge,88.04,42.90322,142.32132,,
04:53.61,04:55.52,293.61,295.52,穂高トンネル,穂高トンネル,tunnel,88.25,42.90398,142.32368,,
05:22.60,05:25.76,322.60,325.76,勇払郡占冠村中央,勇払郡占冠村中央,district,90.58,42.90958,142.35128,90.58,96.43
05:25.76,05:28.88,325.76,328.88,穂高橋,穂高橋,bridge,90.58,42.90958,142.35128,,
05:28.88,05:31.52,328.88,331.52,高翠橋,高翠橋,bridge,90.90,42.90889,142.35485,,
05:31.52,05:34.95,331.52,334.95,占翠橋,占翠橋,bridge,91.14,42.90869,142.35768,,
05:34.95,05:38.08,334.95,338.08,名石駐車場,名石駐車場,parking,93.58,42.90980,142.36986,,
05:38.08,05:40.99,338.08,340.99,名石橋,名石橋,bridge,93.67,42.91029,142.37052,,
05:40.99,05:43.96,340.99,343.96,赤岩大橋,赤岩大橋,bridge,94.35,42.91317,142.37731,,
05:43.96,05:48.62,343.96,348.62,渓流大橋,渓流大橋,bridge,95.37,42.91502,142.38677,,
05:48.62,05:50.73,348.62,350.73,日高トンネル,日高トンネル,tunnel,96.43,42.91588,142.39911,,
07:11.39,07:14.14,431.39,434.14,沙流郡日高町日高,沙流郡日高町日高,district,100.60,42.89710,142.42716,100.60,100.60
07:14.14,07:16.38,434.14,436.38,日夕橋,日夕橋,bridge,100.60,42.89710,142.42716,,
07:28.85,07:31.68,448.85,451.68,沙流郡日高町宮下町,沙流郡日高町宮下町,district,102.36,42.88427,142.43553,102.36,102.36
07:31.68,07:34.09,451.68,454.09,日高神社,日高神社,Shinto shrine,102.36,42.88427,142.43553,,
07:42.90,07:45.51,462.90,465.51,沙流郡日高町本町東,沙流郡日高町本町東,district,103.12,42.87791,142.44001,103.12,103.29
07:45.51,07:49.88,465.51,469.88,新川橋,新川橋,bridge,103.12,42.87791,142.44001,,
07:49.88,07:52.76,469.88,472.76,セイコーマート日高道の駅店,セイコーマート日高道の駅店,convenience store,103.29,42.87734,142.44216,,
07:52.76,07:55.11,472.76,475.11,日高郵便局,日高郵便局,post office,103.29,42.87748,142.44195,,
07:56.69,07:59.36,476.69,479.36,沙流郡日高町千栄,沙流郡日高町千栄,district,104.52,42.87724,142.45788,104.52,104.52
07:59.36,08:02.15,479.36,482.15,北電日高発電所,北海道電力 日高発電所,power station,104.52,42.87724,142.45788,,
08:03.90,08:06.38,483.90,486.38,沙流郡日高町日高,沙流郡日高町日高,district,106.22,42.89314,142.46755,106.22,106.22
08:06.38,08:09.44,486.38,489.44,右左府発電所,右左府発電所,power station,106.22,42.89314,142.46755,,
08:10.93,08:12.66,490.93,492.66,東に続く,,closing,,,,,
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
@@ -0,0 +1 @@
[[108.557,42.89528,142.49683],[108.603,42.89539,142.49736],[108.649,42.89556,142.49787],[108.695,42.89571,142.49842],[108.736,42.89578,142.4989],[108.794,42.89581,142.49962],[108.84,42.89571,142.50016],[108.946,42.89545,142.50142],[109.173,42.89489,142.5041],[109.371,42.89434,142.5064],[109.456,42.8942,142.50743],[109.52,42.89412,142.5082],[110.111,42.89351,142.51541],[110.297,42.89332,142.51768],[110.457,42.89317,142.51963],[110.482,42.89315,142.51995],[110.575,42.89305,142.52107],[110.738,42.89288,142.52306],[111.028,42.89258,142.5266],[111.056,42.89255,142.52694],[111.318,42.89229,142.53014],[111.333,42.89227,142.53031],[111.358,42.89224,142.53062],[111.359,42.89224,142.53064],[111.498,42.89212,142.53233],[111.527,42.89209,142.53269],[111.596,42.89201,142.53353],[111.671,42.89194,142.53444],[111.814,42.89181,142.53619],[111.842,42.89182,142.53653],[111.864,42.89177,142.5368],[111.958,42.89168,142.53794],[112.065,42.89155,142.53924],[112.09,42.89153,142.53954],[112.111,42.89153,142.53981],[112.138,42.89155,142.54013],[112.164,42.89159,142.54045],[112.196,42.89167,142.54083],[112.333,42.89204,142.54243],[112.688,42.89295,142.5466],[112.973,42.89375,142.54994],[113.048,42.89392,142.55082],[113.143,42.89417,142.55194],[113.202,42.89435,142.55262],[113.23,42.89444,142.55295],[113.27,42.89459,142.55338],[113.312,42.89478,142.55384],[113.395,42.8952,142.55467],[113.435,42.8954,142.55508],[113.492,42.89569,142.55566],[113.793,42.89718,142.55874],[113.829,42.89736,142.55911],[113.851,42.89747,142.55933],[113.987,42.89814,142.56073]]
+1
View File
@@ -0,0 +1 @@
[[30.456,42.94898,141.83538],[30.485,42.94902,141.83573],[31.021,42.94975,141.84224],[31.152,42.94995,141.84383],[31.305,42.95011,141.8457],[31.317,42.95013,141.84585],[31.385,42.95022,141.84667],[31.396,42.95024,141.8468],[31.405,42.95025,141.84692],[31.543,42.95043,141.84859],[31.633,42.95055,141.84968],[31.656,42.95058,141.84996],[31.663,42.95059,141.85004],[31.719,42.95067,141.85073],[31.953,42.95099,141.85357],[31.986,42.95103,141.85397],[32.022,42.95104,141.85441],[32.046,42.95104,141.8547],[32.061,42.95102,141.85489],[32.082,42.951,141.85514],[32.102,42.95095,141.85538],[32.124,42.95088,141.85564],[32.148,42.95079,141.8559],[32.17,42.95069,141.85614],[32.192,42.95059,141.85636],[32.214,42.95046,141.85658],[32.249,42.95025,141.8569],[32.278,42.95007,141.85715],[32.304,42.9499,141.85737],[32.315,42.94983,141.85746],[32.398,42.94927,141.85815],[32.462,42.94884,141.85865],[32.474,42.94875,141.85875],[32.607,42.94783,141.85978],[32.639,42.9476,141.86004],[32.72,42.94705,141.86067],[32.929,42.94561,141.86234],[32.957,42.94542,141.86256],[33.001,42.94512,141.86292],[33.138,42.94418,141.86399],[33.305,42.94303,141.8653],[33.489,42.94176,141.86678],[33.731,42.94012,141.86873],[33.99,42.93834,141.87077],[34.132,42.93738,141.87193],[34.161,42.93717,141.87214],[34.308,42.93617,141.87331],[34.584,42.93429,141.87554],[34.753,42.93312,141.87686],[35.006,42.9314,141.87888],[35.192,42.93012,141.88036],[35.335,42.92915,141.8815],[35.443,42.92841,141.88237],[35.527,42.92783,141.88304]]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
# Route data — 274
Copied from `novoyuuparosk-wiki/ncmr-songs/wip/274/`, which is where these are
generated and edited. **Treat everything here as read-only**: change it upstream
and re-copy, or the two will drift.
## Files
| File | What it is |
|---|---|
| `274-lyrics-index.csv` | One row per sung line, resolved to its roster entry, with start/stop timing and coordinates. The file the video syncs to. |
| `274-lookup.csv` | The full roster — every named object on the stretch, sung or not. The page draws all of these; the index only covers the ones that get sung. |
| `274-route.json` | The road itself: `[km, lat, lon]` per point, for drawing the line. |
| `274-route-west.json` | A short run of R274 before the western cutoff, same format — km 30.46 to 35.53, ending exactly on the first point of `274-route.json`. The road arriving before 川端橋, in symmetry with the east stub. Also in the GeoJSON as `kind: route-west`. |
| `274-route-east.json` | A short continuation past the eastern cutoff — km 108.56 to 113.99, overlapping the main shape by 0.18 km so the join is seamless. For `東に続く`: the road visibly carrying on after the last named thing. Also in the GeoJSON as `kind: route-east`. |
| `274-districts.json` | The 大字 bands with entry/exit km — the strip across the top of the transect page. |
| `274-lookup.geojson` | Route line, one point per roster entry, and the bands as geometryless features. For anything that wants GeoJSON directly. |
| `274-transect.html` | The interactive page the video is based on. Self-contained; opens from disk. |
## Using the index
`start` / `stop` are `mm:ss.xx`; `start_s` / `stop_s` are the same in seconds.
`stop` is the next timestamp in the LRC, blank spacer lines included, so every
line has one and the spans butt up against each other without gaps.
Join to `274-lookup.csv` on `object`, which is unique across the roster. The
`sung` column is the line as it appears in the lyric — the three power stations
are sung without their operator prefix, so `sung` and `object` differ for those.
Two row types are not ordinary nodes:
- **`kind = district`** — the address headers. These have no single point; they
carry the first coordinate of the run they introduce, plus `km_from`/`km_to`
spanning it.
- **`kind = closing`** — `東に続く`, which is text rather than an object. No
coordinates.
**モトツ橋にモトツシェルター emits two rows** sharing one timing and one
coordinate. It is one structure to drive through — a bridge roofed by a shelter,
between モトツトンネル and 福山トンネル — and two separately inspected facilities
in the national registers. Light both or either; they are the same place.
## Caveats that matter for the visuals
**All three route shapes are single clean polylines.** Where R274 has a central
reservation — the divided climbs after 福山大橋 and through 占冠 — OpenStreetMap
draws each carriageway as its own way. The shapes follow one spine through those
sections: point spacing adds up to exactly the km span, and km strictly increases
along every file, so the line can be drawn point to point without sideways spikes.
**`km` is not an official kilopost.** It is measured along the OpenStreetMap
route geometry from a datum near 上野幌, because the route relation is broken
across the divided carriageway in 札幌. Official KP is roughly this plus 14 km,
unverified. Ordering, spacing and geometry are all internally consistent; only
the absolute labels would be wrong if shown as real KP.
**`offset_m` is the straight-line distance from the object to the road.** Some
things sung are well off the carriageway — 日高神社 is 221 m away, クオーベツダム
1798 m. If the camera follows the road, an object's own coordinate is not always
where the camera should point.
**The lyric is monotonic by km end to end** — no backward jumps — so a camera
driven by `start_s` moves forward continuously from km 38.41 to 106.22.
**The lyric ends before the music does.** Lines run 14.54 s to 492.66 s (8:12),
while the release master is 619.27 s (10:19). Roughly two minutes of music play
past 東に続く.
## Provenance
How the roster was built, what was verified by hand, and what is derived rather
than measured: see `274-dev.md` in the source directory. Several entries exist
only because they were found on Google Maps and are absent from OpenStreetMap
entirely — ten of them, including four of the eight shrines.
+2
View File
@@ -1,10 +1,12 @@
import "./index.css";
import { MyComposition } from "./Composition";
import { Song274Transect } from "./song-274/Composition";
export const RemotionRoot: React.FC = () => {
return (
<>
<MyComposition />
<Song274Transect />
</>
);
};
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env node
// lrc→srt converter — minimal, no external dependencies
//
// Usage:
// node lrc2srt.ts <input.lrc> [output.srt]
//
// If output is omitted, writes to stdout.
import * as fs from "fs";
import * as path from "path";
function lrcTimeToSeconds(line: string): number | null {
const match = line.match(/\[(\d{1,2}):(\d{2})\.(\d{2,3})\]/);
if (!match) return null;
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const centis = match[3].padEnd(3, "0").substring(0, 3);
const ms = parseInt(centis, 10);
return minutes * 60 + seconds + ms / 1000;
}
function secondsToSrtTime(t: number): string {
const hours = Math.floor(t / 3600);
const minutes = Math.floor((t % 3600) / 60);
const secs = Math.floor(t % 60);
const ms = Math.round((t - Math.floor(t)) * 1000);
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
}
function main() {
const args = process.argv.slice(2);
if (args.length < 1) {
console.error("Usage: lrc2srt.ts <input.lrc> [output.srt]");
process.exit(1);
}
const input = fs.readFileSync(args[0], "utf-8");
const lines = input.split(/\r?\n/);
const entries: { start: number; end: number; text: string }[] = [];
let currentStart: number | null = null;
let currentText: string | null = null;
for (const raw of lines) {
const trimmed = raw.trim();
if (!trimmed) continue;
const timeMatch = trimmed.match(/\[(\d{1,2}):(\d{2})\.(\d{2,3})\]/);
const text = trimmed.replace(/\[\d{1,2}:\d{2}\.\d{2,3}\]/g, "").trim();
if (timeMatch && !currentStart) {
currentStart = lrcTimeToSeconds(raw);
currentText = text;
} else if (currentStart && currentText) {
// If there's a time tag, the current entry ends here
entries.push({ start: currentStart, end: currentStart + 1, text: currentText });
currentStart = lrcTimeToSeconds(raw);
currentText = text;
} else if (currentStart && !currentText) {
currentText = text;
} else if (!currentStart) {
// line without time tag, skip
}
}
if (currentStart && currentText) {
entries.push({ start: currentStart, end: currentStart + 1, text: currentText });
}
// Fix end times: each entry ends when the next one starts
for (let i = 0; i < entries.length; i++) {
if (i + 1 < entries.length) {
entries[i].end = entries[i + 1].start;
} else {
entries[i].end = entries[i].start + 5; // default 5s for last line
}
}
const srtBlocks = entries.map((e, idx) => {
return `${idx + 1}\n${secondsToSrtTime(e.start)} --> ${secondsToSrtTime(e.end)}\n${e.text}`;
});
const output = srtBlocks.join("\n\n") + "\n";
const outPath = args[1];
if (outPath) {
fs.writeFileSync(outPath, output, "utf-8");
} else {
process.stdout.write(output);
}
}
main();
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
import { CalculateMetadataFunction, Composition } from "remotion";
import { loadTransectData } from "./data";
import { darkPalette } from "./palette";
import { Transect, type TransectProps } from "./Transect";
const FPS = 60;
/** Length of song-274/audio/274_release2.wav (see BOM.md). */
const AUDIO_DURATION_S = 619.270083;
const calculateMetadata: CalculateMetadataFunction<TransectProps> = async ({
props,
}) => ({
props: { ...props, data: await loadTransectData() },
});
export const Song274Transect: React.FC = () => (
<Composition
id="Song274Transect"
component={Transect}
durationInFrames={Math.ceil(AUDIO_DURATION_S * FPS)}
fps={FPS}
width={1920}
height={1080}
defaultProps={{
palette: darkPalette,
audio: "audio/274_release2.wav",
data: null,
}}
calculateMetadata={calculateMetadata}
/>
);
+232
View File
@@ -0,0 +1,232 @@
import React, { useMemo } from "react";
import {
AbsoluteFill,
Html5Audio,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import {
blendViews,
followView,
narrationParts,
overviewLeadKm,
overviewView,
overviewWeight,
smoothedFocus,
toCamera,
type Frame,
} from "./camera";
import type { Cue, RosterItem, TransectData } from "./data";
import { blackOpacity } from "./fade";
import {
extendBounds,
GROUND,
nodePositions,
prefixBounds,
projectGround,
routePath,
} from "./layout";
import { useWindowedAudioData } from "@remotion/media-utils";
import { isAwake, mixLooks, nodeLookAt, spectrumLook } from "./nodeAnimation";
import {
AUDIO_WINDOW_S,
audioWindowFrame,
bandLevels,
shuffledBandFrequencies,
spectrumWeight,
} from "./spectrum";
import type { TransectPalette } from "./palette";
import {
coveredRoute,
monotoneCurve,
paintableRoute,
progressKeyframes,
routeAtKm,
} from "./progress";
export type TransectProps = {
palette: TransectPalette;
/** Path under the public dir (song-274/). */
audio: string;
/** Filled in by calculateMetadata from song-274/route/. */
data: TransectData | null;
};
/** Road stroke widths in map units; they grow with zoom like everything else. */
const ROAD_AHEAD_WIDTH = 1.4;
const ROAD_COVERED_WIDTH = 2.4;
/** Each object's cues in time order; most nodes are sung exactly once. */
const cuesByObject = (cues: Cue[]): Map<string, Cue[]> => {
const map = new Map<string, Cue[]>();
for (const cue of cues) {
for (const object of cue.objects) {
const list = map.get(object) ?? [];
list.push(cue);
map.set(object, list);
}
}
return map;
};
/** The cue that governs an object at `t`: the latest one to have started. */
const currentCue = (list: Cue[] | undefined, t: number): Cue | undefined => {
let current: Cue | undefined;
for (const cue of list ?? []) {
if (cue.startS <= t) {
current = cue;
}
}
return current;
};
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
const frame = useCurrentFrame();
const { fps, width, height, durationInFrames } = useVideoConfig();
const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]);
// The main road plus the east continuation. The paint runs into the latter
// after 東に続く; the camera and overview stay on the main road, so that run
// leaves the screen.
const paintRoute = useMemo(() => (data ? paintableRoute(data) : []), [data]);
// The dimmed road ahead includes the east continuation, so it shows all along.
const fullRoad = useMemo(
() => (projection && paintRoute.length > 1 ? routePath(paintRoute, projection) : ""),
[paintRoute, projection],
);
const bounds = useMemo(
() => (data && projection ? prefixBounds(data.route, projection) : []),
[data, projection],
);
const kmAt = useMemo(() => (data ? monotoneCurve(progressKeyframes(data)) : null), [data]);
const westRoad = useMemo(
() =>
data && projection && data.routeWest.length > 1
? routePath(data.routeWest, projection)
: "",
[data, projection],
);
const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [data]);
// Nodes at identical coordinates are spread slightly apart along the road.
const positions = useMemo(
() => (data && projection ? nodePositions(data.items, data.route, projection) : null),
[data, projection],
);
// Each node gets one log-spaced band, dealt out in a seeded shuffle.
const frequencies = useMemo(
() => shuffledBandFrequencies(data ? data.items.length : 0),
[data],
);
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile(audio),
frame: audioWindowFrame(frame, fps),
fps,
windowInSeconds: AUDIO_WINDOW_S,
});
if (!data || !projection || !kmAt || !positions) {
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
}
const t = frame / fps;
const fadeToBlack = blackOpacity(t, (durationInFrames - 1) / fps);
const headAt = (time: number) => {
const km = kmAt(time);
const pos = routeAtKm(data.route, km);
return { km, index: pos.index, x: projection.x(pos.lon), y: projection.y(pos.lat) };
};
const head = headAt(t);
const routeStartKm = data.route[0][0];
const routeEndKm = data.route[data.route.length - 1][0];
const lead = routeAtKm(
data.route,
Math.min(routeEndKm, head.km + overviewLeadKm(head.km - routeStartKm)),
);
const view: Frame = {
worldWidth: GROUND.width,
worldHeight: projection.height,
frameWidth: width,
frameHeight: height,
};
const camera = toCamera(
blendViews(
followView(smoothedFocus(headAt, t)),
overviewView(
extendBounds(bounds[lead.index], projection.x(lead.lon), projection.y(lead.lat)),
view,
),
overviewWeight(parts, t),
),
view,
);
// In the outro the nodes hand over from their lyric looks to the spectrum.
const spectrum = spectrumWeight(t);
const levels =
spectrum > 0 && audioData
? bandLevels({ audioData, dataOffsetInSeconds, frame, fps, frequencies })
: null;
const nodes = data.items.map((item: RosterItem, i) => {
const lyricLook = nodeLookAt(currentCue(cueMap.get(item.object), t), t);
return {
item,
look: levels ? mixLooks(lyricLook, spectrumLook(levels[i]), spectrum) : lyricLook,
};
});
const draw = ({ item, look }: (typeof nodes)[number]) => (
<circle
key={item.object}
cx={positions.get(item.object)?.x ?? projection.x(item.lon)}
cy={positions.get(item.object)?.y ?? projection.y(item.lat)}
r={look.radius}
fill={palette[item.family]}
opacity={look.opacity}
/>
);
return (
<AbsoluteFill style={{ backgroundColor: palette.background }}>
<Html5Audio src={staticFile(audio)} />
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<g transform={`translate(${camera.originX} ${camera.originY}) scale(${camera.scale})`}>
<path
d={fullRoad}
fill="none"
stroke={palette.roadAhead}
strokeWidth={ROAD_AHEAD_WIDTH}
strokeLinejoin="round"
/>
{/* The road arriving from the west: already travelled, so always painted. */}
{westRoad ? (
<path
d={westRoad}
fill="none"
stroke={palette.roadCovered}
strokeWidth={ROAD_COVERED_WIDTH}
strokeLinejoin="round"
strokeLinecap="round"
/>
) : null}
<path
d={routePath(coveredRoute(paintRoute, head.km), projection)}
fill="none"
stroke={palette.roadCovered}
strokeWidth={ROAD_COVERED_WIDTH}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Animating nodes last so they sit on top of their neighbours. */}
{nodes.filter((n) => !isAwake(n.look)).map(draw)}
{nodes.filter((n) => isAwake(n.look)).map(draw)}
</g>
</svg>
{/* Fade from and to black at the two ends (fade.ts). */}
{fadeToBlack > 0 ? (
<AbsoluteFill style={{ backgroundColor: "#000000", opacity: fadeToBlack }} />
) : null}
</AbsoluteFill>
);
};
+172
View File
@@ -0,0 +1,172 @@
import type { Bounds } from "./layout";
/**
* Where the map sits on screen.
*
* The song narrates the road in parts separated by long instrumental breaks.
* During a part the camera holds a fixed close zoom and follows the head of the
* covered road. When a part ends it pulls back to frame everything covered so
* far, and just before the next part it closes in on the head again, arriving
* as the first line starts. After the last part it pulls back for good, which
* by then frames the whole route.
*
* Views are described in map units ({zoom, centre}); `toCamera` turns one into
* the scale and origin the drawing uses: screen = map unit × scale + origin.
*/
/** Zoom while following, relative to the whole route fitting the frame width. */
export const FOLLOW_ZOOM = 4;
/** Lines closer together than this belong to the same narration part. */
export const PART_GAP_S = 20;
/** Pull-back after a part ends, and close-in before the next one starts. */
export const ZOOM_OUT_S = 3;
export const ZOOM_IN_S = 3;
/**
* The camera follows a Gaussian-weighted average of the head's position, so a
* road zigzagging under a nearly straight run of objects does not shake it.
* This is the standard deviation, in seconds; the window spans ±3 of these.
*/
export const FOLLOW_SMOOTH_S = 0.6;
/**
* How often that window is sampled, in seconds. Independent of fps: finer
* sampling costs more per frame and changes the average only slightly.
*/
export const FOLLOW_SAMPLE_S = 1 / 30;
/**
* An overview frames the covered road plus some road ahead of the head, so the
* head does not sit against the frame edge: this fraction of the distance
* covered so far, but never less than the minimum.
*/
export const OVERVIEW_LEAD = 0.25;
export const OVERVIEW_LEAD_MIN_KM = 3;
/** Frame pixels kept clear around an overview. */
export const MARGIN_X = 80;
export const MARGIN_Y = 80;
export type Span = { startS: number; endS: number };
export type Point = { x: number; y: number };
export type View = { zoom: number; centreX: number; centreY: number };
export type Camera = { scale: number; originX: number; originY: number };
export type Frame = {
worldWidth: number;
worldHeight: number;
frameWidth: number;
frameHeight: number;
};
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
const easeInOutCubic = (p: number) =>
p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
/** Merge lyric lines into narration parts wherever the gap is short. */
export const narrationParts = (lines: { startS: number; stopS: number }[]): Span[] => {
const sorted = lines.slice().sort((a, b) => a.startS - b.startS);
const parts: Span[] = [];
for (const line of sorted) {
const last = parts[parts.length - 1];
if (last && line.startS - last.endS <= PART_GAP_S) {
last.endS = Math.max(last.endS, line.stopS);
} else {
parts.push({ startS: line.startS, endS: line.stopS });
}
}
return parts;
};
/**
* 0 while following the head, 1 while showing everything covered so far.
* The intro before the first part counts as following.
*/
export const overviewWeight = (parts: Span[], t: number): number => {
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (t < part.startS) {
if (i === 0) {
return 0;
}
const out = clamp01((t - parts[i - 1].endS) / ZOOM_OUT_S);
const into = clamp01((part.startS - t) / ZOOM_IN_S);
return easeInOutCubic(Math.min(out, into));
}
if (t <= part.endS) {
return 0;
}
}
const lastPart = parts[parts.length - 1];
return lastPart ? easeInOutCubic(clamp01((t - lastPart.endS) / ZOOM_OUT_S)) : 0;
};
/** The head's position averaged over a Gaussian window around `t`. */
export const smoothedFocus = (focusAt: (t: number) => Point, t: number): Point => {
const step = FOLLOW_SAMPLE_S;
const reach = 3 * FOLLOW_SMOOTH_S;
let sumX = 0;
let sumY = 0;
let sumW = 0;
for (let dt = -reach; dt <= reach + 1e-9; dt += step) {
const w = Math.exp(-(dt * dt) / (2 * FOLLOW_SMOOTH_S * FOLLOW_SMOOTH_S));
const p = focusAt(t + dt);
sumX += p.x * w;
sumY += p.y * w;
sumW += w;
}
return { x: sumX / sumW, y: sumY / sumW };
};
const fitScale = (frame: Frame) => (frame.frameWidth - 2 * MARGIN_X) / frame.worldWidth;
/** How much road beyond the head (km) an overview includes. */
export const overviewLeadKm = (coveredKm: number): number =>
Math.max(OVERVIEW_LEAD_MIN_KM, coveredKm * OVERVIEW_LEAD);
export const followView = (focus: Point): View => ({
zoom: FOLLOW_ZOOM,
centreX: focus.x,
centreY: focus.y,
});
/** Frame `bounds` with margins, never closer than following nor wider than the full map. */
export const overviewView = (bounds: Bounds, frame: Frame): View => {
const base = fitScale(frame);
const width = Math.max(bounds.maxX - bounds.minX, 1e-6);
const height = Math.max(bounds.maxY - bounds.minY, 1e-6);
const zoom = Math.min(
(frame.frameWidth - 2 * MARGIN_X) / (width * base),
(frame.frameHeight - 2 * MARGIN_Y) / (height * base),
);
return {
zoom: Math.min(FOLLOW_ZOOM, Math.max(1, zoom)),
centreX: (bounds.minX + bounds.maxX) / 2,
centreY: (bounds.minY + bounds.maxY) / 2,
};
};
/** Blend two views; zoom on a log scale so zooming in and out feel even. */
export const blendViews = (a: View, b: View, w: number): View => ({
zoom: Math.exp(Math.log(a.zoom) + (Math.log(b.zoom) - Math.log(a.zoom)) * w),
centreX: a.centreX + (b.centreX - a.centreX) * w,
centreY: a.centreY + (b.centreY - a.centreY) * w,
});
/**
* Centre on `focus`, but never scroll past the map's ends: once the map is
* smaller than the view, centre the map itself.
*/
const clampAxis = (focus: number, worldSize: number, halfView: number, scale: number) => {
const low = halfView / scale;
const high = worldSize - halfView / scale;
return low >= high ? worldSize / 2 : Math.min(high, Math.max(low, focus));
};
export const toCamera = (view: View, frame: Frame): Camera => {
const scale = fitScale(frame) * view.zoom;
const centreX = clampAxis(view.centreX, frame.worldWidth, frame.frameWidth / 2 - MARGIN_X, scale);
const centreY = clampAxis(view.centreY, frame.worldHeight, frame.frameHeight / 2, scale);
return {
scale,
originX: frame.frameWidth / 2 - centreX * scale,
originY: frame.frameHeight / 2 - centreY * scale,
};
};
+216
View File
@@ -0,0 +1,216 @@
import { staticFile } from "remotion";
import type { FeatureFamily } from "./palette";
/**
* Loads the route data straight from `song-274/route/` (the public dir), so the
* video never holds its own copy that could drift from the wiki. See that
* folder's README for what each file is.
*/
export type RosterItem = {
object: string;
kind: string;
family: FeatureFamily;
/** Distance along the road; the nearest road point for off-road objects. */
km: number;
lat: number;
lon: number;
};
/** `[km, lat, lon]` along the road, in order of km. */
export type RoutePoint = [number, number, number];
/** One sung line: the roster entries it lights, where they are, and when. */
export type Cue = {
startS: number;
stopS: number;
/** Distance along the road; the nearest road point for off-road objects. */
km: number;
objects: string[];
};
export type TransectData = {
route: RoutePoint[];
/**
* The road carrying on past the eastern cutoff (274-route-east.json), painted
* only after 東に続く. Empty if the file is absent.
*/
routeEast: RoutePoint[];
/**
* The road arriving before the western cutoff (274-route-west.json), always
* drawn as covered. Empty if the file is absent.
*/
routeWest: RoutePoint[];
/** When 東に続く (the closing line) starts, or null if the index has none. */
closingStartS: number | null;
/** Only the entries the song names; nothing else is drawn. */
items: RosterItem[];
cues: Cue[];
/** Every lyric line, districts and 東に続く included: when words are sung. */
lines: { startS: number; stopS: number }[];
/** When the last lyric line (東に続く included) ends. */
lyricsEndS: number;
};
/**
* The page's famOf() grouping, plus shrines and railway. Railway goes by name as
* well as kind: some 信号場 are in the roster as `place (locality)`.
*/
const familyOf = (kind: string, object: string): FeatureFamily => {
if (
kind === "railway station" ||
kind === "railway signal post" ||
/信号場$/.test(object)
) {
return "railway";
}
if (
kind === "bridge" ||
kind === "tunnel" ||
kind === "shed" ||
kind === "watercourse"
) {
return kind;
} else if (
kind === "Shinto shrine"
) {
return "shrine";
}
return kind.indexOf("place") === 0 ? "place" : "other";
};
/** RFC 4180 CSV into header-keyed rows. Quoted fields may hold commas. */
const parseCsv = (text: string): Record<string, string>[] => {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (quoted) {
if (c !== '"') {
field += c;
} else if (src[i + 1] === '"') {
field += '"';
i++;
} else {
quoted = false;
}
} else if (c === '"') {
quoted = true;
} else if (c === ",") {
row.push(field);
field = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && src[i + 1] === "\n") {
i++;
}
row.push(field);
rows.push(row);
row = [];
field = "";
} else {
field += c;
}
}
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
const [header, ...body] = rows.filter((r) => r.length > 1 || r[0] !== "");
return body.map((r) => {
const record: Record<string, string> = {};
header.forEach((key, j) => {
record[key] = r[j] === undefined ? "" : r[j];
});
return record;
});
};
const fetchText = async (path: string): Promise<string> => {
const res = await fetch(staticFile(path));
if (!res.ok) {
throw new Error(`Could not load ${path}: HTTP ${res.status}`);
}
return res.text();
};
/** Like fetchText, but a missing file (404) is not an error: returns null. */
const fetchOptionalText = async (path: string): Promise<string | null> => {
const res = await fetch(staticFile(path));
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Could not load ${path}: HTTP ${res.status}`);
}
return res.text();
};
export const loadTransectData = async (): Promise<TransectData> => {
const [routeJson, routeEastJson, routeWestJson, lookupCsv, indexCsv] = await Promise.all([
fetchText("route/274-route.json"),
fetchOptionalText("route/274-route-east.json"),
fetchOptionalText("route/274-route-west.json"),
fetchText("route/274-lookup.csv"),
fetchText("route/274-lyrics-index.csv"),
]);
const roster = new Map<string, RosterItem>();
for (const r of parseCsv(lookupCsv)) {
roster.set(r.object, {
object: r.object,
kind: r.kind,
family: familyOf(r.kind, r.object),
km: Number(r.km),
lat: Number(r.lat),
lon: Number(r.lon),
});
}
const items: RosterItem[] = [];
const cues: Cue[] = [];
const lines: TransectData["lines"] = [];
let lyricsEndS = 0;
let closingStartS: number | null = null;
for (const r of parseCsv(indexCsv)) {
lyricsEndS = Math.max(lyricsEndS, Number(r.stop_s));
lines.push({ startS: Number(r.start_s), stopS: Number(r.stop_s) });
if (r.kind === "closing") {
closingStartS = Number(r.start_s);
}
// Address headers and the closing line have no single node to light.
if (r.kind === "district" || r.kind === "closing") {
continue;
}
const item = roster.get(r.object);
if (!item) {
throw new Error(`Lyrics index names ${r.object}, which is not in the roster`);
}
items.push(item);
const startS = Number(r.start_s);
const stopS = Number(r.stop_s);
const last = cues[cues.length - 1];
// モトツ橋にモトツシェルター emits two rows sharing one timing: light both.
if (last && last.startS === startS && last.stopS === stopS) {
last.objects.push(r.object);
continue;
}
cues.push({ startS, stopS, km: Number(r.km), objects: [r.object] });
}
return {
route: JSON.parse(routeJson) as RoutePoint[],
routeEast: routeEastJson ? (JSON.parse(routeEastJson) as RoutePoint[]) : [],
routeWest: routeWestJson ? (JSON.parse(routeWestJson) as RoutePoint[]) : [],
closingStartS,
items,
cues,
lines,
lyricsEndS,
};
};
+28
View File
@@ -0,0 +1,28 @@
/**
* Fade from and to a completely black screen at the two ends of the video: a
* black layer over everything, fully opaque on the first and last frames.
*/
/** Seconds of complete black at start */
export const FADE_IN_PRE = 0.4;
/** Seconds of complete black at end */
export const FADE_OUT_POST = 0.4;
/** Seconds from black to the picture at the start. 0 disables the fade-in. */
export const FADE_IN_S = 0.3;
/** Seconds from the picture to black at the end. 0 disables the fade-out. */
export const FADE_OUT_S = 0.2;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
/**
* Opacity of the black layer at time `t`.
* @param lastFrameS time of the video's last frame, (durationInFrames - 1) / fps,
* so that frame is fully black rather than nearly so
*/
export const blackOpacity = (t: number, lastFrameS: number): number => {
const fadeIn = (t < FADE_IN_PRE) ? 1 : (FADE_IN_S > 0 ? 1 - clamp01((t - FADE_IN_PRE) / FADE_IN_S) : 0);
// Black from here to the last frame; the fade-out ramp ends exactly here.
const blackFromS = lastFrameS - FADE_OUT_POST;
const fadeOut = (t >= blackFromS) ? 1 : (FADE_OUT_S > 0 ? clamp01((t - (blackFromS - FADE_OUT_S)) / FADE_OUT_S) : 0);
return Math.max(fadeIn, fadeOut);
};
+134
View File
@@ -0,0 +1,134 @@
import type { RosterItem, RoutePoint } from "./data";
/**
* The "Ground — true shape" projection of `274-transect.html`, in the page's
* own units: an equirectangular plot with longitude shrunk by cos(lat), so
* distances read true east–west and north–south. camera.ts decides how these
* units land on screen.
*/
export const GROUND = {
width: 1200,
pad: 34,
} as const;
export type Projection = {
height: number;
x: (lon: number) => number;
y: (lat: number) => number;
};
export const projectGround = (route: RoutePoint[]): Projection => {
const lats = route.map((p) => p[1]);
const lons = route.map((p) => p[2]);
const la0 = Math.min(...lats);
const la1 = Math.max(...lats);
const lo0 = Math.min(...lons);
const lo1 = Math.max(...lons);
const kx = Math.cos((((la0 + la1) / 2) * Math.PI) / 180);
const sx = (lo1 - lo0) * kx;
const sy = la1 - la0;
const iw = GROUND.width - 2 * GROUND.pad;
const ih = (iw * sy) / sx;
return {
height: ih + 2 * GROUND.pad,
x: (lon) => GROUND.pad + (((lon - lo0) * kx) / sx) * iw,
y: (lat) => GROUND.pad + ih - ((lat - la0) / sy) * ih,
};
};
/**
* Distance, in map units, between nodes that share exactly the same roster
* coordinates (草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, モトツ橋 /
* モトツシェルター): small, so they read as a doubled dot on the road. Grows with
* zoom like the nodes themselves. (1 map unit ≈ 44 m on the ground here.)
*/
export const COINCIDENT_SPREAD = 1.5;
export type NodePosition = { x: number; y: number };
/** Direction of the road at `km`, as a unit vector in map units. */
const roadDirection = (route: RoutePoint[], km: number, proj: Projection) => {
const at = (k: number) => {
let i = 1;
while (i < route.length - 1 && route[i][0] < k) {
i++;
}
const [k0, lat0, lon0] = route[i - 1];
const [k1, lat1, lon1] = route[i];
const p = k1 === k0 ? 0 : Math.min(1, Math.max(0, (k - k0) / (k1 - k0)));
return { x: proj.x(lon0 + (lon1 - lon0) * p), y: proj.y(lat0 + (lat1 - lat0) * p) };
};
const a = at(km - 0.1);
const b = at(km + 0.1);
const length = Math.hypot(b.x - a.x, b.y - a.y);
return length > 0 ? { x: (b.x - a.x) / length, y: (b.y - a.y) / length } : { x: 1, y: 0 };
};
/**
* Where each node is drawn, by object name. Usually its roster coordinate; nodes
* sharing exactly the same coordinate are spread COINCIDENT_SPREAD apart along
* the road there, in road order, centred on the shared point. All such pairs are
* on-road structures, so they stay on the road. Keep the spread small: at 6,
* モトツシェルター landed on 福山トンネル, only 90 m further on.
*/
export const nodePositions = (
items: RosterItem[],
route: RoutePoint[],
proj: Projection,
): Map<string, NodePosition> => {
const groups = new Map<string, RosterItem[]>();
for (const item of items) {
const key = `${item.lat},${item.lon}`;
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
}
const positions = new Map<string, NodePosition>();
groups.forEach((group) => {
const x = proj.x(group[0].lon);
const y = proj.y(group[0].lat);
if (group.length === 1) {
positions.set(group[0].object, { x, y });
return;
}
const ordered = group.slice().sort((a, b) => a.km - b.km);
const dir = roadDirection(route, ordered[0].km, proj);
ordered.forEach((item, i) => {
const offset = (i - (ordered.length - 1) / 2) * COINCIDENT_SPREAD;
positions.set(item.object, { x: x + dir.x * offset, y: y + dir.y * offset });
});
});
return positions;
};
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
/** Bounds of the projected route from its start up to each point, inclusive. */
export const prefixBounds = (route: RoutePoint[], proj: Projection): Bounds[] => {
const out: Bounds[] = [];
for (const p of route) {
const x = proj.x(p[2]);
const y = proj.y(p[1]);
const prev = out[out.length - 1];
out.push(prev ? extendBounds(prev, x, y) : { minX: x, minY: y, maxX: x, maxY: y });
}
return out;
};
export const extendBounds = (b: Bounds, x: number, y: number): Bounds => ({
minX: Math.min(b.minX, x),
minY: Math.min(b.minY, y),
maxX: Math.max(b.maxX, x),
maxY: Math.max(b.maxY, y),
});
export const routePath = (points: RoutePoint[], proj: Projection): string =>
points
.map(
(p, i) =>
`${i ? "L" : "M"}${proj.x(p[2]).toFixed(2)} ${proj.y(p[1]).toFixed(2)}`,
)
.join("");
+94
View File
@@ -0,0 +1,94 @@
import type { Cue } from "./data";
/**
* How one node looks at one instant. The animation functions below produce
* these, and Transect.tsx only draws them, so adding a property here (a colour,
* a stroke, a glow) is the only change the drawing needs to follow.
*/
export type NodeLook = {
radius: number;
opacity: number;
};
/**
* A node that is not being sung. Radius is in map units (the page's pixels),
* so nodes grow with the camera's zoom. (The page itself uses 3 at 0.85.)
*/
export const RESTING: NodeLook = { radius: 2.5, opacity: 0.9 };
/** A node while its line is sung: the page's selected mark. */
export const LIT: NodeLook = { radius: 7, opacity: 1 };
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
/** Slightly slower lighting up and switching off. Relatively slower decay. */
const LIGHT_UP_S = 0.12;
const SWITCH_OFF_S = 0.5;
/** Blend two looks; p = 0 is `from`, p = 1 is `to`. */
export const mixLooks = (from: NodeLook, to: NodeLook, p: number): NodeLook => ({
radius: from.radius + (to.radius - from.radius) * p,
opacity: from.opacity + (to.opacity - from.opacity) * p,
});
/**
* A node being switched on.
* @param elapsedS seconds since its line started (0 at the lyric timestamp)
* @param from the look it starts from
*/
export const lightUp = (elapsedS: number, from: NodeLook = RESTING): NodeLook =>
mixLooks(from, LIT, clamp01(elapsedS / LIGHT_UP_S));
/**
* A node being switched off.
* @param elapsedS seconds since its line ended (0 at the next lyric timestamp)
* @param from the look it had when it was switched off
*/
export const switchOff = (elapsedS: number, from: NodeLook = LIT): NodeLook =>
mixLooks(from, RESTING, clamp01(elapsedS / SWITCH_OFF_S));
/** Longest a node holds fully lit, after lighting up, before it switches off. */
export const MAX_LIT_S = 0.9;
/**
* When a cue's nodes start switching off: when the line ends, or once they have
* lit up and held for MAX_LIT_S, whichever comes first, so a long line does not
* keep a node lit for too long.
*/
export const switchOffAt = (cue: Cue): number =>
Math.min(cue.stopS, cue.startS + LIGHT_UP_S + MAX_LIT_S);
/**
* The look of a node at time `t`, given the cue that lights it (or none).
* Before the line: resting. Then lighting up until switchOffAt(). After that:
* switching off, starting from however far the light-up got, so a short line
* never jumps.
*/
export const nodeLookAt = (cue: Cue | undefined, t: number): NodeLook => {
if (!cue || t < cue.startS) {
return RESTING;
}
const offS = switchOffAt(cue);
if (t < offS) {
return lightUp(t - cue.startS);
}
return switchOff(t - offS, lightUp(offS - cue.startS));
};
/** A node at full amplitude in the outro spectrum (see spectrum.ts). */
export const SPECTRUM_PEAK: NodeLook = { radius: 9, opacity: 1 };
/** level scaling helper */
export const levelExponential = (level: number, exponent: number = 2.0): number =>
Math.pow(clamp01(level), exponent);
/**
* A node in the outro spectrum.
* @param level its band's level, 0 (silence: resting) to 1 (SPECTRUM_PEAK)
*/
export const spectrumLook = (level: number): NodeLook =>
mixLooks(RESTING, SPECTRUM_PEAK, clamp01(levelExponential(level)));
/** Whether a look is anything other than resting (drawn on top if so). */
export const isAwake = (look: NodeLook): boolean =>
look.radius !== RESTING.radius || look.opacity !== RESTING.opacity;
+66
View File
@@ -0,0 +1,66 @@
/**
* Every colour the 274 transect video draws, named by what it is for rather than
* what it looks like. Change values here, or override `palette` in the Studio's
* input props, without touching the drawing code.
*
* The dark palette is the video's own, grown from the dark palette of
* `song-274/route/274-transect.html`; comments name the page's CSS variable each
* token corresponds to. The light palette is the page's, extended with the
* tokens the video added (shrine, railway).
*/
export type TransectPalette = {
/** Frame background. */
background: string;
/** The whole road, before the song has covered it. */
roadAhead: string;
/** The road the song has covered so far. */
roadCovered: string;
/** Node fill per feature family, as on the page's legend. */
bridge: string;
tunnel: string;
shed: string;
watercourse: string;
place: string;
/** Stations and every 信号場, whatever their roster kind. */
railway: string;
shrine: string;
other: string;
};
export type FeatureFamily =
| "bridge"
| "tunnel"
| "shed"
| "watercourse"
| "place"
| "railway"
| "shrine"
| "other";
export const darkPalette: TransectPalette = {
background: "#28323d", // --paper
roadAhead: "#49565f", // --rule
roadCovered: "#fdb021", // --road
bridge: "#9be9de", // --bridge
tunnel: "#a6ccc2", // --tunnel
shed: "#eba9eb", // --shed
watercourse: "#5FB6D6", // --water
place: "#68ad7f", // --place
railway: "#68ad7f", // --railway
shrine: "#b94141", // --shrine
other: "#8b9ab8", // --util
};
export const lightPalette: TransectPalette = {
background: "#E7ECEE",
roadAhead: "#C0CCD2",
roadCovered: "#2E4A5A",
bridge: "#116B62",
tunnel: "#3A4650",
shed: "#7A5AA6",
watercourse: "#2F7E9E",
place: "#8A6D1F",
railway: "#1F6E43",
shrine: "#9B2C2C",
other: "#5B6B75",
};
+154
View File
@@ -0,0 +1,154 @@
import type { RoutePoint, TransectData } from "./data";
/**
* How far along the road (km) the song has got at any moment: the head of the
* "covered so far" paint, and what the camera follows.
*
* The head is pinned to each sung object's km at the instant its line starts,
* and glides between those pins on a monotone cubic curve: it passes through
* every pin exactly, never runs backwards, and changes speed smoothly rather
* than lurching at each object.
*/
export type Keyframe = { t: number; km: number };
/** How long the paint takes to run the east continuation once 東に続く starts. */
export const EAST_RUN_S = 75.0;
/**
* Pins, in time order: the start of the route at 0 s and each sung line's
* object. Then the end of the main road as 東に続く starts, and the end of the
* east continuation EAST_RUN_S later. Without a closing line or an east
* continuation, the main road simply ends as the last lyric line ends.
*/
export const progressKeyframes = (data: TransectData): Keyframe[] => {
const { route, routeEast, closingStartS, cues, lyricsEndS } = data;
const keys: Keyframe[] = [{ t: 0, km: route[0][0] }];
// Keep time strictly increasing and km never decreasing.
const pin = (t: number, km: number) => {
const last = keys[keys.length - 1];
if (t <= last.t) {
last.km = Math.max(last.km, km);
} else {
keys.push({ t, km: Math.max(last.km, km) });
}
};
for (const cue of cues) {
pin(cue.startS, cue.km);
}
const mainEndKm = route[route.length - 1][0];
if (closingStartS !== null && routeEast.length > 0) {
pin(closingStartS, mainEndKm);
pin(closingStartS + EAST_RUN_S, routeEast[routeEast.length - 1][0]);
} else {
pin(lyricsEndS, mainEndKm);
}
return keys;
};
/**
* The road the paint can travel: the main route, then the east continuation
* from where it passes the main route's end (the two overlap slightly).
*/
export const paintableRoute = (data: TransectData): RoutePoint[] => {
const mainEndKm = data.route[data.route.length - 1][0];
return data.route.concat(data.routeEast.filter((p) => p[0] > mainEndKm));
};
/**
* Fritsch–Carlson monotone cubic Hermite interpolation through `keys`.
* Returns km as a function of time, held flat before the first and after the
* last keyframe.
*/
export const monotoneCurve = (keys: Keyframe[]): ((t: number) => number) => {
const n = keys.length;
if (n === 1) {
return () => keys[0].km;
}
const secant: number[] = [];
for (let i = 0; i < n - 1; i++) {
secant.push((keys[i + 1].km - keys[i].km) / (keys[i + 1].t - keys[i].t));
}
const tangent: number[] = [secant[0]];
for (let i = 1; i < n - 1; i++) {
tangent.push(secant[i - 1] * secant[i] <= 0 ? 0 : (secant[i - 1] + secant[i]) / 2);
}
tangent.push(secant[n - 2]);
for (let i = 0; i < n - 1; i++) {
if (secant[i] === 0) {
tangent[i] = 0;
tangent[i + 1] = 0;
continue;
}
const a = tangent[i] / secant[i];
const b = tangent[i + 1] / secant[i];
const h = a * a + b * b;
if (h > 9) {
const tau = 3 / Math.sqrt(h);
tangent[i] = tau * a * secant[i];
tangent[i + 1] = tau * b * secant[i];
}
}
return (t) => {
if (t <= keys[0].t) {
return keys[0].km;
}
if (t >= keys[n - 1].t) {
return keys[n - 1].km;
}
let i = 0;
while (keys[i + 1].t < t) {
i++;
}
const h = keys[i + 1].t - keys[i].t;
const s = (t - keys[i].t) / h;
const s2 = s * s;
const s3 = s2 * s;
return (
(2 * s3 - 3 * s2 + 1) * keys[i].km +
(s3 - 2 * s2 + s) * h * tangent[i] +
(-2 * s3 + 3 * s2) * keys[i + 1].km +
(s3 - s2) * h * tangent[i + 1]
);
};
};
export type RoutePosition = {
/** Last route point at or before this km. */
index: number;
lat: number;
lon: number;
};
/** The point on the road at `km`, interpolated between route points. */
export const routeAtKm = (route: RoutePoint[], km: number): RoutePosition => {
const lastIndex = route.length - 1;
if (km <= route[0][0]) {
return { index: 0, lat: route[0][1], lon: route[0][2] };
}
if (km >= route[lastIndex][0]) {
return { index: lastIndex, lat: route[lastIndex][1], lon: route[lastIndex][2] };
}
let low = 0;
let high = lastIndex;
while (high - low > 1) {
const mid = (low + high) >> 1;
if (route[mid][0] <= km) {
low = mid;
} else {
high = mid;
}
}
const [k0, lat0, lon0] = route[low];
const [k1, lat1, lon1] = route[high];
const p = k1 === k0 ? 0 : (km - k0) / (k1 - k0);
return { index: low, lat: lat0 + (lat1 - lat0) * p, lon: lon0 + (lon1 - lon0) * p };
};
/** The covered stretch of road: every route point up to the head, then the head. */
export const coveredRoute = (route: RoutePoint[], km: number): RoutePoint[] => {
const head = routeAtKm(route, km);
return [...route.slice(0, head.index + 1), [km, head.lat, head.lon]];
};
+163
View File
@@ -0,0 +1,163 @@
import { visualizeAudio, type MediaUtilsAudioData } from "@remotion/media-utils";
import { random } from "remotion";
import { secondsToFrames } from "./time";
/**
* The outro spectrum: from SPECTRUM_START_S the nodes stop following the lyric
* and act as a spectrum analyser. The log-spaced frequency bands are dealt out
* to the nodes in a seeded shuffle (see BAND_SEED), with no relation to where
* a node is on the road; each node's size follows its band's amplitude.
*
* Everything here is a pure function of the frame, so Studio previews and
* renders agree. Starting values were calibrated on the outro of
* 274_release2.wav (8:20 to the end, left channel).
*/
/** When the spectrum takes over from the lyric-driven looks. */
export const SPECTRUM_START_S = 504.4;
/** How long the hand-over from lyric looks to spectrum looks takes. */
export const SPECTRUM_FADE_S = 1.5;
/** Band centres are log-spaced across this range, one band per node. */
export const FREQ_LOW_HZ = 40;
export const FREQ_HIGH_HZ = 12000;
/**
* Seed for the node-to-band shuffle. Any string or number; change it to deal a
* different mapping. Seeded (Remotion's random()) rather than Math.random(), so
* every render tab and the Studio agree on which node gets which band.
*/
export const BAND_SEED = "274-spectrum";
/**
* Raw amplitude → level (0–1): (raw × GAIN × (f / FREQ_LOW_HZ)^TREBLE_TILT)^CURVE.
* High bands are far quieter than bass, so TREBLE_TILT lifts them; CURVE below 1
* lifts quiet passages so more than the loudest bands move.
*
* On the release2 outro these give a median level of 0.35 (10–90% range
* 0.18–0.67), with nodes pinned at full size 0.6% of the time. GAIN 10 pinned
* them 11% of the time with the bass nodes nearly always full; CURVE 0.8 with
* GAIN 8 is livelier (0.15–0.85) but pins 6%.
*/
export const GAIN = 5;
export const TREBLE_TILT = 0.5;
export const CURVE = 0.6;
/**
* Release: a node's level is the loudest reading over the RELEASE_WINDOW_S
* before it, sampled every RELEASE_STEP_S and multiplied by RELEASE_DECAY once
* more per step back. Hits jump up at once and fall back smoothly, without any
* state carried between frames.
*
* Sampling by time rather than by frame keeps the tail the same length, and the
* work per frame the same, whatever the fps.
*/
export const RELEASE_WINDOW_S = 0.2;
export const RELEASE_STEP_S = 1 / 30;
export const RELEASE_DECAY = 0.75;
/** FFT size for visualizeAudio: bins are (sample rate / 2) / FFT_SAMPLES wide (23.4 Hz). */
export const FFT_SAMPLES = 1024;
/** Seconds of audio useWindowedAudioData keeps loaded around the current frame. */
export const AUDIO_WINDOW_S = 10;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
const easeInOutCubic = (p: number) =>
p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
/** 0 before the spectrum, 1 once the hand-over has finished. */
export const spectrumWeight = (t: number): number =>
easeInOutCubic(clamp01((t - SPECTRUM_START_S) / SPECTRUM_FADE_S));
/**
* The frame to load audio around: the real frame once the spectrum is active,
* otherwise the spectrum's start, so nothing is fetched for the rest of the song.
*/
export const audioWindowFrame = (frame: number, fps: number): number =>
Math.max(frame, secondsToFrames(SPECTRUM_START_S - SPECTRUM_FADE_S, fps));
/** `count` log-spaced band centres from FREQ_LOW_HZ to FREQ_HIGH_HZ. */
export const bandFrequencies = (count: number): number[] =>
Array.from({ length: count }, (_, i) =>
count === 1
? FREQ_LOW_HZ
: FREQ_LOW_HZ * Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, i / (count - 1)),
);
/**
* One band centre per node, in node order: bandFrequencies(count) dealt out in a
* Fisher–Yates shuffle seeded by BAND_SEED.
*/
export const shuffledBandFrequencies = (count: number): number[] => {
const frequencies = bandFrequencies(count);
for (let i = frequencies.length - 1; i > 0; i--) {
const j = Math.floor(random(`${BAND_SEED}-${i}`) * (i + 1));
const swap = frequencies[i];
frequencies[i] = frequencies[j];
frequencies[j] = swap;
}
return frequencies;
};
/**
* A band's raw amplitude from one spectrum: the mean over the bins between its
* geometric edges, or, for bands narrower than a bin (the bass end), the value
* interpolated at its centre, so neighbouring bass nodes still differ.
*/
const bandValue = (spectrum: number[], centreHz: number, ratio: number, binHz: number) => {
const half = Math.sqrt(ratio);
const low = centreHz / half / binHz;
const high = centreHz * half / binHz;
const first = Math.max(1, Math.ceil(low));
const last = Math.min(spectrum.length - 1, Math.floor(high));
if (last >= first) {
let sum = 0;
for (let k = first; k <= last; k++) {
sum += spectrum[k];
}
return sum / (last - first + 1);
}
const pos = Math.min(spectrum.length - 1.001, Math.max(0, centreHz / binHz));
const k = Math.floor(pos);
return spectrum[k] + (spectrum[k + 1] - spectrum[k]) * (pos - k);
};
/** Every band's level (0–1) at `frame`, release included. */
export const bandLevels = (args: {
audioData: MediaUtilsAudioData;
dataOffsetInSeconds: number;
frame: number;
fps: number;
frequencies: number[];
}): number[] => {
const { audioData, dataOffsetInSeconds, frame, fps, frequencies } = args;
const binHz = audioData.sampleRate / 2 / FFT_SAMPLES;
const ratio =
frequencies.length > 1 ? Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, 1 / (frequencies.length - 1)) : 2;
const levels = frequencies.map(() => 0);
const steps = Math.max(1, Math.round(RELEASE_WINDOW_S / RELEASE_STEP_S));
for (let step = 0; step < steps; step++) {
const back = secondsToFrames(step * RELEASE_STEP_S, fps);
if (frame - back < 0) {
break;
}
const spectrum = visualizeAudio({
audioData,
frame: frame - back,
fps,
numberOfSamples: FFT_SAMPLES,
dataOffsetInSeconds,
optimizeFor: "accuracy",
});
const decay = Math.pow(RELEASE_DECAY, step);
frequencies.forEach((hz, i) => {
const raw = bandValue(spectrum, hz, ratio, binHz);
const level =
Math.pow(clamp01(raw * GAIN * Math.pow(hz / FREQ_LOW_HZ, TREBLE_TILT)), CURVE) * decay;
levels[i] = Math.max(levels[i], level);
});
}
return levels;
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Frame-rate conversions.
*
* Every timing constant in this composition is in seconds, so the animation is
* identical at any fps. Only Remotion's own API counts frames, so convert here
* rather than writing frame counts into the constants.
*/
/** Seconds to a whole number of frames, rounded to the nearest. */
export const secondsToFrames = (seconds: number, fps: number): number =>
Math.round(seconds * fps);
/** A frame number back to its time in seconds. */
export const framesToSeconds = (frames: number, fps: number): number => frames / fps;