feat(materials): add campaign materials overlay for sessions

Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 11:05:38 +08:00
parent 195d4be086
commit 61875be857
39 changed files with 2521 additions and 22 deletions
+151 -2
View File
@@ -43,8 +43,8 @@ import type {
SceneId,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
import type { AssetId, GraphNodeId, MaterialId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId } from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
@@ -225,6 +225,7 @@ export class ZipProjectStore {
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -1002,6 +1003,137 @@ export class ZipProjectStore {
return latest;
}
/**
* Создаёт или обновляет материал кампании.
* При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
*/
async upsertMaterial(input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('Material name is required');
const nameKey = name.toLowerCase();
const existing = open.project.materials ?? [];
const editingId = input.materialId ?? null;
if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) {
throw new Error('Material name already exists');
}
let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null;
if (input.filePath) {
const kind = classifyMediaPath(input.filePath);
if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)');
const ext = path.extname(input.filePath).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('Material must be an image (png/jpg/webp)');
}
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
// keep original buffer
}
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(input.filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
}
await this.updateProject((p) => {
const materials = [...(p.materials ?? [])];
const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
if (editingId) {
const idx = materials.findIndex((m) => m.id === editingId);
if (idx < 0) throw new Error('Material not found');
const prev = materials[idx]!;
const assetId = nextAssetId ?? prev.assetId;
materials[idx] = {
id: editingId,
name,
assetId,
rotationDeg: prev.rotationDeg ?? 0,
};
} else {
if (!nextAssetId) throw new Error('Material image is required');
materials.push({
id: asMaterialId(`mat_${this.randomId()}`),
name,
assetId: nextAssetId,
rotationDeg: 0,
});
}
return { ...p, assets, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialRotation(
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) =>
m.id === materialId ? { ...m, rotationDeg } : m,
);
return { ...p, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteMaterial(materialId: MaterialId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
materials: (p.materials ?? []).filter((m) => m.id !== materialId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialsOrder(materialIds: MaterialId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.materials ?? []).map((m) => [m.id, m]));
const next: NonNullable<Project['materials']> = [];
for (const id of materialIds) {
const m = byId.get(id);
if (m) {
next.push(m);
byId.delete(id);
}
}
for (const m of byId.values()) next.push(m);
return { ...p, materials: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
@@ -1717,6 +1849,22 @@ function normalizeProject(p: Project): Project {
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
.map((m) => {
if (!m || typeof m !== 'object') return null;
const obj = m as { id?: string; name?: string; assetId?: AssetId; rotationDeg?: number };
if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
const rot = obj.rotationDeg;
const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
return { id: asMaterialId(String(obj.id)), name, assetId: obj.assetId, rotationDeg };
})
.filter(
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
Boolean(x),
);
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
@@ -1737,6 +1885,7 @@ function normalizeProject(p: Project): Project {
},
scenes,
campaignAudios,
materials,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,