reelkit-cli 0.6.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +52 -9
- package/skill/SKILL.md +40 -19
- package/skill/THIRD_PARTY.md +104 -2
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/art-styles.md +70 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +25 -19
- package/skill/reference/brand-motion.md +62 -0
- package/skill/reference/captions.md +11 -5
- package/skill/reference/clips.md +3 -3
- package/skill/reference/component-authoring.md +1 -1
- package/skill/reference/continuity.md +26 -7
- package/skill/reference/delivery-review.md +40 -0
- package/skill/reference/hebrew-rtl.md +3 -4
- package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +18 -11
- package/skill/reference/kit.md +144 -12
- package/skill/reference/launch-film.md +194 -0
- package/skill/reference/motion-design.md +18 -16
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/sound-design.md +34 -12
- package/skill/reference/studio-editing.md +55 -0
- package/skill/reference/styles.md +9 -6
- package/skill/reference/three-d.md +135 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +18 -7
- package/src/commands/assets.ts +314 -30
- package/src/commands/build.ts +148 -35
- package/src/commands/init.ts +1 -1
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +5 -3
- package/src/hyperframes/Root.tsx +1 -0
- package/src/hyperframes/fonts.ts +54 -0
- package/src/hyperframes/frame.tsx +46 -0
- package/src/hyperframes/host.tsx +38 -0
- package/src/hyperframes/kit/Assemble3D.tsx +92 -0
- package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
- package/src/hyperframes/kit/BrowserFrame.tsx +83 -0
- package/src/{remotion → hyperframes}/kit/Camera.tsx +7 -5
- package/src/{remotion → hyperframes}/kit/Captions.tsx +34 -18
- package/src/hyperframes/kit/Card3D.tsx +211 -0
- package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
- package/src/hyperframes/kit/ChapterFrame.tsx +68 -0
- package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
- package/src/hyperframes/kit/CounterRoll.tsx +75 -0
- package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
- package/src/hyperframes/kit/GlassPanel.tsx +43 -0
- package/src/hyperframes/kit/Grounds.tsx +177 -0
- package/src/hyperframes/kit/Headline.tsx +97 -0
- package/src/hyperframes/kit/Hero3D.tsx +197 -0
- package/src/hyperframes/kit/HudOverlay.tsx +52 -0
- package/src/hyperframes/kit/ImageLayers.tsx +48 -0
- package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
- package/src/hyperframes/kit/Music.tsx +19 -0
- package/src/hyperframes/kit/NamedCursor.tsx +54 -0
- package/src/hyperframes/kit/Orbit3D.tsx +49 -0
- package/src/hyperframes/kit/Particles3D.tsx +74 -0
- package/src/hyperframes/kit/Place.tsx +12 -0
- package/src/hyperframes/kit/PromptBox.tsx +84 -0
- package/src/hyperframes/kit/Scene3D.tsx +70 -0
- package/src/hyperframes/kit/SceneFrame.tsx +96 -0
- package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
- package/src/hyperframes/kit/SoundCues.tsx +22 -0
- package/src/hyperframes/kit/TerminalLog.tsx +98 -0
- package/src/hyperframes/kit/Text3D.tsx +78 -0
- package/src/hyperframes/kit/TextOnImage.tsx +41 -0
- package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
- package/src/hyperframes/kit/Warp3D.tsx +59 -0
- package/src/hyperframes/kit/bg-math.ts +179 -0
- package/src/hyperframes/kit/brand-transform.ts +25 -0
- package/src/{remotion → hyperframes}/kit/caption-groups.ts +7 -3
- package/src/hyperframes/kit/caption-style.ts +45 -0
- package/src/hyperframes/kit/docs.ts +249 -0
- package/src/hyperframes/kit/image-layers-math.ts +115 -0
- package/src/hyperframes/kit/index.ts +74 -0
- package/src/hyperframes/kit/inter-bold-typeface.ts +3 -0
- package/src/{remotion → hyperframes}/kit/motion-math.ts +36 -2
- package/src/hyperframes/kit/music-math.ts +59 -0
- package/src/hyperframes/kit/quiet-three.ts +11 -0
- package/src/hyperframes/kit/sample-text.ts +55 -0
- package/src/hyperframes/kit/scene3d-context.ts +5 -0
- package/src/hyperframes/kit/seeded.ts +13 -0
- package/src/hyperframes/kit/sound-cues.ts +89 -0
- package/src/hyperframes/kit/sound-kinds.ts +135 -0
- package/src/{remotion → hyperframes}/kit/theme.ts +43 -39
- package/src/hyperframes/kit/three-fx-math.ts +192 -0
- package/src/hyperframes/kit/three-math.ts +145 -0
- package/src/hyperframes/kit/transition-math.ts +116 -0
- package/src/hyperframes/kit/ui-math.ts +145 -0
- package/src/hyperframes/kit/ui-theme.ts +25 -0
- package/src/hyperframes/kit/word-anchor.ts +107 -0
- package/src/hyperframes/math.ts +62 -0
- package/src/hyperframes/three.tsx +10 -0
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +44 -10
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/chromakey.ts +1 -1
- package/src/project/layers.ts +60 -0
- package/src/project/manifest.ts +59 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +4 -1
- package/src/project/serve.ts +2 -2
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/render/component-preview.ts +11 -55
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/render.ts +62 -57
- package/src/render/serve.ts +31 -0
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +15 -4
- package/src/render/validate.ts +4 -4
- package/src/render/word-check.ts +181 -0
- package/src/render/worker.ts +71 -0
- package/src/testing/conformance.ts +12 -0
- package/src/testing/fake-api.ts +4 -4
- package/src/testing/fixtures.ts +4 -1
- package/src/remotion/Root.tsx +0 -31
- package/src/remotion/kit/Music.tsx +0 -19
- package/src/remotion/kit/SceneFrame.tsx +0 -19
- package/src/remotion/kit/docs.ts +0 -124
- package/src/remotion/kit/index.ts +0 -29
- package/src/remotion/kit/music-math.ts +0 -42
- /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
- /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
- /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
- /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
- /package/src/{remotion → hyperframes}/types.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { useMedia } from "./media";
|
|
3
|
-
import { AbsoluteFill, OffthreadVideo } from "
|
|
3
|
+
import { AbsoluteFill, OffthreadVideo } from "../frame";
|
|
4
4
|
|
|
5
5
|
// A generated or library video clip as a scene's picture. Place it inside the scene's SceneFrame, as the bottom layer, so it starts with the scene.
|
|
6
6
|
// When the scene runs longer than the clip, the clip holds its last frame (it does not loop: a looped person or animal jumps back and shows the seam).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { fonts } from "./theme";
|
|
3
|
-
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "
|
|
3
|
+
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "../frame";
|
|
4
4
|
|
|
5
5
|
export const Counter: React.FC<{ from?: number; to: number; durationFrames?: number; prefix?: string; suffix?: string; color?: string }> = ({
|
|
6
6
|
from = 0, to, durationFrames = 30, prefix = "", suffix = "", color = "#ffffff",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { interpolate, useCurrentFrame, useVideoConfig } from "../frame";
|
|
3
|
+
import { Place } from "./Place";
|
|
4
|
+
import { fonts } from "./theme";
|
|
5
|
+
import { DEFAULT_HERO } from "./ui-theme";
|
|
6
|
+
import { rollValue } from "./ui-math";
|
|
7
|
+
|
|
8
|
+
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
|
|
9
|
+
|
|
10
|
+
// An odometer: a number whose digits roll like wheels from `from` to `to` over `frames`, easing out and landing exactly, in tabular digits so nothing
|
|
11
|
+
// shifts sideways. `prefix` and `suffix` ("$", "%", "x") sit around it, `decimals` shows places after the point, `group` puts a comma between thousands
|
|
12
|
+
// (default on), `label` is a small line under it. It is sized to fill `width` (a fraction of the frame, default 0.86) at most `maxSize` of the width (0.3).
|
|
13
|
+
// Times are frames from the component's own mount: it starts at `delay` (default 0) and settleFrame({ delay, frames }) is the frame it lands, for a
|
|
14
|
+
// chime. The wheels stay on the final number after that. Colour: `ink` for the digits (default white), `hero` for the prefix, suffix and label.
|
|
15
|
+
export const CounterRoll: React.FC<{
|
|
16
|
+
to: number;
|
|
17
|
+
from?: number;
|
|
18
|
+
frames?: number;
|
|
19
|
+
delay?: number;
|
|
20
|
+
prefix?: string;
|
|
21
|
+
suffix?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
decimals?: number;
|
|
24
|
+
group?: boolean;
|
|
25
|
+
hero?: string;
|
|
26
|
+
ink?: string;
|
|
27
|
+
font?: string;
|
|
28
|
+
width?: number;
|
|
29
|
+
maxSize?: number;
|
|
30
|
+
y?: number;
|
|
31
|
+
}> = ({ to, from = 0, frames = 45, delay = 0, prefix = "", suffix = "", label, decimals = 0, group = true, hero = DEFAULT_HERO, ink = "#f4f4f5", font = fonts.display, width: fraction = 0.86, maxSize = 0.3, y = 0.5 }) => {
|
|
32
|
+
const frame = useCurrentFrame();
|
|
33
|
+
const { width: W } = useVideoConfig();
|
|
34
|
+
const scale = 10 ** decimals;
|
|
35
|
+
const value = rollValue(frame, { from, to, frames, delay });
|
|
36
|
+
const scaled = value * scale;
|
|
37
|
+
const cols = Math.max(decimals + 1, String(Math.round(Math.max(Math.abs(to), Math.abs(from)) * scale)).length);
|
|
38
|
+
const extra = prefix.length * 0.6 + suffix.length * 0.6 + (group ? Math.floor((cols - 1 - decimals) / 3) * 0.3 : 0) + (decimals ? 0.3 : 0);
|
|
39
|
+
const em = cols * 0.64 + extra;
|
|
40
|
+
const size = Math.min(W * maxSize, (W * fraction) / em);
|
|
41
|
+
const lh = size * 1.06;
|
|
42
|
+
const cells: React.ReactNode[] = [];
|
|
43
|
+
for (let k = cols - 1; k >= 0; k--) {
|
|
44
|
+
const place = 10 ** k;
|
|
45
|
+
const lead = Math.floor(scaled / place) === 0 && k > decimals;
|
|
46
|
+
// The ones wheel turns continuously; every wheel above it only turns as the one below comes round to its carry, like a real odometer.
|
|
47
|
+
const x = scaled / place, whole = Math.floor(x + 1e-9), frac = Math.max(0, x - whole);
|
|
48
|
+
const pos = (whole % 10) + (k === 0 ? frac : Math.max(0, Math.min(1, (frac - 0.8) / 0.2)));
|
|
49
|
+
if (k === decimals - 1 && decimals > 0) cells.push(<span key="dot" style={{ width: size * 0.3, textAlign: "center", lineHeight: `${lh}px` }}>.</span>);
|
|
50
|
+
cells.push(
|
|
51
|
+
<span key={k} style={{ display: "inline-block", width: size * 0.64, height: lh, overflow: "hidden", position: "relative", opacity: lead ? 0 : 1, WebkitMaskImage: "linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%)", maskImage: "linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%)" }}>
|
|
52
|
+
<span style={{ display: "block", transform: `translateY(${-pos * lh}px)` }}>
|
|
53
|
+
{DIGITS.map((d, i) => <span key={i} style={{ display: "block", height: lh, lineHeight: `${lh}px`, textAlign: "center" }}>{d}</span>)}
|
|
54
|
+
</span>
|
|
55
|
+
</span>,
|
|
56
|
+
);
|
|
57
|
+
if (group && k > decimals && (k - decimals) % 3 === 0) {
|
|
58
|
+
const hide = Math.floor(scaled / 10 ** (k - 1)) === 0 && k - 1 > decimals;
|
|
59
|
+
cells.push(<span key={`c${k}`} style={{ display: "inline-block", width: size * 0.3, textAlign: "center", height: lh, lineHeight: `${lh}px`, opacity: hide || lead ? 0 : 1 }}>,</span>);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const settle = interpolate(frame, [delay, delay + frames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
63
|
+
return (
|
|
64
|
+
<Place y={y}>
|
|
65
|
+
<div style={{ textAlign: "center", fontFamily: font, fontWeight: 800, color: ink, fontVariantNumeric: "tabular-nums" }}>
|
|
66
|
+
<div style={{ display: "inline-flex", alignItems: "center", fontSize: size, height: lh }}>
|
|
67
|
+
{prefix ? <span style={{ color: hero, lineHeight: `${lh}px`, marginRight: size * 0.04 }}>{prefix}</span> : null}
|
|
68
|
+
{cells}
|
|
69
|
+
{suffix ? <span style={{ color: hero, lineHeight: `${lh}px`, marginLeft: size * 0.04 }}>{suffix}</span> : null}
|
|
70
|
+
</div>
|
|
71
|
+
{label ? <div style={{ marginTop: size * 0.1, fontSize: Math.max(W * 0.034, size * 0.2), fontWeight: 600, color: hero, opacity: 0.55 + 0.45 * settle, letterSpacing: "0.02em" }}>{label}</div> : null}
|
|
72
|
+
</div>
|
|
73
|
+
</Place>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "
|
|
2
|
+
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "../frame";
|
|
3
3
|
import { ease, springs } from "./theme";
|
|
4
4
|
|
|
5
5
|
// The workhorse entrance: fade + rise + scale together, with an optional faster exit and a slow idle "breathe".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { useMedia } from "./media";
|
|
3
|
-
import { AbsoluteFill, OffthreadVideo } from "
|
|
3
|
+
import { AbsoluteFill, OffthreadVideo } from "../frame";
|
|
4
4
|
|
|
5
5
|
export const FootageLayer: React.FC<{ src: string; muted?: boolean; dim?: number }> = ({ src, muted = true, dim = 0 }) => (
|
|
6
6
|
<AbsoluteFill>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { spring, useCurrentFrame, useVideoConfig } from "../frame";
|
|
3
|
+
import { Place } from "./Place";
|
|
4
|
+
import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
|
|
5
|
+
import { PANEL_SPRING } from "./ui-math";
|
|
6
|
+
|
|
7
|
+
// A slab for your own content: a near-black rounded panel (or a light one with theme="light") with a hairline stroke lit along its top edge, a coloured glow pool
|
|
8
|
+
// behind it, an optional slight perspective `tilt` (degrees about the vertical axis, default 0; the horizontal tilt follows at 0.4 of it) and an entrance that
|
|
9
|
+
// slides up and settles with a small overshoot. `delay` is a frame from the component's own mount (default 0); panelSettleFrame(delay, fps) is the frame it
|
|
10
|
+
// lands, for a "snap" sound. `width` is a fraction of the frame's width (0.86), `height` a fraction of the frame's height (default: as tall as the content),
|
|
11
|
+
// `glow` the colour of the pool (default the hero colour), `y` the height of its middle as a fraction of the frame (0.5). Children are laid out inside
|
|
12
|
+
// with padding; text inside should be at least 0.032 of the frame's width.
|
|
13
|
+
export const GlassPanel: React.FC<{
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
hero?: string;
|
|
16
|
+
glow?: string;
|
|
17
|
+
theme?: UiTheme;
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
tilt?: number;
|
|
21
|
+
delay?: number;
|
|
22
|
+
radius?: number;
|
|
23
|
+
padding?: number;
|
|
24
|
+
y?: number;
|
|
25
|
+
}> = ({ children, hero = DEFAULT_HERO, glow, theme = "dark", width: fraction = 0.86, height, tilt = 0, delay = 0, radius = 0.06, padding = 0.05, y = 0.5 }) => {
|
|
26
|
+
const frame = useCurrentFrame();
|
|
27
|
+
const { fps, width: W, height: H } = useVideoConfig();
|
|
28
|
+
const c = uiColors(theme === "glass" ? "glass" : theme, hero);
|
|
29
|
+
const P = W * fraction;
|
|
30
|
+
const p = spring({ frame: frame - delay, fps, config: PANEL_SPRING });
|
|
31
|
+
const g = glow ?? hero;
|
|
32
|
+
const alpha = Math.min(1, p * 1.4);
|
|
33
|
+
return (
|
|
34
|
+
<Place y={y}>
|
|
35
|
+
<div style={{ position: "relative", width: P, perspective: P * 2.2, opacity: alpha }}>
|
|
36
|
+
<div style={{ position: "absolute", left: "-12%", right: "-12%", top: "-14%", bottom: "-14%", background: `radial-gradient(ellipse at 50% 55%, ${rgba(g, theme === "light" ? 0.35 : 0.62)} 0%, ${rgba(g, 0.0)} 66%)`, filter: `blur(${P * 0.06}px)`, transform: `translateY(${(1 - p) * H * 0.07}px)` }} />
|
|
37
|
+
<div style={{ position: "relative", transform: `translateY(${(1 - p) * H * 0.07}px) rotateY(${tilt * p}deg) rotateX(${-Math.abs(tilt) * 0.4 * p}deg)`, transformStyle: "preserve-3d", width: P, height: height === undefined ? undefined : H * height, boxSizing: "border-box", padding: P * padding, borderRadius: P * radius, background: theme === "light" ? c.panel : `linear-gradient(160deg, ${rgba("#2a2a3c", 0.72)} 0%, ${c.panel} 46%)`, border: `${Math.max(2, P * 0.003)}px solid ${c.stroke}`, boxShadow: `0 ${P * 0.05}px ${P * 0.12}px ${c.shadow}, inset 0 ${Math.max(2, P * 0.003)}px 0 ${theme === "light" ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,0.22)"}` }}>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</Place>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, Img, OffthreadVideo, Sequence, useCurrentFrame, useVideoConfig } from "../frame";
|
|
3
|
+
import { auroraBlobs, beamStates, breath, checkItems, grainSeed, gridOffset, gridRows, groundStrength, hueShift, mixHex, plateView, withAlpha, sequenceLayers, videoLayers, type Drift, type SequenceItem, type SequenceTransition } from "./bg-math";
|
|
4
|
+
import { useMedia } from "./media";
|
|
5
|
+
|
|
6
|
+
const isVideo = (src: string) => /\.(mp4|mov|webm|m4v)(\?|$)/i.test(src);
|
|
7
|
+
|
|
8
|
+
// What every media ground shares: a dark layer so type stays readable, and a tint laid over at low opacity so a clip or picture takes the film's accent.
|
|
9
|
+
const Finish: React.FC<{ dim: number; tint?: string }> = ({ dim, tint }) => (
|
|
10
|
+
<>
|
|
11
|
+
{tint ? <AbsoluteFill style={{ backgroundColor: tint, opacity: 0.18 }} /> : null}
|
|
12
|
+
{dim > 0 ? <AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${Math.min(0.8, Math.max(0, dim))})` }} /> : null}
|
|
13
|
+
</>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
type PlateProps = { src: string; scale: number; tx: number; ty: number; blurPx: number; opacity?: number; wipe?: number; loopVideo?: boolean };
|
|
17
|
+
|
|
18
|
+
// One picture or clip, covering the frame, moved by the given scale and shift. It is drawn muted, always.
|
|
19
|
+
const Plate: React.FC<PlateProps> = ({ src, scale, tx, ty, blurPx, opacity = 1, wipe }) => {
|
|
20
|
+
const { width, height } = useVideoConfig();
|
|
21
|
+
const url = useMedia(src);
|
|
22
|
+
const mask = wipe === undefined ? undefined : `linear-gradient(90deg, #000 ${wipe * 115 - 15}%, transparent ${wipe * 115}%)`;
|
|
23
|
+
const style: React.CSSProperties = { width: "100%", height: "100%", objectFit: "cover" };
|
|
24
|
+
return (
|
|
25
|
+
<AbsoluteFill style={{ opacity, transform: `translate(${tx * width}px, ${ty * height}px) scale(${scale})`, filter: blurPx > 0 ? `blur(${blurPx}px)` : undefined, WebkitMaskImage: mask, maskImage: mask, overflow: "hidden" }}>
|
|
26
|
+
{isVideo(src) ? <OffthreadVideo src={url} muted style={style} /> : <Img src={url} style={style} />}
|
|
27
|
+
</AbsoluteFill>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// A video as the ground, for the whole film or for one scene. dim (0 to 0.8, default 0.35) darkens it so type stays readable; blur is in pixels; zoom is a
|
|
32
|
+
// slow push as a fraction over `durationInFrames` (default the film's length); tint is a colour laid over at low opacity; playbackRate 0.5 is a calmer ground.
|
|
33
|
+
// The clip is always muted. When loop is on (the default) and `durationSec` is given (manifest.background.durationSec), a film longer than the clip starts it
|
|
34
|
+
// again with a short cross-fade over the join, so the restart is not seen; without durationSec the clip plays once and holds its last frame.
|
|
35
|
+
export const BgVideo: React.FC<{ src: string; dim?: number; blur?: number; zoom?: number; tint?: string; loop?: boolean; playbackRate?: number; durationSec?: number; durationInFrames?: number }> = ({
|
|
36
|
+
src, dim = 0.35, blur = 0, zoom = 0.04, tint, loop = true, playbackRate = 1, durationSec, durationInFrames,
|
|
37
|
+
}) => {
|
|
38
|
+
const frame = useCurrentFrame();
|
|
39
|
+
const { fps, width, durationInFrames: total } = useVideoConfig();
|
|
40
|
+
const view = plateView(frame / Math.max(1, durationInFrames ?? total), { zoom, blurPx: blur, width });
|
|
41
|
+
const url = useMedia(src);
|
|
42
|
+
const layers = videoLayers(frame, { clipFrames: durationSec ? (durationSec * fps) / playbackRate : 1, loop: loop && Boolean(durationSec) });
|
|
43
|
+
const clipFrames = Math.max(1, Math.floor(durationSec ? (durationSec * fps) / playbackRate : 1));
|
|
44
|
+
return (
|
|
45
|
+
<AbsoluteFill style={{ overflow: "hidden" }}>
|
|
46
|
+
<AbsoluteFill style={{ transform: `scale(${view.scale})`, filter: blur > 0 ? `blur(${blur}px)` : undefined }}>
|
|
47
|
+
{layers.map((l) => (
|
|
48
|
+
<Sequence key={l.index} from={l.start} durationInFrames={durationSec && loop ? clipFrames : Infinity} layout="none">
|
|
49
|
+
<AbsoluteFill style={{ opacity: l.opacity }}>
|
|
50
|
+
<OffthreadVideo src={url} muted playbackRate={playbackRate} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
51
|
+
</AbsoluteFill>
|
|
52
|
+
</Sequence>
|
|
53
|
+
))}
|
|
54
|
+
</AbsoluteFill>
|
|
55
|
+
<Finish dim={dim} tint={tint} />
|
|
56
|
+
</AbsoluteFill>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// One still picture as the ground. It always covers the frame: the pan and the blur never show an edge. zoom is a slow push over its time on screen
|
|
61
|
+
// (default 0.06), drift a slow pan of a few percent, and fit is "cover".
|
|
62
|
+
export const BgImage: React.FC<{ src: string; dim?: number; blur?: number; tint?: string; zoom?: number; drift?: Drift; fit?: "cover"; durationInFrames?: number }> = ({
|
|
63
|
+
src, dim = 0.35, blur = 0, tint, zoom = 0.06, drift = "none", durationInFrames,
|
|
64
|
+
}) => {
|
|
65
|
+
const frame = useCurrentFrame();
|
|
66
|
+
const { width, durationInFrames: total } = useVideoConfig();
|
|
67
|
+
const v = plateView(frame / Math.max(1, durationInFrames ?? total), { zoom, drift, blurPx: blur, width });
|
|
68
|
+
return (
|
|
69
|
+
<AbsoluteFill style={{ overflow: "hidden" }}>
|
|
70
|
+
<Plate src={src} scale={v.scale} tx={v.tx} ty={v.ty} blurPx={blur} />
|
|
71
|
+
<Finish dim={dim} tint={tint} />
|
|
72
|
+
</AbsoluteFill>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Several grounds that change smoothly over the film. items are { src, from } (frames, in order, the first visible from frame 0; a video src is drawn too, played once from the start of the film and held on its last frame).
|
|
77
|
+
// The change is centred on `from` unless align is "start"; `transition` is "fade" (default), "blur" (a cross-fade through a blur peak), "wipe" (a soft-edged wipe)
|
|
78
|
+
// or "zoom" (the old one pushes in while the new one settles from slightly larger), over transitionFrames (default 18). Each item keeps its own slow zoom running
|
|
79
|
+
// through the change. dim, blur, tint, zoom and drift are shared, so the film keeps one look. bgPerScene builds items from the scenes.
|
|
80
|
+
export const BgSequence: React.FC<{ items: SequenceItem[]; transition?: SequenceTransition; transitionFrames?: number; align?: "center" | "start"; dim?: number; blur?: number; tint?: string; zoom?: number; drift?: Drift }> = ({
|
|
81
|
+
items, transition = "fade", transitionFrames = 18, align = "center", dim = 0.35, blur = 0, tint, zoom = 0.06, drift = "none",
|
|
82
|
+
}) => {
|
|
83
|
+
const frame = useCurrentFrame();
|
|
84
|
+
const { width } = useVideoConfig();
|
|
85
|
+
checkItems(items);
|
|
86
|
+
const layers = sequenceLayers(items, frame, { transition, transitionFrames, align });
|
|
87
|
+
const peak = transition === "blur" ? 24 : 0;
|
|
88
|
+
return (
|
|
89
|
+
<AbsoluteFill style={{ overflow: "hidden" }}>
|
|
90
|
+
{layers.map((l) => {
|
|
91
|
+
const v = plateView(l.progress, { zoom, drift, blurPx: blur + peak, width });
|
|
92
|
+
return <Plate key={l.index} src={items[l.index]!.src} scale={v.scale * l.scale} tx={v.tx} ty={v.ty} blurPx={blur + l.blur * peak} opacity={l.opacity} wipe={l.wipe} />;
|
|
93
|
+
})}
|
|
94
|
+
<Finish dim={dim} tint={tint} />
|
|
95
|
+
</AbsoluteFill>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Three large colour fields on the base colour, in the hero colour and its two neighbours on the colour wheel, drifting and crossing: their edges and
|
|
100
|
+
// where they cross show, and they move plainly within two seconds. Radial gradients and one blend mode: cheap. intensity (0 to 1, default 0.6) is how
|
|
101
|
+
// strongly they light the base; Grade and Vignette on top darken a ground, so raise it (0.8) under them.
|
|
102
|
+
export const BgAurora: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
|
|
103
|
+
const frame = useCurrentFrame();
|
|
104
|
+
const { fps, width, height } = useVideoConfig();
|
|
105
|
+
const longer = Math.max(width, height), k = groundStrength(intensity);
|
|
106
|
+
const colors = [hero, hueShift(mixHex(hero, "#ffffff", 0.12), 42), hueShift(mixHex(hero, bg, 0.1), -46)];
|
|
107
|
+
const peak = [0.95, 0.8, 0.85];
|
|
108
|
+
return (
|
|
109
|
+
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
|
|
110
|
+
{auroraBlobs(frame, fps, speed).map((b, i) => (
|
|
111
|
+
<div key={i} style={{
|
|
112
|
+
position: "absolute", left: b.x * width - b.r * longer, top: b.y * height - b.r * longer, width: b.r * 2 * longer, height: b.r * 2 * longer, mixBlendMode: "screen",
|
|
113
|
+
background: `radial-gradient(circle closest-side, ${withAlpha(colors[i]!, peak[i]! * k)} 0%, ${withAlpha(colors[i]!, peak[i]! * k * 0.7)} 45%, ${withAlpha(colors[i]!, peak[i]! * k * 0.22)} 80%, ${withAlpha(colors[i]!, 0)} 100%)`,
|
|
114
|
+
}} />
|
|
115
|
+
))}
|
|
116
|
+
</AbsoluteFill>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// A perspective grid receding to a horizon and sliding toward the viewer, with a glow where it meets the sky. A few dozen lines in one SVG. intensity (0 to 1,
|
|
121
|
+
// default 0.6) is how bright the lines and the glow are.
|
|
122
|
+
export const BgGrid: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
|
|
123
|
+
const frame = useCurrentFrame();
|
|
124
|
+
const { fps, width, height } = useVideoConfig();
|
|
125
|
+
const horizon = height * 0.42, vx = width / 2, rows = gridRows(gridOffset(frame, fps, speed), 16), cols = 15, k = groundStrength(intensity);
|
|
126
|
+
const line = mixHex(hero, "#ffffff", 0.25);
|
|
127
|
+
return (
|
|
128
|
+
<AbsoluteFill style={{ backgroundColor: bg }}>
|
|
129
|
+
<AbsoluteFill style={{ background: `radial-gradient(ellipse 80% 28% at 50% 42%, ${withAlpha(hero, 0.8 * k)} 0%, ${withAlpha(hero, 0)} 100%)` }} />
|
|
130
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
|
|
131
|
+
<defs><linearGradient id="rk-grid-fade" gradientUnits="userSpaceOnUse" x1="0" y1={horizon} x2="0" y2={height}><stop offset="0" stopColor={line} stopOpacity="0" /><stop offset="0.3" stopColor={line} stopOpacity={0.55 * k} /><stop offset="1" stopColor={line} stopOpacity={Math.min(1, 1.1 * k)} /></linearGradient></defs>
|
|
132
|
+
<g stroke="url(#rk-grid-fade)" strokeWidth={Math.max(1.5, width / 420)} fill="none">
|
|
133
|
+
{rows.map((d, i) => <line key={`r${i}`} x1={0} x2={width} y1={horizon + (height - horizon) * d} y2={horizon + (height - horizon) * d} />)}
|
|
134
|
+
{Array.from({ length: cols }, (_, i) => { const x = (i / (cols - 1) - 0.5) * width * 3; return <line key={`c${i}`} x1={vx} y1={horizon} x2={vx + x} y2={height} />; })}
|
|
135
|
+
</g>
|
|
136
|
+
</svg>
|
|
137
|
+
</AbsoluteFill>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Four wide diagonal beams of light sweeping across the base colour, brightening and dimming, with a glow where they leave the top. They are soft but plainly
|
|
142
|
+
// there: the strongest is about as bright as the hero colour at half strength. intensity (0 to 1, default 0.6) is how bright; Grade and Vignette on top
|
|
143
|
+
// darken a ground, so raise it (0.8) under them.
|
|
144
|
+
export const BgBeams: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
|
|
145
|
+
const frame = useCurrentFrame();
|
|
146
|
+
const { fps, width, height } = useVideoConfig();
|
|
147
|
+
const k = groundStrength(intensity), light = mixHex(hero, "#ffffff", 0.2);
|
|
148
|
+
return (
|
|
149
|
+
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
|
|
150
|
+
<AbsoluteFill style={{ background: `radial-gradient(ellipse 90% 40% at 50% -5%, ${withAlpha(hero, 0.7 * k)} 0%, ${withAlpha(hero, 0)} 100%)` }} />
|
|
151
|
+
{beamStates(frame, fps, speed).map((b, i) => (
|
|
152
|
+
<div key={i} style={{
|
|
153
|
+
position: "absolute", left: (0.3 + b.slide) * width - width * 0.2, top: -height * 0.25, width: width * 0.4, height: height * 1.5, transform: `rotate(${b.angle}deg)`, mixBlendMode: "screen",
|
|
154
|
+
background: `linear-gradient(90deg, ${withAlpha(light, 0)} 0%, ${withAlpha(light, 0.55 * k * b.strength)} 50%, ${withAlpha(light, 0)} 100%)`,
|
|
155
|
+
}} />
|
|
156
|
+
))}
|
|
157
|
+
</AbsoluteFill>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// A still ground with a breathing vignette, a soft glow of the hero colour and film grain. The grain steps every few frames by the frame number, so a
|
|
162
|
+
// frame is always the same. intensity (0 to 1, default 0.6) is how strong the glow and the grain are.
|
|
163
|
+
export const BgGrain: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
|
|
164
|
+
const frame = useCurrentFrame();
|
|
165
|
+
const { fps, width, height } = useVideoConfig();
|
|
166
|
+
const b = breath(frame, fps, speed), k = groundStrength(intensity);
|
|
167
|
+
return (
|
|
168
|
+
<AbsoluteFill style={{ backgroundColor: bg }}>
|
|
169
|
+
<AbsoluteFill style={{ background: `radial-gradient(ellipse at ${50 + 6 * Math.sin((frame / fps) * speed * 0.5)}% ${38 + 4 * b}%, ${withAlpha(hero, 0.55 * k)} 0%, ${withAlpha(hero, 0)} 62%)` }} />
|
|
170
|
+
<AbsoluteFill style={{ background: `radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(0,0,0,${0.28 + 0.16 * b}) 100%)` }} />
|
|
171
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0, opacity: 0.06 + 0.1 * k, mixBlendMode: "overlay" }}>
|
|
172
|
+
<filter id="rk-grain"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" seed={grainSeed(frame)} stitchTiles="stitch" /><feColorMatrix type="saturate" values="0" /></filter>
|
|
173
|
+
<rect width="100%" height="100%" filter="url(#rk-grain)" />
|
|
174
|
+
</svg>
|
|
175
|
+
</AbsoluteFill>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { continueRender, delayRender, spring, useCurrentFrame, useVideoConfig } from "../frame";
|
|
3
|
+
import { Place } from "./Place";
|
|
4
|
+
import { fonts, springs } from "./theme";
|
|
5
|
+
import { inkOn } from "./three-math";
|
|
6
|
+
import { clamp01, headlineFrames, headlineLines, headlineWords, keywordIndex, outCubic } from "./ui-math";
|
|
7
|
+
import { DEFAULT_HERO } from "./ui-theme";
|
|
8
|
+
import { TextInkContext } from "./ImageLayers";
|
|
9
|
+
|
|
10
|
+
// A headline set heavy: two to five words that come in one by one, with ONE keyword emphasised. `emphasis` is "color" (the keyword in the hero colour),
|
|
11
|
+
// "highlight" (a block of the hero colour sweeps in behind it) or "underline" (a bar draws on under it). `keyword` must be one of the words (case and
|
|
12
|
+
// punctuation ignored) or it throws and lists the words. The text breaks into the fewest lines of about 12 characters and is sized to fill `width` (a fraction
|
|
13
|
+
// of the frame's width, default 0.86): it is measured after layout, so nothing leaves the frame, and at most `maxSize` (a fraction of the width, default
|
|
14
|
+
// 0.2) is used. `delay` and `per` are frames from the component's own mount (0 and 4 frames a word); headlineFrames(text, { delay, per }) gives each word's
|
|
15
|
+
// frame for sound. `ink` is the text colour (default white, or the ink a TextOnImage chose for its zone), `y` the height of its middle as a fraction of the frame (0.5).
|
|
16
|
+
export const Headline: React.FC<{
|
|
17
|
+
text: string;
|
|
18
|
+
keyword?: string;
|
|
19
|
+
emphasis?: "color" | "highlight" | "underline";
|
|
20
|
+
hero?: string;
|
|
21
|
+
ink?: string;
|
|
22
|
+
font?: string;
|
|
23
|
+
width?: number;
|
|
24
|
+
maxSize?: number;
|
|
25
|
+
delay?: number;
|
|
26
|
+
per?: number;
|
|
27
|
+
align?: "center" | "left";
|
|
28
|
+
y?: number;
|
|
29
|
+
}> = ({ text, keyword, emphasis = "color", hero = DEFAULT_HERO, ink: given, font = fonts.display, width: fraction = 0.86, maxSize = 0.2, delay = 0, per = 4, align = "center", y = 0.5 }) => {
|
|
30
|
+
const frame = useCurrentFrame();
|
|
31
|
+
const fromText = React.useContext(TextInkContext);
|
|
32
|
+
const ink = given ?? fromText ?? "#f4f4f5";
|
|
33
|
+
const { fps, width: W } = useVideoConfig();
|
|
34
|
+
const words = headlineWords(text);
|
|
35
|
+
const key = keyword === undefined ? -1 : keywordIndex(text, keyword);
|
|
36
|
+
const lines = React.useMemo(() => headlineLines(words), [text]);
|
|
37
|
+
const longest = Math.max(...lines.map((l) => l.map((i) => words[i]!).join(" ").length));
|
|
38
|
+
const target = W * fraction;
|
|
39
|
+
const estimate = Math.min(W * maxSize, target / (longest * 0.64));
|
|
40
|
+
const [fit, setFit] = React.useState(1);
|
|
41
|
+
const fitRef = React.useRef(1);
|
|
42
|
+
const refs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
|
43
|
+
const [hold] = React.useState(() => delayRender("headline fit"));
|
|
44
|
+
// The estimate is corrected once the real letters are laid out (and again when the fonts have arrived, since a face that is not loaded yet measures
|
|
45
|
+
// narrower), so a face that is wider or narrower than expected still fills the width and never leaves it. The frame waits for the correction.
|
|
46
|
+
React.useLayoutEffect(() => {
|
|
47
|
+
let live = true, same = 0, tries = 0, last = -1;
|
|
48
|
+
const measure = (): number => {
|
|
49
|
+
const widest = Math.max(...refs.current.map((r) => r?.offsetWidth ?? 0));
|
|
50
|
+
if (widest > 0 && live) {
|
|
51
|
+
const next = Math.max(0.3, Math.min((W * maxSize) / estimate, target / (widest / fitRef.current)));
|
|
52
|
+
fitRef.current = next;
|
|
53
|
+
setFit(next);
|
|
54
|
+
}
|
|
55
|
+
return widest / fitRef.current;
|
|
56
|
+
};
|
|
57
|
+
// The face arrives a moment after the first layout (it is added to the page while this runs), so the width is read again until it has stopped changing.
|
|
58
|
+
const poll = () => {
|
|
59
|
+
if (!live) return;
|
|
60
|
+
const natural = Math.round(measure() * 10) / 10;
|
|
61
|
+
same = natural === last ? same + 1 : 0;
|
|
62
|
+
last = natural;
|
|
63
|
+
tries++;
|
|
64
|
+
if ((same >= 3 && (typeof document === "undefined" || document.fonts.status === "loaded")) || tries > 60) continueRender(hold); else setTimeout(poll, 40);
|
|
65
|
+
};
|
|
66
|
+
poll();
|
|
67
|
+
return () => { live = false; continueRender(hold); };
|
|
68
|
+
}, [text, font, W, fraction, maxSize, estimate]);
|
|
69
|
+
const size = estimate * fit;
|
|
70
|
+
const ev = headlineFrames(text, { delay, per });
|
|
71
|
+
return (
|
|
72
|
+
<Place y={y}>
|
|
73
|
+
<div style={{ width: target, textAlign: align, fontFamily: font, fontWeight: 800, fontSize: size, lineHeight: 1.04, letterSpacing: "-0.025em", color: ink }}>
|
|
74
|
+
{lines.map((line, li) => (
|
|
75
|
+
<div key={li} style={{ display: "flex", justifyContent: align === "center" ? "center" : "flex-start" }}>
|
|
76
|
+
<span ref={(el) => { refs.current[li] = el; }} style={{ display: "inline-flex", whiteSpace: "nowrap", gap: "0.24em" }}>
|
|
77
|
+
{line.map((wi) => {
|
|
78
|
+
const land = ev.words[wi]!;
|
|
79
|
+
const p = spring({ frame: frame - land + 6, fps, config: springs.snappy });
|
|
80
|
+
const hot = wi === key;
|
|
81
|
+
const sweep = outCubic(clamp01((frame - land - 3) / 12));
|
|
82
|
+
const wordInk = hot && emphasis === "color" ? hero : hot && emphasis === "highlight" ? inkOn(hero) : ink;
|
|
83
|
+
return (
|
|
84
|
+
<span key={wi} style={{ position: "relative", display: "inline-block", opacity: clamp01(p * 1.6), transform: `translateY(${(1 - p) * 0.35}em)`, filter: p < 1 ? `blur(${(1 - p) * size * 0.06}px)` : undefined, color: wordInk }}>
|
|
85
|
+
{hot && emphasis === "highlight" ? <span style={{ position: "absolute", left: "-0.1em", right: "-0.1em", top: "0.06em", bottom: "0.0em", background: hero, borderRadius: "0.12em", transformOrigin: "0 50%", transform: `scaleX(${sweep})`, zIndex: -1 }} /> : null}
|
|
86
|
+
<span style={{ position: "relative" }}>{words[wi]}</span>
|
|
87
|
+
{hot && emphasis === "underline" ? <span style={{ position: "absolute", left: 0, bottom: "-0.02em", height: "0.1em", width: `${sweep * 100}%`, background: hero, borderRadius: "0.1em" }} /> : null}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
</Place>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { continueRender, delayRender, useCurrentFrame, useVideoConfig } from "../frame";
|
|
3
|
+
import { useThree } from "@react-three/fiber";
|
|
4
|
+
import { CanvasTexture, DoubleSide, ExtrudeGeometry, Shape, ShapeGeometry, SRGBColorSpace, TextureLoader, type BufferGeometry, type Texture } from "three";
|
|
5
|
+
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
|
|
6
|
+
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
|
|
7
|
+
import { useMedia } from "./media";
|
|
8
|
+
import { CameraKeysContext } from "./scene3d-context";
|
|
9
|
+
import { typeface } from "./Text3D";
|
|
10
|
+
import { poseAt, type PoseKey } from "./three-fx-math";
|
|
11
|
+
import { degToRad, mixHex, nearestVisible, type Vec3 } from "./three-math";
|
|
12
|
+
|
|
13
|
+
const roundRect = (w: number, h: number, r: number): Shape => {
|
|
14
|
+
const s = new Shape(), x = -w / 2, y = -h / 2, k = Math.min(r, w / 2, h / 2);
|
|
15
|
+
s.moveTo(x + k, y); s.lineTo(x + w - k, y); s.quadraticCurveTo(x + w, y, x + w, y + k); s.lineTo(x + w, y + h - k); s.quadraticCurveTo(x + w, y + h, x + w - k, y + h);
|
|
16
|
+
s.lineTo(x + k, y + h); s.quadraticCurveTo(x, y + h, x, y + h - k); s.lineTo(x, y + k); s.quadraticCurveTo(x, y, x + k, y);
|
|
17
|
+
return s;
|
|
18
|
+
};
|
|
19
|
+
const uvs = (g: ShapeGeometry, w: number, h: number) => { const p = g.attributes.position!, uv = g.attributes.uv!; for (let i = 0; i < p.count; i++) uv.setXY(i, (p.getX(i) + w / 2) / w, (p.getY(i) + h / 2) / h); return g; };
|
|
20
|
+
|
|
21
|
+
// A soft dark ellipse for the contact shadow, drawn once.
|
|
22
|
+
const shadowTex = (): CanvasTexture => {
|
|
23
|
+
const c = document.createElement("canvas");
|
|
24
|
+
c.width = c.height = 256;
|
|
25
|
+
const g = c.getContext("2d")!, r = g.createRadialGradient(128, 128, 0, 128, 128, 128);
|
|
26
|
+
r.addColorStop(0, "rgba(0,0,0,0.75)"); r.addColorStop(0.55, "rgba(0,0,0,0.3)"); r.addColorStop(1, "rgba(0,0,0,0)");
|
|
27
|
+
g.fillStyle = r; g.fillRect(0, 0, 256, 256);
|
|
28
|
+
const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace; return t;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// A screen face when there is no screenshot: a gradient of the hero colour and a few soft bars, so a device is never an empty slab.
|
|
32
|
+
const mockTex = (hero: string, aspect: number): CanvasTexture => {
|
|
33
|
+
const W = 512, H = Math.round(512 / aspect), c = document.createElement("canvas");
|
|
34
|
+
c.width = W; c.height = H;
|
|
35
|
+
const g = c.getContext("2d")!, grad = g.createLinearGradient(0, 0, W, H);
|
|
36
|
+
grad.addColorStop(0, mixHex(hero, "#ffffff", 0.25)); grad.addColorStop(1, mixHex(hero, "#000000", 0.45));
|
|
37
|
+
g.fillStyle = grad; g.fillRect(0, 0, W, H);
|
|
38
|
+
g.fillStyle = "rgba(255,255,255,0.9)"; g.beginPath(); g.roundRect(W * 0.1, H * 0.12, W * 0.45, H * 0.04, 12); g.fill();
|
|
39
|
+
for (let i = 0; i < 5; i++) { g.fillStyle = `rgba(255,255,255,${0.2 - i * 0.02})`; g.beginPath(); g.roundRect(W * 0.1, H * (0.24 + i * 0.13), W * 0.8, H * 0.1, 18); g.fill(); }
|
|
40
|
+
const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace; return t;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type HeroKind = "device" | "logo" | "box" | "coin";
|
|
44
|
+
|
|
45
|
+
type Built = { geometry: BufferGeometry; glyph?: BufferGeometry; w: number; h: number; d: number; screen?: { w: number; h: number; z: number } };
|
|
46
|
+
// The body of each kind, centred, in its own units, with its size.
|
|
47
|
+
function build(kind: HeroKind, text: string, aspect: number): Built {
|
|
48
|
+
if (kind === "device") {
|
|
49
|
+
const w = 1, h = w / aspect, depth = 0.07, g = new ExtrudeGeometry(roundRect(w, h, 0.09 * w), { depth, bevelEnabled: true, bevelThickness: 0.018, bevelSize: 0.018, bevelSegments: 5, curveSegments: 14 });
|
|
50
|
+
g.translate(0, 0, -depth / 2);
|
|
51
|
+
return { geometry: g, w: w + 0.04, h: h + 0.04, d: depth + 0.04, screen: { w: w * 0.92, h: h * 0.94, z: depth / 2 + 0.019 } };
|
|
52
|
+
}
|
|
53
|
+
if (kind === "logo") {
|
|
54
|
+
const g = new TextGeometry(text, { font: typeface(), size: 1, depth: 0.28, curveSegments: 8, bevelEnabled: true, bevelThickness: 0.025, bevelSize: 0.02, bevelSegments: 3 });
|
|
55
|
+
g.computeBoundingBox();
|
|
56
|
+
const b = g.boundingBox!;
|
|
57
|
+
g.translate(-(b.max.x + b.min.x) / 2, -(b.max.y + b.min.y) / 2, -0.14);
|
|
58
|
+
return { geometry: g, w: b.max.x - b.min.x, h: b.max.y - b.min.y, d: 0.34 };
|
|
59
|
+
}
|
|
60
|
+
if (kind === "coin") {
|
|
61
|
+
// A true disc with a raised rim, and the first letter of `text` standing on its face.
|
|
62
|
+
const disc = new Shape();
|
|
63
|
+
disc.absarc(0, 0, 0.5, 0, Math.PI * 2, false);
|
|
64
|
+
const g = new ExtrudeGeometry(disc, { depth: 0.1, bevelEnabled: true, bevelThickness: 0.04, bevelSize: 0.03, bevelSegments: 8, curveSegments: 64 });
|
|
65
|
+
g.translate(0, 0, -0.05);
|
|
66
|
+
const glyph = new TextGeometry(text.slice(0, 1) || "R", { font: typeface(), size: 0.5, depth: 0.04, curveSegments: 8, bevelEnabled: true, bevelThickness: 0.008, bevelSize: 0.008, bevelSegments: 2 });
|
|
67
|
+
glyph.computeBoundingBox();
|
|
68
|
+
const b = glyph.boundingBox!;
|
|
69
|
+
glyph.translate(-(b.max.x + b.min.x) / 2, -(b.max.y + b.min.y) / 2, 0.095);
|
|
70
|
+
return { geometry: g, w: 1.06, h: 1.06, d: 0.2, glyph };
|
|
71
|
+
}
|
|
72
|
+
return { geometry: new RoundedBoxGeometry(1, 1, 1, 6, 0.12), w: 1, h: 1, d: 1 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The screenshot as a texture, loaded once; the frame waits for it (and for its size, which sets the screen's shape) so it is never drawn empty.
|
|
76
|
+
function useScreenshot(url: string): { texture: Texture | null; aspect: number | undefined } {
|
|
77
|
+
const [state, setState] = React.useState<{ texture: Texture | null; aspect: number | undefined; url: string }>({ texture: null, aspect: undefined, url: "" });
|
|
78
|
+
const hold = React.useRef<number | undefined>(undefined);
|
|
79
|
+
const release = () => { if (hold.current !== undefined) { continueRender(hold.current); hold.current = undefined; } };
|
|
80
|
+
React.useLayoutEffect(() => {
|
|
81
|
+
if (!url) { setState({ texture: null, aspect: undefined, url: "" }); return; }
|
|
82
|
+
if (hold.current === undefined) hold.current = delayRender(`screenshot ${url.slice(0, 40)}`);
|
|
83
|
+
let live = true;
|
|
84
|
+
new TextureLoader().load(url, (t) => { t.colorSpace = SRGBColorSpace; const img = t.image as { width: number; height: number }; if (live) setState({ texture: t, aspect: img.width / Math.max(1, img.height), url }); else release(); }, undefined, () => release());
|
|
85
|
+
return () => { live = false; };
|
|
86
|
+
}, [url]);
|
|
87
|
+
// The frame is released only after the texture has been drawn into the scene: two animation frames after it arrived.
|
|
88
|
+
// While rendering, the canvas draws only when the frame changes, so a texture that arrives later has to be drawn by asking for a frame.
|
|
89
|
+
const advance = useThree((t) => t.advance);
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (state.texture && state.url === url) { advance(performance.now()); release(); }
|
|
92
|
+
}, [state, url, advance]);
|
|
93
|
+
React.useEffect(() => release, []);
|
|
94
|
+
return { texture: state.texture, aspect: state.aspect };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const Picture: React.FC<{ texture: Texture; w: number; h: number; opacity?: number }> = ({ texture, w, h, opacity = 1 }) => {
|
|
98
|
+
const g = React.useMemo(() => uvs(new ShapeGeometry(roundRect(w, h, 0.05), 12), w, h), [w, h]);
|
|
99
|
+
return <mesh geometry={g}><meshBasicMaterial map={texture} toneMapped={false} transparent={opacity < 1} opacity={opacity} /></mesh>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// A studio-lit hero: one object, lit like a product shot. `kind` is "device" (a rounded slab with a screen that shows `src`, a screenshot; without it a designed
|
|
103
|
+
// screen), "logo" (`text`, a short word or a single letter, extruded in the kit's typeface), "box" or "coin". Physical material with a clearcoat in `hero`, a key light
|
|
104
|
+
// and two coloured rim lights (`rim1`, `rim2`, by default the hero colour turned toward blue and toward pink). `floor` is "soft" (a contact shadow, default), "mirror"
|
|
105
|
+
// (a flipped, faded copy below) or "none". `spin` is how many degrees a second it turns about the vertical (12 for a logo, box or coin; a device sways by that many
|
|
106
|
+
// degrees instead) and `hover` how much it bobs, 0 to 1 (0.5). It fits the frame by default (`fit`, 0.7, at the closest the camera comes). `poses` are keys {
|
|
107
|
+
// frame, rotation (degrees), position, scale} that re-pose it, the same timing as Camera: one hero carried across scenes. `aspect` is a device's width over height
|
|
108
|
+
// (the screenshot's own when `src` is given, else 0.5).
|
|
109
|
+
export const Hero3D: React.FC<{
|
|
110
|
+
kind?: HeroKind;
|
|
111
|
+
src?: string;
|
|
112
|
+
text?: string;
|
|
113
|
+
hero?: string;
|
|
114
|
+
rim1?: string;
|
|
115
|
+
rim2?: string;
|
|
116
|
+
floor?: "soft" | "mirror" | "none";
|
|
117
|
+
spin?: number;
|
|
118
|
+
hover?: number;
|
|
119
|
+
fit?: number;
|
|
120
|
+
aspect?: number;
|
|
121
|
+
poses?: PoseKey[];
|
|
122
|
+
lights?: boolean;
|
|
123
|
+
position?: Vec3;
|
|
124
|
+
}> = ({ kind = "box", src, text = "R", hero = "#7C3AED", rim1, rim2, floor = "soft", spin, hover = 0.5, fit = 0.7, aspect, poses, lights = true, position = [0, 0, 0] }) => {
|
|
125
|
+
const frame = useCurrentFrame();
|
|
126
|
+
const { fps, width, height } = useVideoConfig();
|
|
127
|
+
const keys = React.useContext(CameraKeysContext);
|
|
128
|
+
const url = useMedia(src ?? "");
|
|
129
|
+
const shot = useScreenshot(src ? url : "");
|
|
130
|
+
const natural = shot.aspect;
|
|
131
|
+
const ratio = aspect ?? natural ?? 0.5;
|
|
132
|
+
const built = React.useMemo(() => build(kind, text.slice(0, 10), ratio), [kind, text, ratio]);
|
|
133
|
+
React.useEffect(() => () => built.geometry.dispose(), [built]);
|
|
134
|
+
const view = nearestVisible(keys, width / height, position[2]);
|
|
135
|
+
// Fit with room for the turn: a thing that spins is as wide as its width and its depth.
|
|
136
|
+
const reach = Math.max(built.w, kind === "device" ? built.w : built.d * 0.6 + built.w * 0.6);
|
|
137
|
+
const base = Math.min((fit * view.width) / reach, (fit * view.height) / built.h);
|
|
138
|
+
const pose = poseAt(poses, frame, fps);
|
|
139
|
+
const t = frame / fps;
|
|
140
|
+
const turn = spin ?? (kind === "device" ? 8 : 12);
|
|
141
|
+
const yaw = kind === "device" ? Math.sin(t * 0.9) * turn : t * turn;
|
|
142
|
+
const bob = Math.sin(t * 1.7) * 0.035 * hover * base * built.h;
|
|
143
|
+
const r1 = rim1 ?? mixHex(hero, "#2a6bff", 0.55), r2 = rim2 ?? mixHex(hero, "#ff3aa8", 0.55);
|
|
144
|
+
const shadow = React.useMemo(shadowTex, []);
|
|
145
|
+
const mock = React.useMemo(() => (kind === "device" && !src ? mockTex(hero, ratio) : undefined), [kind, src, hero, ratio]);
|
|
146
|
+
const metal = kind === "coin" ? 0.85 : kind === "logo" ? 0.35 : 0.2;
|
|
147
|
+
const body = (opacity: number) => (
|
|
148
|
+
<group>
|
|
149
|
+
<mesh geometry={built.geometry}>
|
|
150
|
+
<meshPhysicalMaterial color={kind === "device" ? mixHex(hero, "#101018", 0.8) : hero} roughness={kind === "coin" ? 0.28 : 0.34} metalness={metal} clearcoat={1} clearcoatRoughness={0.12} transparent={opacity < 1} opacity={opacity} />
|
|
151
|
+
</mesh>
|
|
152
|
+
{built.glyph ? (
|
|
153
|
+
<mesh geometry={built.glyph}>
|
|
154
|
+
<meshPhysicalMaterial color={mixHex(hero, "#ffffff", 0.45)} roughness={0.25} metalness={0.9} clearcoat={1} clearcoatRoughness={0.1} transparent={opacity < 1} opacity={opacity} />
|
|
155
|
+
</mesh>
|
|
156
|
+
) : null}
|
|
157
|
+
{kind === "device" && built.screen ? (
|
|
158
|
+
<group position={[0, 0, built.screen.z]}>
|
|
159
|
+
{src ? (shot.texture ? <Picture texture={shot.texture} w={built.screen.w} h={built.screen.h} opacity={opacity} /> : null) : mock ? <mesh geometry={uvs(new ShapeGeometry(roundRect(built.screen.w, built.screen.h, 0.05), 12), built.screen.w, built.screen.h)}><meshBasicMaterial map={mock} toneMapped={false} transparent={opacity < 1} opacity={opacity} /></mesh> : null}
|
|
160
|
+
{/* The sheen: a pale band that crosses the glass once, early, and stays inside it. */}
|
|
161
|
+
<mesh position={[(Math.min(1, Math.max(0, (frame - 10) / 40)) - 0.5) * built.screen.w * 0.9, 0, 0.002]} scale={[built.screen.w * 0.16, built.screen.h * 0.98, 1]}>
|
|
162
|
+
<planeGeometry args={[1, 1]} />
|
|
163
|
+
<meshBasicMaterial color="#ffffff" transparent opacity={0.14 * opacity * (frame < 10 || frame > 50 ? 0 : Math.sin(Math.PI * ((frame - 10) / 40)))} depthWrite={false} side={DoubleSide} />
|
|
164
|
+
</mesh>
|
|
165
|
+
</group>
|
|
166
|
+
) : null}
|
|
167
|
+
</group>
|
|
168
|
+
);
|
|
169
|
+
const floorY = -(built.h / 2) * base - 0.06 * base;
|
|
170
|
+
return (
|
|
171
|
+
<group position={[position[0] + pose.position[0], position[1] + pose.position[1] + bob, position[2] + pose.position[2]]} scale={pose.scale}>
|
|
172
|
+
{lights ? (
|
|
173
|
+
<>
|
|
174
|
+
<directionalLight position={[3, 5, 6]} intensity={2.2} />
|
|
175
|
+
<pointLight position={[-3.5, 1, -3]} color={r1} intensity={55} distance={14} />
|
|
176
|
+
<pointLight position={[3.5, 1.5, -3]} color={r2} intensity={55} distance={14} />
|
|
177
|
+
</>
|
|
178
|
+
) : null}
|
|
179
|
+
<group rotation={[degToRad(pose.rotation[0]), degToRad(pose.rotation[1] + yaw), degToRad(pose.rotation[2])]} scale={base}>{body(1)}</group>
|
|
180
|
+
{floor === "soft" ? (
|
|
181
|
+
<mesh position={[0, floorY, 0]} rotation={[-Math.PI / 2, 0, 0]} scale={[base * built.w * 1.8, base * Math.max(built.d, built.w * 0.5) * 1.6, 1]}>
|
|
182
|
+
<planeGeometry args={[1, 1]} />
|
|
183
|
+
<meshBasicMaterial map={shadow} transparent depthWrite={false} toneMapped={false} />
|
|
184
|
+
</mesh>
|
|
185
|
+
) : null}
|
|
186
|
+
{floor === "mirror" ? (
|
|
187
|
+
<group position={[0, floorY * 2 - 0.0, 0]} scale={[1, -1, 1]}>
|
|
188
|
+
<group rotation={[degToRad(pose.rotation[0]), degToRad(pose.rotation[1] + yaw), degToRad(pose.rotation[2])]} scale={base}>{body(0.2)}</group>
|
|
189
|
+
</group>
|
|
190
|
+
) : null}
|
|
191
|
+
</group>
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// A tilted screen in 3D carrying a screenshot (`src`) or a designed mock, with a bezel, a shadow and a sheen: the interface carried across scenes. It is Hero3D kind="device"
|
|
196
|
+
// with no spin (a gentle sway of 5 degrees), so give it `poses` to re-pose the one screen at each scene. Same props as Hero3D otherwise.
|
|
197
|
+
export const Screen3D: React.FC<Omit<React.ComponentProps<typeof Hero3D>, "kind" | "text">> = (props) => <Hero3D kind="device" spin={5} {...props} />;
|