• Runtimes
  • Swap Spine Images from Web Player runtime

Hi guys! I'm new to this forum - hi y'all <3 - and I want to try and get some help on something I've been working on!

I'm trying to do a Spine web editor where an user can change the spine's project images to generate new assets from a templated one. I've successfully embedded the spine web player into my ReactJS app, and now I'm trying to get information on how to access the images inside the skeleton's slots, and how to save the end result in a new spine project or data structure! Nevertheless, I can't seem to find anything related to the topic.

Any idea on how I could advance through this?

Thank you so much!! ๐Ÿ™‚

Related Discussions
...

Mario Hi Mario, thank you for your quick response!

I've been going through the examples and I can't see how to setup the viewer! I'm trying to see the changes on runtime, so the viewer becomes important to me.

I've been trying to implement this JSON and Binary Data load from the Runtimes API, but can't get it to work! Here is the code I'm using:

import React, {useEffect, useRef} from 'react';
import * as spine from '@esotericsoftware/spine-player';
import '../../styles/SpinePlayer.css'

function SpinePlayer ({ jsonUrl, atlasUrl }) {
    const playerRef = useRef(null);
    let spinePlayer = useRef(null);

    let skeleton = useRef();
    let atlas = useRef();

    useEffect(() => {
        fetch('https://esotericsoftware.com/files/examples/4.2/spineboy/export/spineboy-pro.skel')
            .then(response => response.blob())
            .then(blob => {
                atlas.current = blob;
            });

        fetch('https://esotericsoftware.com/files/examples/4.2/spineboy/export/spineboy-pma.atlas')
            .then(response => response.blob())
            .then(blob => {
                skeleton.current = blob;
            })

        if (playerRef.current) {
            const config = {
                skeleton: jsonUrl,
                atlas: atlasUrl,
            }

            spinePlayer.current = new spine.SpinePlayer(playerRef.current, config);

            const attachmentLoader = new spine.AtlasAttachmentLoader(atlas.current);
            const json = new spine.SkeletonBinary(attachmentLoader);
            const data = json.readSkeletonData(skeleton.current); // THIS RETURNS ERROR WHEN READING PROPERTIES OF UNDEFINED

            console.log(data);

            return () => {
                spinePlayer.current.dispose();
            };
        }
    }, [jsonUrl, atlasUrl, spinePlayer]);


    return (
        <div ref={playerRef} style={{ width: '800px', height: '600px' }}></div>
    );
}

export default SpinePlayer;

Is there any way to keep going my way or this could be completely functional using the pixi runtime?

Thanks!! ๐Ÿ™‚

    EnricDelgado

    There are some problems with your code. I'll try to point out the ones related to Spine resources:

    1. You are assigning the .skel file to atlas.current. Same for skeleton.current.
    2. Why are you getting a blob from your response? For example, the atlas is a text file.
    3. The AtlasAttachmentLoader constructor wants a TextureAtlas, but you are passing a Blob right now.
    4. You are reinitializing the player instance each time the useEffect hook is triggered, losing the reference to the previous player without having the possibility to properly dispose of it.

    I strongly suggest you set up a project using TypeScript so that your editor can directly highlight these issues with the code.

    Apart from that, what are you trying to achieve with the code above? I cannot see a clear connection with the opening post.

      Hi Davide! Thank you for response! ๐Ÿ™‚ I'll try to describe what are my intentions with my code:

      (context: I'm developing a ReactJS tool that allows an user to quickly swap a bone's slot attached image)

      I'm trying to print all the slots that has an image and the image that is attached to the slot. For that, I follow this logic:

      1. Getting the skeleton > getting the slots that have an attachment > get some kind of identifier
      2. Getting the atlas > pair it with the attachment's identifier > retrieve the image

      I'm not following this structure for anything in specific, it's just one of the multiple things I'm trying to do to achieve my goal. I don't really understand how any of the data that I'm working with works, and I'm finding myself kind of lost in the documentation, not really knowing where to look, so any help is kindly welcomed! <3

      Do you have any particular idea on how to achieve the goal I'm seeking? ๐Ÿ™‚

        EnricDelgado

        If you are relying on the spine player, you don't need to reload and reprocess the assets. The spine player does that for you.

        Once the player has finished loading the assets, you can easily access the skeleton and query the data you want from the skeletonData.

        For example, if you want to quickly swap a bone's slot attached image, once the player is loaded you can simply do: player.skeleton.setAttachment("slotName", "newAttachmentName").

        You can pass a success(SpinePlayer) callback to the player to know exactly when the player is ready.

          Davide
          Thank you for the insight! Is there any documentation on how to set attachments, how to retrieve the information from the attachments and how the data is structured?

            EnricDelgado

            The starting point is the runtime guide.
            At the end of it you can find the API Reference.
            Have a look at the class diagram, where you can see how the data are organized in the skeleton.

            You can find individual API documentation on that page. For example, here you can read about skeleton's setAttachment method.

            You can find the Web Player documentation here.

            EnricDelgado

            For insight, now I'm trying a mix of what you said backed by the Generic Rendering, Changing Attachments Runtime Guides, as well as the Slot and Skin API references, but I'm getting null from it, even though I'm retrieving the slot and slot name correctly:

            import React, {useEffect, useRef} from 'react';
            import * as spine from '@esotericsoftware/spine-player';
            import '../../styles/SpinePlayer.css'
            
            function SpinePlayer ({ jsonUrl, atlasUrl }) {
                const playerRef = useRef(null);
            
                useEffect(() => {
                    if (playerRef.current) {
            
                        const player = new spine.SpinePlayer(playerRef.current, {
                            skeleton: jsonUrl,
                            atlas: atlasUrl,
                            success: runSpinePlayer
                        });
            
                        return () => {
                            player.dispose();
                        };
                    }
                }, [jsonUrl, atlasUrl]);
            
            
                function runSpinePlayer(player){
                    const skel = player.skeleton;
                    const slots = skel.slots;
            
                    slots.forEach(slot => {
                        if(slot.attachment === null) return;
            
                        const attachment = skel.getAttachment(slot, slot.attachment.name);
                        debugger;
                        console.log(attachment);
                    })
                }
            
                return (
                    <div ref={playerRef} style={{ width: '800px', height: '600px' }}></div>
                );
            }
            
            export default SpinePlayer;


            As I suggested you previously, you should set up an environment using Typescript.
            That would help you a lot in finding some of your issues.

            getAttachment wants a slot index, not a Slot.

            The corresponding ts version that accepts the slot name is named getAttachmentByName, and yes, the IDE would suggest that to you.

            And show also the documentation:

            Omg that seems amazing! I'm currently using JetBrains' Webstorm and ReactJS, can I setup an environment using Typescript with these technologies?

            Webstorm is perfect for the job.

            The easiest way to setup a TS+React environment is by using some tools like create-react-app. Once you have installed it, you just npx create-react-app my-app --template typescript and your environment is ready to be used.

            I personally use vite when I have to do something with react.

            The other way is to setup a typescript project from scratch and then add missing react types.

              Davide
              Thank you for your kind help!! Now I've finally managed to successfully retrieve the attachments!

              Now I get to the complicated part of getting the images attached to those atttachments for them to be displayed in the screen, and change them by the ones the user provide as input. I'm trying to follow the Loading Skelleton Data guide, but I'm getting kind of lost when it comes to the Texture Loader.

              Where are the images stored? - Atlas, I'm asuming - and how can I access them, retrieve them and modify them? Because once I have the new user input's images, I think I can add them by sending the new image through the setAttachment method as you suggested. - maybe by modifyng the page or region property of the current attachment? I'm still trying to figure that out, sorry for asking so many questions! ๐Ÿ˜ตโ€๐Ÿ’ซ

                EnricDelgado

                Doing that is not trivial. You might expect that since you are seeing your single images displayed, you could simply swap them by providing yours. Unfortunately, it's harder than that.

                Your single images are actually loaded from the png files that pack together all the images. The atlas txt file allows detection of how the single images are organized in your png atlas.

                The spine player (and the underlying spine-webgl) uses WebGL to render your images. The canvas where you are seeing the images is actually a webgl context. You cannot directly extract the images and display them outside that context.
                Have a look at the dress-up example of spine-webgl.
                In that example, a thumbnail of some attachments is shown to the user outside the webgl context. In order to do that an actual image is created by using the toDataURL method.
                You could show individual images also by using other techniques.

                The other thing to do would be the actual image swap. In order to do that, you have to load your image into webgl, then swap the region of the desired attachment or create a new attachment with that image.
                To do that, it might be easier for you to wait for us to implement this feature. I can work on that, but need to close a couple of things before.

                  Davide
                  Thank you again for your response! I'm getting quite confused on how to work with it, for I'm getting difficulties understanding how Spine works on the back. I'll follow your suggestion I'll wait until the example is created.

                  Thank you so much for your help! ๐Ÿ™‚

                  17 days later

                  I didn't have the time to make an example yet, but you can use the following code to get an idea on how to do what you want.

                  As far as I understand, your goal is to allow a user to add their own texture to an attachment in a slot.
                  As in Spine, a slot can have 0 or 1 attachments enabled. An attachment has a region property that contains the information about the texture that has to be rendered.
                  What you want to do is probably to change the image on one of these attachments, or to create a new attachment with the desired image. To make things simpler, the code below shows the former case.

                  First, we patch the asset manager to load an image from a URL:

                  spine.AssetManager.prototype.loadTextureAtlasForSingleImageUrl = function (imgUrl) {
                  	const atlasPath = imgUrl.replace(/\.[^.]+$/, '.atlas');
                  	if (this.assets[atlasPath]) return Promise.resolve(this.assets[atlasPath]);
                  	this.start(atlasPath);
                  
                  	this.start(imgUrl);
                  	let image = new Image();
                  	image.crossOrigin = "anonymous";
                  	image.src = imgUrl;
                  	return new Promise((resolve) => {
                  		image.onload = () => {
                  			const region = new spine.TextureAtlasRegion(new spine.TextureAtlasPage(imgUrl), imgUrl);
                  			region.u = region.v = 0;
                  			region.u2 = region.v2 = 1;
                  			region.page.width = region.width = region.originalWidth = image.width;
                  			region.page.height = region.height = region.originalHeight = image.height;
                  
                  			const atlas = new spine.TextureAtlas("");
                  			atlas.pages.push(region.page);
                  			atlas.regions.push(region);
                  
                  			// set the loaded texture into the page (and internally into all the regions)
                  			region.page.setTexture(this.textureLoader(image));
                  
                  			this.success(() => {}, imgUrl, region.page.texture);
                  			this.success(() => {}, atlasPath, atlas);
                  
                  			resolve(atlas);
                  		};
                  		image.onerror = () => this.error(() => {}, imgUrl, `Couldn't load image: ${imgUrl}`);
                  	});
                  }

                  Then, we use that function to load an image into a new atlas and get the first region:

                  const atlas = await player.assetManager.loadTextureAtlasForSingleImageUrl("assets/my-texture.png");
                  const region = atlas.regions[0];

                  Finally, we select a slot, take its attachment, change its region, and ask the attachment to update based on the new region information:

                  const slot = player.skeleton.slots.find(slot => slot.data.name == "raptor-body");
                  const attachment = slot.attachment;
                  if (attachment) {
                  	attachment.region = region;
                  	slot.attachment.updateRegion();
                  }

                  I tried this code on the basic example of spine-player.
                  This works for both region and mesh attachments.

                    Davide Thank you so much for your help!! I'll give a try to this and I'll get back with my results! Thank you so much! ๐Ÿ™‚ <3

                    Davide

                    Thank you for your code and your help! I've managed to generate a ReactJS / Typescript functional variant of this - I'm adding the component's code for the project is too large to get uploaded.

                    By creating a new ReactJS project and adding this component - as well as the CSS for the Spine Viewer - the results can be checked.

                    // @ts-ignore
                    import React, {useEffect, useRef, useState} from 'react';
                    import {SpinePlayer, Skeleton, AssetManager, TextureAtlasRegion, TextureAtlasPage, TextureAtlas} from "@esotericsoftware/spine-player";
                    import './SpinePlayer.css'
                    
                    function SpineViewer ({ jsonUrl, atlasUrl }) {
                        const playerRef = useRef(null);
                        const [imageAttachments, setImageAttachments] = useState([]);
                        const [player, setPlayer] = useState<SpinePlayer>(null);
                        const [skeleton, setSkeleton] = useState<Skeleton>(null);
                    
                        useEffect(() => {
                            if(playerRef.current){
                                if(player !== null) {
                                    player.dispose();
                                }
                    
                                AssetManager.prototype.loadTextureAtlasForSingleImageUrl = function (imgUrl: string) {
                                    const atlasPath = imgUrl;
                                    if (this.assets[atlasPath]) return Promise.resolve(this.assets[atlasPath]);
                                    this.start(atlasPath);
                                
                                    this.start(imgUrl);
                                    let image = new Image();
                                    image.crossOrigin = "anonymous";
                                    image.src = imgUrl;
                                    
                                    return new Promise((resolve) => {
                                        image.onload = () => {
                                            const region = new TextureAtlasRegion(new TextureAtlasPage(imgUrl), imgUrl);
                                            region.u = region.v = 0;
                                            region.u2 = region.v2 = 1;
                                            region.page.width = region.width = region.originalWidth = image.width;
                                            region.page.height = region.height = region.originalHeight = image.height;
                                
                                            const atlas = new TextureAtlas("");
                                            atlas.pages.push(region.page);
                                            atlas.regions.push(region);
                                
                                            // set the loaded texture into the page (and internally into all the regions)
                                            region.page.setTexture(this.textureLoader(image));
                                
                                            this.success(() => {}, imgUrl, region.page.texture);
                                            this.success(() => {}, atlasPath, atlas);
                                
                                            resolve(atlas);
                                        };
                                        image.onerror = () => this.error(() => {}, imgUrl, `Couldn't load image: ${imgUrl}`);
                                    });
                                }
                    
                                updateSpinePlayer(jsonUrl, atlasUrl, spinePlayerStatesSetup);
                    
                                return() => {
                                    player?.dispose();
                                }
                            }
                        }, []);
                    
                        useEffect(() => {
                            if(skeleton === null) return;
                            populateAttachments();
                        }, [skeleton]);
                    
                    
                        function populateAttachments(){
                            const currentAttachments = [...imageAttachments];
                    
                            skeleton.slots.forEach(slot => {
                                if(slot.attachment === null) return;
                                
                                const attachment = skeleton.getAttachmentByName(slot.data.name, slot.attachment.name);
                                currentAttachments.push(attachment);
                            });
                    
                            setImageAttachments(currentAttachments);
                        }
                    
                        function updateSpinePlayer(skeleton: string, atlas: string, succesCallback?) {
                            //@ts-ignore
                            new SpinePlayer(playerRef.current, {
                                skeleton: skeleton,
                                atlas: atlas,
                                success: succesCallback
                            })
                        }
                    
                        function spinePlayerStatesSetup(pl: SpinePlayer){
                            setPlayer(pl);
                            setSkeleton(pl.skeleton);
                        }
                    
                        async function imageToAtlasRegion(attachment, imageURL) {
                            const atlas = await player.assetManager?.loadTextureAtlasForSingleImageUrl(imageURL);
                            updateAttachment(atlas.regions[0], attachment);
                        }
                    
                        function updateAttachment(region, attachmentName) {
                            if(skeleton === null) return;
                    
                            const slot = skeleton.slots.find(slot => slot.data.name == attachmentName);
                    
                            const attachment = slot?.attachment;
                    
                            if (attachment) {
                                attachment.region = region;
                                slot.attachment?.updateRegion();
                            }
                            else{
                                alert("NO ATTACHMENT FOUND!");
                            }
                        }
                    
                        function generateInputFields(){
                            return(
                                <div style={{width: '100%'}}>
                                    <h2>Attachments</h2>
                                    <div style={{maxHeight: '50vh', overflow:'scroll'}}>
                                        {
                                            imageAttachments.map((item, index) => {
                                                return(
                                                    <div>
                                                        <h3>{item.name}</h3>
                                                        <button id="attachmentInput" onClick={() => imageToAtlasRegion(item.name, '/logo192.png')}>Load Attachment</button>
                                                    </div>
                                                );
                                            })
                                        }
                                    </div>
                                </div>
                            );
                        }
                    
                    
                        return (
                            <div style={{padding: '3rem'}}>
                                <div>
                                    <h1>Spine Slot Swap Example</h1>
                                </div>
                    
                                <div style={{display: 'flex', flexDirection: 'row', justifyContent: 'center'}}>
                                    <div style={{width: '100%'}}>
                                        <div ref={playerRef} style={{ width: '800px', height: '600px' }}></div>
                                    </div>
                                    {generateInputFields()}
                                </div>
                            </div>
                        );
                    }
                    
                    export default SpineViewer;

                    Now I've got one issue: would it be possible to generate a new spine project that includes the modified attachments? For download purposes! I couldn't find any references to this in the API </3

                      EnricDelgado

                      I'm happy you were able to achieve your desired result about texture swapping!

                      EnricDelgado Now I've got one issue: would it be possible to generate a new spine project that includes the modified attachments? For download purposes! I couldn't find any references to this in the API </3

                      You cannot create a .spine file project file, but you can create a new json file that can be imported into Spine. Here you can find the documentation on how to create a json containing skeleton data.

                        Davide
                        Oh I thought about that! But then, how do you manage to keep the newly-swapped images?