javascripttypescriptnext.jshtml5-canvasdynamic-image-generation

Image Generation using HTML canvas stops after one generation


I have an image generation button set up and working using Nextjs and the HTML canvas element, which (almost) works beautifully. When a user clicks the "Generate Image" button, it generates an image with a bunch of smaller images in it with labels underneath each one.

Code:

const downloadImage = () => {
    if (isGeneratingImage) return
    setIsGeneratingImage(true)

    // Define sizes for canvas components
    const canvasWidth = 1000;
    const logoHeight = 70;
    const logoMargin = 16;
    const symbolsPerRow = 6;
    const symbolCardWidth = 140;
    const symbolCardHeight = 175;
    const symbolCardGap = 8;
    const symbolImageSize = 96;

    // Calculate canvas height based on number of symbols
    // Symbols are arranged like a flexbox row with wrap
    const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
    const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2);

    // Create canvas element in the html document
    const canvas = document.createElement('canvas');
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;

    // Get 2d drawing context
    const ctx = canvas.getContext('2d')!;

    // Draw background image (same as the one used for the PageSection)
    const background = new Image();
    background.src = backgroundImageSrc;

    const RobotoBold = new FontFace('Roboto-Bold', 'url(/fonts/Roboto-Bold.ttf)')
    
    RobotoBold.load()
    .then(() => (
        new Promise<void>(resolve => {
            document.fonts.add(RobotoBold);

            background.onload = () => {
                // Calculate scaling factors to cover the canvas while maintaining aspect ratio
                const scaleX = canvasWidth / background.width;
                const scaleY = canvasHeight / background.height;
                const scale = Math.max(scaleX, scaleY);

                // Calculate the new width and height of the image
                const newWidth = background.width * scale;
                const newHeight = background.height * scale;

                // Calculate the position to center the image on the canvas
                const x = (canvasWidth - newWidth) / 2;
                const y = (canvasHeight - newHeight) / 2;

                // Draw the background image with the calculated parameters
                ctx.filter = 'brightness(0.4) blur(10px)';
                ctx.drawImage(background, x, y, newWidth, newHeight);

                // Reset filter
                ctx.filter = 'none';

                resolve();
            };
        })
    ))
    .then(() => {
        // List of promises for loading images
        const imagePromises: Promise<void>[] = [];

        // Load the logo image
        const logo = new Image();
        logo.src = FullLogo.src;
        imagePromises.push(new Promise<void>(resolve => {
            logo.onload = () => {
                // Calculate the scaled width to maintain aspect ratio
                const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth;

                // Draw logo horizontally centered with a margin at the top
                ctx.drawImage(
                    logo,
                    canvasWidth / 2 - scaledWidth / 2,
                    logoMargin,
                    scaledWidth,
                    logoHeight
                );
                resolve();
            }
        }));
        
        // Calculate values for drawing symbols in the last row
        const symbolsInLastRow = imageList.length % symbolsPerRow;
        const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2

        // Draw symbols with rounded backgrounds
        for (let i = 0; i < imageList.length; i++) {
            const imageReference = imageList[i];

            // If the symbol is in the last row, we need to adjust the x position to center it
            const isLastRow = i >= imageList.length - symbolsInLastRow;
        
            const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0);
            const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);

            // Draw transparent gray background for symbol with rounded borders
            ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
            roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16);

            // Draw symbol image
            const image = new Image();
            image.src = imageReference.url;
            imagePromises.push(new Promise<void>(resolve => {
                image.onload = () => {
                    ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize);
                    resolve();
                }
            }));

            // Draw symbol name
            ctx.fillStyle = 'white';
            ctx.font = '20px Roboto-Bold';
            ctx.textAlign = 'center';
            ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16);
        }

        // Convert canvas to Blob and trigger download after all images are loaded
        Promise.all(imagePromises)
        .then(() => {
            canvas.toBlob(blob => {
                // Trigger download
                const a = document.createElement('a');
                a.download = `${calloutSet?.name}.png`;
                a.href = URL.createObjectURL(blob!);
                a.click();
                setIsGeneratingImage(false);
            });
        })
    });
}

Notice how I use Promises to move between each step of the image generation process after the font is loaded, then after the background image is loaded, then after all the smaller images have loaded.

However, the issue is that after the image is generated once (or sometimes several times), it will not work the second time because the background.onload callback is never called, thus the following steps are never executed (I have tested this with console logs). Why is this erratic behavior happening, and how can I fix it?


Solution

  • Because you're setting the onload callback of your background in the load call back of roboto, if your background loads before roboto then the onload of the background will never be called.

    Just move your background.onload outside the roboto load resolver.

    Adapted from your code, without roboto loading.

    The image does not download because stackoverflow does not allow it but I'm changing the url of the "result" img to the generated dataUrl for demo (each time a new random imageList is generated to prove it works multiple times without problem, although a bit of error handling may be needed here ;) )

    const imgUrl = () => `https://picsum.photos/${100 + Math.round(100 + Math.random() * 100)}/${100 + Math.round(200 + Math.random() * 100)}`;
    
    
    const backgroundImageSrc = imgUrl();
    const FullLogo = document.getElementById('FullLogo');
    function roundRect(ctx, x, y, w, h, r) {
      ctx.beginPath();
      ctx.roundRect(x, y, w, h, r);
      ctx.stroke();
    }
    // just maps id to id
    const customNames = [...'abcdefgh'].reduce((a, e) => { a[e] = e; return a; }, {});
    
    let isGeneratingImage = false;
    const downloadImage = () => {
        if (isGeneratingImage) return
        isGeneratingImage = true
    
        // random images
        const imageList = [
          { id: 'a', url: imgUrl() },
          { id: 'b', url: imgUrl() },
          { id: 'c', url: imgUrl() },
          { id: 'd', url: imgUrl() },
          { id: 'e', url: imgUrl() },
          { id: 'f', url: imgUrl() },
          { id: 'g', url: imgUrl() },
          { id: 'h', url: imgUrl() }
        ];
        // Define sizes for canvas components
        const canvasWidth = 1000;
        const logoHeight = 70;
        const logoMargin = 16;
        const symbolsPerRow = 6;
        const symbolCardWidth = 140;
        const symbolCardHeight = 175;
        const symbolCardGap = 8;
        const symbolImageSize = 96;
    
        // Calculate canvas height based on number of symbols
        // Symbols are arranged like a flexbox row with wrap
        const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
        const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2);
    
        // Create canvas element in the html document
        const canvas = document.createElement('canvas');
        canvas.width = canvasWidth;
        canvas.height = canvasHeight;
    
        // Get 2d drawing context
        const ctx = canvas.getContext('2d');
    
        // Draw background image (same as the one used for the PageSection)
        const background = new Image();
        background.setAttribute('crossorigin', 'anonymous');
        background.src = backgroundImageSrc;
        
        background.onload = () => {
            // Calculate scaling factors to cover the canvas while maintaining aspect ratio
            const scaleX = canvasWidth / background.width;
            const scaleY = canvasHeight / background.height;
            const scale = Math.max(scaleX, scaleY);
    
            // Calculate the new width and height of the image
            const newWidth = background.width * scale;
            const newHeight = background.height * scale;
    
            // Calculate the position to center the image on the canvas
            const x = (canvasWidth - newWidth) / 2;
            const y = (canvasHeight - newHeight) / 2;
    
            // Draw the background image with the calculated parameters
            ctx.filter = 'brightness(0.4) blur(10px)';
            ctx.drawImage(background, x, y, newWidth, newHeight);
    
            // Reset filter
            ctx.filter = 'none';
            
            // List of promises for loading images
            const imagePromises = [];
    
            // Load the logo image
            const logo = new Image();
            logo.setAttribute('crossorigin', 'anonymous');
            logo.src = FullLogo.src;
            imagePromises.push(new Promise(resolve => {
                logo.onload = () => {
                    // Calculate the scaled width to maintain aspect ratio
                    const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth;
    
                    // Draw logo horizontally centered with a margin at the top
                    ctx.drawImage(
                        logo,
                        canvasWidth / 2 - scaledWidth / 2,
                        logoMargin,
                        scaledWidth,
                        logoHeight
                    );
                    resolve();
                }
            }));
            
            // Calculate values for drawing symbols in the last row
            const symbolsInLastRow = imageList.length % symbolsPerRow;
            const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2
    
            // Draw symbols with rounded backgrounds
            for (let i = 0; i < imageList.length; i++) {
                const imageReference = imageList[i];
    
                // If the symbol is in the last row, we need to adjust the x position to center it
                const isLastRow = i >= imageList.length - symbolsInLastRow;
            
                const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0);
                const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
    
                // Draw transparent gray background for symbol with rounded borders
                ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
                roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16);
    
                // Draw symbol image
                const image = new Image();
                image.setAttribute('crossorigin', 'anonymous');
                image.src = imageReference.url + '  ';
                
                imagePromises.push(new Promise(resolve => {
    
                    image.onload = () => {
                        ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize);
                        resolve();
                    }
                }));
    
                // Draw symbol name
                ctx.fillStyle = 'white';
                ctx.fontSize = '20px';
                ctx.fontFamily = "'Roboto', sans-serif;";
                ctx.textAlign = 'center';
                ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16);
            }
    
            // Convert canvas to Blob and trigger download after all images are loaded
            Promise.all(imagePromises)
            .then(() => {
                
                const dataUrl = canvas.toDataURL();
                document.getElementById('result').src = dataUrl;
                
                isGeneratingImage = false;
    
                // Trigger download do not work on SO
                // const a = document.createElement('a');
                // a.download = `blabla.png`; // `
                // a.href = dataUrl;
                // a.click();
                
            });
    
        };
            
    }
    
    document.getElementById('dlbutton').addEventListener('click', downloadImage);
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@700&display=swap" rel="stylesheet">
    <button id="dlbutton">download</button>
    <img id="FullLogo" src="https://picsum.photos/200/300" crossorigin="anonymous" />
    
    <img id="result" src="https://picsum.photos/640/480" crossorigin="anonymous" />