feat: cinematic outro that mirrors the intro crane

On arrival the camera cranes back toward the sun on a long lens, letterbox
bars close in on every tier and a closing credit of real identity facts
fades into the upper bar. Leaving the destination reverses it.
This commit is contained in:
2026-10-10 22:25:25 +03:00
parent 0ae27168cd
commit 06b6e996a0
6 changed files with 90 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -246,6 +246,7 @@ onUnmounted(() => removePageListeners());
:policy="motion.policy.value" :policy="motion.policy.value"
:quality="actualQuality" :quality="actualQuality"
:scene-description="content.sceneDescription" :scene-description="content.sceneDescription"
:credit="`${content.identity.name} · ${content.identity.role} · ${content.identity.location.city}, ${content.identity.location.country}`"
:sample-frame="sampleSceneFrame" :sample-frame="sampleSceneFrame"
@state-change="updateSceneState" @state-change="updateSceneState"
/> />

View File

@@ -15,6 +15,8 @@ const props = defineProps<{
policy: MotionPolicy; policy: MotionPolicy;
quality: QualityTier; quality: QualityTier;
sceneDescription: string; sceneDescription: string;
/** Closing credit for the destination letterbox: real identity facts only. */
credit?: string;
sampleFrame?: (start: number, end: number) => unknown; sampleFrame?: (start: number, end: number) => unknown;
}>(); }>();
@@ -55,6 +57,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
:data-static-fallback="!showCanvas" :data-static-fallback="!showCanvas"
:data-poster-failed="posterFailed" :data-poster-failed="posterFailed"
:data-arrived="journey.arrived" :data-arrived="journey.arrived"
:data-credit="credit"
> >
<picture v-if="!posterFailed"> <picture v-if="!posterFailed">
<source media="(max-width: 767px)" :srcset="mobilePosterUrl"> <source media="(max-width: 767px)" :srcset="mobilePosterUrl">

View File

@@ -184,6 +184,9 @@ export async function createProceduralSceneGeneration(
let introProgress: number | null = null; let introProgress: number | null = null;
let introFrames = 0; let introFrames = 0;
let introSlowFrames = 0; let introSlowFrames = 0;
// The outro mirrors the intro: on arrival the camera cranes back up toward
// the sun on a long lens. Leaving the destination reverses it, faster.
let outroProgress = 0;
let gantryState = ''; let gantryState = '';
const resize = (): void => { const resize = (): void => {
@@ -515,9 +518,18 @@ export async function createProceduralSceneGeneration(
if (introSlowFrames >= 3) introProgress = 1; if (introSlowFrames >= 3) introProgress = 1;
introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS); introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS);
} }
outroProgress = THREE.MathUtils.clamp(
outroProgress
+ (snapshot.arrived ? deltaSeconds / OUTRO_SECONDS : -deltaSeconds / OUTRO_RETURN_SECONDS),
0,
1,
);
// Part of the way back, not all: the end shot holds the sun, the
// skyline and the road running into them, like a film's last frame.
const outro = easeInOutCubic(outroProgress) * OUTRO_REACH;
const crane = easeInOutCubic( const crane = easeInOutCubic(
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1), THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
); ) * (1 - outro);
const craneShift = cameraState.shift * crane; const craneShift = cameraState.shift * crane;
camera.setViewOffset( camera.setViewOffset(
width, width,
@@ -597,6 +609,9 @@ export async function createProceduralSceneGeneration(
} }
const INTRO_SECONDS = 4.2; const INTRO_SECONDS = 4.2;
const OUTRO_SECONDS = 5.5;
const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62;
function easeInOutCubic(t: number): number { function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

View File

@@ -504,6 +504,76 @@
} }
} }
/* Round 5 outro: letterbox bars close in on arrival, on every tier (they are
CSS, so the static poster gets them too). They sit in the world layer,
under the reading column, so they frame the sky and never cover content.
The upper bar hangs below the gantry and carries a closing credit built
from real identity facts; the lower bar is left clear for the trip
summary that ends the contact chapter. */
.journey-world::before,
.journey-world::after {
position: absolute;
z-index: 1;
inset-inline: 0;
block-size: max(54px, 10vh);
background: #020108;
content: '';
transform: scaleY(0);
transition: transform 1.6s cubic-bezier(0.65, 0, 0.35, 1);
}
.journey-world::before {
inset-block-start: 0;
display: grid;
align-items: end;
justify-items: center;
block-size: calc(var(--gantry-h, 72px) + max(30px, 4.5vh));
padding: 0 16px max(8px, 1.2vh);
color: rgb(255 226 196 / 0%);
font: 600 0.6rem/1.3 var(--mono, ui-monospace, monospace);
letter-spacing: 0.32em;
text-align: center;
text-transform: uppercase;
white-space: nowrap;
content: attr(data-credit) / '';
transform-origin: top;
transition:
transform 1.6s cubic-bezier(0.65, 0, 0.35, 1),
color 1.2s ease;
}
.journey-world::after {
inset-block-end: 0;
transform-origin: bottom;
}
.journey-world[data-arrived='true']::before,
.journey-world[data-arrived='true']::after {
transform: scaleY(1);
transition-delay: 0.5s;
}
.journey-world[data-arrived='true']::before {
color: rgb(255 226 196 / 74%);
transition-delay: 0.5s, 1.9s;
}
@media (max-width: 767px) {
.journey-world::after { block-size: max(40px, 7vh); }
.journey-world::before {
font-size: 0.5rem;
letter-spacing: 0.14em;
white-space: normal;
}
}
@media (prefers-reduced-motion: reduce) {
.journey-world::before,
.journey-world::after,
.journey-world[data-arrived='true']::before,
.journey-world[data-arrived='true']::after {
transition: none;
}
}
@media (forced-colors: active) {
.journey-world::before,
.journey-world::after { display: none; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.journey-poster { .journey-poster {
filter: saturate(0.86) brightness(0.92); filter: saturate(0.86) brightness(0.92);