first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
/web/vtuber/*
|
||||
@@ -1,2 +1,37 @@
|
||||
# ComfyUI-Live2DViewer
|
||||
a comfyui node for viewing Live2D model
|
||||
|
||||
<div>
|
||||
<figure>
|
||||
<img alt='webpage' src="web.png?raw=true" width="600px"/>
|
||||
<figure>
|
||||
</div>
|
||||
|
||||
# How to use
|
||||
put your `Live2D model` in `ComfyUI-Live2DViewer/web/vtuber` file path
|
||||
|
||||
like as follow:
|
||||
```
|
||||
ComfyUI-Live2DViewer/web/vtuber
|
||||
├── kei_vowels_pro
|
||||
│ └── runtime
|
||||
│ │ └── ***all file
|
||||
│ └── kei_vowels_pro_t02.can3
|
||||
│ └── kei_vowels_pro_t02.cmo3
|
||||
```
|
||||
|
||||
you can find sample live2d model here,[https://www.live2d.com/zh-CHS/learn/sample/](https://www.live2d.com/zh-CHS/learn/sample/)
|
||||
|
||||
## Tutorial
|
||||
[Demo]()
|
||||
|
||||
## WeChat Group && Donate
|
||||
<div>
|
||||
<figure>
|
||||
<img alt='Wechat' src="wechat.jpg?raw=true" width="300px"/>
|
||||
<img alt='donate' src="donate.jpg?raw=true" width="300px"/>
|
||||
<figure>
|
||||
</div>
|
||||
|
||||
## Thanks
|
||||
- [live2d-TTS-LLM-GPT-SoVITS-Vtuber](https://github.com/v3ucn/live2d-TTS-LLM-GPT-SoVITS-Vtuber)
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import os
|
||||
import shutil
|
||||
import folder_paths
|
||||
root_dir = os.getcwd()
|
||||
now_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
shutil.copy(os.path.join(now_dir,"index.html"),os.path.join(root_dir,"web", "index.html"))
|
||||
shutil.copytree(os.path.join(now_dir,"live2djs"),os.path.join(root_dir,"web", "lib","live2djs"),dirs_exist_ok=True)
|
||||
|
||||
input_path = folder_paths.get_input_directory()
|
||||
vtuber_list = os.listdir(os.path.join(now_dir,"web","vtuber"))
|
||||
class Live2DViewer:
|
||||
"""
|
||||
A example node
|
||||
|
||||
Class methods
|
||||
-------------
|
||||
INPUT_TYPES (dict):
|
||||
Tell the main program input parameters of nodes.
|
||||
IS_CHANGED:
|
||||
optional method to control when the node is re executed.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
RETURN_TYPES (`tuple`):
|
||||
The type of each element in the output tulple.
|
||||
RETURN_NAMES (`tuple`):
|
||||
Optional: The name of each output in the output tulple.
|
||||
FUNCTION (`str`):
|
||||
The name of the entry-point method. For example, if `FUNCTION = "execute"` then it will run Example().execute()
|
||||
OUTPUT_NODE ([`bool`]):
|
||||
If this node is an output node that outputs a result/image from the graph. The SaveImage node is an example.
|
||||
The backend iterates on these output nodes and tries to execute all their parents if their parent graph is properly connected.
|
||||
Assumed to be False if not present.
|
||||
CATEGORY (`str`):
|
||||
The category the node should appear in the UI.
|
||||
execute(s) -> tuple || None:
|
||||
The entry point method. The name of this method must be the same as the value of property `FUNCTION`.
|
||||
For example, if `FUNCTION = "execute"` then this method's name must be `execute`, if `FUNCTION = "foo"` then it must be `foo`.
|
||||
"""
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
"""
|
||||
Return a dictionary which contains config for all input fields.
|
||||
Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT".
|
||||
Input types "INT", "STRING" or "FLOAT" are special values for fields on the node.
|
||||
The type can be a list for selection.
|
||||
|
||||
Returns: `dict`:
|
||||
- Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required`
|
||||
- Value input_fields (`dict`): Contains input fields config:
|
||||
* Key field_name (`string`): Name of a entry-point method's argument
|
||||
* Value field_config (`tuple`):
|
||||
+ First value is a string indicate the type of field or a list for selection.
|
||||
+ Secound value is a config for type "INT", "STRING" or "FLOAT".
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"live2d_model_name": (vtuber_list,{
|
||||
"default":"kei_vowels_pro"
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
#RETURN_NAMES = ("image_output_name",)
|
||||
|
||||
FUNCTION = "show"
|
||||
|
||||
OUTPUT_NODE = True
|
||||
|
||||
CATEGORY = "AIFSH_Live2D"
|
||||
|
||||
def show(self,audio,live2d_model_name):
|
||||
audio_name = os.path.basename(audio)
|
||||
audio_type = os.path.basename(os.path.dirname(audio))
|
||||
return {"ui": {"vtuber":[audio_name,audio_type,live2d_model_name]}}
|
||||
|
||||
"""
|
||||
The node will always be re executed if any of the inputs change but
|
||||
this method can be used to force the node to execute again even when the inputs don't change.
|
||||
You can make this node return a number or a string. This value will be compared to the one returned the last time the node was
|
||||
executed, if it is different the node will be executed again.
|
||||
This method is used in the core repo for the LoadImage node where they return the image hash as a string, if the image hash
|
||||
changes between executions the LoadImage node is executed again.
|
||||
"""
|
||||
#@classmethod
|
||||
#def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen):
|
||||
# return ""
|
||||
|
||||
|
||||
class LoadAudio:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
files = [f for f in os.listdir(input_path) if os.path.isfile(os.path.join(input_path, f)) and f.split('.')[-1].lower() in ["wav", "mp3","flac","m4a"]]
|
||||
return {"required":
|
||||
{"audio": (sorted(files),)},
|
||||
}
|
||||
|
||||
CATEGORY = "AIFSH_Live2D"
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
FUNCTION = "load_audio"
|
||||
|
||||
def load_audio(self, audio):
|
||||
audio_path = folder_paths.get_annotated_filepath(audio)
|
||||
return (audio_path,)
|
||||
|
||||
# Set the web directory, any .js file in that directory will be loaded by the frontend as a frontend extension
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# A dictionary that contains all nodes you want to export with their names
|
||||
# NOTE: names should be globally unique
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"LoadAudio": LoadAudio,
|
||||
"Live2DViewer": Live2DViewer
|
||||
}
|
||||
|
||||
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LoadAudio": "LoadAudio",
|
||||
"Live2DViewer": "Live2DViewer Node"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ComfyUI</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<link rel="stylesheet" type="text/css" href="./lib/litegraph.css" />
|
||||
<link rel="stylesheet" type="text/css" href="./style.css" />
|
||||
<link rel="stylesheet" type="text/css" href="./user.css" />
|
||||
<script type="text/javascript" src="./lib/litegraph.core.js"></script>
|
||||
<script type="text/javascript" src="./lib/litegraph.extensions.js" defer></script>
|
||||
<script type="text/javascript" src="./lib/live2djs/live2dcubismcore.min.js"></script>
|
||||
<script type="text/javascript" src="./lib/live2djs/live2d.min.js"></script>
|
||||
<script type="text/javascript" src="./lib/live2djs/pixi.min.js"></script>
|
||||
<script type="text/javascript" src="./lib/live2djs/cubism4.min.js"></script>
|
||||
<script type="module">
|
||||
import { app } from "./scripts/app.js";
|
||||
await app.setup();
|
||||
window.app = app;
|
||||
window.graph = app.graph;
|
||||
</script>
|
||||
</head>
|
||||
<body class="litegraph">
|
||||
<div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
|
||||
<main class="comfy-user-selection-inner">
|
||||
<h1>ComfyUI</h1>
|
||||
<form>
|
||||
<section>
|
||||
<label>New user:
|
||||
<input placeholder="Enter a username" />
|
||||
</label>
|
||||
</section>
|
||||
<div class="comfy-user-existing">
|
||||
<span class="or-separator">OR</span>
|
||||
<section>
|
||||
<label>
|
||||
Existing user:
|
||||
<select>
|
||||
<option hidden disabled selected value> Select a user </option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<span class="comfy-user-error"> </span>
|
||||
<button class="comfy-btn comfy-user-button-next">Next</button>
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+9
File diff suppressed because one or more lines are too long
Vendored
+1108
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from '../../../scripts/api.js'
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js"
|
||||
|
||||
function fitHeight(node) {
|
||||
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]])
|
||||
node?.graph?.setDirtyCanvas(true);
|
||||
}
|
||||
|
||||
function previewAudio(node,file){
|
||||
while (node.widgets.length > 2){
|
||||
node.widgets.pop();
|
||||
}
|
||||
try {
|
||||
var el = document.getElementById("uploadAudio");
|
||||
el.remove();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
var element = document.createElement("div");
|
||||
element.id = "uploadAudio";
|
||||
const previewNode = node;
|
||||
var previewWidget = node.addDOMWidget("audiopreview", "preview", element, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getValue() {
|
||||
return element.value;
|
||||
},
|
||||
setValue(v) {
|
||||
element.value = v;
|
||||
},
|
||||
});
|
||||
previewWidget.computeSize = function(width) {
|
||||
if (this.aspectRatio && !this.parentEl.hidden) {
|
||||
let height = (previewNode.size[0]-20)/ this.aspectRatio + 10;
|
||||
if (!(height > 0)) {
|
||||
height = 0;
|
||||
}
|
||||
this.computedHeight = height + 10;
|
||||
return [width, height];
|
||||
}
|
||||
return [width, -4];//no loaded src, widget should not display
|
||||
}
|
||||
// element.style['pointer-events'] = "none"
|
||||
previewWidget.value = {hidden: false, paused: false, params: {}}
|
||||
previewWidget.parentEl = document.createElement("div");
|
||||
previewWidget.parentEl.className = "audio_preview";
|
||||
previewWidget.parentEl.style['width'] = "100%"
|
||||
element.appendChild(previewWidget.parentEl);
|
||||
previewWidget.audioEl = document.createElement("audio");
|
||||
previewWidget.audioEl.controls = true;
|
||||
previewWidget.audioEl.loop = false;
|
||||
previewWidget.audioEl.muted = false;
|
||||
previewWidget.audioEl.style['width'] = "100%"
|
||||
previewWidget.audioEl.addEventListener("loadedmetadata", () => {
|
||||
|
||||
previewWidget.aspectRatio = previewWidget.audioEl.audioWidth / previewWidget.audioEl.audioHeight;
|
||||
fitHeight(this);
|
||||
});
|
||||
previewWidget.audioEl.addEventListener("error", () => {
|
||||
//TODO: consider a way to properly notify the user why a preview isn't shown.
|
||||
previewWidget.parentEl.hidden = true;
|
||||
fitHeight(this);
|
||||
});
|
||||
|
||||
let params = {
|
||||
"filename": file,
|
||||
"type": "input",
|
||||
}
|
||||
|
||||
previewWidget.parentEl.hidden = previewWidget.value.hidden;
|
||||
previewWidget.audioEl.autoplay = !previewWidget.value.paused && !previewWidget.value.hidden;
|
||||
let target_width = 256
|
||||
if (element.style?.width) {
|
||||
//overscale to allow scrolling. Endpoint won't return higher than native
|
||||
target_width = element.style.width.slice(0,-2)*2;
|
||||
}
|
||||
if (!params.force_size || params.force_size.includes("?") || params.force_size == "Disabled") {
|
||||
params.force_size = target_width+"x?"
|
||||
} else {
|
||||
let size = params.force_size.split("x")
|
||||
let ar = parseInt(size[0])/parseInt(size[1])
|
||||
params.force_size = target_width+"x"+(target_width/ar)
|
||||
}
|
||||
|
||||
previewWidget.audioEl.src = api.apiURL('/view?' + new URLSearchParams(params));
|
||||
|
||||
previewWidget.audioEl.hidden = false;
|
||||
previewWidget.parentEl.appendChild(previewWidget.audioEl)
|
||||
}
|
||||
|
||||
function audioUpload(node, inputName, inputData, app) {
|
||||
const audioWidget = node.widgets.find((w) => w.name === "audio");
|
||||
let uploadWidget;
|
||||
/*
|
||||
A method that returns the required style for the html
|
||||
*/
|
||||
var default_value = audioWidget.value;
|
||||
Object.defineProperty(audioWidget, "value", {
|
||||
set : function(value) {
|
||||
this._real_value = value;
|
||||
},
|
||||
|
||||
get : function() {
|
||||
let value = "";
|
||||
if (this._real_value) {
|
||||
value = this._real_value;
|
||||
} else {
|
||||
return default_value;
|
||||
}
|
||||
|
||||
if (value.filename) {
|
||||
let real_value = value;
|
||||
value = "";
|
||||
if (real_value.subfolder) {
|
||||
value = real_value.subfolder + "/";
|
||||
}
|
||||
|
||||
value += real_value.filename;
|
||||
|
||||
if(real_value.type && real_value.type !== "input")
|
||||
value += ` [${real_value.type}]`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
async function uploadFile(file, updateNode, pasted = false) {
|
||||
try {
|
||||
// Wrap file in formdata so it includes filename
|
||||
const body = new FormData();
|
||||
body.append("image", file);
|
||||
if (pasted) body.append("subfolder", "pasted");
|
||||
const resp = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json();
|
||||
// Add the file to the dropdown list and update the widget value
|
||||
let path = data.name;
|
||||
if (data.subfolder) path = data.subfolder + "/" + path;
|
||||
|
||||
if (!audioWidget.options.values.includes(path)) {
|
||||
audioWidget.options.values.push(path);
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
audioWidget.value = path;
|
||||
previewAudio(node,path)
|
||||
|
||||
}
|
||||
} else {
|
||||
alert(resp.status + " - " + resp.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
const fileInput = document.createElement("input");
|
||||
Object.assign(fileInput, {
|
||||
type: "file",
|
||||
accept: "audio/mp3,audio/wav,audio/flac,audio/m4a",
|
||||
style: "display: none",
|
||||
onchange: async () => {
|
||||
if (fileInput.files.length) {
|
||||
await uploadFile(fileInput.files[0], true);
|
||||
}
|
||||
},
|
||||
});
|
||||
document.body.append(fileInput);
|
||||
|
||||
// Create the button widget for selecting the files
|
||||
uploadWidget = node.addWidget("button", "choose audio file to upload", "Audio", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
uploadWidget.serialize = false;
|
||||
|
||||
previewAudio(node, audioWidget.value);
|
||||
const cb = node.callback;
|
||||
audioWidget.callback = function () {
|
||||
previewAudio(node,audioWidget.value);
|
||||
if (cb) {
|
||||
return cb.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
return { widget: uploadWidget };
|
||||
}
|
||||
|
||||
ComfyWidgets.AUDIOPLOAD = audioUpload;
|
||||
|
||||
app.registerExtension({
|
||||
name: "WhispherX.UploadAudio",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData?.name == "LoadAudio") {
|
||||
nodeData.input.required.upload = ["AUDIOPLOAD"];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from '../../../scripts/api.js'
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js"
|
||||
|
||||
function draggable(model) {
|
||||
model.buttonMode = true;
|
||||
model.on("pointerdown", (e) => {
|
||||
model.dragging = true;
|
||||
model._pointerX = e.data.global.x - model.x;
|
||||
model._pointerY = e.data.global.y - model.y;
|
||||
});
|
||||
model.on("pointermove", (e) => {
|
||||
if (model.dragging) {
|
||||
model.position.x = e.data.global.x - model._pointerX;
|
||||
model.position.y = e.data.global.y - model._pointerY;
|
||||
}
|
||||
});
|
||||
model.on("pointerupoutside", () => (model.dragging = false));
|
||||
model.on("pointerup", () => (model.dragging = false));
|
||||
}
|
||||
|
||||
function talk(model,audio_link){
|
||||
var volume = 1; // [Optional arg, can be null or empty] [0.0 - 1.0]
|
||||
var expression = 8; // [Optional arg, can be null or empty] [index|name of expression]
|
||||
var resetExpression = true; // [Optional arg, can be null or empty] [true|false] [default: true] [if true, expression will be reset to default after animation is over]
|
||||
var crossOrigin = "anonymous"; // [Optional arg, to use not same-origin audios] [DEFAULT: null]
|
||||
|
||||
model.speak(audio_link, {volume: volume, expression:expression, resetExpression:resetExpression, crossOrigin: crossOrigin})
|
||||
|
||||
// Or if you want to keep some things default
|
||||
model.speak(audio_link)
|
||||
model.speak(audio_link, {volume: volume})
|
||||
model.speak(audio_link, {expression:expression, resetExpression:resetExpression})
|
||||
}
|
||||
|
||||
function showVtuber(node, audio_name,audio_type, vtuber) {
|
||||
let params = {
|
||||
"filename": audio_name,
|
||||
"type": audio_type,
|
||||
}
|
||||
const audio_link = api.apiURL('/view?' + new URLSearchParams(params));
|
||||
|
||||
var element = document.createElement("div");
|
||||
|
||||
const previewNode = node;
|
||||
var previewWidget = node.addDOMWidget("vtuberpreview", "preview", element, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getValue() {
|
||||
return element.value;
|
||||
},
|
||||
setValue(v) {
|
||||
element.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
previewWidget.computeSize = function(width) {
|
||||
if (this.aspectRatio && !this.parentEl.hidden) {
|
||||
let height = (previewNode.size[0]-20)/ this.aspectRatio + 10;
|
||||
if (!(height > 0)) {
|
||||
height = 0;
|
||||
}
|
||||
this.computedHeight = height + 10;
|
||||
return [width, height];
|
||||
}
|
||||
return [width, -4];//no loaded src, widget should not display
|
||||
}
|
||||
// element.style['pointer-events'] = "none"
|
||||
previewWidget.value = {hidden: false, paused: false, params: {}}
|
||||
previewWidget.parentEl = document.createElement("canvas");
|
||||
previewWidget.parentEl.id = "canvas";
|
||||
previewWidget.parentEl.style['width'] = "100%"
|
||||
element.appendChild(previewWidget.parentEl);
|
||||
|
||||
const cubism4Model = `extensions/ComfyUI-Live2DViewer/vtuber/${vtuber}/runtime/${vtuber}.model3.json`;
|
||||
|
||||
(async function main() {
|
||||
|
||||
const app = new PIXI.Application({
|
||||
view: document.getElementById("canvas"),
|
||||
autoStart: true,
|
||||
resizeTo: window,
|
||||
backgroundColor: 0x333333
|
||||
});
|
||||
|
||||
const models = await Promise.all([
|
||||
PIXI.live2d.Live2DModel.from(cubism4Model)
|
||||
]);
|
||||
|
||||
models.forEach((model) => {
|
||||
app.stage.addChild(model);
|
||||
|
||||
const scaleX = (innerWidth ) / model.width;
|
||||
const scaleY = (innerHeight ) / model.height;
|
||||
|
||||
// fit the window
|
||||
model.scale.set(Math.min(scaleX, scaleY));
|
||||
|
||||
model.y = innerHeight * 0.1;
|
||||
draggable(model);
|
||||
talk(model,audio_link);
|
||||
});
|
||||
|
||||
const model4 = models[0];
|
||||
|
||||
|
||||
model4.x = innerWidth / 2;
|
||||
|
||||
|
||||
model4.on("hit", (hitAreas) => {
|
||||
if (hitAreas.includes("Body")) {
|
||||
model4.motion("Tap");
|
||||
}
|
||||
|
||||
if (hitAreas.includes("Head")) {
|
||||
model4.expression();
|
||||
}
|
||||
});
|
||||
let btnWidget = node.addWidget("button", "RELOAD", "Audio", () => {
|
||||
talk(model4,audio_link);
|
||||
});
|
||||
element.appendChild(btnWidget);
|
||||
})();
|
||||
return { widget: previewWidget };
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Live2DViewer.ShowVtuber",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData?.name == "Live2DViewer") {
|
||||
nodeType.prototype.onExecuted = function (data) {
|
||||
showVtuber(this, data.vtuber[0],data.vtuber[1],data.vtuber[2]);
|
||||
console.log(data.vtuber);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user