frameyard 0.4.0 → 0.6.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/CHANGELOG.md +101 -0
- package/GLOSSARY.md +156 -24
- package/README.md +103 -31
- package/dist/cli.js +112 -19
- package/dist/cli.js.map +1 -1
- package/dist/composition/adapter.d.ts +30 -0
- package/dist/composition/adapter.d.ts.map +1 -0
- package/dist/composition/adapter.js +2 -0
- package/dist/composition/adapter.js.map +1 -0
- package/dist/composition/animation.d.ts +19 -0
- package/dist/composition/animation.d.ts.map +1 -0
- package/dist/composition/animation.js +55 -0
- package/dist/composition/animation.js.map +1 -0
- package/dist/composition/attributes.d.ts +7 -0
- package/dist/composition/attributes.d.ts.map +1 -0
- package/dist/composition/attributes.js +24 -0
- package/dist/composition/attributes.js.map +1 -0
- package/dist/composition/descriptor.d.ts +30 -0
- package/dist/composition/descriptor.d.ts.map +1 -0
- package/dist/composition/descriptor.js +188 -0
- package/dist/composition/descriptor.js.map +1 -0
- package/dist/composition/diagnostics.d.ts +12 -0
- package/dist/composition/diagnostics.d.ts.map +1 -0
- package/dist/composition/diagnostics.js +38 -0
- package/dist/composition/diagnostics.js.map +1 -0
- package/dist/composition/index.d.ts +12 -0
- package/dist/composition/index.d.ts.map +1 -0
- package/dist/composition/index.js +7 -0
- package/dist/composition/index.js.map +1 -0
- package/dist/composition/media.d.ts +27 -0
- package/dist/composition/media.d.ts.map +1 -0
- package/dist/composition/media.js +162 -0
- package/dist/composition/media.js.map +1 -0
- package/dist/composition/paths.d.ts +5 -0
- package/dist/composition/paths.d.ts.map +1 -0
- package/dist/composition/paths.js +16 -0
- package/dist/composition/paths.js.map +1 -0
- package/dist/composition/transcripts.d.ts +19 -0
- package/dist/composition/transcripts.d.ts.map +1 -0
- package/dist/composition/transcripts.js +57 -0
- package/dist/composition/transcripts.js.map +1 -0
- package/dist/composition/walk.d.ts +10 -0
- package/dist/composition/walk.d.ts.map +1 -0
- package/dist/composition/walk.js +32 -0
- package/dist/composition/walk.js.map +1 -0
- package/dist/contracts.d.ts +14 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/html/document-adapter.d.ts +5 -0
- package/dist/html/document-adapter.d.ts.map +1 -0
- package/dist/html/document-adapter.js +17 -0
- package/dist/html/document-adapter.js.map +1 -0
- package/dist/html/parse.d.ts +2 -6
- package/dist/html/parse.d.ts.map +1 -1
- package/dist/html/parse.js.map +1 -1
- package/dist/html/validate.d.ts.map +1 -1
- package/dist/html/validate.js +92 -375
- package/dist/html/validate.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/preview/main.d.ts.map +1 -1
- package/dist/preview/main.js +93 -13
- package/dist/preview/main.js.map +1 -1
- package/dist/preview/timeline-data.d.ts +85 -0
- package/dist/preview/timeline-data.d.ts.map +1 -0
- package/dist/preview/timeline-data.js +2 -0
- package/dist/preview/timeline-data.js.map +1 -0
- package/dist/preview/timeline-host.d.ts +19 -0
- package/dist/preview/timeline-host.d.ts.map +1 -0
- package/dist/preview/timeline-host.js +297 -0
- package/dist/preview/timeline-host.js.map +1 -0
- package/dist/preview/timeline-main.d.ts +17 -0
- package/dist/preview/timeline-main.d.ts.map +1 -0
- package/dist/preview/timeline-main.js +364 -0
- package/dist/preview/timeline-main.js.map +1 -0
- package/dist/project.d.ts +16 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +81 -26
- package/dist/project.js.map +1 -1
- package/dist/renderer/browser.d.ts +2 -0
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +38 -6
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/ffmpeg.d.ts +61 -0
- package/dist/renderer/ffmpeg.d.ts.map +1 -1
- package/dist/renderer/ffmpeg.js +198 -0
- package/dist/renderer/ffmpeg.js.map +1 -1
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +18 -7
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/timeline.d.ts +109 -0
- package/dist/renderer/timeline.d.ts.map +1 -0
- package/dist/renderer/timeline.js +984 -0
- package/dist/renderer/timeline.js.map +1 -0
- package/dist/runtime/document-adapter.d.ts +4 -0
- package/dist/runtime/document-adapter.d.ts.map +1 -0
- package/dist/runtime/document-adapter.js +19 -0
- package/dist/runtime/document-adapter.js.map +1 -0
- package/dist/runtime/host.d.ts +2 -0
- package/dist/runtime/host.d.ts.map +1 -1
- package/dist/runtime/host.js +78 -183
- package/dist/runtime/host.js.map +1 -1
- package/dist/runtime/media.d.ts.map +1 -1
- package/dist/runtime/media.js +19 -50
- package/dist/runtime/media.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +93 -23
- package/dist/scaffold.js.map +1 -1
- package/dist/server/preview-html.d.ts.map +1 -1
- package/dist/server/preview-html.js +15 -1
- package/dist/server/preview-html.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +39 -1
- package/dist/server/server.js.map +1 -1
- package/dist/server/timeline-preview-data.d.ts +4 -0
- package/dist/server/timeline-preview-data.d.ts.map +1 -0
- package/dist/server/timeline-preview-data.js +97 -0
- package/dist/server/timeline-preview-data.js.map +1 -0
- package/dist/server/timeline-preview-html.d.ts +4 -0
- package/dist/server/timeline-preview-html.d.ts.map +1 -0
- package/dist/server/timeline-preview-html.js +227 -0
- package/dist/server/timeline-preview-html.js.map +1 -0
- package/dist/timeline/plan.d.ts +32 -0
- package/dist/timeline/plan.d.ts.map +1 -0
- package/dist/timeline/plan.js +68 -0
- package/dist/timeline/plan.js.map +1 -0
- package/dist/timeline/resolve.d.ts +60 -0
- package/dist/timeline/resolve.d.ts.map +1 -0
- package/dist/timeline/resolve.js +523 -0
- package/dist/timeline/resolve.js.map +1 -0
- package/dist/timeline/schema.d.ts +71 -0
- package/dist/timeline/schema.d.ts.map +1 -0
- package/dist/timeline/schema.js +425 -0
- package/dist/timeline/schema.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adr/0002-make-frame-and-props-the-only-animation-state.md +2 -0
- package/docs/adr/0005-keep-parallel-rendering-opt-in-and-frame-ordered.md +2 -0
- package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +4 -0
- package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
- package/docs/adr/0008-share-the-composition-document-grammar.md +18 -0
- package/docs/adr/0009-composite-production-frames-in-software.md +37 -0
- package/docs/adr/0010-make-timelines-the-render-unit.md +24 -0
- package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +22 -0
- package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +31 -0
- package/docs/v0.1-design.md +135 -85
- package/examples/demo/README.md +11 -4
- package/examples/demo/timelines/demo.timeline.json +12 -0
- package/examples/demo/video.config.js +4 -0
- package/examples/video/README.md +105 -0
- package/examples/video/assets/clip.mp4 +0 -0
- package/examples/video/assets/clip.webm +0 -0
- package/examples/video/compositions/video-mp4.html +362 -0
- package/examples/video/compositions/video-overlay.html +350 -0
- package/examples/video/compositions/video.html +362 -0
- package/examples/video/props/video-timeline.json +10 -0
- package/examples/video/props/video.json +8 -0
- package/examples/video/scripts/make-clip.sh +109 -0
- package/examples/video/timelines/video-timeline-mp4.timeline.json +44 -0
- package/examples/video/timelines/video-timeline.timeline.json +44 -0
- package/examples/video/video.config.js +12 -0
- package/examples/webgl/README.md +30 -0
- package/examples/webgl/compositions/webgl.controller.js +200 -0
- package/examples/webgl/compositions/webgl.html +215 -0
- package/examples/webgl/props/webgl.json +6 -0
- package/examples/webgl/video.config.js +5 -0
- package/package.json +4 -2
- package/skills/frameyard/SKILL.md +18 -13
- package/skills/frameyard/references/json-results.md +2 -2
- package/skills/frameyard/references/timelines.md +141 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Regenerate examples/video/assets/clip.webm and clip.mp4.
|
|
4
|
+
#
|
|
5
|
+
# The visual is a procedural gradient field warped by two sinusoidal
|
|
6
|
+
# displacement maps. The audio is an additive sine pad with pink-noise air.
|
|
7
|
+
# Fixed seeds keep the generated grain stable across runs on the same FFmpeg.
|
|
8
|
+
#
|
|
9
|
+
# Requires FFmpeg with libvpx-vp9, libx264, Opus, and AAC support.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# examples/video/scripts/make-clip.sh
|
|
13
|
+
# examples/video/scripts/make-clip.sh --out-dir /tmp/frameyard-clip
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
usage() {
|
|
18
|
+
cat <<'EOF'
|
|
19
|
+
Usage: make-clip.sh [--out-dir <directory>]
|
|
20
|
+
|
|
21
|
+
Writes clip.webm (VP9 + Opus) and clip.mp4 (H.264 + AAC) to
|
|
22
|
+
examples/video/assets by default.
|
|
23
|
+
EOF
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
out_dir="$(cd -- "$script_dir/.." && pwd)/assets"
|
|
28
|
+
|
|
29
|
+
while [ "$#" -gt 0 ]; do
|
|
30
|
+
case "$1" in
|
|
31
|
+
--out-dir)
|
|
32
|
+
if [ "$#" -lt 2 ]; then
|
|
33
|
+
echo "make-clip: --out-dir requires a directory." >&2
|
|
34
|
+
usage >&2
|
|
35
|
+
exit 2
|
|
36
|
+
fi
|
|
37
|
+
out_dir="$2"
|
|
38
|
+
shift 2
|
|
39
|
+
;;
|
|
40
|
+
-h | --help)
|
|
41
|
+
usage
|
|
42
|
+
exit 0
|
|
43
|
+
;;
|
|
44
|
+
*)
|
|
45
|
+
echo "make-clip: unknown argument: $1" >&2
|
|
46
|
+
usage >&2
|
|
47
|
+
exit 2
|
|
48
|
+
;;
|
|
49
|
+
esac
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
for tool in ffmpeg ffprobe; do
|
|
53
|
+
if ! command -v "$tool" >/dev/null 2>&1; then
|
|
54
|
+
echo "make-clip: $tool is required on PATH." >&2
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
done
|
|
58
|
+
|
|
59
|
+
mkdir -p "$out_dir"
|
|
60
|
+
out_dir="$(cd -- "$out_dir" && pwd)"
|
|
61
|
+
work_dir="$(mktemp -d "$out_dir/.make-clip.XXXXXX")"
|
|
62
|
+
trap 'rm -rf "$work_dir"' EXIT
|
|
63
|
+
|
|
64
|
+
# 12 seconds of a slow circular gradient, warped by two radial sine/cosine
|
|
65
|
+
# displacement maps. Scaling up and cropping hides the displacement edges.
|
|
66
|
+
ffmpeg -hide_banner -loglevel error -y \
|
|
67
|
+
-f lavfi -i "gradients=s=1280x720:r=30:d=12:c0=0x071827:c1=0x0d3a3a:c2=0x2f8a7e:c3=0xffd7a0:c4=0x123a58:c5=0x8fe6c8:n=6:type=circular:speed=0.02:seed=9" \
|
|
68
|
+
-f lavfi -i "nullsrc=s=1280x720:r=30:d=12,geq=lum='128+72*sin(hypot(X-640,Y-360)/42-T*0.55)':cb=128:cr=128" \
|
|
69
|
+
-f lavfi -i "nullsrc=s=1280x720:r=30:d=12,geq=lum='128+68*cos(hypot(X-640,Y-360)/55+T*0.47)':cb=128:cr=128" \
|
|
70
|
+
-filter_complex "[0:v][1:v][2:v]displace=edge=smear,scale=1440:810,crop=1280:720,gblur=sigma=6,eq=contrast=1.26:brightness=0.015:saturation=1.06,noise=alls=4:allf=t+u:all_seed=7,vignette=PI/3.6,format=yuv420p[v]" \
|
|
71
|
+
-map "[v]" -t 12 -an \
|
|
72
|
+
-c:v libvpx-vp9 -crf 31 -b:v 0 -row-mt 1 -tile-columns 2 -cpu-used 2 -g 60 \
|
|
73
|
+
"$work_dir/clip-video.webm"
|
|
74
|
+
|
|
75
|
+
# A slow A-minor-ish pad with movement from chorus/echo and a filtered
|
|
76
|
+
# pink-noise bed. The clip fades in and out so the timeline edit is soft.
|
|
77
|
+
ffmpeg -hide_banner -loglevel error -y \
|
|
78
|
+
-f lavfi -i "aevalsrc=exprs='0.24*sin(2*PI*110*t)+0.12*sin(2*PI*164.81*t)+0.10*sin(2*PI*220*t)*(0.8+0.2*sin(2*PI*0.07*t))+0.06*sin(2*PI*261.63*t)+0.04*sin(2*PI*329.63*t)+0.018*sin(2*PI*440*t)':s=48000:d=12" \
|
|
79
|
+
-f lavfi -i "anoisesrc=color=pink:sample_rate=48000:duration=12:amplitude=0.08:seed=7" \
|
|
80
|
+
-filter_complex "[0:a]chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.3:2|1.3,aecho=0.8:0.6:280|520:0.25|0.16,lowpass=f=4200,volume=0.9[pad];[1:a]lowpass=f=700,volume=0.075[air];[pad][air]amix=inputs=2:duration=longest:normalize=0,afade=t=in:st=0:d=2,afade=t=out:st=9.4:d=2.6,alimiter=limit=0.9,pan=stereo|c0=c0|c1=c0[a]" \
|
|
81
|
+
-map "[a]" -t 12 -c:a libopus -b:a 96k \
|
|
82
|
+
"$work_dir/clip-audio.webm"
|
|
83
|
+
|
|
84
|
+
ffmpeg -hide_banner -loglevel error -y \
|
|
85
|
+
-i "$work_dir/clip-video.webm" \
|
|
86
|
+
-i "$work_dir/clip-audio.webm" \
|
|
87
|
+
-map 0:v:0 -map 1:a:0 -c copy \
|
|
88
|
+
"$work_dir/clip.webm"
|
|
89
|
+
|
|
90
|
+
# The MP4 variant is a transcode of the WebM source with the same timeline.
|
|
91
|
+
ffmpeg -hide_banner -loglevel error -y \
|
|
92
|
+
-i "$work_dir/clip.webm" \
|
|
93
|
+
-c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p -g 60 \
|
|
94
|
+
-c:a aac -b:a 128k -movflags +faststart \
|
|
95
|
+
"$work_dir/clip.mp4"
|
|
96
|
+
|
|
97
|
+
for file in "$work_dir/clip.webm" "$work_dir/clip.mp4"; do
|
|
98
|
+
ffprobe -v error -select_streams v:0 \
|
|
99
|
+
-show_entries stream=codec_name,width,height,r_frame_rate \
|
|
100
|
+
-of default=noprint_wrappers=1 "$file" >/dev/null
|
|
101
|
+
ffprobe -v error -select_streams a:0 -show_entries stream=codec_name \
|
|
102
|
+
-of default=noprint_wrappers=1 "$file" >/dev/null
|
|
103
|
+
done
|
|
104
|
+
|
|
105
|
+
mv -f "$work_dir/clip.webm" "$out_dir/clip.webm"
|
|
106
|
+
mv -f "$work_dir/clip.mp4" "$out_dir/clip.mp4"
|
|
107
|
+
|
|
108
|
+
echo "Wrote $out_dir/clip.webm (VP9 + Opus)"
|
|
109
|
+
echo "Wrote $out_dir/clip.mp4 (H.264 + AAC)"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"name": "Video on a track, HTML overlay on top (MP4)",
|
|
4
|
+
"tracks": [
|
|
5
|
+
{ "id": "v1", "kind": "video", "name": "Hero video" },
|
|
6
|
+
{ "id": "v2", "kind": "video", "name": "Detail video" },
|
|
7
|
+
{ "id": "v3", "kind": "video", "name": "HTML overlay" }
|
|
8
|
+
],
|
|
9
|
+
"clips": [
|
|
10
|
+
{
|
|
11
|
+
"id": "hero",
|
|
12
|
+
"track": "v1",
|
|
13
|
+
"source": { "kind": "video", "src": "assets/clip.mp4" },
|
|
14
|
+
"start": 0,
|
|
15
|
+
"audio": { "gain": 0.85 }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "detail",
|
|
19
|
+
"track": "v2",
|
|
20
|
+
"source": { "kind": "video", "src": "assets/clip.mp4" },
|
|
21
|
+
"start": 120,
|
|
22
|
+
"sourceIn": 174,
|
|
23
|
+
"duration": 150,
|
|
24
|
+
"transform": {
|
|
25
|
+
"fit": "cover",
|
|
26
|
+
"width": 440,
|
|
27
|
+
"height": 248,
|
|
28
|
+
"x": 592,
|
|
29
|
+
"y": 262,
|
|
30
|
+
"rotation": 0,
|
|
31
|
+
"opacity": 1
|
|
32
|
+
},
|
|
33
|
+
"audio": { "muted": true }
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "overlay",
|
|
37
|
+
"track": "v3",
|
|
38
|
+
"source": { "kind": "composition", "id": "video-overlay" },
|
|
39
|
+
"start": 0,
|
|
40
|
+
"duration": 360,
|
|
41
|
+
"props": { "format": "H.264 + AAC / 30 FPS" }
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"name": "Video on a track, HTML overlay on top",
|
|
4
|
+
"tracks": [
|
|
5
|
+
{ "id": "v1", "kind": "video", "name": "Hero video" },
|
|
6
|
+
{ "id": "v2", "kind": "video", "name": "Detail video" },
|
|
7
|
+
{ "id": "v3", "kind": "video", "name": "HTML overlay" }
|
|
8
|
+
],
|
|
9
|
+
"clips": [
|
|
10
|
+
{
|
|
11
|
+
"id": "hero",
|
|
12
|
+
"track": "v1",
|
|
13
|
+
"source": { "kind": "video", "src": "assets/clip.webm" },
|
|
14
|
+
"start": 0,
|
|
15
|
+
"audio": { "gain": 0.85 }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "detail",
|
|
19
|
+
"track": "v2",
|
|
20
|
+
"source": { "kind": "video", "src": "assets/clip.webm" },
|
|
21
|
+
"start": 120,
|
|
22
|
+
"sourceIn": 174,
|
|
23
|
+
"duration": 150,
|
|
24
|
+
"transform": {
|
|
25
|
+
"fit": "cover",
|
|
26
|
+
"width": 440,
|
|
27
|
+
"height": 248,
|
|
28
|
+
"x": 592,
|
|
29
|
+
"y": 262,
|
|
30
|
+
"rotation": 0,
|
|
31
|
+
"opacity": 1
|
|
32
|
+
},
|
|
33
|
+
"audio": { "muted": true }
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "overlay",
|
|
37
|
+
"track": "v3",
|
|
38
|
+
"source": { "kind": "composition", "id": "video-overlay" },
|
|
39
|
+
"start": 0,
|
|
40
|
+
"duration": 360,
|
|
41
|
+
"props": { "format": "VP9 + Opus / 30 FPS" }
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
format: { width: 1920, height: 1080, fps: 30 },
|
|
3
|
+
compositions: {
|
|
4
|
+
video: "./compositions/video.html",
|
|
5
|
+
"video-mp4": "./compositions/video-mp4.html",
|
|
6
|
+
"video-overlay": "./compositions/video-overlay.html",
|
|
7
|
+
},
|
|
8
|
+
timelines: {
|
|
9
|
+
"video-timeline": "./timelines/video-timeline.timeline.json",
|
|
10
|
+
"video-timeline-mp4": "./timelines/video-timeline-mp4.timeline.json",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Frameyard WebGL example
|
|
2
|
+
|
|
3
|
+
A 12-second, 1920×1080 composition that renders a procedural light field with a
|
|
4
|
+
WebGL fragment shader and layers HTML/CSS typography over it:
|
|
5
|
+
|
|
6
|
+
- a WebGL 1 fullscreen triangle with one cached program and cached uniforms
|
|
7
|
+
- shader motion derived only from the composition frame
|
|
8
|
+
- props for the kicker, title, subtitle, and accent color
|
|
9
|
+
- frame-scheduled CSS typography and a frame-state progress bar
|
|
10
|
+
- controller-updated frame and time readouts
|
|
11
|
+
|
|
12
|
+
The composition lives in `compositions/webgl.html`; the external controller is
|
|
13
|
+
`compositions/webgl.controller.js`. Draw every frame from `update()` and do not
|
|
14
|
+
rely on the previous drawing buffer. Production sessions pin software WebGL so
|
|
15
|
+
the captured pixels do not depend on the host GPU.
|
|
16
|
+
|
|
17
|
+
From the repository root:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm build
|
|
21
|
+
node dist/cli.js check webgl --project examples/webgl
|
|
22
|
+
node dist/cli.js still webgl --frame 210 --project examples/webgl --out webgl-still.png
|
|
23
|
+
node dist/cli.js render webgl --project examples/webgl --props examples/webgl/props/webgl.json
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Start the interactive preview with:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
node dist/cli.js dev --project examples/webgl
|
|
30
|
+
```
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const VERTEX_SHADER = `
|
|
2
|
+
attribute vec2 a_position;
|
|
3
|
+
|
|
4
|
+
void main() {
|
|
5
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
6
|
+
}
|
|
7
|
+
`;
|
|
8
|
+
|
|
9
|
+
const FRAGMENT_SHADER = `
|
|
10
|
+
precision highp float;
|
|
11
|
+
|
|
12
|
+
uniform vec2 u_resolution;
|
|
13
|
+
uniform float u_frame;
|
|
14
|
+
uniform float u_duration;
|
|
15
|
+
uniform vec3 u_accent;
|
|
16
|
+
|
|
17
|
+
const float TAU = 6.28318530718;
|
|
18
|
+
|
|
19
|
+
float hash(vec2 p) {
|
|
20
|
+
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
void main() {
|
|
24
|
+
vec2 uv = gl_FragCoord.xy / u_resolution;
|
|
25
|
+
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / u_resolution.y;
|
|
26
|
+
float phase = (u_frame / max(u_duration, 1.0)) * TAU;
|
|
27
|
+
|
|
28
|
+
// A slow domain warp turns the contour field into a liquid surface.
|
|
29
|
+
vec2 q = p;
|
|
30
|
+
q.x += 0.045 * sin(p.y * 3.4 - phase * 0.8);
|
|
31
|
+
q.y += 0.10 * sin(p.x * 2.2 + phase * 0.55) + 0.035 * sin(p.x * 5.7 - phase * 0.9);
|
|
32
|
+
|
|
33
|
+
float terrain = q.y + 0.07 * sin(q.x * 3.6 + phase * 0.7) + 0.025 * sin(q.x * 8.4 - phase * 1.1);
|
|
34
|
+
float contour = abs(fract(terrain * 14.0 + phase * 0.5) - 0.5);
|
|
35
|
+
float line = smoothstep(0.455, 0.5, contour);
|
|
36
|
+
float glow = smoothstep(0.26, 0.5, contour);
|
|
37
|
+
|
|
38
|
+
vec3 ink = mix(vec3(0.016, 0.026, 0.038), vec3(0.035, 0.078, 0.085), uv.y);
|
|
39
|
+
vec3 color = ink;
|
|
40
|
+
|
|
41
|
+
float right = smoothstep(0.14, 0.66, uv.x);
|
|
42
|
+
color += u_accent * line * (0.10 + 0.38 * right);
|
|
43
|
+
color += u_accent * glow * 0.045 * right;
|
|
44
|
+
|
|
45
|
+
// A soft moving light and one thin orbit ring anchor the right side.
|
|
46
|
+
vec2 light = vec2(0.34 + 0.05 * cos(phase * 0.6), 0.06 + 0.04 * sin(phase * 0.45));
|
|
47
|
+
float radius = length(p - light);
|
|
48
|
+
float halo = 0.035 / (radius + 0.035);
|
|
49
|
+
vec3 white = mix(u_accent, vec3(0.92, 1.0, 0.98), 0.7);
|
|
50
|
+
color += white * halo * 0.42;
|
|
51
|
+
|
|
52
|
+
float ring = 1.0 - smoothstep(0.0, 0.007, abs(radius - 0.27 - 0.012 * sin(phase * 1.4)));
|
|
53
|
+
color += white * ring * 0.22;
|
|
54
|
+
|
|
55
|
+
// Static grain keeps the gradient from banding without adding temporal noise.
|
|
56
|
+
color += (hash(gl_FragCoord.xy) - 0.5) * 0.02;
|
|
57
|
+
float vignette = 1.0 - smoothstep(0.62, 1.28, length(p * vec2(0.86, 1.0)));
|
|
58
|
+
color *= 0.58 + 0.42 * vignette;
|
|
59
|
+
|
|
60
|
+
gl_FragColor = vec4(color, 1.0);
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
let gl;
|
|
65
|
+
let program;
|
|
66
|
+
let buffer;
|
|
67
|
+
let uniforms;
|
|
68
|
+
let labels;
|
|
69
|
+
let durationFrames = 360;
|
|
70
|
+
let fps = 30;
|
|
71
|
+
|
|
72
|
+
function compileShader(type, source) {
|
|
73
|
+
const shader = gl.createShader(type);
|
|
74
|
+
gl.shaderSource(shader, source);
|
|
75
|
+
gl.compileShader(shader);
|
|
76
|
+
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
|
|
77
|
+
const log = gl.getShaderInfoLog(shader);
|
|
78
|
+
gl.deleteShader(shader);
|
|
79
|
+
throw new Error(log ?? "Could not compile the WebGL shader.");
|
|
80
|
+
}
|
|
81
|
+
return shader;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function linkProgram() {
|
|
85
|
+
const vertex = compileShader(gl.VERTEX_SHADER, VERTEX_SHADER);
|
|
86
|
+
const fragment = compileShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
|
87
|
+
const next = gl.createProgram();
|
|
88
|
+
gl.attachShader(next, vertex);
|
|
89
|
+
gl.attachShader(next, fragment);
|
|
90
|
+
gl.linkProgram(next);
|
|
91
|
+
gl.detachShader(next, vertex);
|
|
92
|
+
gl.detachShader(next, fragment);
|
|
93
|
+
gl.deleteShader(vertex);
|
|
94
|
+
gl.deleteShader(fragment);
|
|
95
|
+
if (gl.getProgramParameter(next, gl.LINK_STATUS) !== true) {
|
|
96
|
+
const log = gl.getProgramInfoLog(next);
|
|
97
|
+
gl.deleteProgram(next);
|
|
98
|
+
throw new Error(log ?? "Could not link the WebGL program.");
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hexToRgb(value) {
|
|
104
|
+
const match = /^#([0-9a-f]{6})$/i.exec(typeof value === "string" ? value : "");
|
|
105
|
+
if (!match) {
|
|
106
|
+
return [0.47, 0.94, 0.78];
|
|
107
|
+
}
|
|
108
|
+
const number = Number.parseInt(match[1], 16);
|
|
109
|
+
return [((number >> 16) & 0xff) / 255, ((number >> 8) & 0xff) / 255, (number & 0xff) / 255];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default {
|
|
113
|
+
validateProps(props) {
|
|
114
|
+
const errors = [];
|
|
115
|
+
for (const key of ["kicker", "title", "subtitle"]) {
|
|
116
|
+
if (typeof props[key] !== "string") {
|
|
117
|
+
errors.push(key + " must be a string");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (typeof props.accent !== "string" || !/^#[0-9a-f]{6}$/i.test(props.accent)) {
|
|
121
|
+
errors.push("accent must be a #rrggbb color");
|
|
122
|
+
}
|
|
123
|
+
return errors;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
mount({ root, props }) {
|
|
127
|
+
const canvas = root.querySelector("#field");
|
|
128
|
+
fps = Number(root.getAttribute("fps") ?? 30);
|
|
129
|
+
durationFrames = Number(root.getAttribute("duration-in-frames") ?? 360);
|
|
130
|
+
|
|
131
|
+
gl = canvas.getContext("webgl", {
|
|
132
|
+
alpha: false,
|
|
133
|
+
antialias: false,
|
|
134
|
+
depth: false,
|
|
135
|
+
stencil: false,
|
|
136
|
+
preserveDrawingBuffer: true,
|
|
137
|
+
});
|
|
138
|
+
if (!gl) {
|
|
139
|
+
throw new Error("WebGL is unavailable. The production renderer pins SwiftShader.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
program = linkProgram();
|
|
143
|
+
gl.useProgram(program);
|
|
144
|
+
|
|
145
|
+
buffer = gl.createBuffer();
|
|
146
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
147
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
148
|
+
const position = gl.getAttribLocation(program, "a_position");
|
|
149
|
+
gl.enableVertexAttribArray(position);
|
|
150
|
+
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
|
151
|
+
|
|
152
|
+
uniforms = {
|
|
153
|
+
frame: gl.getUniformLocation(program, "u_frame"),
|
|
154
|
+
resolution: gl.getUniformLocation(program, "u_resolution"),
|
|
155
|
+
duration: gl.getUniformLocation(program, "u_duration"),
|
|
156
|
+
accent: gl.getUniformLocation(program, "u_accent"),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const accent = hexToRgb(props.accent);
|
|
160
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
161
|
+
gl.uniform2f(uniforms.resolution, canvas.width, canvas.height);
|
|
162
|
+
gl.uniform1f(uniforms.duration, durationFrames);
|
|
163
|
+
gl.uniform3f(uniforms.accent, accent[0], accent[1], accent[2]);
|
|
164
|
+
gl.clearColor(0.016, 0.026, 0.038, 1);
|
|
165
|
+
|
|
166
|
+
labels = {
|
|
167
|
+
frame: root.querySelector("#frame"),
|
|
168
|
+
total: root.querySelector("#total"),
|
|
169
|
+
clock: root.querySelector("#clock"),
|
|
170
|
+
};
|
|
171
|
+
labels.total.textContent = String(durationFrames - 1).padStart(3, "0");
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
update({ frame }) {
|
|
175
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
176
|
+
gl.uniform1f(uniforms.frame, frame);
|
|
177
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
178
|
+
|
|
179
|
+
labels.frame.textContent = String(frame).padStart(3, "0");
|
|
180
|
+
labels.clock.textContent = (frame / fps).toFixed(2) + " S";
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
dispose() {
|
|
184
|
+
if (gl) {
|
|
185
|
+
if (buffer) {
|
|
186
|
+
gl.deleteBuffer(buffer);
|
|
187
|
+
}
|
|
188
|
+
if (program) {
|
|
189
|
+
gl.deleteProgram(program);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
gl = undefined;
|
|
193
|
+
program = undefined;
|
|
194
|
+
buffer = undefined;
|
|
195
|
+
uniforms = undefined;
|
|
196
|
+
labels = undefined;
|
|
197
|
+
durationFrames = 360;
|
|
198
|
+
fps = 30;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<script type="application/json" data-fy="props">
|
|
6
|
+
{
|
|
7
|
+
"kicker": "Shader Study / 001",
|
|
8
|
+
"title": "Liquid Light",
|
|
9
|
+
"subtitle": "A frame-driven WebGL field, resolved in software so every captured pixel stays reproducible.",
|
|
10
|
+
"accent": "#78f0c8"
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<style>
|
|
14
|
+
@keyframes rise {
|
|
15
|
+
from {
|
|
16
|
+
opacity: 0;
|
|
17
|
+
transform: translateY(44px);
|
|
18
|
+
}
|
|
19
|
+
to {
|
|
20
|
+
opacity: 1;
|
|
21
|
+
transform: translateY(0);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
@keyframes fade {
|
|
25
|
+
from {
|
|
26
|
+
opacity: 0;
|
|
27
|
+
}
|
|
28
|
+
to {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
@keyframes draw {
|
|
33
|
+
from {
|
|
34
|
+
opacity: 0;
|
|
35
|
+
transform: scaleX(0);
|
|
36
|
+
}
|
|
37
|
+
to {
|
|
38
|
+
opacity: 1;
|
|
39
|
+
transform: scaleX(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
html,
|
|
43
|
+
body,
|
|
44
|
+
fy-composition {
|
|
45
|
+
background: #060a0e;
|
|
46
|
+
}
|
|
47
|
+
#scene {
|
|
48
|
+
position: relative;
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 100%;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
background: #060a0e;
|
|
53
|
+
color: #edf6f3;
|
|
54
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
55
|
+
}
|
|
56
|
+
#field {
|
|
57
|
+
position: absolute;
|
|
58
|
+
inset: 0;
|
|
59
|
+
display: block;
|
|
60
|
+
width: 100%;
|
|
61
|
+
height: 100%;
|
|
62
|
+
}
|
|
63
|
+
#scrim {
|
|
64
|
+
position: absolute;
|
|
65
|
+
inset: 0;
|
|
66
|
+
background: linear-gradient(
|
|
67
|
+
90deg,
|
|
68
|
+
rgba(4, 8, 11, 0.94) 0%,
|
|
69
|
+
rgba(4, 8, 11, 0.78) 34%,
|
|
70
|
+
rgba(4, 8, 11, 0.18) 66%,
|
|
71
|
+
rgba(4, 8, 11, 0) 100%
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
#vignette {
|
|
75
|
+
position: absolute;
|
|
76
|
+
inset: 0;
|
|
77
|
+
background: radial-gradient(ellipse at 60% 45%, transparent 34%, rgba(0, 0, 0, 0.46) 100%);
|
|
78
|
+
pointer-events: none;
|
|
79
|
+
}
|
|
80
|
+
#kicker {
|
|
81
|
+
position: absolute;
|
|
82
|
+
top: 72px;
|
|
83
|
+
left: 148px;
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: 16px;
|
|
87
|
+
font:
|
|
88
|
+
600 14px/1 ui-monospace,
|
|
89
|
+
monospace;
|
|
90
|
+
letter-spacing: 0.26em;
|
|
91
|
+
text-transform: uppercase;
|
|
92
|
+
color: var(--fy-prop-accent);
|
|
93
|
+
animation: fade 1s linear both;
|
|
94
|
+
}
|
|
95
|
+
#kicker::before {
|
|
96
|
+
content: "";
|
|
97
|
+
width: 8px;
|
|
98
|
+
height: 8px;
|
|
99
|
+
background: var(--fy-prop-accent);
|
|
100
|
+
box-shadow: 0 0 18px var(--fy-prop-accent);
|
|
101
|
+
}
|
|
102
|
+
#meta {
|
|
103
|
+
position: absolute;
|
|
104
|
+
top: 72px;
|
|
105
|
+
right: 148px;
|
|
106
|
+
text-align: right;
|
|
107
|
+
font:
|
|
108
|
+
500 13px/1.9 ui-monospace,
|
|
109
|
+
monospace;
|
|
110
|
+
letter-spacing: 0.12em;
|
|
111
|
+
color: #80908e;
|
|
112
|
+
animation: fade 1s linear both;
|
|
113
|
+
}
|
|
114
|
+
#meta strong {
|
|
115
|
+
color: #dce9e6;
|
|
116
|
+
font-weight: 500;
|
|
117
|
+
}
|
|
118
|
+
#title {
|
|
119
|
+
position: absolute;
|
|
120
|
+
top: 292px;
|
|
121
|
+
left: 148px;
|
|
122
|
+
width: 780px;
|
|
123
|
+
margin: 0;
|
|
124
|
+
font-size: 168px;
|
|
125
|
+
line-height: 0.88;
|
|
126
|
+
letter-spacing: -0.062em;
|
|
127
|
+
font-weight: 660;
|
|
128
|
+
animation: rise 1s cubic-bezier(0.215, 0.61, 0.355, 1) both;
|
|
129
|
+
}
|
|
130
|
+
#subtitle {
|
|
131
|
+
position: absolute;
|
|
132
|
+
top: 604px;
|
|
133
|
+
left: 152px;
|
|
134
|
+
width: 720px;
|
|
135
|
+
margin: 0;
|
|
136
|
+
font-size: 30px;
|
|
137
|
+
line-height: 1.45;
|
|
138
|
+
font-weight: 420;
|
|
139
|
+
color: #96a7a3;
|
|
140
|
+
animation: rise 1s cubic-bezier(0.215, 0.61, 0.355, 1) both;
|
|
141
|
+
}
|
|
142
|
+
#footer {
|
|
143
|
+
position: absolute;
|
|
144
|
+
left: 148px;
|
|
145
|
+
right: 148px;
|
|
146
|
+
bottom: 64px;
|
|
147
|
+
animation: fade 1s linear both;
|
|
148
|
+
}
|
|
149
|
+
#rule {
|
|
150
|
+
position: relative;
|
|
151
|
+
height: 1px;
|
|
152
|
+
background: rgba(237, 246, 243, 0.14);
|
|
153
|
+
transform-origin: left center;
|
|
154
|
+
animation: draw 1s cubic-bezier(0.215, 0.61, 0.355, 1) both;
|
|
155
|
+
}
|
|
156
|
+
#progress {
|
|
157
|
+
position: absolute;
|
|
158
|
+
inset: 0 auto 0 0;
|
|
159
|
+
width: calc(var(--fy-progress) * 100%);
|
|
160
|
+
background: var(--fy-prop-accent);
|
|
161
|
+
box-shadow: 0 0 16px var(--fy-prop-accent);
|
|
162
|
+
}
|
|
163
|
+
#footer-row {
|
|
164
|
+
display: flex;
|
|
165
|
+
justify-content: space-between;
|
|
166
|
+
align-items: baseline;
|
|
167
|
+
gap: 24px;
|
|
168
|
+
margin-top: 18px;
|
|
169
|
+
font:
|
|
170
|
+
500 13px/1 ui-monospace,
|
|
171
|
+
monospace;
|
|
172
|
+
letter-spacing: 0.14em;
|
|
173
|
+
color: #758581;
|
|
174
|
+
animation: fade 1s linear both;
|
|
175
|
+
}
|
|
176
|
+
#footer-row strong {
|
|
177
|
+
color: var(--fy-prop-accent);
|
|
178
|
+
font-weight: 600;
|
|
179
|
+
}
|
|
180
|
+
#footer-meta {
|
|
181
|
+
display: flex;
|
|
182
|
+
gap: 28px;
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
<fy-composition width="1920" height="1080" fps="30" duration-in-frames="360">
|
|
188
|
+
<div id="scene">
|
|
189
|
+
<canvas id="field" width="1920" height="1080"></canvas>
|
|
190
|
+
<div id="scrim"></div>
|
|
191
|
+
<div id="vignette"></div>
|
|
192
|
+
<div id="kicker" data-fy-text="kicker" data-fy-anim-start="0" data-fy-anim-duration="28"></div>
|
|
193
|
+
<div id="meta" data-fy-anim-start="18" data-fy-anim-duration="34">
|
|
194
|
+
<div><strong>WEBGL 1.0</strong> / SWIFTSHADER</div>
|
|
195
|
+
<div>1920 × 1080 · 30 FPS</div>
|
|
196
|
+
<div>12.00 S · 360 FRAMES</div>
|
|
197
|
+
</div>
|
|
198
|
+
<h1 id="title" data-fy-text="title" data-fy-anim-start="6" data-fy-anim-duration="56"></h1>
|
|
199
|
+
<p id="subtitle" data-fy-text="subtitle" data-fy-anim-start="34" data-fy-anim-duration="52"></p>
|
|
200
|
+
<div id="footer" data-fy-anim-start="28" data-fy-anim-duration="30">
|
|
201
|
+
<div id="rule" data-fy-anim-start="38" data-fy-anim-duration="60">
|
|
202
|
+
<div id="progress"></div>
|
|
203
|
+
</div>
|
|
204
|
+
<div id="footer-row" data-fy-anim-start="44" data-fy-anim-duration="30">
|
|
205
|
+
<span>FRAME <strong id="frame">000</strong> / <span id="total">359</span></span>
|
|
206
|
+
<span id="clock">0.00 S</span>
|
|
207
|
+
<span id="footer-meta"><span>FRAME-SEEKED</span><span>NO RAF</span></span>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</fy-composition>
|
|
212
|
+
|
|
213
|
+
<script type="text/fy-controller" src="./webgl.controller.js"></script>
|
|
214
|
+
</body>
|
|
215
|
+
</html>
|