Skip to content

Commit

Permalink
refactor: use css var for animatable tags
Browse files Browse the repository at this point in the history
  • Loading branch information
weizhenye committed Jul 28, 2024
1 parent 4b19674 commit d17bebb
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 94 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ ASS.js uses many Web APIs to render subtitles, some features will be disabled if
| Animations<br>(`\t`, `\move`, `\fade`, Effect) | [Web Animations API](https://caniuse.com/web-animation) | 36 | 33 | 13.1 |
| Animations<br>(`\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
Expand Down
78 changes: 70 additions & 8 deletions src/global.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.ASS-box {
font-family: Arial;
overflow: hidden;
pointer-events: none;
position: absolute;
Expand All @@ -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))
Expand Down
8 changes: 4 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[]} */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 2 additions & 6 deletions src/internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)();
Expand Down
65 changes: 44 additions & 21 deletions src/renderer/animation.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: '<number>',
inherits: true,
initialValue: '0',
});
});
window.CSS.registerProperty({
name: '--ass-fill-color',
syntax: '<color>',
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 = [
Expand All @@ -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;
Expand All @@ -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 });
}
Expand Down
73 changes: 25 additions & 48 deletions src/renderer/dom.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand All @@ -29,63 +35,34 @@ 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;
cssText += `vertical-align:calc(var(--ass-scale) * ${pbo}px);`;
}

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 = '';
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 2 additions & 3 deletions src/renderer/drawing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand Down
Loading

0 comments on commit d17bebb

Please sign in to comment.