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"
|
: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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user