From d17bebbf127f6eb40eaa63e349d9852734be9fca Mon Sep 17 00:00:00 2001 From: weizhenye Date: Sun, 28 Jul 2024 20:31:20 +0800 Subject: [PATCH] refactor: use css var for animatable tags --- README.md | 1 + src/global.css | 78 +++++++++++++++++++++++++++++++++++---- src/index.js | 8 ++-- src/internal.js | 8 +--- src/renderer/animation.js | 65 +++++++++++++++++++++----------- src/renderer/dom.js | 73 +++++++++++++----------------------- src/renderer/drawing.js | 5 +-- src/renderer/stroke.js | 43 +++++++++++++++++++-- 8 files changed, 187 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index c873dd8..b300aa9 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ ASS.js uses many Web APIs to render subtitles, some features will be disabled if | Animations
(`\t`, `\move`, `\fade`, Effect) | [Web Animations API](https://caniuse.com/web-animation) | 36 | 33 | 13.1 | | Animations
(`\t`, `\move`, `\fade`, Effect) | [registerProperty()](https://caniuse.com/mdn-api_css_registerproperty_static) | 78 | 128 | 16.4 | | `\q0` | [text-wrap: balance](https://caniuse.com/css-text-wrap-balance) | 114 | 121 | 17.5 | +| `\bord0` when BorderStyle=3 | [@container](https://caniuse.com/mdn-css_at-rules_container_style_queries_for_custom_properties) | 111 | - | 18.0 | ## TODO diff --git a/src/global.css b/src/global.css index c7bf678..08e6236 100644 --- a/src/global.css +++ b/src/global.css @@ -1,4 +1,5 @@ .ASS-box { + font-family: Arial; overflow: hidden; pointer-events: none; position: absolute; @@ -8,32 +9,93 @@ position: absolute; z-index: 0; } -.ASS-dialogue [data-stroke] { +.ASS-dialogue span { + display: inline-block; +} +.ASS-dialogue [data-text] { + display: inline-block; + color: var(--ass-fill-color); + font-size: calc(var(--ass-scale) * var(--ass-real-fs) * 1px); + line-height: calc(var(--ass-scale) * var(--ass-tag-fs) * 1px); + letter-spacing: calc(var(--ass-scale) * var(--ass-tag-fsp) * 1px); +} +.ASS-dialogue [data-wrap-style="0"], +.ASS-dialogue [data-wrap-style="3"] { + text-wrap: balance; +} +.ASS-dialogue [data-wrap-style="1"] { + word-break: break-word; + white-space: normal; +} +.ASS-dialogue [data-wrap-style="2"] { + word-break: normal; + white-space: nowrap; +} +.ASS-dialogue [data-border-style="1"] { position: relative; } -.ASS-dialogue [data-stroke]::before, -.ASS-dialogue [data-stroke]::after { - content: attr(data-stroke); +.ASS-dialogue [data-border-style="1"]::before, +.ASS-dialogue [data-border-style="1"]::after { + content: attr(data-text); position: absolute; top: 0; left: 0; z-index: -1; - filter: var(--ass-blur); + filter: blur(calc(var(--ass-tag-blur) * 1px)); } -.ASS-dialogue [data-stroke]::before { +.ASS-dialogue [data-border-style="1"]::before { color: var(--ass-shadow-color); - transform: translate(var(--ass-shadow-offset)); + transform: translate( + calc(var(--ass-scale-stroke) * var(--ass-tag-xshad) * 1px), + calc(var(--ass-scale-stroke) * var(--ass-tag-yshad) * 1px) + ); -webkit-text-stroke: var(--ass-border-width) var(--ass-shadow-color); text-shadow: var(--ass-shadow-delta); opacity: var(--ass-shadow-opacity); } -.ASS-dialogue [data-stroke]::after { +.ASS-dialogue [data-border-style="1"]::after { color: transparent; -webkit-text-stroke: var(--ass-border-width) var(--ass-border-color); text-shadow: var(--ass-border-delta); opacity: var(--ass-border-opacity); } +.ASS-dialogue [data-border-style="3"] { + padding: + calc(var(--ass-scale-stroke) * var(--ass-tag-xbord) * 1px) + calc(var(--ass-scale-stroke) * var(--ass-tag-ybord) * 1px); + position: relative; + filter: blur(calc(var(--ass-tag-blur) * 1px)); +} +.ASS-dialogue [data-border-style="3"]::before, +.ASS-dialogue [data-border-style="3"]::after { + content: ""; + width: 100%; + height: 100%; + position: absolute; + z-index: -1; +} +.ASS-dialogue [data-border-style="3"]::before { + background-color: var(--ass-shadow-color); + left: calc(var(--ass-scale-stroke) * var(--ass-tag-xshad) * 1px); + top: calc(var(--ass-scale-stroke) * var(--ass-tag-yshad) * 1px); +} +.ASS-dialogue [data-border-style="3"]::after { + background-color: var(--ass-border-color); + left: 0; + top: 0; +} +@container style(--ass-tag-xbord: 0) and style(--ass-tag-ybord: 0) { + .ASS-dialogue [data-border-style="3"]::after { + background-color: transparent; + } +} +@container style(--ass-tag-xshad: 0) and style(--ass-tag-yshad: 0) { + .ASS-dialogue [data-border-style="3"]::before { + background-color: transparent; + } +} .ASS-dialogue [data-rotate] { + /* TODO: {\an5\fs80\bord0\shad60\frx30\frz30\fry30}1234567890 */ /* https://github.com/libass/libass/issues/805 */ transform: perspective(312.5px) rotateY(calc(var(--ass-tag-fry) * 1deg)) diff --git a/src/index.js b/src/index.js index 552695f..2492aaa 100644 --- a/src/index.js +++ b/src/index.js @@ -46,8 +46,8 @@ export default class ASS { resampledRes: {}, /** current index of dialogues to match currentTime */ index: 0, - /** @type {import('ass-compiler').ScriptInfo} */ - info: {}, + /** @type {boolean} ScaledBorderAndShadow */ + sbas: true, /** @type {import('ass-compiler').CompiledASSStyle} */ styles: {}, /** @type {import('ass-compiler').Dialogue[]} */ @@ -106,7 +106,7 @@ export default class ASS { if (!container) throw new Error('Missing container.'); const { info, width, height, styles, dialogues } = compile(content); - this.#store.info = info; + this.#store.sbas = /yes/i.test(info.ScaledBorderAndShadow); this.#store.layoutRes = { width: info.LayoutResX * 1 || video.videoWidth || video.clientWidth, height: info.LayoutResY * 1 || video.videoHeight || video.clientHeight, @@ -153,7 +153,7 @@ export default class ASS { this.resampling = resampling; dialogues.forEach((dialogue) => { - setKeyframes(dialogue, styles); + setKeyframes(dialogue, this.#store); }); const observer = new ResizeObserver(this.#resize); diff --git a/src/internal.js b/src/internal.js index 7c8ab92..7f205ab 100644 --- a/src/internal.js +++ b/src/internal.js @@ -118,14 +118,10 @@ export function createResize(that, store) { store.height = bh; store.resampledRes = { width: rw, height: rh }; - const cssText = ( - `width:${bw}px;` - + `height:${bh}px;` - + `top:${(ch - bh) / 2}px;` - + `left:${(cw - bw) / 2}px;` - ); + const cssText = `width:${bw}px;height:${bh}px;top:${(ch - bh) / 2}px;left:${(cw - bw) / 2}px;`; box.style.cssText = cssText; box.style.setProperty('--ass-scale', store.scale); + box.style.setProperty('--ass-scale-stroke', store.sbas ? store.scale : 1); svg.style.cssText = cssText; createSeek(store)(); diff --git a/src/renderer/animation.js b/src/renderer/animation.js index fcdc0a5..fdac96e 100644 --- a/src/renderer/animation.js +++ b/src/renderer/animation.js @@ -1,5 +1,6 @@ import { color2rgba } from '../utils.js'; import { getRealFontSize } from './font-size.js'; +import { createCSSStroke } from './stroke.js'; import { createTransform } from './transform.js'; // TODO: multi \t can't be merged directly @@ -98,8 +99,36 @@ function createTransformKeyframes({ fromTag, tag, fragment }) { return Object.fromEntries(createTransform(toTag)); } +export function createAnimatableVars(tag) { + return [ + ['real-fs', getRealFontSize(tag.fn, tag.fs)], + ['tag-fs', tag.fs], + ['tag-fsp', tag.fsp], + ['fill-color', color2rgba(tag.a1 + tag.c1)], + ] + .filter(([, v]) => v) + .map(([k, v]) => [`--ass-${k}`, v]); +} + +if (window.CSS.registerProperty) { + ['real-fs', 'tag-fs', 'tag-fsp'].forEach((k) => { + window.CSS.registerProperty({ + name: `--ass-${k}`, + syntax: '', + inherits: true, + initialValue: '0', + }); + }); + window.CSS.registerProperty({ + name: '--ass-fill-color', + syntax: '', + inherits: true, + initialValue: 'transparent', + }); +} + // TODO: accel is not implemented yet, maybe it can be simulated by cubic-bezier? -export function setKeyframes(dialogue, styles) { +export function setKeyframes(dialogue, store) { const { start, end, effect, move, fade, slices } = dialogue; const duration = (end - start) * 1000; const keyframes = [ @@ -111,7 +140,7 @@ export function setKeyframes(dialogue, styles) { Object.assign(dialogue, { keyframes }); } slices.forEach((slice) => { - const sliceTag = styles[slice.style].tag; + const sliceTag = store.styles[slice.style].tag; slice.fragments.forEach((fragment) => { if (!fragment.tag.t || fragment.tag.t.length === 0) { return; @@ -128,25 +157,19 @@ export function setKeyframes(dialogue, styles) { return tag; }, {}); const fDuration = Math.max(duration, ...tTags.map(({ t2 }) => t2)); - const kfs = tTags.map(({ t2, tag }) => { - const hasAlpha = ( - tag.a1 !== undefined - && tag.a1 === tag.a2 - && tag.a2 === tag.a3 - && tag.a3 === tag.a4 - ); - // TODO: border and shadow, should animate CSS vars - return { - offset: t2 / fDuration, - ...(tag.fs && { 'font-size': `calc(calc(var(--ass-scale) * ${getRealFontSize(tag.fn, tag.fs)}px)` }), - ...(tag.fsp && { 'letter-spacing': `calc(calc(var(--ass-scale) * ${tag.fsp}px)` }), - ...((tag.c1 || (tag.a1 && !hasAlpha)) && { - color: color2rgba((tag.a1 || fromTag.a1) + (tag.c1 || fromTag.c1)), - }), - ...(hasAlpha && { opacity: 1 - Number.parseInt(tag.a1, 16) / 255 }), - ...createTransformKeyframes({ fromTag, tag, fragment }), - }; - }).sort((a, b) => a.offset - b.offset); + const kfs = tTags.map(({ t2, tag }) => ({ + offset: t2 / fDuration, + ...Object.fromEntries(createAnimatableVars({ + ...tag, + a1: tag.a1 || fromTag.a1, + c1: tag.c1 || fromTag.c1, + })), + ...Object.fromEntries(createCSSStroke( + { ...fromTag, ...tag }, + store.sbas ? store.scale : 1, + )), + ...createTransformKeyframes({ fromTag, tag, fragment }), + })).sort((a, b) => a.offset - b.offset); if (kfs.length > 0) { Object.assign(fragment, { keyframes: kfs, duration: fDuration }); } diff --git a/src/renderer/dom.js b/src/renderer/dom.js index ecbedf8..2c408a1 100644 --- a/src/renderer/dom.js +++ b/src/renderer/dom.js @@ -1,6 +1,6 @@ -import { color2rgba, initAnimation } from '../utils.js'; +import { initAnimation } from '../utils.js'; import { createDrawing } from './drawing.js'; -import { getRealFontSize } from './font-size.js'; +import { createAnimatableVars } from './animation.js'; import { createCSSStroke } from './stroke.js'; import { rotateTags, scaleTags, skewTags, createTransform } from './transform.js'; @@ -12,11 +12,17 @@ function encodeText(text, q) { } export function createDialogue(dialogue, store) { - const { video, styles, info } = store; + const { video, styles } = store; const $div = document.createElement('div'); $div.className = 'ASS-dialogue'; const df = document.createDocumentFragment(); const { align, slices, start, end } = dialogue; + [ + ['--ass-align-h', ['left', 'center', 'right'][align.h]], + ['--ass-align-v', ['bottom', 'center', 'top'][align.v]], + ].forEach(([k, v]) => { + $div.style.setProperty(k, v); + }); const animationOptions = { duration: (end - start) * 1000, delay: Math.min(0, start - (video.currentTime - store.delay)) * 1000, @@ -29,50 +35,17 @@ export function createDialogue(dialogue, store) { slice.fragments.forEach((fragment) => { const { text, drawing } = fragment; const tag = { ...sliceTag, ...fragment.tag }; - let cssText = 'display:inline-block;'; - const cssVars = [ - ['--ass-align-h', ['left', 'center', 'right'][align.h]], - ['--ass-align-v', ['bottom', 'center', 'top'][align.v]], - ]; + let cssText = ''; + const cssVars = []; if (!drawing) { - cssText += `font-family:"${tag.fn}",Arial;`; - cssText += `font-size:calc(var(--ass-scale) * ${getRealFontSize(tag.fn, tag.fs)}px);`; - cssText += `line-height:calc(var(--ass-scale) * ${tag.fs}px);`; - cssText += `color:${color2rgba(tag.a1 + tag.c1)};`; - const scale = /yes/i.test(info.ScaledBorderAndShadow) ? store.scale : 1; - if (borderStyle === 1) { - cssVars.push(...createCSSStroke(tag, scale)); - } - if (borderStyle === 3) { - // TODO: \bord0\shad16 - const bc = color2rgba(tag.a3 + tag.c3); - const bx = tag.xbord * scale; - const by = tag.ybord * scale; - const sc = color2rgba(tag.a4 + tag.c4); - const sx = tag.xshad * scale; - const sy = tag.yshad * scale; - cssText += ( - `${bx || by ? `background-color:${bc};` : ''}` - + `border:0 solid ${bc};` - + `border-width:${bx}px ${by}px;` - + `margin:${-bx}px ${-by}px;` - + `box-shadow:${sx}px ${sy}px ${sc};` - ); - } + cssVars.push(...createAnimatableVars(tag)); + const scale = store.sbas ? store.scale : 1; + cssVars.push(...createCSSStroke(tag, scale)); + + cssText += `font-family:"${tag.fn}";`; cssText += tag.b ? `font-weight:${tag.b === 1 ? 'bold' : tag.b};` : ''; cssText += tag.i ? 'font-style:italic;' : ''; cssText += (tag.u || tag.s) ? `text-decoration:${tag.u ? 'underline' : ''} ${tag.s ? 'line-through' : ''};` : ''; - cssText += tag.fsp ? `letter-spacing:calc(var(--ass-scale) * ${tag.fsp}px);` : ''; - // TODO: q0 and q3 is same for now, at least better than nothing. - if (tag.q === 0 || tag.q === 3) { - cssText += 'text-wrap:balance;'; - } - if (tag.q === 1) { - cssText += 'word-break:break-word;white-space:normal;'; - } - if (tag.q === 2) { - cssText += 'word-break:normal;white-space:nowrap;'; - } } if (drawing && tag.pbo) { const pbo = -tag.pbo * (tag.fscy || 100) / 100; @@ -80,12 +53,16 @@ export function createDialogue(dialogue, store) { } cssVars.push(...createTransform(tag)); - const hasRotate = rotateTags.some((x) => tag[x] || tag.t?.[x]); - const hasScale = scaleTags.some((x) => tag[x] !== 100 || tag.t?.[x] !== 100); - const hasSkew = skewTags.some((x) => tag[x] || tag.t?.[x]); + const tags = [tag, ...(tag.t || []).map((t) => t.tag)]; + const hasRotate = rotateTags.some((x) => tags.some((t) => t[x])); + const hasScale = scaleTags.some((x) => tags.some((t) => t[x] !== undefined && t[x] !== 100)); + const hasSkew = skewTags.some((x) => tags.some((t) => t[x])); + encodeText(text, tag.q).split('\n').forEach((content, idx) => { const $span = document.createElement('span'); const $ssspan = document.createElement('span'); + $span.dataset.wrapStyle = tag.q; + $span.dataset.borderStyle = borderStyle; if (hasScale || hasSkew) { if (hasScale) { $ssspan.dataset.scale = ''; @@ -115,11 +92,11 @@ export function createDialogue(dialogue, store) { } else { $span.textContent = content; } + const el = hasScale || hasSkew ? $ssspan : $span; if (tag.xbord || tag.ybord || tag.xshad || tag.yshad) { - $span.dataset.stroke = content; + el.dataset.text = content; } } - // TODO: maybe it can be optimized $span.style.cssText += cssText; cssVars.forEach(([k, v]) => { $span.style.setProperty(k, v); diff --git a/src/renderer/drawing.js b/src/renderer/drawing.js index f51dc04..af711cf 100644 --- a/src/renderer/drawing.js +++ b/src/renderer/drawing.js @@ -3,10 +3,9 @@ import { createSVGStroke } from './stroke.js'; export function createDrawing(fragment, styleTag, store) { if (!fragment.drawing.d) return null; - const { scale, info } = store; const tag = { ...styleTag, ...fragment.tag }; const { minX, minY, width, height } = fragment.drawing; - const baseScale = scale / (1 << (tag.p - 1)); + const baseScale = store.scale / (1 << (tag.p - 1)); const scaleX = (tag.fscx ? tag.fscx / 100 : 1) * baseScale; const scaleY = (tag.fscy ? tag.fscy / 100 : 1) * baseScale; const blur = tag.blur || tag.be || 0; @@ -19,7 +18,7 @@ export function createDrawing(fragment, styleTag, store) { ['height', vbh], ['viewBox', `${-vbx} ${-vby} ${vbw} ${vbh}`], ]); - const strokeScale = /yes/i.test(info.ScaledBorderAndShadow) ? scale : 1; + const strokeScale = store.sbas ? store.scale : 1; const filterId = `ASS-${uuid()}`; const $defs = createSVGEl('defs'); $defs.append(createSVGStroke(tag, filterId, strokeScale)); diff --git a/src/renderer/stroke.js b/src/renderer/stroke.js index 155ab11..8838954 100644 --- a/src/renderer/stroke.js +++ b/src/renderer/stroke.js @@ -128,19 +128,54 @@ export function createCSSStroke(tag, scale) { const bx = tag.xbord * scale; const by = tag.ybord * scale; const sc = color2rgba(`00${tag.c4}`); - const sx = tag.xshad * scale; - const sy = tag.yshad * scale; const blur = tag.blur || tag.be || 0; + // TODO: is there any way to remove this hack? const deltaOffsets = getOffsets(bx, by); return [ ['border-width', `${Math.min(bx, by) * 2}px`], ['border-color', bc], ['border-opacity', alpha2opacity(tag.a3)], ['border-delta', deltaOffsets.map(([x, y]) => `${x}px ${y}px ${bc}`).join(',')], - ['shadow-offset', `${sx}px, ${sy}px`], ['shadow-color', sc], ['shadow-opacity', alpha2opacity(tag.a4)], ['shadow-delta', deltaOffsets.map(([x, y]) => `${x}px ${y}px ${sc}`).join(',')], - ['blur', `blur(${blur}px)`], + ['tag-blur', blur], + ['tag-xbord', tag.xbord], + ['tag-ybord', tag.ybord], + ['tag-xshad', tag.xshad], + ['tag-yshad', tag.yshad], ].map(([k, v]) => [`--ass-${k}`, v]); } + +if (window.CSS.registerProperty) { + window.CSS.registerProperty({ + name: '--ass-border-width', + syntax: '', + inherits: true, + initialValue: '0px', + }); + ['border-color', 'shadow-color'].forEach((k) => { + window.CSS.registerProperty({ + name: `--ass-${k}`, + syntax: '', + inherits: true, + initialValue: 'transparent', + }); + }); + ['border-opacity', 'shadow-opacity'].forEach((k) => { + window.CSS.registerProperty({ + name: `--ass-${k}`, + syntax: '', + inherits: true, + initialValue: '1', + }); + }); + ['blur', 'xbord', 'ybord', 'xshad', 'yshad'].forEach((k) => { + window.CSS.registerProperty({ + name: `--ass-tag-${k}`, + syntax: '', + inherits: true, + initialValue: '0', + }); + }); +}