mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Emote cache
This commit is contained in:
@@ -7,6 +7,15 @@ const scriptLayer = document.getElementById("broadcast-script-layer");
|
||||
setUpElectronWindowFrame();
|
||||
|
||||
const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast });
|
||||
fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load Twitch emotes");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((catalog) => renderer.setEmoteCatalog(catalog))
|
||||
.catch((error) => console.warn("Unable to load Twitch emotes", error));
|
||||
const disconnectChat = connectTwitchChat(
|
||||
broadcaster,
|
||||
({ channel, displayName, message, tags, prefix, raw }) => {
|
||||
|
||||
@@ -26,6 +26,9 @@ export class BroadcastRenderer {
|
||||
this.scriptAttachmentCache = new Map();
|
||||
this.scriptAttachmentsByAssetId = new Map();
|
||||
this.chatMessages = [];
|
||||
this.emoteCatalog = [];
|
||||
this.emoteCatalogById = new Map();
|
||||
this.lastChatPruneAt = 0;
|
||||
|
||||
this.obsBrowser = !!globalThis.obsstudio;
|
||||
this.supportsAnimatedDecode =
|
||||
@@ -322,6 +325,11 @@ export class BroadcastRenderer {
|
||||
}
|
||||
|
||||
renderFrame() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastChatPruneAt > 1000) {
|
||||
this.lastChatPruneAt = now;
|
||||
this.pruneChatMessages(now);
|
||||
}
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
getRenderOrder(this.state).forEach((asset) => this.drawAsset(asset));
|
||||
}
|
||||
@@ -412,6 +420,7 @@ export class BroadcastRenderer {
|
||||
);
|
||||
this.scriptWorkerReady = true;
|
||||
this.updateScriptWorkerChatMessages();
|
||||
this.updateScriptWorkerEmoteCatalog();
|
||||
}
|
||||
|
||||
updateScriptWorkerCanvas() {
|
||||
@@ -439,14 +448,119 @@ export class BroadcastRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
updateScriptWorkerEmoteCatalog() {
|
||||
if (!this.scriptWorker || !this.scriptWorkerReady) {
|
||||
return;
|
||||
}
|
||||
this.scriptWorker.postMessage({
|
||||
type: "emoteCatalog",
|
||||
payload: {
|
||||
emotes: this.emoteCatalog,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setEmoteCatalog(catalog) {
|
||||
const globalEmotes = Array.isArray(catalog?.global) ? catalog.global : [];
|
||||
const channelEmotes = Array.isArray(catalog?.channel) ? catalog.channel : [];
|
||||
this.emoteCatalog = [...globalEmotes, ...channelEmotes];
|
||||
this.emoteCatalogById = new Map(
|
||||
this.emoteCatalog.map((entry) => [String(entry?.id || ""), entry]).filter(([key]) => key),
|
||||
);
|
||||
if (this.chatMessages.length) {
|
||||
this.chatMessages = this.chatMessages.map((message) => {
|
||||
if (!Array.isArray(message.fragments)) {
|
||||
return message;
|
||||
}
|
||||
const fragments = message.fragments.map((fragment) => {
|
||||
if (fragment.type !== "emote" || fragment.url) {
|
||||
return fragment;
|
||||
}
|
||||
const emoteInfo = this.emoteCatalogById.get(String(fragment.id));
|
||||
if (!emoteInfo) {
|
||||
return fragment;
|
||||
}
|
||||
return { ...fragment, url: emoteInfo.url, name: emoteInfo.name || fragment.name };
|
||||
});
|
||||
return { ...message, fragments };
|
||||
});
|
||||
this.updateScriptWorkerChatMessages();
|
||||
}
|
||||
this.updateScriptWorkerEmoteCatalog();
|
||||
}
|
||||
|
||||
pruneChatMessages(now = Date.now()) {
|
||||
const cutoff = now - 120_000;
|
||||
const pruned = this.chatMessages.filter((item) => item.timestamp >= cutoff);
|
||||
if (pruned.length !== this.chatMessages.length) {
|
||||
this.chatMessages = pruned;
|
||||
this.updateScriptWorkerChatMessages();
|
||||
}
|
||||
}
|
||||
|
||||
parseEmoteOffsets(rawEmotes) {
|
||||
if (!rawEmotes) {
|
||||
return [];
|
||||
}
|
||||
return rawEmotes
|
||||
.split("/")
|
||||
.flatMap((emoteEntry) => {
|
||||
if (!emoteEntry) {
|
||||
return [];
|
||||
}
|
||||
const [id, positions] = emoteEntry.split(":");
|
||||
if (!id || !positions) {
|
||||
return [];
|
||||
}
|
||||
return positions.split(",").map((range) => {
|
||||
const [start, end] = range.split("-").map((value) => Number.parseInt(value, 10));
|
||||
return Number.isFinite(start) && Number.isFinite(end) ? { id, start, end } : null;
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
buildMessageFragments(message, tags) {
|
||||
if (!message) {
|
||||
return [];
|
||||
}
|
||||
const emotes = this.parseEmoteOffsets(tags?.emotes);
|
||||
if (!emotes.length) {
|
||||
return [{ type: "text", text: message }];
|
||||
}
|
||||
const sorted = emotes.sort((a, b) => a.start - b.start);
|
||||
const fragments = [];
|
||||
let cursor = 0;
|
||||
sorted.forEach((emote) => {
|
||||
if (emote.start > cursor) {
|
||||
fragments.push({ type: "text", text: message.slice(cursor, emote.start) });
|
||||
}
|
||||
const emoteText = message.slice(emote.start, emote.end + 1);
|
||||
const emoteInfo = this.emoteCatalogById.get(String(emote.id));
|
||||
fragments.push({
|
||||
type: "emote",
|
||||
id: emote.id,
|
||||
text: emoteText,
|
||||
name: emoteInfo?.name || emoteText,
|
||||
url: emoteInfo?.url || null,
|
||||
});
|
||||
cursor = emote.end + 1;
|
||||
});
|
||||
if (cursor < message.length) {
|
||||
fragments.push({ type: "text", text: message.slice(cursor) });
|
||||
}
|
||||
return fragments;
|
||||
}
|
||||
|
||||
receiveChatMessage(message) {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const entry = { ...message, timestamp: now };
|
||||
const cutoff = now - 120_000;
|
||||
this.chatMessages = [...this.chatMessages, entry].filter((item) => item.timestamp >= cutoff);
|
||||
const fragments = this.buildMessageFragments(message.message || "", message.tags);
|
||||
const entry = { ...message, fragments, timestamp: now };
|
||||
this.chatMessages = [...this.chatMessages, entry];
|
||||
this.pruneChatMessages(now);
|
||||
this.updateScriptWorkerChatMessages();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const allowedImportUrls = new Set();
|
||||
const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null;
|
||||
const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"];
|
||||
let chatMessages = [];
|
||||
let emoteCatalog = [];
|
||||
|
||||
function normalizeUrl(url) {
|
||||
try {
|
||||
@@ -39,48 +40,6 @@ function importAllowedScripts(...urls) {
|
||||
return nativeImportScripts(...resolved);
|
||||
}
|
||||
|
||||
function disableNetworkApis() {
|
||||
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
|
||||
const blockedApis = {
|
||||
fetch: (...args) => {
|
||||
if (!nativeFetch) {
|
||||
throw new Error("Network access is disabled in asset scripts.");
|
||||
}
|
||||
const request = new Request(...args);
|
||||
const url = normalizeUrl(request.url);
|
||||
if (!allowedFetchUrls.has(url)) {
|
||||
throw new Error("Network access is disabled in asset scripts.");
|
||||
}
|
||||
return nativeFetch(request);
|
||||
},
|
||||
XMLHttpRequest: undefined,
|
||||
WebSocket: undefined,
|
||||
EventSource: undefined,
|
||||
importScripts: (...urls) => importAllowedScripts(...urls),
|
||||
};
|
||||
|
||||
Object.entries(blockedApis).forEach(([key, value]) => {
|
||||
if (!(key in self)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(self, key, {
|
||||
value,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
self[key] = value;
|
||||
} catch (_error) {
|
||||
// ignore if the API cannot be overridden in this environment
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disableNetworkApis();
|
||||
|
||||
function loadSharedDependencies() {
|
||||
if (!nativeImportScripts || sharedDependencyUrls.length === 0) {
|
||||
return;
|
||||
@@ -106,6 +65,32 @@ function refreshAllowedFetchUrls() {
|
||||
}
|
||||
});
|
||||
});
|
||||
if (Array.isArray(emoteCatalog)) {
|
||||
emoteCatalog.forEach((emote) => {
|
||||
if (emote?.url) {
|
||||
const normalized = normalizeUrl(emote.url);
|
||||
if (normalized) {
|
||||
allowedFetchUrls.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(chatMessages)) {
|
||||
chatMessages.forEach((message) => {
|
||||
const fragments = message?.fragments;
|
||||
if (!Array.isArray(fragments)) {
|
||||
return;
|
||||
}
|
||||
fragments.forEach((fragment) => {
|
||||
if (fragment?.url) {
|
||||
const normalized = normalizeUrl(fragment.url);
|
||||
if (normalized) {
|
||||
allowedFetchUrls.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function reportScriptError(id, stage, error) {
|
||||
@@ -139,6 +124,7 @@ function updateScriptContexts() {
|
||||
script.context.width = script.canvas?.width ?? 0;
|
||||
script.context.height = script.canvas?.height ?? 0;
|
||||
script.context.chatMessages = chatMessages;
|
||||
script.context.emoteCatalog = emoteCatalog;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,7 +168,7 @@ function stopTickLoopIfIdle() {
|
||||
|
||||
function createScriptHandlers(source, context, state, sourceLabel = "") {
|
||||
const contextPrelude =
|
||||
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, playAudio } = context;";
|
||||
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio } = context;";
|
||||
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
||||
const factory = new Function(
|
||||
"context",
|
||||
@@ -242,6 +228,7 @@ self.addEventListener("message", (event) => {
|
||||
elapsedMs: 0,
|
||||
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
|
||||
chatMessages,
|
||||
emoteCatalog,
|
||||
playAudio: (attachment) => {
|
||||
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
|
||||
if (!attachmentId) {
|
||||
@@ -310,6 +297,13 @@ self.addEventListener("message", (event) => {
|
||||
|
||||
if (type === "chatMessages") {
|
||||
chatMessages = Array.isArray(payload?.messages) ? payload.messages : [];
|
||||
refreshAllowedFetchUrls();
|
||||
updateScriptContexts();
|
||||
}
|
||||
|
||||
if (type === "emoteCatalog") {
|
||||
emoteCatalog = Array.isArray(payload?.emotes) ? payload.emotes : [];
|
||||
refreshAllowedFetchUrls();
|
||||
updateScriptContexts();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user