From fede5290702752ff54a884bdcfa1814ba6cb072b Mon Sep 17 00:00:00 2001 From: weizhenye Date: Sun, 23 Jun 2024 17:59:55 +0800 Subject: [PATCH] refactor: use CSS var for scale --- src/global.css | 5 ++ src/internal.js | 20 ++++---- src/renderer/animation.js | 36 +++++++-------- src/renderer/dom.js | 15 +++--- src/renderer/position.js | 16 +++---- src/renderer/renderer.js | 6 ++- src/renderer/scroll.js | 19 ++++++++ src/renderer/style.js | 19 +++----- src/utils.js | 4 +- test/renderer/animation.js | 94 +++++++++++++++++++++++++++++++++++++- 10 files changed, 170 insertions(+), 64 deletions(-) create mode 100644 src/renderer/scroll.js diff --git a/src/global.css b/src/global.css index 9a53101..5e8fe05 100644 --- a/src/global.css +++ b/src/global.css @@ -46,3 +46,8 @@ top: 0; left: 0; } +.ASS-scroll-area { + position: absolute; + width: 100%; + overflow: hidden; +} diff --git a/src/internal.js b/src/internal.js index e617fa7..f963b27 100644 --- a/src/internal.js +++ b/src/internal.js @@ -16,16 +16,11 @@ export function clear(store) { } function framing(store) { - const { video, dialogues, actives, resampledRes } = store; + const { video, dialogues, actives } = store; const vct = video.currentTime - store.delay; for (let i = actives.length - 1; i >= 0; i -= 1) { const dia = actives[i]; - let { end } = dia; - if (dia.effect && /scroll/.test(dia.effect.name)) { - const { y1, y2, delay } = dia.effect; - const duration = ((y2 || resampledRes.height) - y1) / (1000 / delay); - end = Math.min(end, dia.start + duration); - } + const { end } = dia; if (end < vct) { dia.$div.remove(); dia.$clipPath?.remove(); @@ -39,7 +34,7 @@ function framing(store) { if (vct < dialogues[store.index].end) { const dia = renderer(dialogues[store.index], store); if (!video.paused) { - batchAnimate(dia.$div, 'play'); + batchAnimate(dia, 'play'); } actives.push(dia); } @@ -81,8 +76,8 @@ export function createPlay(store) { }; cancelAnimationFrame(store.requestId); store.requestId = requestAnimationFrame(frame); - store.actives.forEach(({ $div }) => { - batchAnimate($div, 'play'); + store.actives.forEach((dia) => { + batchAnimate(dia, 'play'); }); }; } @@ -91,8 +86,8 @@ export function createPause(store) { return function pause() { cancelAnimationFrame(store.requestId); store.requestId = 0; - store.actives.forEach(({ $div }) => { - batchAnimate($div, 'pause'); + store.actives.forEach((dia) => { + batchAnimate(dia, 'pause'); }); }; } @@ -135,6 +130,7 @@ export function createResize(that, store) { + `left:${(cw - bw) / 2}px;` ); box.style.cssText = cssText; + box.style.setProperty('--ass-scale', store.scale); svg.style.cssText = cssText; svg.setAttributeNS(null, 'viewBox', `0 0 ${sw} ${sh}`); diff --git a/src/renderer/animation.js b/src/renderer/animation.js index 09208fd..0db3d8a 100644 --- a/src/renderer/animation.js +++ b/src/renderer/animation.js @@ -15,39 +15,37 @@ function mergeT(ts) { }, []); } -function createEffectKeyframes({ effect, duration }, store) { +export function createEffectKeyframes({ effect, duration }) { // TODO: when effect and move both exist, its behavior is weird, for now only move works. - const { name, delay, lefttoright, y1 } = effect; - const y2 = effect.y2 || store.resampledRes.height; + const { name, delay, leftToRight } = effect; if (name === 'banner') { - const tx = store.scale * (duration / delay) * (lefttoright ? 1 : -1); - return [0, `${tx}px`].map((x, i) => ({ + const tx = (duration / (delay || 1)) * (leftToRight ? 1 : -1); + return [0, `calc(var(--ass-scale) * ${tx}px)`].map((x, i) => ({ offset: i, transform: `translateX(${x})`, })); } if (name.startsWith('scroll')) { + // speed is 1000px/s when delay=1 const updown = /up/.test(name) ? -1 : 1; - const dp = (y2 - y1) / (duration / delay); - return [y1, y2] - .map((y) => store.scale * y * updown) - .map((y, i) => ({ - offset: Math.min(i, dp), - transform: `translateY${y}`, - })); + const y = duration / (delay || 1) * updown; + return [ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: `translateY(calc(var(--ass-scale) * ${y}px))` }, + ]; } return []; } -function createMoveKeyframes({ move, duration, dialogue }, store) { +function createMoveKeyframes({ move, duration, dialogue }) { const { x1, y1, x2, y2, t1, t2 } = move; const t = [t1, t2 || duration]; const pos = dialogue.pos || { x: 0, y: 0 }; return [[x1, y1], [x2, y2]] - .map(([x, y]) => [store.scale * (x - pos.x), store.scale * (y - pos.y)]) + .map(([x, y]) => [(x - pos.x), (y - pos.y)]) .map(([x, y], index) => ({ offset: Math.min(t[index] / duration, 1), - transform: `translate(${x}px, ${y}px)`, + transform: `translate(calc(var(--ass-scale) * ${x}px), calc(var(--ass-scale) * ${y}px))`, })); } @@ -105,8 +103,8 @@ export function setKeyframes(dialogue, store) { const { start, end, effect, move, fade, slices } = dialogue; const duration = (end - start) * 1000; const keyframes = [ - ...(effect && !move ? createEffectKeyframes({ effect, duration }, store) : []), - ...(move ? createMoveKeyframes({ move, duration, dialogue }, store) : []), + ...(effect && !move ? createEffectKeyframes({ effect, duration }) : []), + ...(move ? createMoveKeyframes({ move, duration, dialogue }) : []), ...(fade ? createFadeKeyframes(fade, duration) : []), ].sort((a, b) => a.offset - b.offset); if (keyframes.length > 0) { @@ -140,8 +138,8 @@ export function setKeyframes(dialogue, store) { // TODO: border and shadow, should animate CSS vars return { offset: t2 / fDuration, - ...(tag.fs && { 'font-size': `${store.scale * getRealFontSize(tag.fn, tag.fs)}px` }), - ...(tag.fsp && { 'letter-spacing': `${store.scale * tag.fsp}px` }), + ...(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)), }), diff --git a/src/renderer/dom.js b/src/renderer/dom.js index 4d16c27..433ebcf 100644 --- a/src/renderer/dom.js +++ b/src/renderer/dom.js @@ -22,7 +22,7 @@ export function createDialogue(dialogue, store) { delay: Math.min(0, start - (video.currentTime - store.delay)) * 1000, fill: 'forwards', }; - $div.animations = []; + const animations = []; slices.forEach((slice) => { const sliceTag = styles[slice.style].tag; const borderStyle = styles[slice.style].style.BorderStyle; @@ -33,7 +33,7 @@ export function createDialogue(dialogue, store) { const cssVars = []; if (!drawing) { cssText += `line-height:normal;font-family:"${tag.fn}",Arial;`; - cssText += `font-size:${store.scale * getRealFontSize(tag.fn, tag.fs)}px;`; + cssText += `font-size:calc(var(--ass-scale) * ${getRealFontSize(tag.fn, tag.fs)}px);`; cssText += `color:${color2rgba(tag.a1 + tag.c1)};`; const scale = /yes/i.test(info.ScaledBorderAndShadow) ? store.scale : 1; if (borderStyle === 1) { @@ -58,7 +58,7 @@ export function createDialogue(dialogue, store) { 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:${store.scale * tag.fsp}px;` : ''; + 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;'; @@ -80,8 +80,8 @@ export function createDialogue(dialogue, store) { } } if (drawing && tag.pbo) { - const pbo = store.scale * -tag.pbo * (tag.fscy || 100) / 100; - cssText += `vertical-align:${pbo}px;`; + const pbo = -tag.pbo * (tag.fscy || 100) / 100; + cssText += `vertical-align:calc(var(--ass-scale) * ${pbo}px);`; } const hasRotate = /"fr[x-z]":[^0]/.test(JSON.stringify(tag)); @@ -116,15 +116,16 @@ export function createDialogue(dialogue, store) { fragment.keyframes, { ...animationOptions, duration: fragment.duration }, ); - $div.animations.push(animation); + animations.push(animation); } df.append($span); }); }); }); if (dialogue.keyframes) { - $div.animations.push(initAnimation($div, dialogue.keyframes, animationOptions)); + animations.push(initAnimation($div, dialogue.keyframes, animationOptions)); } + dialogue.animations = animations; $div.append(df); return $div; } diff --git a/src/renderer/position.js b/src/renderer/position.js index 8a1befb..a7e0d47 100644 --- a/src/renderer/position.js +++ b/src/renderer/position.js @@ -78,15 +78,13 @@ export function getPosition(dialogue, store) { const { effect, move, align, width, height, margin, slices } = dialogue; let x = 0; let y = 0; - if (effect) { - if (effect.name === 'banner') { - x = effect.lefttoright ? -width : store.width; - y = [ - store.height - height - margin.vertical, - (store.height - height) / 2, - margin.vertical, - ][align.v]; - } + if (effect && effect.name === 'banner') { + x = effect.lefttoright ? -width : store.width; + y = [ + store.height - height - margin.vertical, + (store.height - height) / 2, + margin.vertical, + ][align.v]; } else if (dialogue.pos || move) { const pos = dialogue.pos || { x: 0, y: 0 }; const sx = scale * pos.x; diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 41df4d3..af407ae 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -3,6 +3,7 @@ import { createDialogue } from './dom.js'; import { getPosition } from './position.js'; import { createStyle } from './style.js'; import { setTransformOrigin } from './transform.js'; +import { getScrollEffect } from './scroll.js'; export function renderer(dialogue, store) { const $div = createDialogue(dialogue, store); @@ -10,7 +11,7 @@ export function renderer(dialogue, store) { store.box.append($div); const { width } = $div.getBoundingClientRect(); Object.assign(dialogue, { width }); - $div.style.cssText += createStyle(dialogue, store); + $div.style.cssText += createStyle(dialogue); // height may be changed after createStyle const { height } = $div.getBoundingClientRect(); Object.assign(dialogue, { height }); @@ -19,5 +20,8 @@ export function renderer(dialogue, store) { $div.style.cssText += `width:${width}px;height:${height}px;left:${x}px;top:${y}px;`; setTransformOrigin(dialogue, store.scale); Object.assign(dialogue, getClipPath(dialogue, store)); + if (dialogue.effect?.name?.startsWith('scroll')) { + Object.assign(dialogue, getScrollEffect(dialogue, store)); + } return dialogue; } diff --git a/src/renderer/scroll.js b/src/renderer/scroll.js new file mode 100644 index 0000000..683b46d --- /dev/null +++ b/src/renderer/scroll.js @@ -0,0 +1,19 @@ +export function getScrollEffect(dialogue, store) { + const $scrollArea = document.createElement('div'); + $scrollArea.className = 'ASS-scroll-area'; + store.box.insertBefore($scrollArea, dialogue.$div); + $scrollArea.append(dialogue.$div); + const { height } = store.scriptRes; + const { name, y1, y2 } = dialogue.effect; + const min = Math.min(y1, y2); + const max = Math.max(y1, y2); + const top = min / height * 100; + const bottom = (height - max) / height * 100; + $scrollArea.style.cssText += `top:${top}%;bottom:${bottom}%;`; + const up = /up/.test(name); + // eslint-disable-next-line no-param-reassign + dialogue.$div.style.cssText += up ? 'top:100%;' : 'top:0%;'; + return { + $div: $scrollArea, + }; +} diff --git a/src/renderer/style.js b/src/renderer/style.js index fcb57a2..b1805f8 100644 --- a/src/renderer/style.js +++ b/src/renderer/style.js @@ -1,21 +1,16 @@ -export function createStyle(dialogue, store) { - const { layer, align, effect, pos, margin, width } = dialogue; +export function createStyle(dialogue) { + const { layer, align, effect, pos, margin } = dialogue; let cssText = ''; if (layer) cssText += `z-index:${layer};`; cssText += `text-align:${['left', 'center', 'right'][align.h]};`; if (!effect) { - const mw = store.width - store.scale * (margin.left + margin.right); - cssText += `max-width:${mw}px;`; + cssText += `max-width:calc(100% - var(--ass-scale) * ${margin.left + margin.right}px);`; if (!pos) { - if (align.h === 0) { - cssText += `margin-left:${store.scale * margin.left}px;`; + if (align.h !== 0) { + cssText += `margin-right:calc(var(--ass-scale) * ${margin.right}px);`; } - if (align.h === 2) { - cssText += `margin-right:${store.scale * margin.right}px;`; - } - if (width > store.width - store.scale * (margin.left + margin.right)) { - cssText += `margin-left:${store.scale * margin.left}px;`; - cssText += `margin-right:${store.scale * margin.right}px;`; + if (align.h !== 2) { + cssText += `margin-left:calc(var(--ass-scale) * ${margin.left}px);`; } } } diff --git a/src/utils.js b/src/utils.js index b35c02a..62af08f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -76,8 +76,8 @@ export function initAnimation($el, keyframes, options) { return animation; } -export function batchAnimate($el, action) { - ($el.animations || []).forEach((animation) => { +export function batchAnimate(dia, action) { + (dia.animations || []).forEach((animation) => { animation[action](); }); } diff --git a/test/renderer/animation.js b/test/renderer/animation.js index 1f6a4ac..05b4bad 100644 --- a/test/renderer/animation.js +++ b/test/renderer/animation.js @@ -1,8 +1,98 @@ import { describe, it, expect } from 'vitest'; -import { createFadeKeyframes } from '../../src/renderer/animation.js'; +import { createEffectKeyframes, createFadeKeyframes } from '../../src/renderer/animation.js'; describe('render animation', () => { - it('shoud create \\fad() keyframes', () => { + it('should create Banner keyframes', () => { + expect(createEffectKeyframes({ + effect: { name: 'banner', delay: 0, leftToRight: 0, fadeAwayWidth: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateX(0)' }, + { offset: 1, transform: 'translateX(calc(var(--ass-scale) * -1000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'banner', delay: 1, leftToRight: 0, fadeAwayWidth: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateX(0)' }, + { offset: 1, transform: 'translateX(calc(var(--ass-scale) * -1000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'banner', delay: 2, leftToRight: 0, fadeAwayWidth: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateX(0)' }, + { offset: 1, transform: 'translateX(calc(var(--ass-scale) * -500px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'banner', delay: 1, leftToRight: 1, fadeAwayWidth: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateX(0)' }, + { offset: 1, transform: 'translateX(calc(var(--ass-scale) * 1000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'banner', delay: 1, leftToRight: 0, fadeAwayWidth: 0 }, + duration: 5000, + })).to.deep.equal([ + { offset: 0, transform: 'translateX(0)' }, + { offset: 1, transform: 'translateX(calc(var(--ass-scale) * -5000px))' }, + ]); + }); + + it('should create Scroll up/Scroll down keyframes', () => { + expect(createEffectKeyframes({ + effect: { name: 'scroll up', y1: 0, y2: 360, delay: 1, fadeAwayHeight: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * -1000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'scroll up', y1: 0, y2: 360, delay: 1, fadeAwayHeight: 0 }, + duration: 2000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * -2000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'scroll up', y1: 0, y2: 360, delay: 2, fadeAwayHeight: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * -500px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'scroll up', y1: 0, y2: 360, delay: 0, fadeAwayHeight: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * -1000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'scroll down', y1: 0, y2: 360, delay: 1, fadeAwayHeight: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * 1000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'scroll down', y1: 0, y2: 360, delay: 1, fadeAwayHeight: 0 }, + duration: 2000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * 2000px))' }, + ]); + expect(createEffectKeyframes({ + effect: { name: 'scroll down', y1: 0, y2: 360, delay: 2, fadeAwayHeight: 0 }, + duration: 1000, + })).to.deep.equal([ + { offset: 0, transform: 'translateY(-100%)' }, + { offset: 1, transform: 'translateY(calc(var(--ass-scale) * 500px))' }, + ]); + }); + + it('should create \\fad() keyframes', () => { expect(createFadeKeyframes({ type: 'fad', t1: 0, t2: 0 }, 5000)).to.deep.equal([ { offset: 0, opacity: 1 }, { offset: 1, opacity: 1 },