Files

598 lines
21 KiB
HTML
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.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene — HyperFrames Messaging Multi-Phrase</title>
<!--
Blueprint: messaging-multi-phrase (HyperFrames preview)
HyperFrames-native version of the scene-9-multi-phrase concept.
Choreography (3 phrases, ~7.5 seconds total, content-driven duration):
Phrase 1 [0.00 – 3.86 s]
"With an AI assistant " (21 chars × 0.083 s = 1.75 s)
+ "you can trust" (13 chars × 0.083 s = 1.08 s, accent pink)
+ hold 1.00 s
→ total 1.75 + 1.08 + 1.00 ≈ 3.83 s (computed by JS)
Phrase 2 [3.86 – 5.27 s]
"Instant " (8 chars × 0.083 s = 0.67 s)
+ "insights" (8 chars × 0.083 s = 0.67 s, accent pink)
+ hold 1.00 s
→ total 0.67 + 0.67 + 1.00 ≈ 2.33 s, but starts at 3.86 → endTime ≈ 6.18 s
Phrase 3 [6.18 – 9.45 s]
"Endless " (8 chars × 0.083 = 0.67 s)
+ "possibilities" (13 chars × 0.083 = 1.08 s, accent pink)
+ hold 2.00 s (longer — closing beat)
→ total 0.67 + 1.08 + 2.00 ≈ 3.75 s, but starts at 6.18 → endTime ≈ 9.93 s
Continuous: cursor blink (square wave via `anim.time() % 1.0`)
color, cursor blink. No per-phrase tweens.
- No conditional DOM — phrase container exists from t=0 with empty text;
onUpdate overwrites textContent in-place at each frame.
- TIMELINE is a plain const computed once at script load (no useMemo).
- Frames become seconds: charSpeed 2.5 frames → 0.083 s; hold 30 frames → 1.0 s.
- Cursor blink: `(t % BLINK_CYCLE) < BLINK_CYCLE / 2 ? 1 : 0` inside the same
onUpdate, not a separate component with `useCurrentFrame`.
- textContent !== visible guard skips redundant DOM writes during hold windows.
-->
<script src="https://cdn.jsdelivr.net/npm/animejs@4.5.0/dist/bundles/anime.umd.min.js"></script>
<style>
:root {
--text-primary: #ffffff;
--text-accent: #32fff6;
--text-accent-2: #a3ff7a;
--bg: linear-gradient(
135deg,
#3a3a3a 0%,
#17211f 30%,
#0b2328 48%,
#1f3518 70%,
#343434 100%
);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* ============================================================
PHRASE STAGE — one shared line with three inline children:
.phrase-main primary-color portion
.phrase-accent accent-color portion
.phrase-cursor block cursor (color overridden per-frame)
`white-space: pre` keeps the trailing space in textMain so the
accent doesn't merge with the lead-in.
============================================================ */
.phrase-stage {
position: absolute;
inset: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 150px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
white-space: pre;
text-align: center;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.16);
}
.phrase-main {
color: var(--text-primary);
}
.phrase-accent {
color: var(--text-accent);
text-shadow: 0 0 12px rgba(50, 255, 246, 0.28);
}
.phrase-cursor {
display: inline-block;
flex: 0 0 auto;
width: 8px;
height: 162px; /* ≈ 1.08 × fontSize */
background: var(--text-primary);
margin-left: 12px;
vertical-align: middle;
transform: translateY(10px); /* baseline alignment */
will-change: opacity, background-color;
box-shadow: 0 0 12px rgba(50, 255, 246, 0.36);
}
/* ============================================================
BACKGROUND TINT — subtle radial glow so pure black doesn't
feel sterile. Drops to ~rgb(8,8,12) at corners.
============================================================ */
.bg-tint {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 42% 48%, rgba(24, 217, 232, 0.32), transparent 42%),
radial-gradient(ellipse at 58% 52%, rgba(123, 234, 90, 0.26), transparent 44%),
radial-gradient(ellipse at 50% 50%, rgba(8, 16, 18, 0.42), transparent 70%);
pointer-events: none;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="7.5"
data-width="1920"
data-height="1080"
>
<div
id="multi-phrase-scene"
class="stage clip"
data-start="0"
data-duration="7.5"
data-track-index="1"
>
<div class="bg-tint"></div>
<div class="phrase-stage">
<span class="phrase-main"></span><span class="phrase-accent"></span
><span class="phrase-cursor"></span>
</div>
</div>
</div>
<script>
const transformState = new WeakMap();
const transformKeys = new Set([
"x",
"y",
"z",
"translateX",
"translateY",
"translateZ",
"xPercent",
"yPercent",
"scale",
"scaleX",
"scaleY",
"rotation",
"rotate",
"rotationX",
"rotateX",
"rotationY",
"rotateY",
]);
const propMap = new Map([
["x", "translateX"],
["y", "translateY"],
["z", "translateZ"],
["rotation", "rotate"],
["rotationX", "rotateX"],
["rotationY", "rotateY"],
["rotationZ", "rotateZ"],
]);
function ms(seconds) {
return Math.round(Number(seconds || 0) * 1000);
}
function resolveTargets(target) {
if (Array.isArray(target)) return target.flatMap(resolveTargets);
if (typeof target === "string") return [...document.querySelectorAll(target)];
return target ? [target] : [];
}
function cssLength(value) {
return typeof value === "number" ? value + "px" : String(value);
}
function cssAngle(value) {
return typeof value === "number" ? value + "deg" : String(value);
}
function renderTransform(state) {
const parts = [];
if (state.xPercent !== undefined) parts.push("translateX(" + state.xPercent + "%)");
if (state.yPercent !== undefined) parts.push("translateY(" + state.yPercent + "%)");
const tx = state.x ?? state.translateX;
const ty = state.y ?? state.translateY;
const tz = state.z ?? state.translateZ;
if (tx !== undefined) parts.push("translateX(" + cssLength(tx) + ")");
if (ty !== undefined) parts.push("translateY(" + cssLength(ty) + ")");
if (tz !== undefined) parts.push("translateZ(" + cssLength(tz) + ")");
const rotate = state.rotation ?? state.rotate;
const rotateX = state.rotationX ?? state.rotateX;
const rotateY = state.rotationY ?? state.rotateY;
const rotateZ = state.rotationZ ?? state.rotateZ;
if (rotate !== undefined) parts.push("rotate(" + cssAngle(rotate) + ")");
if (rotateX !== undefined) parts.push("rotateX(" + cssAngle(rotateX) + ")");
if (rotateY !== undefined) parts.push("rotateY(" + cssAngle(rotateY) + ")");
if (rotateZ !== undefined) parts.push("rotateZ(" + cssAngle(rotateZ) + ")");
if (state.scale !== undefined) parts.push("scale(" + state.scale + ")");
if (state.scaleX !== undefined) parts.push("scaleX(" + state.scaleX + ")");
if (state.scaleY !== undefined) parts.push("scaleY(" + state.scaleY + ")");
return parts.join(" ");
}
function setTweenState(target, vars) {
for (const item of resolveTargets(target)) {
if (!(item instanceof Element)) {
Object.assign(item, vars);
continue;
}
const state = transformState.get(item) ?? {};
let changedTransform = false;
for (const [key, value] of Object.entries(vars)) {
if (key === "attr") {
for (const [attrName, attrValue] of Object.entries(value))
item.setAttribute(attrName, attrValue);
} else if (key === "textContent") {
item.textContent = value;
} else if (key.startsWith("--")) {
item.style.setProperty(key, value);
} else if (transformKeys.has(key)) {
state[key] = value;
changedTransform = true;
} else if (key === "opacity") {
item.style.opacity = value;
} else if (key === "backgroundColor") {
item.style.backgroundColor = value;
} else if (key === "boxShadow") {
item.style.boxShadow = value;
} else {
item.style[key] = value;
}
}
if (changedTransform) {
transformState.set(item, state);
item.style.transform = renderTransform(state);
}
}
}
function getTweenProperty(target, key) {
const item = resolveTargets(target)[0];
if (!item) return undefined;
if (item instanceof Element && key.startsWith("--"))
return getComputedStyle(item).getPropertyValue(key);
if (item instanceof Element && transformKeys.has(key))
return transformState.get(item)?.[key];
return item[key];
}
function mapEase(ease) {
if (typeof ease !== "string") return ease;
const compact = ease.replace(/\s+/g, "");
const table = {
none: "linear",
"power1.in": "inQuad",
"power1.out": "outQuad",
"power1.inOut": "inOutQuad",
"power2.in": "inCubic",
"power2.out": "outCubic",
"power2.inOut": "inOutCubic",
"power3.in": "inQuart",
"power3.out": "outQuart",
"power3.inOut": "inOutQuart",
"power4.in": "inQuint",
"power4.out": "outQuint",
"power4.inOut": "inOutQuint",
"sine.inOut": "inOutSine",
"sine.out": "outSine",
"expo.out": "outExpo",
"expo.inOut": "inOutExpo",
};
if (table[compact]) return table[compact];
const back = /^back\.(in|out|inOut)\(([^)]*)\)$/.exec(compact);
if (back) return back[1] + "Back(" + back[2] + ")";
return ease;
}
function normalizeStagger(stagger) {
if (stagger === undefined) return undefined;
if (typeof stagger === "number") return anime.stagger(ms(stagger));
const span = stagger.each ?? stagger.amount;
if (span === undefined) return undefined;
const options = {};
if (stagger.from !== undefined) options.from = stagger.from;
if (stagger.grid !== undefined) options.grid = stagger.grid;
if (stagger.axis !== undefined) options.axis = stagger.axis;
if (stagger.start !== undefined) options.start = ms(stagger.start);
if (stagger.ease !== undefined) options.ease = mapEase(stagger.ease);
return anime.stagger(ms(span), options);
}
function normalizeTween(vars, fromVars, target, position) {
const props = {};
let driverKey = null;
for (const [key, value] of Object.entries(vars)) {
if (
["duration", "ease", "stagger", "repeat", "yoyo", "overwrite", "onUpdate"].includes(key)
)
continue;
const animeKey = propMap.get(key) ?? key;
const fromValue = fromVars?.[key];
props[animeKey] = fromValue === undefined ? value : [fromValue, value];
if (driverKey === null) driverKey = key;
}
props.duration = ms(vars.duration ?? 0.5);
if (vars.ease !== undefined) props.ease = mapEase(vars.ease);
const delay = normalizeStagger(vars.stagger);
if (delay !== undefined) props.delay = delay;
if (vars.repeat !== undefined)
props.loop = vars.repeat === -1 ? true : Math.max(0, Math.floor(vars.repeat)) + 1;
if (vars.yoyo === true) props.alternate = true;
if (typeof vars.onUpdate === "function") {
const userUpdate = vars.onUpdate;
const durationSeconds = Number(vars.duration ?? 0.5);
props.onUpdate = function (self) {
const first = resolveTargets(target)[0];
let progress = 0;
if (driverKey !== null) {
const raw = keyValue(first, driverKey);
const start = Number(fromVars?.[driverKey] ?? 0);
const end = Number(vars[driverKey]);
const current = Number(raw);
if (
Number.isFinite(current) &&
Number.isFinite(start) &&
Number.isFinite(end) &&
end !== start
) {
progress = (current - start) / (end - start);
}
}
adapterTime.current =
Number(position || 0) + Math.max(0, Math.min(1, progress)) * durationSeconds;
userUpdate.call(this, self);
};
}
return props;
}
function keyValue(target, key) {
if (!target) return undefined;
if (target instanceof Element && key.startsWith("--"))
return getComputedStyle(target).getPropertyValue(key);
return target[key];
}
const adapterTime = { current: 0 };
function createTimelineAdapter(timeline) {
return {
to(target, vars, position = 0) {
timeline.add(target, normalizeTween(vars, undefined, target, position), ms(position));
return this;
},
fromTo(target, fromVars, toVars, position = 0) {
timeline.add(target, normalizeTween(toVars, fromVars, target, position), ms(position));
return this;
},
set(target, vars, position = 0) {
timeline.add(
{ tick: 0 },
{
tick: [0, 1],
duration: 1,
ease: "linear",
onUpdate: () => setTweenState(target, vars),
},
ms(position),
);
return this;
},
time() {
return adapterTime.current;
},
};
}
const tl = anime.createTimeline({ autoplay: false });
const anim = createTimelineAdapter(tl);
/* ============================================================
SCRIPT — content + per-phrase timing config.
Change SCRIPT and the timeline reshapes itself.
============================================================ */
const SCRIPT = [
{
textMain: "Build video with ",
textAccent: "HTML",
charSpeed: 0.083, // seconds per character (= 2.5 frames @ 30 fps)
hold: 1.0,
},
{
textMain: "Seek ",
textAccent: "any frame",
charSpeed: 0.083,
hold: 1.0,
},
{
textMain: "Render to ",
textAccent: "MP4",
charSpeed: 0.083,
hold: 2.0, // longer closing beat
},
];
/* ============================================================
TIMELINE — flat reduce, computed once.
Each entry gains absolute startTime + endTime in seconds.
============================================================ */
let acc = 0;
const TIMELINE = SCRIPT.map((item) => {
const totalChars = item.textMain.length + item.textAccent.length;
const typingDuration = totalChars * item.charSpeed;
const totalDuration = typingDuration + item.hold;
const start = acc;
const end = start + totalDuration;
acc = end;
return Object.assign({}, item, {
startTime: start,
endTime: end,
typingDuration,
});
});
const TOTAL = TIMELINE[TIMELINE.length - 1].endTime;
// TOTAL ≈ 9.93s with the SCRIPT above. The composition root caps at 7.5s
// (data-duration), which is the actual render window — phrases 1+2 fit
// comfortably; phrase 3 holds long enough that any time after ~6.18s is
// already on the closing line. Adjust SCRIPT durations to fit your budget.
/* ============================================================
COLORS & BLINK
============================================================ */
const MAIN_COLOR = "#FFFFFF";
const ACCENT_COLOR = "#32FFF6";
const BLINK_CYCLE = 1.0; // seconds — 0.5s on, 0.5s off (matches 30-frame square wave)
const mainEl = document.querySelector(".phrase-main");
const accentEl = document.querySelector(".phrase-accent");
const cursorEl = document.querySelector(".phrase-cursor");
/* ============================================================
MASTER ENGINE — single onUpdate writes text + cursor every frame.
============================================================ */
// Cache the most-recently-active phrase index for seek-safe scans.
let lastIdx = 0;
function findPhrase(t) {
// Hot path: same phrase still active
if (t >= TIMELINE[lastIdx].startTime && t < TIMELINE[lastIdx].endTime) {
return TIMELINE[lastIdx];
}
// Cold path: linear scan (handles forward + backward seeks)
for (let i = 0; i < TIMELINE.length; i++) {
if (t >= TIMELINE[i].startTime && t < TIMELINE[i].endTime) {
lastIdx = i;
return TIMELINE[i];
}
}
return null;
}
function renderAt(t) {
// ----- Cursor blink (square wave, runs always) -----
cursorEl.style.opacity = t % BLINK_CYCLE < BLINK_CYCLE / 2 ? "1" : "0";
// ----- Find current phrase -----
const phrase = findPhrase(t);
if (!phrase) {
if (mainEl.textContent !== "") mainEl.textContent = "";
if (accentEl.textContent !== "") accentEl.textContent = "";
cursorEl.style.background = MAIN_COLOR;
return;
}
// ----- Compute visible characters -----
const activeT = t - phrase.startTime;
const charIdx = Math.floor(activeT / phrase.charSpeed);
const mainLen = phrase.textMain.length;
const visMain = phrase.textMain.slice(0, Math.min(charIdx, mainLen));
const accentLen = Math.max(0, charIdx - mainLen);
const visAccent = phrase.textAccent.slice(0, accentLen);
// ----- DOM writes (guarded) -----
if (mainEl.textContent !== visMain) mainEl.textContent = visMain;
if (accentEl.textContent !== visAccent) accentEl.textContent = visAccent;
// ----- Cursor color follows active segment -----
const inAccent = visMain.length === mainLen && visAccent.length > 0;
cursorEl.style.background = inAccent ? ACCENT_COLOR : MAIN_COLOR;
}
anim.to(
{ tick: 0 },
{
tick: 1,
duration: 7.5, // matches data-duration; clips the timeline
ease: "none",
onUpdate: () => renderAt(anim.time()),
},
0,
);
const pauseTimeline = tl.pause.bind(tl);
tl.pause = (time, suppressEvents) => {
const result = pauseTimeline(time, suppressEvents);
if (typeof time === "number") renderAt(time);
return result;
};
function wrapSeekOwner(owner) {
if (!owner || owner.__multiPhraseSeekWrapped) return;
let currentSeek = owner.seek;
Object.defineProperty(owner, "seek", {
configurable: true,
get: () => currentSeek,
set: (value) => {
if (typeof value !== "function") {
currentSeek = value;
return;
}
currentSeek = function (time) {
const result = value.apply(this, arguments);
if (typeof time === "number") renderAt(time);
return result;
};
},
});
owner.seek = currentSeek;
owner.__multiPhraseSeekWrapped = true;
}
function installWindowSeekHook(key) {
if (window[key]) {
wrapSeekOwner(window[key]);
return;
}
let pendingOwner;
Object.defineProperty(window, key, {
configurable: true,
get: () => pendingOwner,
set: (value) => {
pendingOwner = value;
wrapSeekOwner(value);
},
});
}
installWindowSeekHook("__player");
installWindowSeekHook("__hf");
renderAt(0);
hyperframesAnime.register("main", tl);
</script>
</body>
</html>