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
This commit is contained in:
2026-09-18 02:00:29 +09:00
parent 2731d9d332
commit e23772af17
2 changed files with 31 additions and 1 deletions
+7 -1
View File
@@ -18,6 +18,7 @@ import {
type Frame, type Frame,
} from "./camera"; } from "./camera";
import type { Cue, RosterItem, TransectData } from "./data"; import type { Cue, RosterItem, TransectData } from "./data";
import { blackOpacity } from "./fade";
import { import {
extendBounds, extendBounds,
GROUND, GROUND,
@@ -82,7 +83,7 @@ const currentCue = (list: Cue[] | undefined, t: number): Cue | undefined => {
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => { export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig(); const { fps, width, height, durationInFrames } = useVideoConfig();
const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]); const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]);
// The main road plus the east continuation. The paint runs into the latter // 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 // after 東に続く; the camera and overview stay on the main road, so that run
@@ -129,6 +130,7 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
} }
const t = frame / fps; const t = frame / fps;
const fadeToBlack = blackOpacity(t, (durationInFrames - 1) / fps);
const headAt = (time: number) => { const headAt = (time: number) => {
const km = kmAt(time); const km = kmAt(time);
const pos = routeAtKm(data.route, km); const pos = routeAtKm(data.route, km);
@@ -221,6 +223,10 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
{nodes.filter((n) => isAwake(n.look)).map(draw)} {nodes.filter((n) => isAwake(n.look)).map(draw)}
</g> </g>
</svg> </svg>
{/* Fade from and to black at the two ends (fade.ts). */}
{fadeToBlack > 0 ? (
<AbsoluteFill style={{ backgroundColor: "#000000", opacity: fadeToBlack }} />
) : null}
</AbsoluteFill> </AbsoluteFill>
); );
}; };
+24
View File
@@ -0,0 +1,24 @@
/**
* 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 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);
const fadeOut = FADE_OUT_S > 0 ? clamp01((t - (lastFrameS - FADE_OUT_S)) / FADE_OUT_S) : 0;
return Math.max(fadeIn, fadeOut);
};