The same library as the Lost in Production trailer: 29 semantic names (tap, type, pop, notif, whoosh, register, printer, stamp, tick, bell…) mapped to soundeffect-lab.info files in sfx/sources.json, each with its measured onset so the mixer puts the transient exactly on the beat. The site's terms allow commercial use but not redistributing the files, so they are gitignored and `npm run sfx` downloads them; catalog.json indexes the whole library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
5.4 KiB
Bash
Executable File
137 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Fetches the reel's sound effects from 効果音ラボ (https://soundeffect-lab.info/)
|
||
# into public/sfx/<name>.mp3, from the picks in sfx/sources.json.
|
||
#
|
||
# The library is free for commercial use with no credit required, but its terms
|
||
# forbid redistributing the files themselves, so they are gitignored and fetched
|
||
# on demand. The site serves an mp3 only to a browser-looking request whose
|
||
# Referer is its own category page (https://soundeffect-lab.info/sound/<cat>/).
|
||
#
|
||
# An entry with a "clip" ({start, end, gainDb?, fadeIn?, fadeOut?}, seconds/dB) is
|
||
# cut out of its source with ffmpeg (the system one, else Remotion's bundled one),
|
||
# padded with 30 ms of silence so the mp3 ends clean, and re-encoded; every other
|
||
# file is the site's original, byte for byte.
|
||
#
|
||
# npm run sfx fetch what is missing or whose pick changed
|
||
# bash sfx/fetch-sfx.sh --force refetch everything
|
||
# bash sfx/fetch-sfx.sh tap click only these names
|
||
set -euo pipefail
|
||
|
||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
ROOT="$(dirname "$HERE")"
|
||
DEST="$ROOT/public/sfx"
|
||
STATE="$HERE/.fetched" # "<name>\t<spec>" of what is on disk (gitignored)
|
||
BASE="https://soundeffect-lab.info/sound"
|
||
UA="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36"
|
||
|
||
force=0
|
||
only=()
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
-f | --force) force=1 ;;
|
||
-h | --help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"; exit 0 ;;
|
||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||
*) only+=("$arg") ;;
|
||
esac
|
||
done
|
||
|
||
mkdir -p "$DEST"
|
||
touch "$STATE"
|
||
TMP="$(mktemp -d)"
|
||
trap 'rm -rf "$TMP"' EXIT
|
||
|
||
# One line per pick: name, path, clip start, end, gainDb, fadeIn, fadeOut ("-" = none).
|
||
entries="$(node -e '
|
||
const s = require(process.argv[1]);
|
||
const v = (x) => (x === undefined || x === null ? "-" : String(x));
|
||
for (const [name, e] of Object.entries(s)) {
|
||
if (!/^[a-z]+\/mp3\/[\w.-]+\.mp3$/.test(e.path)) throw new Error(`${name}: bad path ${e.path}`);
|
||
const c = e.clip || {};
|
||
console.log([name, e.path, v(c.start), v(c.end), v(c.gainDb), v(c.fadeIn), v(c.fadeOut)].join("\t"));
|
||
}' "$HERE/sources.json")"
|
||
|
||
for name in "${only[@]+"${only[@]}"}"; do
|
||
cut -f1 <<<"$entries" | grep -qxF -- "$name" || { echo "no such sfx in sources.json: $name" >&2; exit 2; }
|
||
done
|
||
|
||
is_mp3() { # non-trivial file that starts with an ID3 tag or an MPEG audio frame sync
|
||
[[ -s "$1" && $(wc -c <"$1") -gt 1024 ]] || return 1
|
||
local magic
|
||
magic="$(head -c 3 "$1" | od -An -tx1 | tr -d ' \n')"
|
||
[[ $magic == 494433 || $magic =~ ^ff[ef] ]]
|
||
}
|
||
|
||
ffmpeg_run() { # the system ffmpeg, else the one bundled with Remotion
|
||
if command -v ffmpeg >/dev/null 2>&1; then
|
||
ffmpeg "$@"
|
||
elif [[ -x "$ROOT/node_modules/.bin/remotion" ]]; then
|
||
(cd "$ROOT" && npx --no-install remotion ffmpeg "$@")
|
||
else
|
||
echo "ffmpeg not found: install it, or run npm install (Remotion bundles one)" >&2
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
AFADE=""
|
||
has_afade() { # Remotion's bundled ffmpeg has no afade; cut points sit in near-silence anyway
|
||
[[ -n $AFADE ]] || { ffmpeg_run -hide_banner -filters 2>/dev/null | grep -qw afade && AFADE=yes || AFADE=no; }
|
||
[[ $AFADE == yes ]]
|
||
}
|
||
|
||
SRC=""
|
||
download() { # $1 = <cat>/mp3/<file>.mp3 → $SRC (each source fetched once per run)
|
||
local rel="$1" cat="${1%%/*}"
|
||
SRC="$TMP/${rel//\//_}"
|
||
[[ -s "$SRC" ]] && return 0
|
||
local meta
|
||
if ! meta="$(curl -sSL --retry 2 --max-time 60 -A "$UA" -e "$BASE/$cat/" -o "$SRC" \
|
||
-w '%{http_code} %{content_type}' "$BASE/$rel")"; then
|
||
echo "download failed: $BASE/$rel" >&2
|
||
exit 1
|
||
fi
|
||
if ! is_mp3 "$SRC"; then
|
||
echo "BLOCKED: $BASE/$rel answered '$meta', not an mp3." >&2
|
||
echo " The site may have changed its UA/referer rules or moved the file; check $BASE/$cat/" >&2
|
||
exit 1
|
||
fi
|
||
sleep 0.3 # be polite
|
||
}
|
||
|
||
clip() { # src out start end gainDb fadeIn fadeOut
|
||
local af="atrim=start=$3:end=$4,asetpts=PTS-STARTPTS"
|
||
[[ $5 == - ]] || af+=",volume=${5}dB"
|
||
if has_afade; then
|
||
[[ $6 == - ]] || af+=",afade=t=in:st=0:d=$6"
|
||
[[ $7 == - ]] || af+=",afade=t=out:st=$(awk -v s="$3" -v e="$4" -v f="$7" 'BEGIN { printf "%.4f", e - s - f }'):d=$7"
|
||
fi
|
||
af+=",apad=pad_dur=0.03" # digital silence after the cut: no codec smear at the very end
|
||
ffmpeg_run -nostdin -hide_banner -v error -y -i "$1" -af "$af" -c:a libmp3lame -q:a 2 -f mp3 "$2"
|
||
}
|
||
|
||
fetched=0
|
||
kept=0
|
||
while IFS=$'\t' read -r name rel start end gain fadein fadeout; do
|
||
if [[ ${#only[@]} -gt 0 ]] && ! printf '%s\n' "${only[@]}" | grep -qxF -- "$name"; then continue; fi
|
||
out="$DEST/$name.mp3"
|
||
spec="$rel $start $end $gain $fadein $fadeout"
|
||
if [[ $force -eq 0 ]] && is_mp3 "$out" &&
|
||
awk -F'\t' -v n="$name" -v s="$spec" '$1 == n && $2 == s { f = 1 } END { exit !f }' "$STATE"; then
|
||
kept=$((kept + 1))
|
||
continue
|
||
fi
|
||
download "$rel"
|
||
if [[ $start == - ]]; then
|
||
cp "$SRC" "$out.part"
|
||
else
|
||
clip "$SRC" "$out.part" "$start" "$end" "$gain" "$fadein" "$fadeout"
|
||
fi
|
||
is_mp3 "$out.part" || { echo "not an mp3 after processing: $name" >&2; exit 1; }
|
||
mv "$out.part" "$out"
|
||
{ awk -F'\t' -v n="$name" '$1 != n' "$STATE"; printf '%s\t%s\n' "$name" "$spec"; } >"$STATE.tmp"
|
||
mv "$STATE.tmp" "$STATE"
|
||
if [[ $start == - ]]; then echo "fetched $name ← $rel"; else echo "fetched $name ← $rel [$start–$end s]"; fi
|
||
fetched=$((fetched + 1))
|
||
done <<<"$entries"
|
||
|
||
echo "sfx ready in ${DEST#"$ROOT/"}/ ($fetched fetched, $kept already up to date)"
|