Skip to content

Commit a69ecd5

Browse files
committed
Crispy Text
1 parent f863e6f commit a69ecd5

File tree

1 file changed

+163
-57
lines changed

1 file changed

+163
-57
lines changed

src/utils/ExportCanvas.tsx

Lines changed: 163 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,18 @@ const DrawComposite = (
1414
gl: THREE.WebGLRenderer,
1515
width: number,
1616
height: number,
17-
colors: {bgColor: string, textColor: string}
17+
colors: {bgColor: string, textColor: string},
18+
animate=false,
19+
preview=true,
1820
): HTMLCanvasElement | undefined => {
1921

2022
const {bgColor, textColor} = colors;
2123
const { doubleSize, includeBackground, mainTitle,
2224
cbarLabel, cbarLoc, cbarNum, includeColorbar} = useImageExportStore.getState()
2325
const {valueScales, variable, metadata } = useGlobalStore.getState()
24-
2526
const ctx = compositeCanvas.getContext('2d')
2627
if (!ctx){return}
28+
2729
ctx.imageSmoothingEnabled = true;
2830
ctx.imageSmoothingQuality = 'high';
2931
if (includeBackground) {
@@ -35,38 +37,26 @@ const DrawComposite = (
3537

3638
ctx.drawImage(gl.domElement, 0, 0, width, height)
3739

38-
// ---- TITLE ---- //
39-
const variableSize = doubleSize ? 72 : 36
40-
ctx.fillStyle = textColor
41-
ctx.font = `${variableSize}px "Segoe UI"`
42-
ctx.textBaseline = 'middle'
43-
ctx.fillText(mainTitle?? variable, doubleSize ? 40 : 20, doubleSize ? 100 : 50) // Variable in top Left
44-
4540
const cbarTickSize = doubleSize ? 36 : 18
4641
const unitSize = doubleSize ? 52 : 26
47-
48-
// ---- COLORBAR ---- //
49-
if (includeColorbar){
50-
const secondCanvas = document.getElementById('colorbar-canvas')
5142

52-
let cbarWidth = doubleSize ? Math.min(1024, width*0.8) : Math.min(512, width*0.8)
53-
let cbarHeight = doubleSize ? 48: 24;
43+
let cbarWidth = doubleSize ? Math.min(1024, width*0.8) : Math.min(512, width*0.8)
44+
let cbarHeight = doubleSize ? 48: 24;
5445

55-
let cbarStartPos = Math.round(width/2 - cbarWidth/2)
56-
let cbarTop = cbarLoc === 'top' ? (doubleSize ? 140 : 70) : (doubleSize ? height - 140 : height-70)
57-
58-
const transpose = cbarLoc === 'right' || cbarLoc === 'left'
59-
60-
if (transpose){
61-
const tempWidth = cbarWidth
62-
cbarWidth = cbarHeight
63-
cbarHeight = tempWidth
64-
cbarTop = Math.round(height/2 - cbarHeight/2)
65-
cbarStartPos = cbarLoc === 'right' ? (doubleSize ? width - 140 : width - 70) : (doubleSize ? 140 : 70)
66-
}
46+
let cbarStartPos = Math.round(width/2 - cbarWidth/2)
47+
let cbarTop = cbarLoc === 'top' ? (doubleSize ? 140 : 70) : (doubleSize ? height - 140 : height-70)
48+
const transpose = cbarLoc === 'right' || cbarLoc === 'left'
6749

50+
// ---- COLORBAR ---- //
51+
if (includeColorbar){
52+
const secondCanvas = document.getElementById('colorbar-canvas')
6853
if (secondCanvas instanceof HTMLCanvasElement) {
6954
if (transpose) {
55+
const tempWidth = cbarWidth
56+
cbarWidth = cbarHeight
57+
cbarHeight = tempWidth
58+
cbarTop = Math.round(height/2 - cbarHeight/2)
59+
cbarStartPos = cbarLoc === 'right' ? (doubleSize ? width - 140 : width - 70) : (doubleSize ? 140 : 70)
7060
// Save the current canvas state
7161
ctx.save()
7262

@@ -88,21 +78,129 @@ const DrawComposite = (
8878

8979
// Restore the canvas state
9080
ctx.restore()
91-
} else if(cbarLoc === 'top'){
81+
}else if(cbarLoc === 'top'){
9282
ctx.drawImage(secondCanvas, cbarStartPos, cbarTop, cbarWidth, cbarHeight)
93-
}else {
83+
}else{
9484
ctx.drawImage(secondCanvas, cbarStartPos, cbarTop, cbarWidth, cbarHeight)
9585
}
9686
}
87+
}
88+
89+
// ---- TEXT ---- //
90+
if (!animate){ // If still image write text onto image
91+
// ---- TITLE ---- //
92+
const variableSize = doubleSize ? 72 : 36
93+
ctx.fillStyle = textColor
94+
ctx.font = `${variableSize}px "Segoe UI"`
95+
ctx.textBaseline = 'middle'
96+
ctx.fillText(mainTitle?? variable, doubleSize ? 40 : 20, doubleSize ? 100 : 50) // Variable in top Left
97+
98+
// ---- WATERMARK ---- //
99+
const waterMarkSize = doubleSize ? 40 : 20
100+
ctx.fillStyle = "#888888"
101+
ctx.font = `${waterMarkSize}px "Segoe UI", serif `
102+
ctx.textAlign = 'left'
103+
ctx.textBaseline = 'bottom'
104+
ctx.fillText("browzarr.io", doubleSize ? 20 : 10, doubleSize ? height - 20 : height - 10) // Watermark
105+
106+
if (includeColorbar){
107+
// ---- TickLabels ---- //
108+
ctx.font = `${cbarTickSize}px "Segoe UI"`
109+
const labelNum = cbarNum; // Number of cbar "ticks"
110+
const valRange = valueScales.maxVal-valueScales.minVal;
111+
const valScale = 1/(labelNum-1)
112+
const posDelta = transpose ? 1/(labelNum-1)*cbarHeight : 1/(labelNum-1)*cbarWidth
113+
if (transpose){
114+
const tempWidth = cbarWidth
115+
cbarWidth = cbarHeight
116+
cbarHeight = tempWidth
117+
cbarTop = Math.round(height/2 - cbarHeight/2)
118+
cbarStartPos = cbarLoc === 'right' ? (doubleSize ? width - 140 : width - 70) : (doubleSize ? 140 : 70)
119+
ctx.textBaseline = 'middle'
120+
ctx.textAlign = cbarLoc == 'left' ? 'left' : 'right'
121+
for (let i =0; i < labelNum; i++){
122+
if (cbarLoc == 'left'){
123+
ctx.fillText(String((valueScales.minVal+(i*valScale*valRange)).toFixed(2)), cbarStartPos+cbarWidth+6, cbarTop+cbarHeight-i*posDelta)
124+
} else{
125+
ctx.fillText(String((valueScales.minVal+(i*valScale*valRange)).toFixed(2)), cbarStartPos-6, cbarTop+cbarHeight-i*posDelta)
126+
}
127+
}
128+
}else{
129+
ctx.textBaseline = 'top'
130+
ctx.textAlign = 'center'
131+
for (let i =0; i < labelNum; i++){
132+
ctx.fillText(String((valueScales.minVal+(i*valScale*valRange)).toFixed(2)), cbarStartPos+i*posDelta, cbarTop+cbarHeight+6)
133+
}
134+
}
135+
136+
// ---- Cbar Label/Units ---- //
137+
ctx.fillStyle = textColor
138+
ctx.font = `${unitSize}px "Segoe UI" bold`
139+
ctx.textAlign = 'center'
140+
ctx.fillText(cbarLabel?? metadata?.units, cbarStartPos+cbarWidth/2, cbarTop-unitSize-4)
141+
}
142+
}
143+
144+
}
145+
146+
async function DrawTextOverlay(
147+
width: number,
148+
height: number,
149+
textColor:string,
150+
ffmpeg: FFmpeg
151+
){
152+
const scaling = 4;
153+
const { doubleSize, mainTitle,
154+
cbarLabel, cbarLoc, cbarNum, includeColorbar} = useImageExportStore.getState()
155+
const {valueScales, variable, metadata } = useGlobalStore.getState()
156+
const textCanvas = document.createElement("canvas");
157+
textCanvas.width = width * scaling;
158+
textCanvas.height = height * scaling;
159+
const ctx = textCanvas.getContext("2d");
160+
if (!ctx)return;
161+
162+
ctx.scale(scaling, scaling)
163+
const cbarTickSize = doubleSize ? 36 : 18
164+
const unitSize = doubleSize ? 52 : 26
165+
166+
let cbarWidth = doubleSize ? Math.min(1024, width*0.8) : Math.min(512, width*0.8)
167+
let cbarHeight = doubleSize ? 48: 24;
168+
169+
let cbarStartPos = Math.round(width/2 - cbarWidth/2)
170+
let cbarTop = cbarLoc === 'top' ? (doubleSize ? 140 : 70) : (doubleSize ? height - 140 : height-70)
171+
const transpose = cbarLoc === 'right' || cbarLoc === 'left'
172+
173+
// ---- TEXT ---- //
174+
175+
// ---- TITLE ---- //
176+
const variableSize = doubleSize ? 72 : 36
177+
ctx.fillStyle = textColor
178+
ctx.font = `${variableSize}px "Segoe UI"`
179+
ctx.textBaseline = 'middle'
180+
ctx.textAlign = 'left'
181+
ctx.fillText(mainTitle?? variable, doubleSize ? 40 : 20, doubleSize ? 100 : 50) // Variable in top Left
182+
183+
// ---- WATERMARK ---- //
184+
const waterMarkSize = doubleSize ? 40 : 20
185+
ctx.fillStyle = "#888888"
186+
ctx.font = `${waterMarkSize}px "Segoe UI", serif `
187+
ctx.textBaseline = 'bottom'
188+
ctx.fillText("browzarr.io", doubleSize ? 20 : 10, doubleSize ? height - 20 : height - 10) // Watermark
189+
190+
if (includeColorbar){
191+
// ---- TickLabels ---- //
192+
ctx.font = `${cbarTickSize}px "Segoe UI"`;
193+
ctx.fillStyle = textColor;
97194
const labelNum = cbarNum; // Number of cbar "ticks"
98195
const valRange = valueScales.maxVal-valueScales.minVal;
99196
const valScale = 1/(labelNum-1)
100197
const posDelta = transpose ? 1/(labelNum-1)*cbarHeight : 1/(labelNum-1)*cbarWidth
101-
102-
// ---- TickLabels ---- //
103-
ctx.font = `${cbarTickSize}px "Segoe UI"`
104-
105198
if (transpose){
199+
const tempWidth = cbarWidth
200+
cbarWidth = cbarHeight
201+
cbarHeight = tempWidth
202+
cbarTop = Math.round(height/2 - cbarHeight/2)
203+
cbarStartPos = cbarLoc === 'right' ? (doubleSize ? width - 140 : width - 70) : (doubleSize ? 140 : 70)
106204
ctx.textBaseline = 'middle'
107205
ctx.textAlign = cbarLoc == 'left' ? 'left' : 'right'
108206
for (let i =0; i < labelNum; i++){
@@ -112,30 +210,32 @@ const DrawComposite = (
112210
ctx.fillText(String((valueScales.minVal+(i*valScale*valRange)).toFixed(2)), cbarStartPos-6, cbarTop+cbarHeight-i*posDelta)
113211
}
114212
}
115-
} else{
213+
}else{
116214
ctx.textBaseline = 'top'
117215
ctx.textAlign = 'center'
118216
for (let i =0; i < labelNum; i++){
119217
ctx.fillText(String((valueScales.minVal+(i*valScale*valRange)).toFixed(2)), cbarStartPos+i*posDelta, cbarTop+cbarHeight+6)
120218
}
121219
}
122220

221+
// ---- Cbar Label/Units ---- //
123222
ctx.fillStyle = textColor
124223
ctx.font = `${unitSize}px "Segoe UI" bold`
125224
ctx.textAlign = 'center'
126-
ctx.fillText(cbarLabel?? metadata?.units, cbarStartPos+cbarWidth/2, cbarTop-unitSize-4) // Cbar Units above middle of cbar
225+
ctx.fillText(cbarLabel?? metadata?.units, cbarStartPos+cbarWidth/2, cbarTop-unitSize-4)
127226
}
128227

129-
const waterMarkSize = doubleSize ? 40 : 20
130-
ctx.fillStyle = "#888888"
131-
ctx.font = `${waterMarkSize}px "Segoe UI", serif `
132-
ctx.textAlign = 'left'
133-
ctx.textBaseline = 'bottom'
134-
ctx.fillText("browzarr.io", doubleSize ? 20 : 10, doubleSize ? height - 20 : height - 10) // Watermark
228+
const blob = await new Promise(resolve => {
229+
textCanvas.toBlob(resolve, 'image/png');
230+
});
231+
if (blob) {
232+
const buf = await (blob as Blob).arrayBuffer();
233+
// Write frames to internal ffMpeg filesystem
234+
await ffmpeg.writeFile(`textOverlay.png`, new Uint8Array(buf));
235+
}
135236
}
136237

137238

138-
139239
const ExportCanvas = ({show}:{show: boolean}) => {
140240
const {exportImg, enableExport, animate, frames, frameRate, useTime, timeRate, orbit, loopTime,
141241
animViz, initialState, finalState, preview, useCustomRes, customRes, doubleSize, setHideAxis, setHideAxisControls
@@ -161,7 +261,6 @@ const ExportCanvas = ({show}:{show: boolean}) => {
161261

162262
const origQuality = usePlotStore.getState().quality;
163263
setQuality(preview ? 50 : 1000);
164-
165264
const domWidth = gl.domElement.width;
166265
const domHeight = gl.domElement.height;
167266
let docWidth = useCustomRes ? customRes[0] : (doubleSize ? domWidth * 2 : domWidth);
@@ -180,22 +279,19 @@ const ExportCanvas = ({show}:{show: boolean}) => {
180279
const originalSize = gl.getSize(new THREE.Vector2())
181280
let originalCameraSettings: any = {};
182281

183-
if (useCustomRes || doubleSize){
282+
function SetCamera(){
184283
if (camera instanceof THREE.PerspectiveCamera) {
185284
originalCameraSettings = { aspect: camera.aspect }
186285
camera.aspect = docWidth / docHeight
187-
camera.updateProjectionMatrix()
188286
} else if (camera instanceof THREE.OrthographicCamera) {
189287
originalCameraSettings = {
190288
left: camera.left,
191289
right: camera.right,
192290
top: camera.top,
193291
bottom: camera.bottom
194292
}
195-
196293
const newAspect = docWidth / docHeight
197294
const currentAspect = (camera.right - camera.left) / (camera.top - camera.bottom)
198-
199295
if (newAspect > currentAspect) {
200296
// Wider - expand left/right
201297
const width = (camera.top - camera.bottom) * newAspect
@@ -209,9 +305,11 @@ const ExportCanvas = ({show}:{show: boolean}) => {
209305
camera.top = center + height / 2
210306
camera.bottom = center - height / 2
211307
}
212-
camera.updateProjectionMatrix()
213308
}
309+
214310
gl.setSize(docWidth, docHeight)
311+
camera.updateProjectionMatrix()
312+
invalidate();
215313
}
216314

217315
if (animate){
@@ -221,6 +319,7 @@ const ExportCanvas = ({show}:{show: boolean}) => {
221319
if (!ffmpeg.loaded) {
222320
await ffmpeg.load();
223321
}
322+
224323
ffmpeg.on('progress', ({ progress, time }) => {
225324
// progress is a value between 0 and 1
226325
setProgress(Math.round(progress * 100));
@@ -247,7 +346,7 @@ const ExportCanvas = ({show}:{show: boolean}) => {
247346
camera.position.z = radius * Math.cos(newAngle);
248347
camera.lookAt(0, 0, 0);
249348
camera.updateProjectionMatrix();
250-
invalidate();
349+
!(useCustomRes || doubleSize) && invalidate(); // We will invalidate later if needed. Otherwise do it now
251350
}
252351
if (useTime){
253352
let newProg = dt * Math.floor(frame*timeRatio);
@@ -283,11 +382,13 @@ const ExportCanvas = ({show}:{show: boolean}) => {
283382
});
284383
usePlotStore.setState(lerpedState)
285384
}
286-
385+
if (useCustomRes || doubleSize){
386+
SetCamera()
387+
}
287388
// ----- RENDER TO CANVAS---- //
288389
gl.render(scene, camera);
289390
DrawComposite(compositeCanvasRef.current as HTMLCanvasElement, gl, docWidth, docHeight,
290-
{bgColor, textColor}
391+
{bgColor, textColor}, true
291392
)
292393

293394
const blob = await new Promise(resolve => {
@@ -300,16 +401,21 @@ const ExportCanvas = ({show}:{show: boolean}) => {
300401
}
301402
}
302403
setStatus("Building Animation")
303-
// Generate Animation
404+
await DrawTextOverlay(docWidth, docHeight, textColor, ffmpeg)
304405
const execResult = await ffmpeg.exec([
305406
'-framerate', `${frameRate}`,
306407
'-i', 'frame%04d.png',
408+
'-i', 'textOverlay.png',
409+
'-filter_complex', `[1:v]scale=${docWidth}:${docHeight}[overlay];[0:v][overlay]overlay=0:0`,
307410
'-c:v', 'libx264',
308-
'-pix_fmt', 'yuv420p',
309-
'-preset', `${preview ? 'ultrafast' : 'medium'}`,
310-
'-crf',`${preview ? 28 : 16}`,
411+
'-pix_fmt', 'yuv444p',
412+
'-preset', `${preview ? 'ultrafast' : 'slow'}`,
413+
'-crf', `${preview ? 28 : 16}`,
414+
'-tune', 'stillimage',
415+
'-profile:v', 'high444',
311416
'output.mp4'
312417
]);
418+
if (execResult === 1){setStatus(null)}
313419
setStatus("Fetching Animation")
314420
const videoData = await ffmpeg.readFile('output.mp4');
315421
setStatus(null)
@@ -358,9 +464,9 @@ const ExportCanvas = ({show}:{show: boolean}) => {
358464
camera.right = originalCameraSettings.right;
359465
camera.top = originalCameraSettings.top;
360466
camera.bottom = originalCameraSettings.bottom;
361-
}
362-
camera.updateProjectionMatrix();
467+
}
363468
gl.setSize(originalSize.x, originalSize.y);
469+
camera.updateProjectionMatrix();
364470
}
365471
setQuality(origQuality);
366472

0 commit comments

Comments
 (0)