Files
ai-agent/reel/sfx/fetch-sfx.sh
Gabriel Vidal 450c940b89 feat(reel): UI sound palette from 効果音ラボ, fetched on demand
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>
2026-10-11 04:50:48 +02:00

137 lines
5.4 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)"