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.
Files changed (175) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/GLOSSARY.md +156 -24
  3. package/README.md +103 -31
  4. package/dist/cli.js +112 -19
  5. package/dist/cli.js.map +1 -1
  6. package/dist/composition/adapter.d.ts +30 -0
  7. package/dist/composition/adapter.d.ts.map +1 -0
  8. package/dist/composition/adapter.js +2 -0
  9. package/dist/composition/adapter.js.map +1 -0
  10. package/dist/composition/animation.d.ts +19 -0
  11. package/dist/composition/animation.d.ts.map +1 -0
  12. package/dist/composition/animation.js +55 -0
  13. package/dist/composition/animation.js.map +1 -0
  14. package/dist/composition/attributes.d.ts +7 -0
  15. package/dist/composition/attributes.d.ts.map +1 -0
  16. package/dist/composition/attributes.js +24 -0
  17. package/dist/composition/attributes.js.map +1 -0
  18. package/dist/composition/descriptor.d.ts +30 -0
  19. package/dist/composition/descriptor.d.ts.map +1 -0
  20. package/dist/composition/descriptor.js +188 -0
  21. package/dist/composition/descriptor.js.map +1 -0
  22. package/dist/composition/diagnostics.d.ts +12 -0
  23. package/dist/composition/diagnostics.d.ts.map +1 -0
  24. package/dist/composition/diagnostics.js +38 -0
  25. package/dist/composition/diagnostics.js.map +1 -0
  26. package/dist/composition/index.d.ts +12 -0
  27. package/dist/composition/index.d.ts.map +1 -0
  28. package/dist/composition/index.js +7 -0
  29. package/dist/composition/index.js.map +1 -0
  30. package/dist/composition/media.d.ts +27 -0
  31. package/dist/composition/media.d.ts.map +1 -0
  32. package/dist/composition/media.js +162 -0
  33. package/dist/composition/media.js.map +1 -0
  34. package/dist/composition/paths.d.ts +5 -0
  35. package/dist/composition/paths.d.ts.map +1 -0
  36. package/dist/composition/paths.js +16 -0
  37. package/dist/composition/paths.js.map +1 -0
  38. package/dist/composition/transcripts.d.ts +19 -0
  39. package/dist/composition/transcripts.d.ts.map +1 -0
  40. package/dist/composition/transcripts.js +57 -0
  41. package/dist/composition/transcripts.js.map +1 -0
  42. package/dist/composition/walk.d.ts +10 -0
  43. package/dist/composition/walk.d.ts.map +1 -0
  44. package/dist/composition/walk.js +32 -0
  45. package/dist/composition/walk.js.map +1 -0
  46. package/dist/contracts.d.ts +14 -0
  47. package/dist/contracts.d.ts.map +1 -1
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/errors.d.ts +1 -1
  50. package/dist/errors.d.ts.map +1 -1
  51. package/dist/errors.js.map +1 -1
  52. package/dist/html/document-adapter.d.ts +5 -0
  53. package/dist/html/document-adapter.d.ts.map +1 -0
  54. package/dist/html/document-adapter.js +17 -0
  55. package/dist/html/document-adapter.js.map +1 -0
  56. package/dist/html/parse.d.ts +2 -6
  57. package/dist/html/parse.d.ts.map +1 -1
  58. package/dist/html/parse.js.map +1 -1
  59. package/dist/html/validate.d.ts.map +1 -1
  60. package/dist/html/validate.js +92 -375
  61. package/dist/html/validate.js.map +1 -1
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/preview/main.d.ts.map +1 -1
  67. package/dist/preview/main.js +93 -13
  68. package/dist/preview/main.js.map +1 -1
  69. package/dist/preview/timeline-data.d.ts +85 -0
  70. package/dist/preview/timeline-data.d.ts.map +1 -0
  71. package/dist/preview/timeline-data.js +2 -0
  72. package/dist/preview/timeline-data.js.map +1 -0
  73. package/dist/preview/timeline-host.d.ts +19 -0
  74. package/dist/preview/timeline-host.d.ts.map +1 -0
  75. package/dist/preview/timeline-host.js +297 -0
  76. package/dist/preview/timeline-host.js.map +1 -0
  77. package/dist/preview/timeline-main.d.ts +17 -0
  78. package/dist/preview/timeline-main.d.ts.map +1 -0
  79. package/dist/preview/timeline-main.js +364 -0
  80. package/dist/preview/timeline-main.js.map +1 -0
  81. package/dist/project.d.ts +16 -1
  82. package/dist/project.d.ts.map +1 -1
  83. package/dist/project.js +81 -26
  84. package/dist/project.js.map +1 -1
  85. package/dist/renderer/browser.d.ts +2 -0
  86. package/dist/renderer/browser.d.ts.map +1 -1
  87. package/dist/renderer/browser.js +38 -6
  88. package/dist/renderer/browser.js.map +1 -1
  89. package/dist/renderer/ffmpeg.d.ts +61 -0
  90. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  91. package/dist/renderer/ffmpeg.js +198 -0
  92. package/dist/renderer/ffmpeg.js.map +1 -1
  93. package/dist/renderer/index.d.ts.map +1 -1
  94. package/dist/renderer/index.js +18 -7
  95. package/dist/renderer/index.js.map +1 -1
  96. package/dist/renderer/timeline.d.ts +109 -0
  97. package/dist/renderer/timeline.d.ts.map +1 -0
  98. package/dist/renderer/timeline.js +984 -0
  99. package/dist/renderer/timeline.js.map +1 -0
  100. package/dist/runtime/document-adapter.d.ts +4 -0
  101. package/dist/runtime/document-adapter.d.ts.map +1 -0
  102. package/dist/runtime/document-adapter.js +19 -0
  103. package/dist/runtime/document-adapter.js.map +1 -0
  104. package/dist/runtime/host.d.ts +2 -0
  105. package/dist/runtime/host.d.ts.map +1 -1
  106. package/dist/runtime/host.js +78 -183
  107. package/dist/runtime/host.js.map +1 -1
  108. package/dist/runtime/media.d.ts.map +1 -1
  109. package/dist/runtime/media.js +19 -50
  110. package/dist/runtime/media.js.map +1 -1
  111. package/dist/scaffold.d.ts.map +1 -1
  112. package/dist/scaffold.js +93 -23
  113. package/dist/scaffold.js.map +1 -1
  114. package/dist/server/preview-html.d.ts.map +1 -1
  115. package/dist/server/preview-html.js +15 -1
  116. package/dist/server/preview-html.js.map +1 -1
  117. package/dist/server/server.d.ts.map +1 -1
  118. package/dist/server/server.js +39 -1
  119. package/dist/server/server.js.map +1 -1
  120. package/dist/server/timeline-preview-data.d.ts +4 -0
  121. package/dist/server/timeline-preview-data.d.ts.map +1 -0
  122. package/dist/server/timeline-preview-data.js +97 -0
  123. package/dist/server/timeline-preview-data.js.map +1 -0
  124. package/dist/server/timeline-preview-html.d.ts +4 -0
  125. package/dist/server/timeline-preview-html.d.ts.map +1 -0
  126. package/dist/server/timeline-preview-html.js +227 -0
  127. package/dist/server/timeline-preview-html.js.map +1 -0
  128. package/dist/timeline/plan.d.ts +32 -0
  129. package/dist/timeline/plan.d.ts.map +1 -0
  130. package/dist/timeline/plan.js +68 -0
  131. package/dist/timeline/plan.js.map +1 -0
  132. package/dist/timeline/resolve.d.ts +60 -0
  133. package/dist/timeline/resolve.d.ts.map +1 -0
  134. package/dist/timeline/resolve.js +523 -0
  135. package/dist/timeline/resolve.js.map +1 -0
  136. package/dist/timeline/schema.d.ts +71 -0
  137. package/dist/timeline/schema.d.ts.map +1 -0
  138. package/dist/timeline/schema.js +425 -0
  139. package/dist/timeline/schema.js.map +1 -0
  140. package/dist/version.d.ts +1 -1
  141. package/dist/version.js +1 -1
  142. package/docs/adr/0002-make-frame-and-props-the-only-animation-state.md +2 -0
  143. package/docs/adr/0005-keep-parallel-rendering-opt-in-and-frame-ordered.md +2 -0
  144. package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +4 -0
  145. package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
  146. package/docs/adr/0008-share-the-composition-document-grammar.md +18 -0
  147. package/docs/adr/0009-composite-production-frames-in-software.md +37 -0
  148. package/docs/adr/0010-make-timelines-the-render-unit.md +24 -0
  149. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +22 -0
  150. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +31 -0
  151. package/docs/v0.1-design.md +135 -85
  152. package/examples/demo/README.md +11 -4
  153. package/examples/demo/timelines/demo.timeline.json +12 -0
  154. package/examples/demo/video.config.js +4 -0
  155. package/examples/video/README.md +105 -0
  156. package/examples/video/assets/clip.mp4 +0 -0
  157. package/examples/video/assets/clip.webm +0 -0
  158. package/examples/video/compositions/video-mp4.html +362 -0
  159. package/examples/video/compositions/video-overlay.html +350 -0
  160. package/examples/video/compositions/video.html +362 -0
  161. package/examples/video/props/video-timeline.json +10 -0
  162. package/examples/video/props/video.json +8 -0
  163. package/examples/video/scripts/make-clip.sh +109 -0
  164. package/examples/video/timelines/video-timeline-mp4.timeline.json +44 -0
  165. package/examples/video/timelines/video-timeline.timeline.json +44 -0
  166. package/examples/video/video.config.js +12 -0
  167. package/examples/webgl/README.md +30 -0
  168. package/examples/webgl/compositions/webgl.controller.js +200 -0
  169. package/examples/webgl/compositions/webgl.html +215 -0
  170. package/examples/webgl/props/webgl.json +6 -0
  171. package/examples/webgl/video.config.js +5 -0
  172. package/package.json +4 -2
  173. package/skills/frameyard/SKILL.md +18 -13
  174. package/skills/frameyard/references/json-results.md +2 -2
  175. package/skills/frameyard/references/timelines.md +141 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "kicker": "Frameyard / Clip Study 001",
3
+ "title": "Moving",
4
+ "titleAccent": "Image",
5
+ "subtitle": "A local clip, loaded once and seeked twice from the composition clock.",
6
+ "detail": "Detail / second pass",
7
+ "accent": "#ffc37a"
8
+ }
@@ -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>
@@ -0,0 +1,6 @@
1
+ {
2
+ "kicker": "Shader Study / 001",
3
+ "title": "Liquid Light",
4
+ "subtitle": "A frame-driven WebGL field, resolved in software so every captured pixel stays reproducible.",
5
+ "accent": "#78f0c8"
6
+ }
@@ -0,0 +1,5 @@
1
+ export default {
2
+ compositions: {
3
+ webgl: "./compositions/webgl.html",
4
+ },
5
+ };