UNPKG

assjs

Version:

A lightweight JavaScript ASS subtitle renderer

206 lines (194 loc) 7 kB
import { color2rgba, initAnimation } from '../utils.js'; import { getRealFontSize } from './font-size.js'; // eslint-disable-next-line import/no-cycle import { createRectClip } from './clip.js'; import { rotateTags, skewTags, scaleTags } from './transform.js'; const strokeTags = ['blur', 'xbord', 'ybord', 'xshad', 'yshad']; if (window.CSS.registerProperty) { [ 'real-fs', 'tag-fs', 'tag-fsp', 'border-width', ...[...strokeTags, ...rotateTags, ...skewTags].map((tag) => `tag-${tag}`), ].forEach((k) => { window.CSS.registerProperty({ name: `--ass-${k}`, syntax: '<number>', inherits: true, initialValue: 0, }); }); [ 'border-opacity', 'shadow-opacity', ...scaleTags.map((tag) => `tag-${tag}`), ].forEach((k) => { window.CSS.registerProperty({ name: `--ass-${k}`, syntax: '<number>', inherits: true, initialValue: 1, }); }); ['fill-color', 'border-color', 'shadow-color'].forEach((k) => { window.CSS.registerProperty({ name: `--ass-${k}`, syntax: '<color>', inherits: true, initialValue: 'transparent', }); }); } export function createEffect(effect, duration) { // TODO: when effect and move both exist, its behavior is weird, for now only move works. const { name, delay, leftToRight } = effect; const translate = name === 'banner' ? 'X' : 'Y'; const dir = ({ X: leftToRight ? 1 : -1, Y: /up/.test(name) ? -1 : 1, })[translate]; const start = -100 * dir; // speed is 1000px/s when delay=1 const distance = (duration / (delay || 1)) * dir; const keyframes = [ { offset: 0, transform: `translate${translate}(${start}%)` }, { offset: 1, transform: `translate${translate}(calc(${start}% + var(--ass-scale) * ${distance}px))` }, ]; return [keyframes, { duration, fill: 'forwards' }]; } function multiplyScale(v) { return `calc(var(--ass-scale) * ${v}px)`; } export function createMove(move, duration) { const { x1, y1, x2, y2, t1, t2 } = move; const start = `translate(${multiplyScale(x1)}, ${multiplyScale(y1)})`; const end = `translate(${multiplyScale(x2)}, ${multiplyScale(y2)})`; const moveDuration = Math.max(t2, duration); const keyframes = [ { offset: 0, transform: start }, t1 > 0 ? { offset: t1 / moveDuration, transform: start } : null, (t2 > 0 && t2 < duration) ? { offset: t2 / moveDuration, transform: end } : null, { offset: 1, transform: end }, ].filter(Boolean); const options = { duration: moveDuration, fill: 'forwards' }; return [keyframes, options]; } export function createFadeList(fade, duration) { const { type, a1, a2, a3, t1, t2, t3, t4 } = fade; // \fad(<t1>, <t2>) if (type === 'fad') { // For example dialogue starts at 0 and ends at 5000 with \fad(4000, 4000) // * <t1> means opacity from 0 to 1 in (0, 4000) // * <t2> means opacity from 1 to 0 in (1000, 5000) // <t1> and <t2> are overlaped in (1000, 4000), <t1> will take affect // so the result is: // * opacity from 0 to 1 in (0, 4000) // * opacity from 0.25 to 0 in (4000, 5000) const t1Keyframes = [{ offset: 0, opacity: 0 }, { offset: 1, opacity: 1 }]; const t2Keyframes = [{ offset: 0, opacity: 1 }, { offset: 1, opacity: 0 }]; return [ [t2Keyframes, { duration: t2, delay: duration - t2, fill: 'forwards' }], [t1Keyframes, { duration: t1, composite: 'replace' }], ]; } // \fade(<a1>, <a2>, <a3>, <t1>, <t2>, <t3>, <t4>) const fadeDuration = Math.max(duration, t4); const opacities = [a1, a2, a3].map((a) => 1 - a / 255); const offsets = [0, t1, t2, t3, t4].map((t) => t / fadeDuration); const keyframes = offsets.map((t, i) => ({ offset: t, opacity: opacities[i >> 1] })); return [ [keyframes, { duration: fadeDuration, fill: 'forwards' }], ]; } 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]); } // use linear() to simulate accel function getEasing(duration, accel) { if (accel === 1) return 'linear'; // 60fps const frames = Math.ceil(duration / 1000 * 60); const points = Array.from({ length: frames + 1 }) .map((_, i) => (i / frames) ** accel); return `linear(${points.join(',')})`; } export function createDialogueAnimations(el, dialogue) { const { start, end, effect, move, fade } = dialogue; const duration = (end - start) * 1000; return [ effect && !move ? createEffect(effect, duration) : null, move ? createMove(move, duration) : null, ...(fade ? createFadeList(fade, duration) : []), ] .filter(Boolean) .map(([keyframes, options]) => initAnimation(el, keyframes, options)); } function createTagKeyframes(fromTag, tag, key) { const value = tag[key]; if (value === undefined) return []; if (key === 'clip') return []; if (key === 'a1' || key === 'c1') { return [['fill-color', color2rgba((tag.a1 || fromTag.a1) + (tag.c1 || fromTag.c1))]]; } if (key === 'a3' || key === 'c3') { return [['border-color', color2rgba((tag.a3 || fromTag.a3) + (tag.c3 || fromTag.c3))]]; } if (key === 'a4' || key === 'c4') { return [['shadow-color', color2rgba((tag.a4 || fromTag.a4) + (tag.c4 || fromTag.c4))]]; } if (key === 'fs') { return [ ['real-fs', getRealFontSize(tag.fn || fromTag.fn, tag.fs)], ['tag-fs', value], ]; } if (key === 'fscx' || key === 'fscy') { return [[`tag-${key}`, (value || 100) / 100]]; } if (key === 'xbord' || key === 'ybord') { return [['border-width', value * 2]]; } return [[`tag-${key}`, value]]; } export function createTagAnimations(el, fragment, sliceTag) { const fromTag = { ...sliceTag, ...fragment.tag }; return (fragment.tag.t || []).map(({ t1, t2, accel, tag }) => { const keyframe = Object.fromEntries( Object.keys(tag) .flatMap((key) => createTagKeyframes(fromTag, tag, key)) .map(([k, v]) => [`--ass-${k}`, v]) // .concat(tag.clip ? [['clipPath', ]] : []) .concat([['offset', 1]]), ); const duration = Math.max(0, t2 - t1); return initAnimation(el, [keyframe], { duration, delay: t1, fill: 'forwards', easing: getEasing(duration, accel), }); }); } export function createClipAnimations(el, dialogue, store) { return dialogue.slices .flatMap((slice) => slice.fragments) .flatMap((fragment) => fragment.tag.t || []) .filter(({ tag }) => tag.clip) .map(({ t1, t2, accel, tag }) => { const keyframe = { offset: 1, clipPath: createRectClip(tag.clip, store.scriptRes.width, store.scriptRes.height), }; const duration = Math.max(0, t2 - t1); return initAnimation(el, [keyframe], { duration, delay: t1, fill: 'forwards', easing: getEasing(duration, accel), }); }); }