@subvo/renderer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/renderer.d.ts +66 -0
- package/dist/renderer.esm.js +146 -0
- package/dist/renderer.iife.js +171 -0
- package/package.json +25 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
type SchemaVersion = "1.0";
|
|
2
|
+
type RenderJob = {
|
|
3
|
+
schemaVersion: SchemaVersion;
|
|
4
|
+
rendererVersion: string;
|
|
5
|
+
fps: number;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
playRes: {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
style: RenderStyle;
|
|
12
|
+
lines: RenderLine[];
|
|
13
|
+
layoutHints?: RenderLayoutHints;
|
|
14
|
+
metadata?: RenderMetadata;
|
|
15
|
+
};
|
|
16
|
+
type RenderLine = {
|
|
17
|
+
id: string;
|
|
18
|
+
startMs: number;
|
|
19
|
+
endMs: number;
|
|
20
|
+
words: RenderWord[];
|
|
21
|
+
};
|
|
22
|
+
type RenderWord = {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
startMs: number;
|
|
26
|
+
endMs: number;
|
|
27
|
+
};
|
|
28
|
+
type RenderLayoutHints = {
|
|
29
|
+
maxWordsPerLine?: number;
|
|
30
|
+
};
|
|
31
|
+
type RenderMetadata = {
|
|
32
|
+
jobId?: string;
|
|
33
|
+
source?: string;
|
|
34
|
+
createdAt?: string;
|
|
35
|
+
tags?: string[];
|
|
36
|
+
};
|
|
37
|
+
type RenderPosition = "bottom_safe" | "center";
|
|
38
|
+
type RenderPreset = "fire" | "clean" | "luxury" | "pop" | "ghost";
|
|
39
|
+
type RenderStyle = {
|
|
40
|
+
fontFamily: string;
|
|
41
|
+
fontSize: number;
|
|
42
|
+
fontWeight: number;
|
|
43
|
+
lineSpacing: number;
|
|
44
|
+
baseColor: string;
|
|
45
|
+
highlightColor: string;
|
|
46
|
+
outlineWidth: number;
|
|
47
|
+
outlineColor: string;
|
|
48
|
+
shadow: boolean;
|
|
49
|
+
shadowDepth: number;
|
|
50
|
+
glowIntensity: number;
|
|
51
|
+
highlightStrength: number;
|
|
52
|
+
pulse: number;
|
|
53
|
+
karaokeScale: number;
|
|
54
|
+
maxWordsPerLine: number;
|
|
55
|
+
position: RenderPosition;
|
|
56
|
+
offsetY: number;
|
|
57
|
+
fadeInMs: number;
|
|
58
|
+
fadeOutMs: number;
|
|
59
|
+
preset: RenderPreset;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
declare function renderFrame(ctx: CanvasRenderingContext2D, renderJob: RenderJob, timeMs: number): void;
|
|
63
|
+
|
|
64
|
+
declare const VERSION: string;
|
|
65
|
+
|
|
66
|
+
export { type RenderJob, type RenderLayoutHints, type RenderLine, type RenderMetadata, type RenderPosition, type RenderPreset, type RenderStyle, type RenderWord, type SchemaVersion, VERSION, renderFrame };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/layout.ts
|
|
2
|
+
function computeLayout(ctx, renderJob) {
|
|
3
|
+
const { style } = renderJob;
|
|
4
|
+
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
5
|
+
ctx.textBaseline = "alphabetic";
|
|
6
|
+
return renderJob.lines.map((line) => {
|
|
7
|
+
const rows = chunkWords(line.words, style.maxWordsPerLine);
|
|
8
|
+
return {
|
|
9
|
+
line,
|
|
10
|
+
rows: rows.map((words) => {
|
|
11
|
+
let rowWidth = 0;
|
|
12
|
+
const measuredWords = words.map((word, index) => {
|
|
13
|
+
const text = index < words.length - 1 ? `${word.text} ` : word.text;
|
|
14
|
+
const metrics = ctx.measureText(text);
|
|
15
|
+
const width = metrics.width;
|
|
16
|
+
rowWidth += width;
|
|
17
|
+
return {
|
|
18
|
+
word,
|
|
19
|
+
text,
|
|
20
|
+
width
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
words: measuredWords,
|
|
25
|
+
width: rowWidth
|
|
26
|
+
};
|
|
27
|
+
})
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function chunkWords(words, maxPerLine) {
|
|
32
|
+
if (maxPerLine <= 0) return [words];
|
|
33
|
+
const rows = [];
|
|
34
|
+
let current = [];
|
|
35
|
+
for (const word of words) {
|
|
36
|
+
current.push(word);
|
|
37
|
+
if (current.length >= maxPerLine) {
|
|
38
|
+
rows.push(current);
|
|
39
|
+
current = [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (current.length > 0) {
|
|
43
|
+
rows.push(current);
|
|
44
|
+
}
|
|
45
|
+
return rows;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/effects.ts
|
|
49
|
+
function clamp(value, min = 0, max = 1) {
|
|
50
|
+
return Math.min(max, Math.max(min, value));
|
|
51
|
+
}
|
|
52
|
+
function computeFadeAlpha(style, line, timeMs) {
|
|
53
|
+
let alpha = 1;
|
|
54
|
+
if (style.fadeInMs > 0) {
|
|
55
|
+
alpha = Math.min(alpha, (timeMs - line.startMs) / style.fadeInMs);
|
|
56
|
+
}
|
|
57
|
+
if (style.fadeOutMs > 0) {
|
|
58
|
+
alpha = Math.min(alpha, (line.endMs - timeMs) / style.fadeOutMs);
|
|
59
|
+
}
|
|
60
|
+
return clamp(alpha);
|
|
61
|
+
}
|
|
62
|
+
function computeWordProgress(word, timeMs) {
|
|
63
|
+
const duration = word.endMs - word.startMs;
|
|
64
|
+
if (duration <= 0) return 0;
|
|
65
|
+
return clamp((timeMs - word.startMs) / duration);
|
|
66
|
+
}
|
|
67
|
+
function computePulseScale(style, progress) {
|
|
68
|
+
return 1 + (style.pulse - 1) * progress * style.highlightStrength;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/drawText.ts
|
|
72
|
+
function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
|
|
73
|
+
const invScale = 1 / scaleFactor;
|
|
74
|
+
ctx.save();
|
|
75
|
+
ctx.translate(x, y);
|
|
76
|
+
ctx.translate(0, -ascent);
|
|
77
|
+
ctx.scale(scaleFactor, scaleFactor);
|
|
78
|
+
ctx.translate(0, ascent);
|
|
79
|
+
ctx.fillStyle = fillColor;
|
|
80
|
+
if (style.outlineWidth > 0) {
|
|
81
|
+
ctx.lineWidth = style.outlineWidth * invScale;
|
|
82
|
+
ctx.strokeStyle = style.outlineColor;
|
|
83
|
+
ctx.strokeText(text, 0, 0);
|
|
84
|
+
}
|
|
85
|
+
if (style.shadow || style.glowIntensity > 0) {
|
|
86
|
+
ctx.shadowColor = style.outlineColor;
|
|
87
|
+
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
88
|
+
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
89
|
+
ctx.shadowBlur = style.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
|
|
90
|
+
} else {
|
|
91
|
+
ctx.shadowColor = "transparent";
|
|
92
|
+
ctx.shadowBlur = 0;
|
|
93
|
+
ctx.shadowOffsetX = 0;
|
|
94
|
+
ctx.shadowOffsetY = 0;
|
|
95
|
+
}
|
|
96
|
+
ctx.fillText(text, 0, 0);
|
|
97
|
+
ctx.restore();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/renderFrame.ts
|
|
101
|
+
function renderFrame(ctx, renderJob, timeMs) {
|
|
102
|
+
const { playRes, style } = renderJob;
|
|
103
|
+
const layout = computeLayout(ctx, renderJob);
|
|
104
|
+
const active = layout.find(
|
|
105
|
+
(line) => timeMs >= line.line.startMs && timeMs <= line.line.endMs
|
|
106
|
+
);
|
|
107
|
+
if (!active) return;
|
|
108
|
+
const transform = ctx.getTransform();
|
|
109
|
+
const canvasWidth = ctx.canvas.width / (transform.a || 1);
|
|
110
|
+
const canvasHeight = ctx.canvas.height / (transform.d || 1);
|
|
111
|
+
const scale = Math.max(canvasWidth / playRes.width, canvasHeight / playRes.height);
|
|
112
|
+
const offsetX = (canvasWidth - playRes.width * scale) / 2;
|
|
113
|
+
const offsetY = (canvasHeight - playRes.height * scale) / 2;
|
|
114
|
+
const alpha = computeFadeAlpha(style, active.line, timeMs);
|
|
115
|
+
ctx.save();
|
|
116
|
+
ctx.translate(offsetX, offsetY);
|
|
117
|
+
ctx.scale(scale, scale);
|
|
118
|
+
ctx.globalAlpha = alpha;
|
|
119
|
+
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
120
|
+
ctx.textBaseline = "alphabetic";
|
|
121
|
+
const metrics = ctx.measureText("Mg");
|
|
122
|
+
const ascent = metrics.actualBoundingBoxAscent;
|
|
123
|
+
const centerX = playRes.width / 2;
|
|
124
|
+
let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
|
|
125
|
+
for (const row of active.rows) {
|
|
126
|
+
let x = centerX - row.width / 2;
|
|
127
|
+
for (const { word, text, width } of row.words) {
|
|
128
|
+
const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
|
|
129
|
+
const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
|
|
130
|
+
const scaleFactor = computePulseScale(style, progress);
|
|
131
|
+
const fillColor = activeWord ? style.highlightColor : style.baseColor;
|
|
132
|
+
drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor);
|
|
133
|
+
x += width;
|
|
134
|
+
}
|
|
135
|
+
y += style.fontSize * style.lineSpacing;
|
|
136
|
+
}
|
|
137
|
+
ctx.restore();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/index.ts
|
|
141
|
+
var injectedVersion = "1.0.0".length > 0 ? "1.0.0" : "dev";
|
|
142
|
+
var VERSION = injectedVersion;
|
|
143
|
+
export {
|
|
144
|
+
VERSION,
|
|
145
|
+
renderFrame
|
|
146
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var SubvoRenderer = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var src_exports = {};
|
|
23
|
+
__export(src_exports, {
|
|
24
|
+
VERSION: () => VERSION,
|
|
25
|
+
renderFrame: () => renderFrame
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// src/layout.ts
|
|
29
|
+
function computeLayout(ctx, renderJob) {
|
|
30
|
+
const { style } = renderJob;
|
|
31
|
+
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
32
|
+
ctx.textBaseline = "alphabetic";
|
|
33
|
+
return renderJob.lines.map((line) => {
|
|
34
|
+
const rows = chunkWords(line.words, style.maxWordsPerLine);
|
|
35
|
+
return {
|
|
36
|
+
line,
|
|
37
|
+
rows: rows.map((words) => {
|
|
38
|
+
let rowWidth = 0;
|
|
39
|
+
const measuredWords = words.map((word, index) => {
|
|
40
|
+
const text = index < words.length - 1 ? `${word.text} ` : word.text;
|
|
41
|
+
const metrics = ctx.measureText(text);
|
|
42
|
+
const width = metrics.width;
|
|
43
|
+
rowWidth += width;
|
|
44
|
+
return {
|
|
45
|
+
word,
|
|
46
|
+
text,
|
|
47
|
+
width
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
words: measuredWords,
|
|
52
|
+
width: rowWidth
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function chunkWords(words, maxPerLine) {
|
|
59
|
+
if (maxPerLine <= 0) return [words];
|
|
60
|
+
const rows = [];
|
|
61
|
+
let current = [];
|
|
62
|
+
for (const word of words) {
|
|
63
|
+
current.push(word);
|
|
64
|
+
if (current.length >= maxPerLine) {
|
|
65
|
+
rows.push(current);
|
|
66
|
+
current = [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (current.length > 0) {
|
|
70
|
+
rows.push(current);
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/effects.ts
|
|
76
|
+
function clamp(value, min = 0, max = 1) {
|
|
77
|
+
return Math.min(max, Math.max(min, value));
|
|
78
|
+
}
|
|
79
|
+
function computeFadeAlpha(style, line, timeMs) {
|
|
80
|
+
let alpha = 1;
|
|
81
|
+
if (style.fadeInMs > 0) {
|
|
82
|
+
alpha = Math.min(alpha, (timeMs - line.startMs) / style.fadeInMs);
|
|
83
|
+
}
|
|
84
|
+
if (style.fadeOutMs > 0) {
|
|
85
|
+
alpha = Math.min(alpha, (line.endMs - timeMs) / style.fadeOutMs);
|
|
86
|
+
}
|
|
87
|
+
return clamp(alpha);
|
|
88
|
+
}
|
|
89
|
+
function computeWordProgress(word, timeMs) {
|
|
90
|
+
const duration = word.endMs - word.startMs;
|
|
91
|
+
if (duration <= 0) return 0;
|
|
92
|
+
return clamp((timeMs - word.startMs) / duration);
|
|
93
|
+
}
|
|
94
|
+
function computePulseScale(style, progress) {
|
|
95
|
+
return 1 + (style.pulse - 1) * progress * style.highlightStrength;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/drawText.ts
|
|
99
|
+
function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
|
|
100
|
+
const invScale = 1 / scaleFactor;
|
|
101
|
+
ctx.save();
|
|
102
|
+
ctx.translate(x, y);
|
|
103
|
+
ctx.translate(0, -ascent);
|
|
104
|
+
ctx.scale(scaleFactor, scaleFactor);
|
|
105
|
+
ctx.translate(0, ascent);
|
|
106
|
+
ctx.fillStyle = fillColor;
|
|
107
|
+
if (style.outlineWidth > 0) {
|
|
108
|
+
ctx.lineWidth = style.outlineWidth * invScale;
|
|
109
|
+
ctx.strokeStyle = style.outlineColor;
|
|
110
|
+
ctx.strokeText(text, 0, 0);
|
|
111
|
+
}
|
|
112
|
+
if (style.shadow || style.glowIntensity > 0) {
|
|
113
|
+
ctx.shadowColor = style.outlineColor;
|
|
114
|
+
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
115
|
+
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
116
|
+
ctx.shadowBlur = style.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
|
|
117
|
+
} else {
|
|
118
|
+
ctx.shadowColor = "transparent";
|
|
119
|
+
ctx.shadowBlur = 0;
|
|
120
|
+
ctx.shadowOffsetX = 0;
|
|
121
|
+
ctx.shadowOffsetY = 0;
|
|
122
|
+
}
|
|
123
|
+
ctx.fillText(text, 0, 0);
|
|
124
|
+
ctx.restore();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/renderFrame.ts
|
|
128
|
+
function renderFrame(ctx, renderJob, timeMs) {
|
|
129
|
+
const { playRes, style } = renderJob;
|
|
130
|
+
const layout = computeLayout(ctx, renderJob);
|
|
131
|
+
const active = layout.find(
|
|
132
|
+
(line) => timeMs >= line.line.startMs && timeMs <= line.line.endMs
|
|
133
|
+
);
|
|
134
|
+
if (!active) return;
|
|
135
|
+
const transform = ctx.getTransform();
|
|
136
|
+
const canvasWidth = ctx.canvas.width / (transform.a || 1);
|
|
137
|
+
const canvasHeight = ctx.canvas.height / (transform.d || 1);
|
|
138
|
+
const scale = Math.max(canvasWidth / playRes.width, canvasHeight / playRes.height);
|
|
139
|
+
const offsetX = (canvasWidth - playRes.width * scale) / 2;
|
|
140
|
+
const offsetY = (canvasHeight - playRes.height * scale) / 2;
|
|
141
|
+
const alpha = computeFadeAlpha(style, active.line, timeMs);
|
|
142
|
+
ctx.save();
|
|
143
|
+
ctx.translate(offsetX, offsetY);
|
|
144
|
+
ctx.scale(scale, scale);
|
|
145
|
+
ctx.globalAlpha = alpha;
|
|
146
|
+
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
147
|
+
ctx.textBaseline = "alphabetic";
|
|
148
|
+
const metrics = ctx.measureText("Mg");
|
|
149
|
+
const ascent = metrics.actualBoundingBoxAscent;
|
|
150
|
+
const centerX = playRes.width / 2;
|
|
151
|
+
let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
|
|
152
|
+
for (const row of active.rows) {
|
|
153
|
+
let x = centerX - row.width / 2;
|
|
154
|
+
for (const { word, text, width } of row.words) {
|
|
155
|
+
const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
|
|
156
|
+
const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
|
|
157
|
+
const scaleFactor = computePulseScale(style, progress);
|
|
158
|
+
const fillColor = activeWord ? style.highlightColor : style.baseColor;
|
|
159
|
+
drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor);
|
|
160
|
+
x += width;
|
|
161
|
+
}
|
|
162
|
+
y += style.fontSize * style.lineSpacing;
|
|
163
|
+
}
|
|
164
|
+
ctx.restore();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/index.ts
|
|
168
|
+
var injectedVersion = "1.0.0".length > 0 ? "1.0.0" : "dev";
|
|
169
|
+
var VERSION = injectedVersion;
|
|
170
|
+
return __toCommonJS(src_exports);
|
|
171
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@subvo/renderer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/renderer.esm.js",
|
|
6
|
+
"module": "./dist/renderer.esm.js",
|
|
7
|
+
"types": "./dist/renderer.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/renderer.d.ts",
|
|
11
|
+
"import": "./dist/renderer.esm.js"
|
|
12
|
+
},
|
|
13
|
+
"./iife": "./dist/renderer.iife.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"tsup": "^8.0.0",
|
|
20
|
+
"typescript": "^5.7.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup"
|
|
24
|
+
}
|
|
25
|
+
}
|