mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
* fix(registry): make motion-blur match After Effects shutter-based blur The motion-blur catalog snippet estimated speed from the x/y delta between consecutive timeline updates and drew three one-sided ghost copies with decaying alpha plus a Gaussian on top. After Effects does temporal supersampling: each frame integrates the layer over a shutter window (shutter angle, shutter phase, samples per frame), producing a symmetric box smear of length speed x shutter time. The snippet now does the same inside the page. After every timeline update a deferred pass seeks the GSAP timeline to N sub-frame times across the shutter window (events suppressed), reads each target's x/y, restores the frame time, and drives an SVG filter that averages N feOffset copies with equal weight (feComposite arithmetic, 1/N, sRGB). Defaults follow AE: shutterAngle 180, shutterPhase -90, samplesPerFrame 16; fps comes from the render config, then the root data-fps, then 30. Displacements are mapped through the inverse of the element's own scale/rotation; percent x/y values are resolved to px; SVG targets use their bbox; an existing inline filter is kept and the blur appended to it. The pass runs in a microtask rather than inside the tracker tween's onUpdate, because seeking GSAP from within its own render reads stale tween state and suppresses the composition's callbacks on forward seeks. The heuristic options blurScale/blurMax/stretchScale/stretchMax are removed. Reference: an AE-algorithm reconstruction of the same composition rendered without the component at an effective 960 fps and averaged over the 16 sub-frames of the 180 degree window per output frame. Against it (1920x1080, 30 fps, solid + text translating linear then power2.inOut): old mean PSNR 53.2 dB (min 26.7), SSIM 0.9930; new mean PSNR 72.4 dB (min 52.6), SSIM 0.9998. The 10-90 edge ramp at 66.7 px/frame is 26.8 px in the reference, 27.5 px new, 14.5/6.3 px (one-sided) old; at the ease peak 79.3 vs 79.5 vs 7.9/17.7 px, with smear centres matching to 0.1 px. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(registry): drop render-config fps probe from motion-blur and refresh its docs The snippet resolved fps by reconstructing the renderer's private seek config key so the compiler's inline-script guard would not strip it. A public catalog snippet must not depend on that key, so fps is now the explicit option, then the root data-fps, then 30; renders with an fps override pass fps explicitly. The catalog page's Usage, How it works and Options sections still described the old velocity heuristic and are rewritten for the shutter model. The 1/N weight keeps full precision. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(registry): match the After Effects reference export's shutter window The shutter model landed on this branch was built against a reconstruction of After Effects' algorithm, not against After Effects. A real 1920x1080 / 30 fps export of translating text disagrees with it on three points, all measurable off the export itself. The window is two frames wide, not half a frame. On the measured frame the outermost duplicate on the trailing side sits 128 px back while the previous frame is 125.8 px back, and the outermost leading duplicate sits 189 px ahead while the next frame is 184.9 px ahead: the window ends land on the neighbouring frames. Defaults become shutterAngle 720, shutterPhase -360. Duplicates sit on the sub-interval boundaries, both ends included, not at sub-interval centres. The staircase across a stroke steps every 15.6 px on a 125.8 px displacement and every 23.0 px on a 184.9 px one, i.e. an eighth of a frame, with the run closing exactly on the neighbouring frame's position. samplesPerFrame now means sub-intervals of the window, so there are N+1 copies at k/N rather than N copies at (k+0.5)/N. The frame-time instance is drawn sharp over the smear. At full ink the core bounding box is the whole word at every frame and total ink rises to 1.44x the unblurred frame's, neither of which an average can produce. A final feComposite over of SourceGraphic reproduces it. Each duplicate stays at 1/N: the staircase plateaus step by 0.063 +- 0.002 of the sharp text intensity, flat, with no taper toward the window edges, and triangle weighting scores 1.6 dB worse against the export. Verified by replaying the export's translating section as a composition whose sprite is the export's own rest-frame ink and whose eight legs follow its own measured per-frame positions, rendered at 1920x1080 / 30 fps and compared frame by frame over the text band. Mean PSNR against the export rises from 24.19 dB (min 17.07) to 32.51 dB (min 27.57) across 72 frames, and from 19.83 dB (min 17.07) to 32.03 dB (min 27.57) across the 26 frames that actually move. Ghost extents match to a few px and the rendered staircase plateaus match the export's to two decimal places. The export's later scale, skew, flip and 3D rotation sections are out of reach here: integrating anything but translation inside the shutter is engine work tracked in issue #4010. The filter region now clamps its bounds through zero. It was derived from the duplicate offsets alone, which was correct while the output was nothing but duplicates; the sharp instance draws at offset zero, so a window sitting entirely on one side of the frame time - any shutterPhase above 0 or below -shutterAngle - produced a region starting inside the element's own box and cut the instance off. The defaults straddle the frame time and were never affected, so the measured numbers above are unchanged. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
1 line
20 KiB
JSON
1 line
20 KiB
JSON
{"html":"<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=1920, height=1080\" />\n <title>Motion Blur — Demo</title>\n <script src=\"https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js\"></script>\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n html,\n body {\n margin: 0;\n width: 1920px;\n height: 1080px;\n overflow: hidden;\n background: #06060a;\n }\n\n #demo {\n position: relative;\n width: 1920px;\n height: 1080px;\n overflow: hidden;\n font-family: \"SF Mono\", ui-monospace, monospace;\n background: #06060a;\n }\n\n .header {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n height: 80px;\n display: flex;\n align-items: flex-end;\n padding: 0 72px 20px;\n border-bottom: 1px solid #0f0f18;\n }\n .header-title {\n font-size: 11px;\n letter-spacing: 0.4em;\n text-transform: uppercase;\n color: #1e1e2a;\n }\n .header-sub {\n margin-left: auto;\n font-size: 11px;\n letter-spacing: 0.25em;\n text-transform: uppercase;\n color: #14141c;\n }\n\n .group {\n position: absolute;\n left: 0;\n right: 0;\n height: 184px;\n }\n\n .group::after {\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 72px;\n right: 72px;\n height: 1px;\n background: #0d0d15;\n }\n\n .dur {\n position: absolute;\n left: 72px;\n top: 0;\n width: 90px;\n height: 160px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n }\n .dur-num {\n font-size: 28px;\n font-weight: 700;\n letter-spacing: -0.04em;\n color: #fff;\n line-height: 1;\n }\n .dur-unit {\n font-size: 9px;\n letter-spacing: 0.25em;\n text-transform: uppercase;\n color: #252532;\n margin-top: 5px;\n }\n\n .row-hint {\n position: absolute;\n left: 176px;\n width: 44px;\n height: 72px;\n display: flex;\n align-items: center;\n font-size: 8px;\n letter-spacing: 0.2em;\n text-transform: uppercase;\n color: #161620;\n }\n\n .spd {\n position: absolute;\n right: 72px;\n top: 0;\n width: 100px;\n height: 160px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: flex-end;\n }\n .spd-label {\n font-size: 10px;\n letter-spacing: 0.3em;\n text-transform: uppercase;\n color: #181822;\n }\n\n .pill {\n position: absolute;\n left: 0;\n top: 0;\n width: 200px;\n height: 72px;\n border-radius: 36px;\n /* Flat L1 ink: every row is the same object, so only the blur differs. */\n background: rgba(244, 244, 245, 0.72);\n will-change: transform, filter;\n }\n\n .word {\n position: absolute;\n left: 0;\n top: 88px;\n height: 72px;\n line-height: 72px;\n white-space: nowrap;\n font-size: 56px;\n font-weight: 800;\n letter-spacing: -0.03em;\n color: #f4f4f5;\n will-change: transform, filter;\n }\n\n .inner-div {\n position: absolute;\n left: 220px;\n right: 200px;\n top: 80px;\n height: 1px;\n background: #0a0a12;\n }\n\n .track-a,\n .track-b {\n position: absolute;\n left: 220px;\n right: 200px;\n height: 1px;\n background: #0c0c14;\n }\n .track-a {\n top: 35px;\n }\n .track-b {\n top: 123px;\n }\n </style>\n </head>\n <body>\n <div\n id=\"demo\"\n data-composition-id=\"motion-blur-demo\"\n data-width=\"1920\"\n data-height=\"1080\"\n data-start=\"0\"\n data-duration=\"4\"\n >\n <div class=\"header\">\n <span class=\"header-title\">Motion Blur — Velocity Showcase</span>\n <span class=\"header-sub\"\n >17 duplicates at 1/16 · 720° shutter · smear = speed × shutter time</span\n >\n </div>\n\n <div class=\"group\" style=\"top: 80px\">\n <div class=\"dur\"><span class=\"dur-num\">3.0</span><span class=\"dur-unit\">seconds</span></div>\n <div class=\"row-hint\" style=\"top: 0\">shape</div>\n <div class=\"row-hint\" style=\"top: 88px\">text</div>\n <div class=\"track-a\"></div>\n <div class=\"inner-div\"></div>\n <div class=\"track-b\"></div>\n <div class=\"pill\" id=\"s1\"></div>\n <div class=\"word\" id=\"t1\">DRIFT</div>\n <div class=\"spd\"><span class=\"spd-label\">slow</span></div>\n </div>\n\n <div class=\"group\" style=\"top: 264px\">\n <div class=\"dur\"><span class=\"dur-num\">1.5</span><span class=\"dur-unit\">seconds</span></div>\n <div class=\"row-hint\" style=\"top: 0\">shape</div>\n <div class=\"row-hint\" style=\"top: 88px\">text</div>\n <div class=\"track-a\"></div>\n <div class=\"inner-div\"></div>\n <div class=\"track-b\"></div>\n <div class=\"pill\" id=\"s2\"></div>\n <div class=\"word\" id=\"t2\">GLIDE</div>\n <div class=\"spd\"><span class=\"spd-label\">medium</span></div>\n </div>\n\n <div class=\"group\" style=\"top: 448px\">\n <div class=\"dur\"><span class=\"dur-num\">0.7</span><span class=\"dur-unit\">seconds</span></div>\n <div class=\"row-hint\" style=\"top: 0\">shape</div>\n <div class=\"row-hint\" style=\"top: 88px\">text</div>\n <div class=\"track-a\"></div>\n <div class=\"inner-div\"></div>\n <div class=\"track-b\"></div>\n <div class=\"pill\" id=\"s3\"></div>\n <div class=\"word\" id=\"t3\">RUSH</div>\n <div class=\"spd\"><span class=\"spd-label\">fast</span></div>\n </div>\n\n <div class=\"group\" style=\"top: 632px\">\n <div class=\"dur\">\n <span class=\"dur-num\">0.35</span><span class=\"dur-unit\">seconds</span>\n </div>\n <div class=\"row-hint\" style=\"top: 0\">shape</div>\n <div class=\"row-hint\" style=\"top: 88px\">text</div>\n <div class=\"track-a\"></div>\n <div class=\"inner-div\"></div>\n <div class=\"track-b\"></div>\n <div class=\"pill\" id=\"s4\"></div>\n <div class=\"word\" id=\"t4\">BLAST</div>\n <div class=\"spd\"><span class=\"spd-label\">faster</span></div>\n </div>\n\n <div class=\"group\" style=\"top: 816px\">\n <div class=\"dur\"><span class=\"dur-num\">0.2</span><span class=\"dur-unit\">seconds</span></div>\n <div class=\"row-hint\" style=\"top: 0\">shape</div>\n <div class=\"row-hint\" style=\"top: 88px\">text</div>\n <div class=\"track-a\"></div>\n <div class=\"inner-div\"></div>\n <div class=\"track-b\"></div>\n <div class=\"pill\" id=\"s5\"></div>\n <div class=\"word\" id=\"t5\">WARP</div>\n <div class=\"spd\"><span class=\"spd-label\">extreme</span></div>\n </div>\n\n <script>\n (function () {\n if (!window._hfMbUid) window._hfMbUid = 0;\n\n // Shutter length is measured in frames, so the snippet must know the\n // composition frame rate: explicit option, else the root's data-fps, else 30.\n function resolveFps(optionFps) {\n var explicit = Number(optionFps);\n if (explicit > 0) return explicit;\n\n var root = document.querySelector(\"[data-composition-id][data-fps]\");\n var rootFps = root ? Number(root.getAttribute(\"data-fps\")) : 0;\n if (rootFps > 0) return rootFps;\n\n return 30;\n }\n\n function numOption(value, fallback) {\n return value !== undefined ? Number(value) : fallback;\n }\n\n // objectBoundingBox percentage for a px offset plus padding.\n function regionPercent(offset, size, padPercent) {\n return ((offset / size) * 100 + padPercent).toFixed(2) + \"%\";\n }\n\n window.attachMotionBlur = function (selector, tl, opts) {\n opts = opts || {};\n var shutterAngle = numOption(opts.shutterAngle, 720);\n var shutterPhase = numOption(opts.shutterPhase, -360);\n var requestedSamples = numOption(opts.samplesPerFrame, 16);\n var samples = Number.isFinite(requestedSamples)\n ? Math.max(2, Math.min(64, Math.round(requestedSamples)))\n : 16;\n var axis = opts.axis || \"both\";\n var fps = resolveFps(opts.fps);\n var useX = axis !== \"y\";\n var useY = axis !== \"x\";\n\n // Untransformed box size; SVG elements have no offsetWidth, so use the\n // same bbox GSAP uses to resolve their percent transforms.\n function boxSize(el, horizontal) {\n var box = typeof el.offsetWidth === \"number\" ? el : el.getBBox ? el.getBBox() : null;\n var size = box\n ? horizontal\n ? box.offsetWidth || box.width\n : box.offsetHeight || box.height\n : 0;\n return size || el.getBoundingClientRect()[horizontal ? \"width\" : \"height\"] || 1;\n }\n\n // GSAP x/y may carry a % unit and xPercent/yPercent is a separate term;\n // resolve both against the element's own box so samples are in px.\n function readAxis(el, prop, percentProp, size) {\n var px = parseFloat(gsap.getProperty(el, prop, \"px\")) || 0;\n var percent = parseFloat(gsap.getProperty(el, percentProp)) || 0;\n return px + (percent * size) / 100;\n }\n\n function readPosition(el) {\n return {\n x: useX ? readAxis(el, \"x\", \"xPercent\", boxSize(el, true)) : 0,\n y: useY ? readAxis(el, \"y\", \"yPercent\", boxSize(el, false)) : 0,\n };\n }\n\n // CSS filters run in the element's pre-transform space while GSAP x/y are\n // post-transform, so a displacement is mapped through the inverse of the\n // element's own scale/rotation. Ancestor transforms are not compensated.\n function toFilterSpace(el, dx, dy) {\n var sx = parseFloat(gsap.getProperty(el, \"scaleX\")) || 1;\n var sy = parseFloat(gsap.getProperty(el, \"scaleY\")) || 1;\n var theta = ((parseFloat(gsap.getProperty(el, \"rotation\")) || 0) * Math.PI) / 180;\n var cos = Math.cos(theta);\n var sin = Math.sin(theta);\n return { dx: (dx * cos + dy * sin) / sx, dy: (-dx * sin + dy * cos) / sy };\n }\n\n var items = Array.isArray(selector) ? selector : [selector];\n var targets = items.reduce(function (acc, s) {\n if (typeof s === \"string\") {\n document.querySelectorAll(s).forEach(function (el) {\n acc.push(el);\n });\n } else if (s instanceof Element) {\n acc.push(s);\n }\n return acc;\n }, []);\n\n var ns = \"http://www.w3.org/2000/svg\";\n // `samples` counts sub-intervals of the shutter window, so there is one copy at\n // every interval boundary — both ends of the window included — and each carries\n // 1/samples of the source. That is what the reference export shows: the outermost\n // copy sits exactly one frame away from the frame time, not half a step short.\n var copies = samples + 1;\n var weight = String(1 / samples);\n\n // One SVG filter per target: N+1 offset copies of SourceGraphic added at 1/N\n // each, then the element itself over the top. Accumulating in sRGB matches\n // AE's default (non-linear) 8-bpc working space.\n var state = targets.map(function (el) {\n var uid = \"hf-mb-\" + window._hfMbUid++;\n var svg = document.createElementNS(ns, \"svg\");\n svg.setAttribute(\"style\", \"position:absolute;width:0;height:0;overflow:hidden;\");\n\n var filter = document.createElementNS(ns, \"filter\");\n filter.id = uid;\n filter.setAttribute(\"color-interpolation-filters\", \"sRGB\");\n\n var offsets = [];\n for (var k = 0; k < copies; k++) {\n var feOff = document.createElementNS(ns, \"feOffset\");\n feOff.setAttribute(\"in\", \"SourceGraphic\");\n feOff.setAttribute(\"dx\", \"0\");\n feOff.setAttribute(\"dy\", \"0\");\n feOff.setAttribute(\"result\", \"s\" + k);\n filter.appendChild(feOff);\n offsets.push(feOff);\n }\n\n // Pairwise accumulation: the first composite scales both inputs by 1/N, every\n // later one adds the next copy at 1/N to the running sum. The N+1 weights total\n // (N+1)/N, so an element wider than its own smear saturates through the middle\n // and the filter clamps there. That is the saturation the reference shows, and\n // the sharp instance covers the same region regardless.\n var accumulated = \"s0\";\n for (var c = 1; c < copies; c++) {\n var feComp = document.createElementNS(ns, \"feComposite\");\n feComp.setAttribute(\"operator\", \"arithmetic\");\n feComp.setAttribute(\"in\", accumulated);\n feComp.setAttribute(\"in2\", \"s\" + c);\n feComp.setAttribute(\"k1\", \"0\");\n feComp.setAttribute(\"k2\", c === 1 ? weight : \"1\");\n feComp.setAttribute(\"k3\", weight);\n feComp.setAttribute(\"k4\", \"0\");\n accumulated = \"a\" + c;\n feComp.setAttribute(\"result\", accumulated);\n filter.appendChild(feComp);\n }\n\n // The frame-time instance is drawn sharp, at full opacity, over the smear.\n // Every blurred copy stays at 1/N, so nothing but the instance the frame is\n // actually at reads as solid. Unnamed, so it is the filter output.\n var feOver = document.createElementNS(ns, \"feComposite\");\n feOver.setAttribute(\"operator\", \"over\");\n feOver.setAttribute(\"in\", \"SourceGraphic\");\n feOver.setAttribute(\"in2\", accumulated);\n filter.appendChild(feOver);\n\n svg.appendChild(filter);\n document.body.appendChild(svg);\n\n return { el: el, offsets: offsets, filter: filter, filterId: uid, xs: [], ys: [] };\n });\n\n // The element may carry its own inline filter (authored or tweened by\n // GSAP); the blur is appended to that chain and removed from it, never\n // written over it.\n var OWN_FILTER = /\\s*url\\([\"']?#hf-mb-\\d+[\"']?\\)/g;\n\n function baseFilter(el) {\n return el.style.filter.replace(OWN_FILTER, \"\").trim();\n }\n\n function clearBlur(s) {\n s.el.style.filter = baseFilter(s.el);\n }\n\n var scheduled = false;\n\n function applyShutter() {\n var shutterTime = shutterAngle / 360 / fps;\n if (!(shutterTime > 0)) {\n state.forEach(clearBlur);\n return;\n }\n\n var t0 = tl.time();\n var duration = tl.duration();\n var windowStart = t0 + shutterPhase / 360 / fps;\n\n // Sample the real trajectory: seek to each sub-frame time with events\n // suppressed (so no tween callbacks, including this tracker's, fire),\n // read positions, then restore the frame time.\n try {\n for (var k = 0; k < copies; k++) {\n var tk = windowStart + (k / samples) * shutterTime;\n tl.time(Math.min(duration, Math.max(0, tk)), true);\n state.forEach(function (s) {\n var pos = readPosition(s.el);\n s.xs[k] = pos.x;\n s.ys[k] = pos.y;\n });\n }\n } finally {\n tl.time(t0, true);\n }\n\n state.forEach(function (s) {\n var current = readPosition(s.el);\n var minDx = Infinity;\n var maxDx = -Infinity;\n var minDy = Infinity;\n var maxDy = -Infinity;\n for (var i = 0; i < copies; i++) {\n var d = toFilterSpace(s.el, s.xs[i] - current.x, s.ys[i] - current.y);\n var dx = d.dx;\n var dy = d.dy;\n s.offsets[i].setAttribute(\"dx\", dx.toFixed(3));\n s.offsets[i].setAttribute(\"dy\", dy.toFixed(3));\n if (dx < minDx) minDx = dx;\n if (dx > maxDx) maxDx = dx;\n if (dy < minDy) minDy = dy;\n if (dy > maxDy) maxDy = dy;\n }\n\n // Below half a pixel of smear the average is indistinguishable from\n // the source — render sharp instead of paying for the filter.\n if (maxDx - minDx < 0.5 && maxDy - minDy < 0.5) {\n clearBlur(s);\n return;\n }\n\n // Grow the filter region to cover every copy (objectBoundingBox\n // units, 10% padding so anti-aliased edges are not clipped).\n var w = boxSize(s.el, true);\n var h = boxSize(s.el, false);\n s.filter.setAttribute(\"x\", regionPercent(minDx, w, -10));\n s.filter.setAttribute(\"width\", regionPercent(maxDx - minDx, w, 120));\n s.filter.setAttribute(\"y\", regionPercent(minDy, h, -10));\n s.filter.setAttribute(\"height\", regionPercent(maxDy - minDy, h, 120));\n var base = baseFilter(s.el);\n s.el.style.filter = (base ? base + \" \" : \"\") + \"url(#\" + s.filterId + \")\";\n });\n }\n\n // tl.eventCallback(\"onUpdate\") is unavailable under the HyperFrames runtime proxy, so a\n // tracker tween's onUpdate fires on every seek. Sampling is deferred to a microtask:\n // seeking GSAP from inside its own render reads stale tween state, while the microtask\n // runs after the seek returns and before the frame is captured or painted.\n var _proxy = { t: 0 };\n tl.to(\n _proxy,\n {\n t: 1,\n duration: Math.max(tl.duration(), 0.1),\n ease: \"none\",\n onUpdate: function () {\n if (scheduled) return;\n scheduled = true;\n Promise.resolve().then(function () {\n scheduled = false;\n applyShutter();\n });\n },\n },\n 0,\n );\n };\n\n var tl = gsap.timeline({ paused: true });\n\n gsap.set([\"#s1\", \"#s2\", \"#s3\", \"#s4\", \"#s5\", \"#t1\", \"#t2\", \"#t3\", \"#t4\", \"#t5\"], {\n x: -400,\n });\n\n tl.to([\"#s1\", \"#t1\"], { x: 1640, duration: 3.0, ease: \"power3.inOut\" }, 0);\n tl.to([\"#s2\", \"#t2\"], { x: 1640, duration: 1.5, ease: \"power3.inOut\" }, 0);\n tl.to([\"#s3\", \"#t3\"], { x: 1640, duration: 0.7, ease: \"power3.inOut\" }, 0);\n tl.to([\"#s4\", \"#t4\"], { x: 1640, duration: 0.35, ease: \"power3.inOut\" }, 0);\n tl.to([\"#s5\", \"#t5\"], { x: 1640, duration: 0.2, ease: \"power3.inOut\" }, 0);\n\n tl.set(document.body, {}, 4);\n\n attachMotionBlur(\n [\"#s1\", \"#s2\", \"#s3\", \"#s4\", \"#s5\", \"#t1\", \"#t2\", \"#t3\", \"#t4\", \"#t5\"],\n tl,\n { axis: \"x\" },\n );\n\n window.__timelines = window.__timelines || {};\n window.__timelines[\"motion-blur-demo\"] = tl;\n })();\n </script>\n </div>\n </body>\n</html>\n"} |