Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ working/*

# Sentry Config File
.env.sentry-build-plugin

# IDEs
.idea/
.vscode/

2 changes: 1 addition & 1 deletion i18next-parser.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// run i18next
export default {
defaultNamespace: 'common',
locales: ['en', 'zh', 'de', 'ka'],
locales: ['en', 'zh', 'de', 'ka', 'ru'],
output: 'src/i18n/locales/$LOCALE/$NAMESPACE.json',
input: ["src/**/*.{ts,tsx}"],
keepRemoved: true,
Expand Down
120 changes: 120 additions & 0 deletions scripts/console-indexeddb-vrm-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Вставка в консоль браузера (на странице приложения).
* База: AmicaVrmDatabase, store: vrms.
* Запись: { hash, saveType, vrmData, vrmUrl, thumbData }.
*/

// --------------- 1) Проверить, что в базе есть ---------------
function listVrms() {
return new Promise((resolve, reject) => {
const r = indexedDB.open('AmicaVrmDatabase');
r.onsuccess = () => {
const db = r.result;
if (!db.objectStoreNames.contains('vrms')) {
db.close();
return resolve([]);
}
const t = db.transaction('vrms', 'readonly');
const s = t.objectStore('vrms');
const req = s.getAll();
req.onsuccess = () => { db.close(); resolve(req.result || []); };
req.onerror = () => { db.close(); reject(req.error); };
};
r.onerror = () => reject(r.error);
});
}
// В консоли: listVrms().then(console.log)

// --------------- 2) Добавить одну тестовую запись (минимальная, без реального VRM) ---------------
function addTestRecord() {
const record = {
hash: 'test-' + Date.now(),
saveType: 'local',
vrmData: 'data:application/octet-stream;base64,', // пустой base64
vrmUrl: '',
thumbData: ''
};
return new Promise((resolve, reject) => {
const r = indexedDB.open('AmicaVrmDatabase');
r.onsuccess = () => {
const db = r.result;
const t = db.transaction('vrms', 'readwrite');
const s = t.objectStore('vrms');
const req = s.put(record);
req.onsuccess = () => { db.close(); resolve(record.hash); };
req.onerror = () => { db.close(); reject(req.error); };
};
r.onerror = () => reject(r.error);
});
}
// В консоли: addTestRecord().then(h => console.log('Добавлен hash:', h))
// Затем обнови страницу (F5) — в списке моделей может появиться карточка (по клику будет ошибка загрузки — это нормально для теста).

// --------------- 3) Добавить реальный VRM из выбранного файла ---------------
function addVrmFromFile(file) {
if (!file || !file.name.toLowerCase().endsWith('.vrm')) {
return Promise.reject(new Error('Нужен файл .vrm'));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const vrmData = reader.result;
if (typeof vrmData !== 'string') {
reject(new Error('Ожидалась строка data URL'));
return;
}
const len = vrmData.length;
const S = 100000;
let h = 0;
for (let i = 0; i < Math.min(S, len); i++) h = ((h << 5) - h + vrmData.charCodeAt(i)) << 0;
for (let i = Math.max(0, len - S); i < len; i++) h = ((h << 5) - h + vrmData.charCodeAt(i)) << 0;
h = ((h << 5) - h + len) << 0;
const hash = String(h);
const record = {
hash,
saveType: 'local',
vrmData,
vrmUrl: '',
thumbData: ''
};
const r = indexedDB.open('AmicaVrmDatabase');
r.onsuccess = () => {
const db = r.result;
const t = db.transaction('vrms', 'readwrite');
const s = t.objectStore('vrms');
const req = s.put(record);
req.onsuccess = () => { db.close(); resolve({ hash, size: vrmData.length }); };
req.onerror = () => { db.close(); reject(req.error); };
};
r.onerror = () => reject(r.error);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}

// Создать input и по выбору файла добавить в IndexedDB
function addVrmViaFilePicker() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.vrm,.VRM';
input.onchange = () => {
const file = input.files && input.files[0];
if (!file) return;
addVrmFromFile(file)
.then((r) => console.log('[VRM] Запись в IndexedDB:', r))
.catch((e) => console.error('[VRM] Ошибка:', e));
};
input.click();
}

// Экспорт в глобал, чтобы в консоли вызывать по имени
if (typeof window !== 'undefined') {
window.__vrmIndexedDB = {
list: listVrms,
addTest: addTestRecord,
addFromFile: addVrmFromFile,
pickAndAdd: addVrmViaFilePicker
};
console.log('VRM IndexedDB helper: __vrmIndexedDB.list() | .addTest() | .pickAndAdd()');
}
91 changes: 64 additions & 27 deletions src/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { TextButton } from "@/components/textButton";
import { ViewerContext } from "@/features/vrmViewer/viewerContext";
import { config, updateConfig } from "@/utils/config";
import { hashCodeLarge } from "./settings/common";
import { vrmDataProvider } from "@/features/vrmStore/vrmDataProvider";


import { Link } from "./settings/common";
Expand Down Expand Up @@ -81,7 +83,7 @@ export const Settings = ({
onClickClose: () => void;
}) => {
const { viewer } = useContext(ViewerContext);
const { vrmList, vrmListAddFile } = useVrmStoreContext();
const { vrmList, vrmListAddFile, addVrmFromStored } = useVrmStoreContext();
useKeyboardShortcut("Escape", onClickClose);

const [page, setPage] = useState('main_menu');
Expand Down Expand Up @@ -187,9 +189,62 @@ export const Settings = ({
const [useWebGPU, setUseWebGPU] = useState<boolean>(config("use_webgpu") === 'true' ? true : false);

const vrmFileInputRef = useRef<HTMLInputElement>(null);
const handleClickOpenVrmFile = useCallback(() => {
vrmFileInputRef.current?.click();
}, []);

const processVrmFile = useCallback(
(file: File) => {
if (!file?.name.toLowerCase().endsWith(".vrm")) {
console.warn("[VRM] Неверное расширение (ожидается .vrm)");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
if (typeof dataUrl !== "string") return;
const hash = hashCodeLarge(dataUrl);
const exists = vrmList.some((v) => v.getHash() === hash);
if (exists) {
viewer.loadVrm(dataUrl, () => {}).catch((e) => console.error("[VRM]", e));
updateConfig("vrm_save_type", "local");
updateConfig("vrm_hash", hash);
updateConfig("vrm_url", dataUrl);
Comment on lines +207 to +209
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Storing full VRM data URL in localStorage will exceed quota and fail.

Same critical issue as flagged in vrmIndexedDBConsoleHelper.ts: updateConfig("vrm_url", dataUrl) persists the entire base64-encoded VRM file to localStorage, which has a ~5-10 MB limit. Real VRM files are typically 10-50+ MB, producing data URLs of 13-67+ MB. The updateConfig call will throw a QuotaExceededError and corrupt the config flow.

Store only the hash in config (e.g., vrm_hash) and resolve the VRM data from IndexedDB at load time. The vrm_url config should only hold a short URL or be left empty for local VRMs.

Also applies to: 215-217

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings.tsx` around lines 207 - 209, The code is saving the
entire base64 VRM into localStorage via updateConfig("vrm_url", dataUrl), which
will exceed quota; instead remove or stop writing the full dataUrl to vrm_url
and only persist the short identifiers: call updateConfig("vrm_save_type",
"local") and updateConfig("vrm_hash", hash) (keep vrm_url empty or a short
pointer), and change any other occurrences (the same pattern at the other
occurrence around the 215-217 block) so that VRM binary stays in IndexedDB and
the app resolves the full VRM from IndexedDB at load time using vrm_hash; ensure
updateConfig is only used for small strings and that loading code looks up the
VRM from IndexedDB when vrm_save_type === "local".

return;
}
Comment on lines +205 to +211
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing React state updates when an already-stored VRM is selected.

When the VRM already exists in the list (line 204), updateConfig is called but setVrmHash, setVrmUrl, setVrmSaveType, and setSettingsUpdated are never called. Compare with CharacterModelPage's own click handler (lines 57-69 in CharacterModelPage.tsx) which correctly updates all of these. The UI will not reflect the selection.

🐛 Proposed fix
         if (exists) {
           viewer.loadVrm(dataUrl, () => {}).catch((e) => console.error("[VRM]", e));
           updateConfig("vrm_save_type", "local");
           updateConfig("vrm_hash", hash);
           updateConfig("vrm_url", dataUrl);
+          setVrmSaveType("local");
+          setVrmHash(hash);
+          setVrmUrl(dataUrl);
+          setSettingsUpdated(true);
           return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (exists) {
viewer.loadVrm(dataUrl, () => {}).catch((e) => console.error("[VRM]", e));
updateConfig("vrm_save_type", "local");
updateConfig("vrm_hash", hash);
updateConfig("vrm_url", dataUrl);
return;
}
if (exists) {
viewer.loadVrm(dataUrl, () => {}).catch((e) => console.error("[VRM]", e));
updateConfig("vrm_save_type", "local");
updateConfig("vrm_hash", hash);
updateConfig("vrm_url", dataUrl);
setVrmSaveType("local");
setVrmHash(hash);
setVrmUrl(dataUrl);
setSettingsUpdated(true);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings.tsx` around lines 205 - 211, When handling the case
where the VRM already exists (the exists branch using viewer.loadVrm and
updateConfig), also update the React state so the UI reflects the selection:
call setVrmHash(hash), setVrmUrl(dataUrl), setVrmSaveType("local") and
setSettingsUpdated(true) after viewer.loadVrm resolves (or immediately after
updateConfig) to mirror CharacterModelPage's click handler behavior; ensure you
reference the same state setters (setVrmHash, setVrmUrl, setVrmSaveType,
setSettingsUpdated) and keep the existing
updateConfig("vrm_save_type","vrm_hash","vrm_url") and viewer.loadVrm(...)
logic.

vrmDataProvider
.addItem(hash, "local", dataUrl)
.then(() => {
updateConfig("vrm_save_type", "local");
updateConfig("vrm_hash", hash);
updateConfig("vrm_url", dataUrl);
addVrmFromStored(hash, dataUrl);
return viewer.loadVrm(dataUrl, () => {});
})
.then(() => {
viewer.getScreenshotBlob((thumbBlob: Blob | null) => {
if (thumbBlob) {
import("@/utils/blobDataUtils").then(({ BlobToBase64 }) => {
BlobToBase64(thumbBlob).then((thumbData) => {
vrmDataProvider.updateItemThumb(hash, thumbData);
});
});
}
});
})
.catch((e) => console.error("[VRM] Ошибка загрузки VRM:", e));
Comment on lines +212 to +232
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing state updates after new VRM is stored and loaded.

Similar to the "already exists" path, the "new VRM" path (lines 212-232) calls updateConfig and addVrmFromStored but never calls setVrmHash, setVrmUrl, setVrmSaveType, or setSettingsUpdated. The settings UI won't reflect the newly loaded VRM.

🐛 Proposed fix — add state updates after addVrmFromStored
         vrmDataProvider
           .addItem(hash, "local", dataUrl)
           .then(() => {
             updateConfig("vrm_save_type", "local");
             updateConfig("vrm_hash", hash);
             updateConfig("vrm_url", dataUrl);
             addVrmFromStored(hash, dataUrl);
+            setVrmSaveType("local");
+            setVrmHash(hash);
+            setVrmUrl(dataUrl);
+            setSettingsUpdated(true);
             return viewer.loadVrm(dataUrl, () => {});
           })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
vrmDataProvider
.addItem(hash, "local", dataUrl)
.then(() => {
updateConfig("vrm_save_type", "local");
updateConfig("vrm_hash", hash);
updateConfig("vrm_url", dataUrl);
addVrmFromStored(hash, dataUrl);
return viewer.loadVrm(dataUrl, () => {});
})
.then(() => {
viewer.getScreenshotBlob((thumbBlob: Blob | null) => {
if (thumbBlob) {
import("@/utils/blobDataUtils").then(({ BlobToBase64 }) => {
BlobToBase64(thumbBlob).then((thumbData) => {
vrmDataProvider.updateItemThumb(hash, thumbData);
});
});
}
});
})
.catch((e) => console.error("[VRM] Ошибка загрузки VRM:", e));
vrmDataProvider
.addItem(hash, "local", dataUrl)
.then(() => {
updateConfig("vrm_save_type", "local");
updateConfig("vrm_hash", hash);
updateConfig("vrm_url", dataUrl);
addVrmFromStored(hash, dataUrl);
setVrmSaveType("local");
setVrmHash(hash);
setVrmUrl(dataUrl);
setSettingsUpdated(true);
return viewer.loadVrm(dataUrl, () => {});
})
.then(() => {
viewer.getScreenshotBlob((thumbBlob: Blob | null) => {
if (thumbBlob) {
import("@/utils/blobDataUtils").then(({ BlobToBase64 }) => {
BlobToBase64(thumbBlob).then((thumbData) => {
vrmDataProvider.updateItemThumb(hash, thumbData);
});
});
}
});
})
.catch((e) => console.error("[VRM] Ошибка загрузки VRM:", e));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings.tsx` around lines 212 - 232, After successfully
storing and loading a new VRM in the vrmDataProvider.addItem(...) promise chain
(the block that calls updateConfig and addVrmFromStored and then
viewer.loadVrm), update the React state the same way as the "already exists"
path: call setVrmHash(hash), setVrmUrl(dataUrl), setVrmSaveType("local"), and
setSettingsUpdated(true) after addVrmFromStored (or immediately after
updateConfig calls) so the settings UI reflects the new VRM; ensure these calls
are placed before or inside the subsequent then that triggers getScreenshotBlob
so state is settled when the thumbnail/save flow continues.

};
reader.onerror = () => console.error("[VRM] FileReader ошибка:", reader.error);
reader.readAsDataURL(file);
},
[viewer, vrmList, addVrmFromStored]
);

const handleChangeVrmFile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) processVrmFile(file);
event.target.value = "";
},
[processVrmFile]
);

const bgImgFileInputRef = useRef<HTMLInputElement>(null);
const handleClickOpenBgImgFile = useCallback(() => {
Expand All @@ -201,25 +256,6 @@ export const Settings = ({
const mainMenuRef = useRef<HTMLDivElement>(null);
const notificationsRef = useRef<HTMLDivElement>(null);

const handleChangeVrmFile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;

const file = files[0];
if (!file) return;

const file_type = file.name.split(".").pop();

if (file_type === "vrm") {
vrmListAddFile(file, viewer);
}

event.target.value = "";
},
[viewer]
);

function handleChangeBgImgFile(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.target.files;
if (!files) return;
Expand Down Expand Up @@ -438,7 +474,7 @@ export const Settings = ({
setVrmUrl={setVrmUrl}
setVrmSaveType={setVrmSaveType}
setSettingsUpdated={setSettingsUpdated}
handleClickOpenVrmFile={handleClickOpenVrmFile}
onPickVrmFile={processVrmFile}
/>

case 'character_animation':
Expand Down Expand Up @@ -897,15 +933,16 @@ export const Settings = ({
</div>

<input
id="vrm-file-input"
type="file"
className="hidden"
accept=".vrm"
className="sr-only"
accept=".vrm,.VRM,model/gltf-binary"
ref={vrmFileInputRef}
onChange={handleChangeVrmFile}
/>
<input
type="file"
className="hidden"
className="sr-only"
accept=".jpg,.jpeg,.png,.gif,.webp"
ref={bgImgFileInputRef}
onChange={handleChangeBgImgFile}
Expand Down
28 changes: 19 additions & 9 deletions src/components/settings/CharacterModelPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next';
import { clsx } from "clsx";
import { BasicPage } from "./common";
import { updateConfig } from "@/utils/config";
import { TextButton } from "@/components/textButton";
import { VrmData } from '@/features/vrmStore/vrmData';
import { Viewer } from '@/features/vrmViewer/viewer';

Expand All @@ -16,21 +15,32 @@ export function CharacterModelPage({
setVrmUrl,
setVrmSaveType,
setSettingsUpdated,
handleClickOpenVrmFile,
onPickVrmFile,
}: {
viewer: Viewer;
vrmHash: string;
vrmUrl: string;
vrmSaveType: string;
vrmList: VrmData[],
vrmList: VrmData[];
setVrmHash: (hash: string) => void;
setVrmUrl: (url: string) => void;
setVrmSaveType: (saveType: string) => void;
setSettingsUpdated: (updated: boolean) => void;
handleClickOpenVrmFile: () => void;
onPickVrmFile: (file: File) => void;
}) {
const { t } = useTranslation();

const handleLoadVrmClick = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".vrm,.VRM";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) onPickVrmFile(file);
};
input.click();
};

return (
<BasicPage
title={t("Character Model")}
Expand Down Expand Up @@ -73,14 +83,14 @@ export function CharacterModelPage({
</button>
)}
</div>
<TextButton
className="rounded-t-none text-lg ml-4 px-8 shadow-lg bg-secondary hover:bg-secondary-hover active:bg-secondary-active"
onClick={handleClickOpenVrmFile}
<button
type="button"
onClick={handleLoadVrmClick}
className="px-4 py-2 text-white font-bold rounded-lg rounded-t-none text-lg ml-4 px-8 shadow-lg bg-secondary hover:bg-secondary-hover active:bg-secondary-active cursor-pointer inline-block"
>
{t("Load VRM")}
</TextButton>
</button>
</BasicPage>

);
}

13 changes: 13 additions & 0 deletions src/components/settings/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function FormRow({label, children}: {
}

export function basename(path: string) {
if (!path || typeof path !== 'string') return "";
const a = path.split("/");
return a[a.length - 1];
}
Expand All @@ -106,6 +107,18 @@ export function hashCode(str: string): string {
return hash.toString();
}

/** Хеш для больших строк (VRM base64): берём начало, конец и длину, чтобы не блокировать UI. */
const SAMPLE_SIZE = 100000;
export function hashCodeLarge(str: string): string {
const len = str.length;
if (len <= SAMPLE_SIZE * 2) return hashCode(str);
let h = 0;
for (let i = 0; i < SAMPLE_SIZE; i++) h = ((h << 5) - h + str.charCodeAt(i)) << 0;
for (let i = len - SAMPLE_SIZE; i < len; i++) h = ((h << 5) - h + str.charCodeAt(i)) << 0;
h = ((h << 5) - h + len) << 0;
return h.toString();
}
Comment on lines +110 to +120
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Weak hash with partial sampling creates a real collision risk for VRM files.

hashCodeLarge only examines the first and last 100k characters (≈75 KB of binary each) plus the total length. Two different VRM files that share the same base64 header/trailer structure and identical length will produce the same hash. Since this hash is used as the IndexedDB primary key (via put), a collision silently overwrites an existing VRM record.

Consider using crypto.subtle.digest('SHA-256', ...) on the full ArrayBuffer (read via readAsArrayBuffer instead of readAsDataURL) for a collision-resistant hash. This is async and avoids blocking the main thread as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/common.tsx` around lines 110 - 120, The current
hashCodeLarge function (and constant SAMPLE_SIZE) samples only ends of the
base64 string and is collision-prone for VRM files; replace it with an async,
collision-resistant SHA-256 computation that digests the full file ArrayBuffer
using crypto.subtle.digest. Change the call site to read the file with
FileReader.readAsArrayBuffer (instead of readAsDataURL), convert the resulting
ArrayBuffer to a hex (or base64) string, and return that from an async function
(e.g., computeSha256Hash or an async-replaced hashCodeLarge); update any
IndexedDB put usage to await the async hash before using it as the primary key
so writes no longer risk silent overwrites.


export type Link = {
key: string;
label: string;
Expand Down
6 changes: 4 additions & 2 deletions src/features/vrmStore/vrmDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export class VrmDataProvider {
this.db.close();
}

public addItem(hash: string, saveType: 'local' | 'web', vrmData: string = "", vrmUrl: string = "", thumbData: string = ""): void {
this.db.vrms.put(new VrmDbModel(hash, saveType, vrmData, vrmUrl, thumbData));
public addItem(hash: string, saveType: 'local' | 'web', vrmData: string = "", vrmUrl: string = "", thumbData: string = ""): Promise<void> {
return this.db.vrms.put(new VrmDbModel(hash, saveType, vrmData, vrmUrl, thumbData))
.then(() => { console.log("[VRM] IndexedDB: запись vrms.put успешна, hash =", hash); })
.catch((err) => { console.error("[VRM] IndexedDB: ошибка vrms.put", err); throw err; });
}

public async getItems(): Promise<VrmDbModel[]> {
Expand Down
Loading