diff --git a/common/api.js b/common/api.js index d82a57a..826057c 100644 --- a/common/api.js +++ b/common/api.js @@ -56,10 +56,7 @@ var default_api = 'https://api.global-interaction.com/'; return isLocalDevOrigin() ? devProxyBase('/__api_test__/') : normalizeBaseURL(test_api); - if (env === 'local') - return isLocalDevOrigin() - ? devProxyBase('/__api_local__/') - : normalizeBaseURL(local_api); + if (env === 'local') return normalizeBaseURL(local_api); return normalizeBaseURL(default_api); } @@ -76,6 +73,17 @@ var default_api = 'https://api.global-interaction.com/'; return url.toString(); } + function isSameOriginURL(url) { + try { + return ( + new URL(url, window.location.href).origin === + window.location.origin + ); + } catch (_) { + return false; + } + } + function getAccessToken() { if (memoryAccessToken) return memoryAccessToken; if (!shouldPersist()) return ''; @@ -171,9 +179,11 @@ var default_api = 'https://api.global-interaction.com/'; var headers = Object.assign({}, opts.headers || {}); var token = getAccessToken(); var appCode = getAppCode(); + var url = buildURL(path, opts.query); if (token) headers.Authorization = 'Bearer ' + token; if (appCode) headers['X-App-Code'] = appCode; - if (!shouldPersist()) headers['Cache-Control'] = 'no-cache'; + if (!shouldPersist() && isSameOriginURL(url)) + headers['Cache-Control'] = 'no-cache'; var body = opts.body; if (body && typeof body === 'object' && !(body instanceof FormData)) { @@ -183,7 +193,7 @@ var default_api = 'https://api.global-interaction.com/'; } return window - .fetch(buildURL(path, opts.query), { + .fetch(url, { method: opts.method || 'GET', headers: headers, body: body, diff --git a/common/effect-player.js b/common/effect-player.js new file mode 100644 index 0000000..a01f37f --- /dev/null +++ b/common/effect-player.js @@ -0,0 +1,1003 @@ +(function () { + var DEFAULT_SVGA_SCRIPT = + 'https://cdn.jsdelivr.net/npm/svgaplayerweb@2.3.2/build/svga.min.js'; + var DEFAULT_PAG_BASE = 'https://cdn.jsdelivr.net/npm/libpag@4.5.68/lib/'; + var scriptCache = {}; + var pagInitCache = {}; + var videoLayoutCache = {}; + var MIN_SPLIT_MATCH_FRAMES = 3; + var MIN_USEFUL_FRAME_BRIGHTNESS = 0.035; + var MIN_SPLIT_SATURATION_GAP = 0.08; + var MAX_SPLIT_MASK_SATURATION = 0.28; + var MAX_SPLIT_MASK_GRAY_ERROR = 0.06; + + function extend(target, source) { + var output = target || {}; + source = source || {}; + Object.keys(source).forEach(function (key) { + if (source[key] !== undefined) output[key] = source[key]; + }); + return output; + } + + function resolveElement(target) { + if (!target) return null; + if (typeof target === 'string') return document.querySelector(target); + return target; + } + + function loadScriptOnce(url) { + if (scriptCache[url]) return scriptCache[url]; + scriptCache[url] = new Promise(function (resolve, reject) { + var script = document.createElement('script'); + script.src = url; + script.async = true; + script.onload = resolve; + script.onerror = function () { + reject(new Error('script_load_failed')); + }; + document.head.appendChild(script); + }); + return scriptCache[url]; + } + + function waitForWindowLoad() { + if (document.readyState === 'complete') return Promise.resolve(); + return new Promise(function (resolve) { + window.addEventListener('load', resolve, { once: true }); + }); + } + + function extensionFromURL(url) { + var clean = String(url || '').split('?')[0].split('#')[0]; + var match = clean.match(/\.([a-z0-9]+)$/i); + return match ? match[1].toLowerCase() : ''; + } + + function inferType(url, explicitType) { + var type = String(explicitType || '').toLowerCase(); + if (type) return type; + var ext = extensionFromURL(url); + if (ext === 'mp4' || ext === 'webm' || ext === 'mov') return 'video'; + if (ext === 'svga') return 'svga'; + if (ext === 'pag') return 'pag'; + return ''; + } + + function sameOrigin(url) { + try { + return ( + new URL(url, window.location.href).origin === + window.location.origin + ); + } catch (_) { + return true; + } + } + + function proxyBinaryURL(url, type, options) { + if (options.proxy === false) return url; + if (type !== 'svga' && type !== 'pag') return url; + if (sameOrigin(url)) return url; + if (typeof options.proxyURL === 'function') { + return options.proxyURL(url, type) || url; + } + if (typeof options.proxyURL === 'string' && options.proxyURL) { + var base = options.proxyURL; + var separator = base.indexOf('?') >= 0 ? '&' : '?'; + return base + separator + 'url=' + encodeURIComponent(url); + } + if ( + window.HyAppAPI && + typeof window.HyAppAPI.buildURL === 'function' + ) { + return window.HyAppAPI.buildURL('/api/v1/media/effects/proxy', { + url: url, + }); + } + return url; + } + + function mediaSize(container, options) { + return { + width: + Number(options.width) || + (container && container.clientWidth) || + 300, + height: + Number(options.height) || + (container && container.clientHeight) || + 300, + }; + } + + function styleNode(node, options) { + node.style.display = 'block'; + node.style.width = '100%'; + node.style.height = '100%'; + if (node.tagName === 'VIDEO' || node.tagName === 'CANVAS') { + node.style.objectFit = options.fit || 'contain'; + } + } + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function median(values) { + if (!values.length) return 0; + var sorted = values.slice().sort(function (a, b) { + return a - b; + }); + return sorted[Math.floor(sorted.length / 2)]; + } + + function fullFrameLayout(video, kind, hasNativeAlpha) { + var width = Math.max(1, video.videoWidth || 1); + var height = Math.max(1, video.videoHeight || 1); + var rect = { x: 0, y: 0, width: 1, height: 1 }; + return { + kind: kind || 'normal', + hasAlphaMask: false, + hasNativeAlpha: !!hasNativeAlpha, + contentAspectRatio: width / height, + colorRect: rect, + alphaRect: rect, + }; + } + + function waitForMediaEvent(target, eventName, timeoutMS) { + return new Promise(function (resolve, reject) { + var timer = 0; + function cleanup() { + window.clearTimeout(timer); + target.removeEventListener(eventName, handleEvent); + target.removeEventListener('error', handleError); + } + function handleEvent() { + cleanup(); + resolve(); + } + function handleError() { + cleanup(); + reject(new Error('video_' + eventName + '_failed')); + } + target.addEventListener(eventName, handleEvent, { once: true }); + target.addEventListener('error', handleError, { once: true }); + timer = window.setTimeout(function () { + cleanup(); + reject(new Error('video_' + eventName + '_timeout')); + }, timeoutMS || 4000); + }); + } + + function loadVideoMetadata(video) { + if (video.readyState >= 1 && video.videoWidth && video.videoHeight) { + return Promise.resolve(); + } + return waitForMediaEvent(video, 'loadedmetadata', 8000); + } + + function seekVideo(video, time) { + var duration = Number(video.duration || 0); + var targetTime = duration + ? clamp(time, 0, Math.max(0, duration - 0.05)) + : Math.max(0, time); + if (Math.abs(video.currentTime - targetTime) < 0.04) { + return Promise.resolve(); + } + video.currentTime = targetTime; + return waitForMediaEvent(video, 'seeked', 2500).catch(function () {}); + } + + function videoSampleTimes(duration, mode) { + var base = + mode === 'packed' + ? [0, 0.5, 1, 2, 3] + : [0.3, 0.6, 0.9, 1.2, 1.5, 1.8, 2.1, 2.4, 2.7, 3]; + var filtered = base.filter(function (time) { + return !duration || !Number.isFinite(duration) || time <= duration; + }); + return filtered.length ? filtered : [duration ? duration / 2 : 0]; + } + + function uniqueTimes(times) { + var seen = {}; + return times + .slice() + .sort(function (a, b) { + return a - b; + }) + .filter(function (time) { + var key = time.toFixed(2); + if (seen[key]) return false; + seen[key] = true; + return true; + }); + } + + function readVideoFrame(video, maxWidth) { + var sourceWidth = Math.max(1, video.videoWidth || 1); + var sourceHeight = Math.max(1, video.videoHeight || 1); + var sampleWidth = Math.min(sourceWidth, Number(maxWidth) || 480); + var sampleHeight = Math.max( + 1, + Math.round((sampleWidth * sourceHeight) / sourceWidth) + ); + var canvas = document.createElement('canvas'); + var context = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = sampleWidth; + canvas.height = sampleHeight; + context.clearRect(0, 0, sampleWidth, sampleHeight); + context.drawImage(video, 0, 0, sampleWidth, sampleHeight); + return { + width: sampleWidth, + height: sampleHeight, + data: context.getImageData(0, 0, sampleWidth, sampleHeight).data, + }; + } + + function pixelAt(frame, x, y) { + var safeX = clamp(Math.round(x), 0, frame.width - 1); + var safeY = clamp(Math.round(y), 0, frame.height - 1); + var index = (safeY * frame.width + safeX) * 4; + return { + r: frame.data[index], + g: frame.data[index + 1], + b: frame.data[index + 2], + a: frame.data[index + 3], + }; + } + + function hasNativeAlpha(frames) { + for (var index = 0; index < frames.length; index += 1) { + var data = frames[index].data; + var transparent = 0; + var sampled = 0; + var step = Math.max(4, Math.floor(data.length / (4 * 6000)) * 4); + for (var offset = 3; offset < data.length; offset += step) { + sampled += 1; + if (data[offset] < 245) transparent += 1; + } + if (sampled && transparent / sampled > 0.003) return true; + } + return false; + } + + function isLikelyAlphaMaskPixel(pixel) { + var maxChannel = Math.max(pixel.r, pixel.g, pixel.b); + if (maxChannel < 22) return false; + var minChannel = Math.min(pixel.r, pixel.g, pixel.b); + var saturation = maxChannel + ? (maxChannel - minChannel) / maxChannel + : 0; + var grayError = + (Math.abs(pixel.r - pixel.g) + + Math.abs(pixel.g - pixel.b) + + Math.abs(pixel.b - pixel.r)) / + (3 * 255); + return saturation < 0.16 && grayError < 0.055; + } + + function detectPackedAlphaTopRightInFrame(frame) { + var width = frame.width; + var height = frame.height; + if (width < 12 || height < 12) return null; + + var searchStart = clamp(Math.floor(width * 0.54), 0, width - 1); + var sampleHeight = clamp(Math.floor(height * 0.58), 1, height); + var stepY = Math.max(1, Math.floor(sampleHeight / 220)); + var rowSamples = Math.max(1, Math.ceil(sampleHeight / stepY)); + var threshold = Math.max(8, Math.floor(rowSamples * 0.32)); + var grayColumns = []; + + for (var x = searchStart; x < width; x += 1) { + var grayCount = 0; + for (var y = 0; y < sampleHeight; y += stepY) { + if (isLikelyAlphaMaskPixel(pixelAt(frame, x, y))) { + grayCount += 1; + } + } + grayColumns[x] = grayCount >= threshold; + } + + var minRangeWidth = Math.max(24, Math.floor(width * 0.12)); + var bestStart = -1; + var bestEnd = -1; + var cursor = searchStart; + while (cursor < width) { + while (cursor < width && !grayColumns[cursor]) cursor += 1; + if (cursor >= width) break; + var start = cursor; + while (cursor < width && grayColumns[cursor]) cursor += 1; + var end = cursor - 1; + if (end - start + 1 >= minRangeWidth && end > bestEnd) { + bestStart = start; + bestEnd = end; + } + } + if (bestStart < 0 || bestEnd < 0) return null; + return { + frameWidth: width, + frameHeight: height, + alphaStartX: bestStart, + alphaEndX: bestEnd, + }; + } + + function detectPackedAlphaTopRight(frames) { + var candidates = frames + .map(detectPackedAlphaTopRightInFrame) + .filter(Boolean); + if (!candidates.length) return null; + var frameWidth = median( + candidates.map(function (item) { + return item.frameWidth; + }) + ); + var frameHeight = median( + candidates.map(function (item) { + return item.frameHeight; + }) + ); + var alphaStartX = median( + candidates.map(function (item) { + return item.alphaStartX; + }) + ); + var alphaEndX = median( + candidates.map(function (item) { + return item.alphaEndX; + }) + ); + var alphaWidth = alphaEndX - alphaStartX + 1; + var alphaStartRatio = alphaStartX / frameWidth; + var alphaWidthRatio = alphaWidth / frameWidth; + var colorToAlphaRatio = alphaStartX / alphaWidth; + if ( + alphaStartRatio < 0.58 || + alphaStartRatio > 0.78 || + alphaWidthRatio < 0.18 || + alphaWidthRatio > 0.42 || + colorToAlphaRatio < 1.45 || + colorToAlphaRatio > 2.65 || + alphaEndX < frameWidth * 0.82 + ) { + return null; + } + + var colorWidth = clamp(alphaStartX, 1, frameWidth - 1); + var safeAlphaEndX = clamp(alphaEndX, colorWidth, frameWidth - 1); + var safeAlphaWidth = Math.max(1, safeAlphaEndX - colorWidth + 1); + var alphaHeight = clamp( + Math.round((frameHeight * safeAlphaWidth) / colorWidth), + 1, + frameHeight + ); + return { + kind: 'packedAlphaTopRight', + hasAlphaMask: true, + hasNativeAlpha: false, + contentAspectRatio: colorWidth / frameHeight, + colorRect: { + x: 0, + y: 0, + width: colorWidth / frameWidth, + height: 1, + }, + alphaRect: { + x: colorWidth / frameWidth, + y: 0, + width: safeAlphaWidth / frameWidth, + height: alphaHeight / frameHeight, + }, + }; + } + + function statsForRect(frame, startX, startY, endX, endY) { + var width = Math.max(1, endX - startX); + var height = Math.max(1, endY - startY); + var stepX = Math.max(1, Math.floor(width / 80)); + var stepY = Math.max(1, Math.floor(height / 120)); + var saturationSum = 0; + var grayErrorSum = 0; + var brightnessSum = 0; + var count = 0; + for (var y = startY; y < endY; y += stepY) { + for (var x = startX; x < endX; x += stepX) { + var pixel = pixelAt(frame, x, y); + var red = pixel.r / 255; + var green = pixel.g / 255; + var blue = pixel.b / 255; + var maxChannel = Math.max(red, green, blue); + var minChannel = Math.min(red, green, blue); + saturationSum += maxChannel + ? (maxChannel - minChannel) / maxChannel + : 0; + grayErrorSum += + (Math.abs(red - green) + + Math.abs(green - blue) + + Math.abs(blue - red)) / + 3; + brightnessSum += red * 0.299 + green * 0.587 + blue * 0.114; + count += 1; + } + } + if (!count) { + return { saturation: 0, grayError: 0, brightness: 0 }; + } + return { + saturation: saturationSum / count, + grayError: grayErrorSum / count, + brightness: brightnessSum / count, + }; + } + + function statsByHorizontalHalves(frame) { + var half = Math.floor(frame.width / 2); + return [ + statsForRect(frame, 0, 0, half, frame.height), + statsForRect(frame, half, 0, frame.width, frame.height), + ]; + } + + function statsByVerticalHalves(frame) { + var half = Math.floor(frame.height / 2); + return [ + statsForRect(frame, 0, 0, frame.width, half), + statsForRect(frame, 0, half, frame.width, frame.height), + ]; + } + + function addSplitScore(score, first, second) { + if ( + Math.max(first.brightness, second.brightness) < + MIN_USEFUL_FRAME_BRIGHTNESS + ) { + return; + } + var alphaStats = + first.saturation < second.saturation ? first : second; + var saturationGap = Math.abs(second.saturation - first.saturation); + if ( + saturationGap < MIN_SPLIT_SATURATION_GAP || + alphaStats.saturation > MAX_SPLIT_MASK_SATURATION || + alphaStats.grayError > MAX_SPLIT_MASK_GRAY_ERROR + ) { + return; + } + score.direction += second.saturation - first.saturation; + score.saturationGap += saturationGap; + score.maskSaturation += alphaStats.saturation; + score.maskGrayError += alphaStats.grayError; + score.frames += 1; + } + + function splitCandidateFromScore(orientation, score) { + if (score.frames < MIN_SPLIT_MATCH_FRAMES) return null; + var averageSaturationGap = score.saturationGap / score.frames; + var averageMaskSaturation = score.maskSaturation / score.frames; + var averageMaskGrayError = score.maskGrayError / score.frames; + if ( + averageSaturationGap < MIN_SPLIT_SATURATION_GAP || + averageMaskSaturation > MAX_SPLIT_MASK_SATURATION || + averageMaskGrayError > MAX_SPLIT_MASK_GRAY_ERROR + ) { + return null; + } + return { + candidate: { + orientation: orientation, + alphaFirst: score.direction > 0, + }, + score: averageSaturationGap, + }; + } + + function splitSourceLayout(candidate, video) { + var left = { x: 0, y: 0, width: 0.5, height: 1 }; + var right = { x: 0.5, y: 0, width: 0.5, height: 1 }; + var top = { x: 0, y: 0, width: 1, height: 0.5 }; + var bottom = { x: 0, y: 0.5, width: 1, height: 0.5 }; + var isHorizontal = candidate.orientation === 'horizontal'; + var first = isHorizontal ? left : top; + var second = isHorizontal ? right : bottom; + var width = Math.max(1, video.videoWidth || 1); + var height = Math.max(1, video.videoHeight || 1); + return { + kind: isHorizontal + ? 'splitAlphaHorizontal' + : 'splitAlphaVertical', + hasAlphaMask: true, + hasNativeAlpha: false, + contentAspectRatio: isHorizontal + ? width / 2 / height + : width / (height / 2), + colorRect: candidate.alphaFirst ? second : first, + alphaRect: candidate.alphaFirst ? first : second, + }; + } + + function detectSplitAlpha(frames, video) { + var horizontalScore = { + direction: 0, + saturationGap: 0, + maskSaturation: 0, + maskGrayError: 0, + frames: 0, + }; + var verticalScore = { + direction: 0, + saturationGap: 0, + maskSaturation: 0, + maskGrayError: 0, + frames: 0, + }; + frames.forEach(function (frame) { + var horizontal = statsByHorizontalHalves(frame); + var vertical = statsByVerticalHalves(frame); + addSplitScore(horizontalScore, horizontal[0], horizontal[1]); + addSplitScore(verticalScore, vertical[0], vertical[1]); + }); + var candidates = [ + splitCandidateFromScore('horizontal', horizontalScore), + splitCandidateFromScore('vertical', verticalScore), + ].filter(Boolean); + if (!candidates.length) return null; + candidates.sort(function (a, b) { + return b.score - a.score; + }); + return splitSourceLayout(candidates[0].candidate, video); + } + + function sampleVideoFrames(video, mode, maxWidth) { + var times = uniqueTimes(videoSampleTimes(video.duration || 0, mode)); + var frames = []; + var chain = Promise.resolve(); + times.forEach(function (time) { + chain = chain + .then(function () { + return seekVideo(video, time); + }) + .then(function () { + frames.push(readVideoFrame(video, maxWidth)); + }); + }); + return chain.then(function () { + return frames; + }); + } + + function detectVideoLayout(video, url, options) { + var cacheKey = String(url || ''); + if (videoLayoutCache[cacheKey]) { + return Promise.resolve(videoLayoutCache[cacheKey]); + } + var maxWidth = Number(options.videoDetectionSampleWidth) || 480; + return loadVideoMetadata(video) + .then(function () { + return sampleVideoFrames(video, 'packed', maxWidth); + }) + .then(function (packedFrames) { + if (hasNativeAlpha(packedFrames)) { + return fullFrameLayout(video, 'nativeAlpha', true); + } + return ( + detectPackedAlphaTopRight(packedFrames) || + sampleVideoFrames(video, 'split', maxWidth).then(function ( + splitFrames + ) { + return ( + detectSplitAlpha(splitFrames, video) || + fullFrameLayout(video, 'normal', false) + ); + }) + ); + }) + .then(function (layout) { + videoLayoutCache[cacheKey] = layout; + return layout; + }); + } + + function rectToPixels(rect, width, height) { + var x = clamp(Math.round(rect.x * width), 0, width - 1); + var y = clamp(Math.round(rect.y * height), 0, height - 1); + var right = clamp(Math.round((rect.x + rect.width) * width), x + 1, width); + var bottom = clamp( + Math.round((rect.y + rect.height) * height), + y + 1, + height + ); + return { + x: x, + y: y, + width: Math.max(1, right - x), + height: Math.max(1, bottom - y), + }; + } + + function layoutCanvasSize(video, layout, options) { + var videoWidth = Math.max(1, video.videoWidth || 1); + var videoHeight = Math.max(1, video.videoHeight || 1); + var rect = layout.hasAlphaMask + ? rectToPixels(layout.colorRect, videoWidth, videoHeight) + : { width: videoWidth, height: videoHeight }; + var maxDimension = Number(options.maxVideoCanvasDimension) || 1200; + var scale = Math.min( + 1, + maxDimension / Math.max(rect.width, rect.height) + ); + return { + width: Math.max(1, Math.round(rect.width * scale)), + height: Math.max(1, Math.round(rect.height * scale)), + }; + } + + function renderMaskedVideoFrame(video, canvas, layout, scratch) { + var videoWidth = Math.max(1, video.videoWidth || 1); + var videoHeight = Math.max(1, video.videoHeight || 1); + var outWidth = canvas.width; + var outHeight = canvas.height; + var context = scratch.outputContext; + context.clearRect(0, 0, outWidth, outHeight); + + if (!layout.hasAlphaMask) { + context.drawImage(video, 0, 0, outWidth, outHeight); + return; + } + + var colorRect = rectToPixels(layout.colorRect, videoWidth, videoHeight); + var alphaRect = rectToPixels(layout.alphaRect, videoWidth, videoHeight); + scratch.colorCanvas.width = outWidth; + scratch.colorCanvas.height = outHeight; + scratch.alphaCanvas.width = outWidth; + scratch.alphaCanvas.height = outHeight; + scratch.colorContext.clearRect(0, 0, outWidth, outHeight); + scratch.alphaContext.clearRect(0, 0, outWidth, outHeight); + scratch.colorContext.drawImage( + video, + colorRect.x, + colorRect.y, + colorRect.width, + colorRect.height, + 0, + 0, + outWidth, + outHeight + ); + scratch.alphaContext.drawImage( + video, + alphaRect.x, + alphaRect.y, + alphaRect.width, + alphaRect.height, + 0, + 0, + outWidth, + outHeight + ); + var colorData = scratch.colorContext.getImageData( + 0, + 0, + outWidth, + outHeight + ); + var alphaData = scratch.alphaContext.getImageData( + 0, + 0, + outWidth, + outHeight + ); + for (var offset = 0; offset < colorData.data.length; offset += 4) { + colorData.data[offset + 3] = + alphaData.data[offset] * 0.299 + + alphaData.data[offset + 1] * 0.587 + + alphaData.data[offset + 2] * 0.114; + } + context.putImageData(colorData, 0, 0); + } + + function initPAG(libBase) { + var base = libBase || DEFAULT_PAG_BASE; + if (pagInitCache[base]) return pagInitCache[base]; + pagInitCache[base] = loadScriptOnce(base + 'libpag.umd.js').then( + function () { + if (!window.libpag) throw new Error('pag_unavailable'); + return window.libpag.PAGInit({ + locateFile: function (file) { + return base + file; + }, + }); + } + ); + return pagInitCache[base]; + } + + function EffectPlayer(target, options) { + this.container = resolveElement(target); + this.options = extend( + { + activeClass: 'is-active', + fit: 'contain', + loop: true, + muted: true, + waitForWindowLoad: true, + svgaScript: DEFAULT_SVGA_SCRIPT, + pagBase: DEFAULT_PAG_BASE, + }, + options || {} + ); + this.url = ''; + this.active = false; + this.cleanup = null; + this.current = null; + this.sequence = 0; + this.destroyed = false; + } + + EffectPlayer.prototype._teardown = function () { + if (this.cleanup) { + try { + this.cleanup(); + } catch (_) {} + } + this.cleanup = null; + this.active = false; + this.current = null; + if (this.container) { + this.container.classList.remove(this.options.activeClass); + this.container.innerHTML = ''; + } + }; + + EffectPlayer.prototype._mount = function (node) { + if (!this.container) throw new Error('container_unavailable'); + this.container.innerHTML = ''; + if (node) this.container.appendChild(node); + this.container.classList.add(this.options.activeClass); + this.active = true; + }; + + EffectPlayer.prototype._isCurrent = function (url) { + return !this.destroyed && this.url === url; + }; + + EffectPlayer.prototype._createVideoElement = function ( + url, + options, + useCORS + ) { + var video = document.createElement('video'); + if (useCORS) video.crossOrigin = 'anonymous'; + video.src = url; + video.autoplay = true; + video.loop = options.loop !== false; + video.muted = options.muted !== false; + video.playsInline = true; + video.setAttribute('webkit-playsinline', 'true'); + video.preload = options.preload || 'auto'; + return video; + }; + + EffectPlayer.prototype._playPlainVideo = function (url, options, video) { + if (!this._isCurrent(url)) return Promise.resolve(); + video = video || this._createVideoElement(url, options, false); + styleNode(video, options); + this._mount(video); + this.cleanup = function () { + video.pause(); + video.removeAttribute('src'); + video.load(); + }; + return video.play().catch(function () {}); + }; + + EffectPlayer.prototype._playCanvasVideo = function (url, options, video, layout) { + var self = this; + if (!this._isCurrent(url)) return Promise.resolve(); + var size = layoutCanvasSize(video, layout, options); + var canvas = document.createElement('canvas'); + canvas.width = size.width; + canvas.height = size.height; + styleNode(canvas, options); + var scratch = { + outputContext: canvas.getContext('2d', { + alpha: true, + willReadFrequently: !!layout.hasAlphaMask, + }), + colorCanvas: document.createElement('canvas'), + alphaCanvas: document.createElement('canvas'), + }; + scratch.colorContext = scratch.colorCanvas.getContext('2d', { + willReadFrequently: true, + }); + scratch.alphaContext = scratch.alphaCanvas.getContext('2d', { + willReadFrequently: true, + }); + var raf = 0; + function draw() { + if (!self._isCurrent(url)) return; + if (video.readyState >= 2) { + renderMaskedVideoFrame(video, canvas, layout, scratch); + } + raf = window.requestAnimationFrame(draw); + } + this._mount(canvas); + this.cleanup = function () { + window.cancelAnimationFrame(raf); + video.pause(); + video.removeAttribute('src'); + video.load(); + }; + draw(); + return seekVideo(video, 0) + .then(function () { + return video.play().catch(function () {}); + }); + }; + + EffectPlayer.prototype._playVideo = function (url, options) { + var self = this; + if (!this._isCurrent(url)) return Promise.resolve(); + if (options.detectTransparentVideo === false) { + return this._playPlainVideo(url, options); + } + var video = this._createVideoElement(url, options, true); + return detectVideoLayout(video, url, options) + .then(function (layout) { + if (!self._isCurrent(url)) return undefined; + if (!layout.hasAlphaMask && !layout.hasNativeAlpha) { + return self._playPlainVideo(url, options, video); + } + return self._playCanvasVideo(url, options, video, layout); + }) + .catch(function () { + if (!self._isCurrent(url)) return undefined; + return self._playPlainVideo(url, options); + }); + }; + + EffectPlayer.prototype._playSVGA = function (url, options) { + var self = this; + return loadScriptOnce(options.svgaScript).then(function () { + if (!self._isCurrent(url)) return undefined; + if (!window.SVGA) throw new Error('svga_unavailable'); + var sourceURL = proxyBinaryURL(url, 'svga', options); + var size = mediaSize(self.container, options); + var canvas = document.createElement('canvas'); + canvas.width = size.width; + canvas.height = size.height; + styleNode(canvas, options); + self._mount(canvas); + var player = new window.SVGA.Player(canvas); + var parser = new window.SVGA.Parser(canvas); + self.cleanup = function () { + if (player.stopAnimation) player.stopAnimation(); + if (player.clear) player.clear(); + }; + return new Promise(function (resolve, reject) { + parser.load( + sourceURL, + function (videoItem) { + if (!self._isCurrent(url)) { + if (player.stopAnimation) player.stopAnimation(); + resolve(); + return; + } + player.setVideoItem(videoItem); + player.startAnimation(); + resolve(); + }, + reject + ); + }); + }); + }; + + EffectPlayer.prototype._playPAG = function (url, options) { + var self = this; + var sourceURL = proxyBinaryURL(url, 'pag', options); + return initPAG(options.pagBase) + .then(function (PAG) { + if (!self._isCurrent(url)) return undefined; + return fetch(sourceURL).then(function (response) { + if (!response.ok) throw new Error('pag_fetch_failed'); + return response.arrayBuffer().then(function (buffer) { + return { PAG: PAG, buffer: buffer }; + }); + }); + }) + .then(function (payload) { + if (!payload || !self._isCurrent(url)) return undefined; + var size = mediaSize(self.container, options); + var canvas = document.createElement('canvas'); + canvas.width = size.width; + canvas.height = size.height; + styleNode(canvas, options); + self._mount(canvas); + return payload.PAG.PAGFile.load(payload.buffer).then( + function (pagFile) { + return payload.PAG.PAGView.init(pagFile, canvas, { + useScale: false, + }); + } + ); + }) + .then(function (pagView) { + if (!self._isCurrent(url)) return undefined; + if (!pagView) throw new Error('pag_view_unavailable'); + self.cleanup = function () { + if (pagView.destroy) pagView.destroy(); + }; + return pagView.play(); + }); + }; + + EffectPlayer.prototype._playNow = function (url, options) { + var type = inferType(url, options.type); + if (type === 'video' || type === 'mp4') { + return this._playVideo(url, options); + } + if (type === 'svga') return this._playSVGA(url, options); + if (type === 'pag') return this._playPAG(url, options); + return Promise.reject(new Error('unsupported_effect_type')); + }; + + EffectPlayer.prototype.play = function (url, options) { + var self = this; + var nextURL = String(url || '').trim(); + var nextOptions = extend(extend({}, this.options), options || {}); + if (!nextURL) { + this.stop(); + return Promise.resolve(); + } + if (this.url === nextURL && this.current) return this.current; + this.stop(); + this.url = nextURL; + var sequence = this.sequence; + var run = function () { + if (self.destroyed || sequence !== self.sequence) { + return Promise.resolve(); + } + self.current = Promise.resolve() + .then(function () { + return self._playNow(nextURL, nextOptions); + }) + .catch(function (error) { + if (sequence === self.sequence) self.stop(); + throw error; + }); + return self.current; + }; + if (nextOptions.waitForWindowLoad === false) return run(); + this.current = waitForWindowLoad().then(run); + return this.current; + }; + + EffectPlayer.prototype.stop = function () { + this.sequence += 1; + this.url = ''; + this._teardown(); + }; + + EffectPlayer.prototype.destroy = function () { + this.destroyed = true; + this.stop(); + this.container = null; + }; + + window.HyAppEffectPlayer = { + create: function (target, options) { + return new EffectPlayer(target, options); + }, + play: function (target, url, options) { + var player = new EffectPlayer(target, options); + return player.play(url).then(function () { + return player; + }); + }, + inferType: inferType, + loadScriptOnce: loadScriptOnce, + }; +})(); diff --git a/common/toast.js b/common/toast.js index 7a895e7..ddfbac9 100644 --- a/common/toast.js +++ b/common/toast.js @@ -13,14 +13,15 @@ 'left:50%;', 'top:max(84px,calc(env(safe-area-inset-top) + 24px));', 'z-index:10000;', - 'max-width:min(320px,calc(100vw - 48px));', - 'padding:10px 14px;', - 'border-radius:8px;', - 'background:rgba(0,0,0,.82);', + 'min-width:168px;', + 'max-width:min(340px,calc(100vw - 40px));', + 'padding:13px 22px;', + 'border-radius:24px;', + 'background:rgba(0,0,0,.86);', 'color:#fff;', - 'font:800 14px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;', + 'font:800 15px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;', 'text-align:center;', - 'box-shadow:0 10px 28px rgba(0,0,0,.22);', + 'box-shadow:0 12px 32px rgba(0,0,0,.36);', 'opacity:0;', 'pointer-events:none;', 'transform:translate(-50%,-8px);', diff --git a/vip/assets/top-ornaments.png b/vip/assets/top-ornaments.png new file mode 100644 index 0000000..9a434be Binary files /dev/null and b/vip/assets/top-ornaments.png differ diff --git a/vip/index.html b/vip/index.html index 31c6633..4a511ee 100644 --- a/vip/index.html +++ b/vip/index.html @@ -50,17 +50,9 @@ width: 375px; height: 766px; overflow: hidden; + touch-action: pan-y; background: #130a2b; } - .figma-bg { - position: absolute; - inset: 0; - z-index: 0; - width: 375px; - height: 766px; - object-fit: cover; - pointer-events: none; - } .bg { position: absolute; inset: 0; @@ -94,6 +86,14 @@ height: 389px; object-fit: cover; } + .bg-ornaments { + position: absolute; + left: 0; + top: 0; + width: 375px; + height: 244px; + object-fit: cover; + } .bg::after { content: ''; position: absolute; @@ -104,12 +104,14 @@ background: #11071f; } .nav { - position: absolute; - left: 0; + position: fixed; + left: 50%; top: 0; - z-index: 4; + z-index: 70; width: 375px; height: 44px; + transform: translateX(-50%) scale(var(--app-scale)); + transform-origin: top center; } .back { position: absolute; @@ -117,15 +119,49 @@ top: 10px; width: 24px; height: 24px; - background: url('assets/back.png') center/24px 24px no-repeat; + } + .back::before { + content: ''; + position: absolute; + left: 8px; + top: 5px; + width: 12px; + height: 12px; + border-left: 2px solid #fff; + border-bottom: 2px solid #fff; + border-radius: 1px; + transform: rotate(45deg); } .title-vip { position: absolute; - left: 167px; - top: 13px; - width: 41px; - height: 18px; - object-fit: contain; + left: 50%; + top: 10px; + width: 50px; + height: 22px; + color: #5f270f; + font-family: Georgia, 'Times New Roman', serif; + font-size: 23px; + line-height: 22px; + font-style: italic; + font-weight: 900; + text-align: center; + letter-spacing: 0; + transform: translateX(-50%) skewX(-7deg); + -webkit-text-stroke: 1px #55250f; + text-shadow: + 0 1px 0 #cb7628, + 0 2px 1px rgba(43, 16, 5, 0.75), + 0 0 4px rgba(255, 199, 83, 0.48); + } + .title-vip::before { + content: attr(aria-label); + position: absolute; + inset: 0; + background: linear-gradient(180deg, #fffbe0 0%, #ffe27a 30%, #d87525 72%, #6e2b12 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: 0.35px #fff0a8; } .hero-badge { position: absolute; @@ -134,79 +170,218 @@ top: 55px; width: 167px; height: 167px; + } + .hero-badge-image { + display: block; + width: 167px; + height: 167px; object-fit: contain; } + .hero-badge-image.is-dynamic-hidden { + visibility: hidden; + } + .hero-badge-animation { + position: absolute; + inset: 0; + z-index: 4; + display: none; + pointer-events: none; + } + .hero-badge-animation.is-active { + display: block; + } + .hero-badge-animation video, + .hero-badge-animation canvas { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } + .vip-switch { + position: absolute; + z-index: 12; + top: 116px; + width: 36px; + height: 64px; + } + .vip-switch::before { + content: ''; + position: absolute; + left: 13px; + top: 23px; + width: 13px; + height: 13px; + border-left: 3px solid #fff; + border-bottom: 3px solid #fff; + border-radius: 2px; + filter: drop-shadow(0 0 4px rgba(230, 151, 255, 0.72)); + transform: rotate(45deg); + } + .vip-switch-next::before { + left: 8px; + transform: rotate(225deg); + } + .vip-switch-prev { + left: 16px; + } + .vip-switch-next { + right: 16px; + } + .vip-switch[disabled] { + opacity: 0.36; + cursor: default; + } + .vip-switch.is-hidden { + display: none; + } + .vip-level-stamp { + position: absolute; + left: 50%; + z-index: 3; + transform: translateX(-50%); + color: #fff5af; + font-family: Georgia, 'Times New Roman', serif; + font-weight: 900; + font-style: italic; + letter-spacing: 0; + text-align: center; + text-shadow: + 0 1px 0 #7a2b14, + 0 0 4px rgba(117, 31, 255, 0.9), + 0 0 6px rgba(255, 226, 92, 0.58); + -webkit-text-stroke: 0.7px #7a2b14; + } + .vip-level-stamp.is-hidden { + display: none; + } + .hero-vip-stamp { + bottom: 40px; + min-width: 42px; + padding: 0 3px; + font-size: 17px; + line-height: 18px; + } .section-title { position: absolute; z-index: 3; display: flex; align-items: center; - gap: 4px; + justify-content: center; + gap: 6px; height: 18px; } .section-title img { - width: 59px; - height: 14px; - object-fit: fill; + display: none; } - .section-title .right { + .section-title::before, + .section-title::after { + content: ''; + width: 74px; + height: 16px; + flex: 1 1 74px; + max-width: 96px; + background: + radial-gradient(circle at 100% 50%, #f1c7ff 0 1.6px, transparent 2px), + radial-gradient(ellipse at 86% 50%, transparent 0 4px, #d997ff 4.4px 5.3px, transparent 5.7px), + radial-gradient(ellipse at 73% 50%, transparent 0 5px, rgba(217, 151, 255, 0.95) 5.3px 6.2px, transparent 6.6px), + linear-gradient(90deg, transparent 0, rgba(217, 151, 255, 0.12) 18%, rgba(217, 151, 255, 0.95) 72%, transparent 100%) center / 100% 1px no-repeat; + opacity: 1; + } + .section-title::after { transform: scaleX(-1); } .section-title span { display: block; color: #fff; - font-size: 16px; - line-height: 16px; - font-weight: 800; + font-family: Arial, Helvetica, sans-serif; + font-size: 17px; + line-height: 18px; + font-weight: 900; white-space: nowrap; text-align: center; - text-shadow: 0 2px 0 #be1e21; - background: linear-gradient( - 108deg, - #fff 27%, - #f4cbff 49%, - #fff 72% - ); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; + text-shadow: + 0 1px 0 #a3229d, + 0 2px 0 #b31942, + 1px 0 0 #b94cff, + -1px 0 0 #b94cff, + 0 -1px 0 #b94cff, + 0 0 5px rgba(233, 116, 255, 0.72); + -webkit-text-stroke: 0.35px #f0b5ff; + -webkit-text-fill-color: #fff; } .decoration-title { - left: 38px; + left: 18px; top: 246px; + width: 339px; } .vip-title { - left: 69px; + left: 68px; top: 522px; + width: 239px; } .decor-card { position: absolute; z-index: 3; color: #fff; text-align: center; + --frame-line: #d9a6ff; + --frame-corner: #ffd9ff; + border: 1px solid var(--frame-line); + background: + radial-gradient(circle at 50% 26%, rgba(98, 52, 132, 0.22), transparent 46%), + rgba(17, 6, 30, 0.62); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.06), + inset 0 0 18px rgba(90, 35, 129, 0.2); + } + .decor-card.is-previewable { + cursor: pointer; + } + .decor-card::before { + content: ''; + position: absolute; + inset: -2px; + pointer-events: none; + background: + linear-gradient(var(--frame-corner), var(--frame-corner)) left top / 11px 1px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) left top / 1px 11px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) right top / 11px 1px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) right top / 1px 11px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) left bottom / 11px 1px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) left bottom / 1px 11px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) right bottom / 11px 1px no-repeat, + linear-gradient(var(--frame-corner), var(--frame-corner)) right bottom / 1px 11px no-repeat; } .decor-card .frame { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - object-fit: fill; - pointer-events: none; + display: none; } .decor-card .item { position: absolute; object-fit: contain; pointer-events: none; } + .hero-badge-image:not([src]), + .hero-badge-image[src=''], + .decor-card .item:not([src]), + .decor-card .item[src=''] { + display: none; + } + .card-vip-stamp { + top: 52px; + min-width: 26px; + font-size: 9px; + line-height: 10px; + } .decor-card .label { position: absolute; - left: 0; - right: 0; + left: 4px; + right: 4px; bottom: 13px; font-size: 13px; line-height: 13px; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .card-badge { left: 16px; @@ -263,11 +438,11 @@ height: 110px; } .card-entry .item { - left: 19px; - top: 24px; - width: 189px; - height: 44px; - object-fit: fill; + left: 11px; + top: 31px; + width: 205px; + height: 33px; + object-fit: contain; } .privileges { position: absolute; @@ -290,22 +465,58 @@ height: 56px; margin: 0 auto; border-radius: 50%; - background: #4d2f7d; + background: #3d2766; } - .privilege .circle img.bg-circle { - position: absolute; - inset: 0; - width: 56px; - height: 56px; - object-fit: contain; + .privilege .circle img { + display: none; } - .privilege .circle img.icon { + .privilege.profile .circle::before { + content: ''; position: absolute; - left: 16px; + left: 15px; top: 16px; + width: 25px; + height: 22px; + border: 3px solid #fff; + border-radius: 3px; + background: + radial-gradient(circle at 74% 28%, #fff 0 2px, transparent 2.5px), + linear-gradient(135deg, transparent 48%, #fff 49% 55%, transparent 56%); + } + .privilege.nickname .circle::before { + content: ''; + position: absolute; + left: 14px; + top: 17px; + width: 28px; + height: 20px; + border-radius: 2px; + background: + radial-gradient(circle at 25% 50%, #3d2766 0 3px, transparent 3.4px), + linear-gradient(#3d2766, #3d2766) 15px 6px / 11px 2px no-repeat, + linear-gradient(#3d2766, #3d2766) 15px 12px / 11px 2px no-repeat, + #fff; + } + .privilege.room .circle::before { + content: ''; + position: absolute; + left: 17px; + top: 24px; + width: 22px; + height: 18px; + border-radius: 2px; + background: #fff; + } + .privilege.room .circle::after { + content: ''; + position: absolute; + left: 15px; + top: 17px; width: 24px; height: 24px; - object-fit: contain; + border-left: 5px solid #fff; + border-top: 5px solid #fff; + transform: rotate(45deg); } .privilege .id-mark { position: absolute; @@ -367,9 +578,10 @@ top: 31px; display: flex; align-items: center; - gap: 4px; + gap: 3px; + max-width: 184px; color: #ffe4bc; - font-size: 18px; + font-size: 16px; line-height: 18px; font-weight: 500; white-space: nowrap; @@ -504,17 +716,20 @@ .toast { position: fixed; left: 50%; - bottom: calc(126px * var(--app-scale)); - z-index: 60; - max-width: 300px; + bottom: calc(132px * var(--app-scale)); + z-index: 140; + min-width: 168px; + max-width: 328px; transform: translateX(-50%); - padding: 8px 12px; - border-radius: 16px; - background: rgba(0, 0, 0, 0.62); + padding: 13px 22px; + border-radius: 24px; + background: rgba(0, 0, 0, 0.82); color: #fff; - font-size: 12px; - line-height: 16px; + font-size: 15px; + line-height: 20px; + font-weight: 700; text-align: center; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.36); opacity: 0; pointer-events: none; transition: opacity 0.18s ease; @@ -522,16 +737,88 @@ .toast.show { opacity: 1; } - .bg, - .title-vip, - .hero-badge, - .section-title, - .decor-card, - .privileges { + .effect-preview-overlay { + position: fixed; + inset: 0; + z-index: 120; display: none; + align-items: center; + justify-content: center; + padding: calc(env(safe-area-inset-top) + 28px) 18px + calc(env(safe-area-inset-bottom) + 28px); + background: rgba(0, 0, 0, 0.9); } - .back { - background: transparent; + .effect-preview-overlay.is-open { + display: flex; + } + .effect-preview-close { + position: absolute; + right: 16px; + top: calc(env(safe-area-inset-top) + 16px); + z-index: 3; + width: 40px; + height: 40px; + } + .effect-preview-close::before, + .effect-preview-close::after { + content: ''; + position: absolute; + left: 10px; + top: 19px; + width: 20px; + height: 2px; + border-radius: 2px; + background: #fff; + box-shadow: 0 0 8px rgba(230, 151, 255, 0.64); + } + .effect-preview-close::before { + transform: rotate(45deg); + } + .effect-preview-close::after { + transform: rotate(-45deg); + } + .effect-preview-stage { + position: relative; + width: var(--preview-width, calc(100vw - 64px)); + height: var(--preview-height, calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 156px)); + max-width: calc(100vw - 64px); + max-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 156px); + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + } + .effect-preview-image, + .effect-preview-animation { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + } + .effect-preview-image { + object-fit: contain; + object-position: center center; + } + .effect-preview-image.is-dynamic-hidden { + visibility: hidden; + } + .effect-preview-animation { + display: none; + pointer-events: none; + } + .effect-preview-animation.is-active { + display: block; + } + .effect-preview-animation video, + .effect-preview-animation canvas { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center center; } @keyframes cat-float { 0%, @@ -567,31 +854,39 @@
- - - - + + +
@@ -599,47 +894,79 @@
-
+
-
+ + + VIP + + SVIP Badge + +
-
+ + + VIP + + Frame + +
-
+ + Mount + +
-
+ + Mic Animation + +
+ + Entry Effect +
@@ -736,10 +1063,15 @@
+ +
- 30.5 M / 30 Days + 30,500,000 / 30 Days
+ +