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:
BIN
docs/redesign/round5/outro-after-1440.webp
Normal file
BIN
docs/redesign/round5/outro-after-1440.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/redesign/round5/outro-before-1440.webp
Normal file
BIN
docs/redesign/round5/outro-before-1440.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -246,6 +246,7 @@ onUnmounted(() => removePageListeners());
|
||||
:policy="motion.policy.value"
|
||||
:quality="actualQuality"
|
||||
:scene-description="content.sceneDescription"
|
||||
:credit="`${content.identity.name} · ${content.identity.role} · ${content.identity.location.city}, ${content.identity.location.country}`"
|
||||
:sample-frame="sampleSceneFrame"
|
||||
@state-change="updateSceneState"
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
||||
policy: MotionPolicy;
|
||||
quality: QualityTier;
|
||||
sceneDescription: string;
|
||||
/** Closing credit for the destination letterbox: real identity facts only. */
|
||||
credit?: string;
|
||||
sampleFrame?: (start: number, end: number) => unknown;
|
||||
}>();
|
||||
|
||||
@@ -55,6 +57,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
||||
:data-static-fallback="!showCanvas"
|
||||
:data-poster-failed="posterFailed"
|
||||
:data-arrived="journey.arrived"
|
||||
:data-credit="credit"
|
||||
>
|
||||
<picture v-if="!posterFailed">
|
||||
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
|
||||
|
||||
@@ -184,6 +184,9 @@ export async function createProceduralSceneGeneration(
|
||||
let introProgress: number | null = null;
|
||||
let introFrames = 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 = '';
|
||||
|
||||
const resize = (): void => {
|
||||
@@ -515,9 +518,18 @@ export async function createProceduralSceneGeneration(
|
||||
if (introSlowFrames >= 3) introProgress = 1;
|
||||
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(
|
||||
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
|
||||
);
|
||||
) * (1 - outro);
|
||||
const craneShift = cameraState.shift * crane;
|
||||
camera.setViewOffset(
|
||||
width,
|
||||
@@ -597,6 +609,9 @@ export async function createProceduralSceneGeneration(
|
||||
}
|
||||
|
||||
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 {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
@@ -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) {
|
||||
.journey-poster {
|
||||
filter: saturate(0.86) brightness(0.92);
|
||||
|
||||
Reference in New Issue
Block a user