@idosgames/mcp 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cli.js +2235 -27
- package/package.json +4 -1
- package/registry/host.json +3 -3
- package/registry/index.json +309 -156
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +14 -14
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +32 -15
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +8 -8
- package/registry/modules/friends.json +9 -9
- package/registry/modules/hero-run.json +497 -0
- package/registry/modules/idle-rpg.json +70 -18
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +10 -10
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +8 -8
- package/registry/modules/marketplace.json +8 -8
- package/registry/modules/mini-game.json +8 -8
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +8 -8
- package/registry/modules/rewards.json +8 -8
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +228 -0
- package/registry/modules/voxelcraft.json +42 -125
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/hero-run.json +6 -0
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/video-studio.json +6 -0
- package/registry/video.json +9800 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "video-studio",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Video studio",
|
|
5
|
+
"type": "game",
|
|
6
|
+
"engine": "three",
|
|
7
|
+
"genre": "video"
|
|
8
|
+
},
|
|
9
|
+
"catalog": {
|
|
10
|
+
"type": "template",
|
|
11
|
+
"uses": [
|
|
12
|
+
"workshop",
|
|
13
|
+
"premium",
|
|
14
|
+
"store",
|
|
15
|
+
"leaderboard"
|
|
16
|
+
],
|
|
17
|
+
"summary": "Video studio: players make viral short videos with voice-over, captions and music — from ready characters and scenes in seconds, or with AI characters, backgrounds and props made for their idea.",
|
|
18
|
+
"description": "A whole app of its own mode (React + a three.js stage), the 'Video studio' template. A player picks a trending format (dialogue of two, a story with a narrator, top-5, a quiz, 'what if'), writes an idea (optionally a photo or their own voice) and gets a video in one tap: a hidden AI director (title feature \"video-director\", JSON answer) writes the VideoSpec — cast, scene, lines, beats, emotions, music, captions — and a plan by the ladder of requests for every element: from the library (free), combined from what exists (recolour, outfit, a prop — free), the player's own media (free), an AI asset (a voxel character, a 2D hero recipe, a background, a prop, music) or an AI video insert. The estimate in the studio currency is shown BEFORE anything paid. The timeline engine (@idosgames/timeline) plays the video right away with a draft voice; the player edits it in slots (hero, scene, voice, music, lines) or on the tracks (move clips, camera, beats, captions — kept as overrides on top of the format), pays for the final voice, and exports MP4 on the device (WebCodecs), shares a live link, or publishes to the app's feed (Workshop type \"video.spec\"). Anyone can press 'Remix' — a copy with the parent (the remix tree): unchanged lines keep their voice and cost nothing. Characters are the platform's standard (@idosgames/characters): voxel recipes (CharacterSpec), the 2D hero with the full wardrobe, GLB on the humanoid-v1 skeleton and cut-outs (photos, logos, AI creatures) with procedural animation and a mouth insert; every one speaks with lip-sync and plays the six emotions. Own assets go to 'My assets' and to the Workshop (\"video.asset\", packs \"video.pack\" free or for a price). Screens: Trends, Editor, My videos, My assets, Feed, Challenges (weekly leaderboard), Author profile. Money: every AI step is a title AI feature with its price in the studio currency (\"FRAMES\"), daily limits and the title's credit budget; the first video is free with a watermark, VIP removes it.",
|
|
19
|
+
"provides": [
|
|
20
|
+
"short videos with voice-over, word-by-word captions and music",
|
|
21
|
+
"video formats: dialogue of two, story with a narrator, top-5, quiz, what-if",
|
|
22
|
+
"AI director: idea → VideoSpec and an estimate by the ladder of requests",
|
|
23
|
+
"estimate in the studio currency before paid steps",
|
|
24
|
+
"live preview on the device with a draft voice",
|
|
25
|
+
"editing in slots (hero, scene, voice, music, lines) and on tracks (camera, beats, captions)",
|
|
26
|
+
"lip-sync and emotions for voxel, 2D, GLB and cut-out characters",
|
|
27
|
+
"AI characters (voxel and 2D), backgrounds, props, music and video inserts for a video",
|
|
28
|
+
"the player's photo as a cut-out actor (sticker with an outline)",
|
|
29
|
+
"MP4 export on the device (WebCodecs) and a live link",
|
|
30
|
+
"publishing videos and remixes in the Workshop (remix tree)",
|
|
31
|
+
"My assets: own characters and scenes reused for free, published as packs",
|
|
32
|
+
"weekly challenge with a leaderboard, quests for videos",
|
|
33
|
+
"watermark on free videos, VIP without it"
|
|
34
|
+
],
|
|
35
|
+
"tags": [
|
|
36
|
+
"video",
|
|
37
|
+
"shorts",
|
|
38
|
+
"tiktok",
|
|
39
|
+
"reels",
|
|
40
|
+
"viral",
|
|
41
|
+
"remix",
|
|
42
|
+
"ugc",
|
|
43
|
+
"ai",
|
|
44
|
+
"voice",
|
|
45
|
+
"captions",
|
|
46
|
+
"animation",
|
|
47
|
+
"studio",
|
|
48
|
+
"three"
|
|
49
|
+
],
|
|
50
|
+
"author": {
|
|
51
|
+
"name": "iDos Games",
|
|
52
|
+
"url": "https://idosgames.com"
|
|
53
|
+
},
|
|
54
|
+
"version": "0.1.1"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@idosgames/characters": "0.1.1",
|
|
58
|
+
"@idosgames/core": "0.23.0",
|
|
59
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
60
|
+
"@idosgames/react": "0.6.0",
|
|
61
|
+
"@idosgames/timeline": "0.1.0",
|
|
62
|
+
"react": "19.2.7",
|
|
63
|
+
"three": "0.185.1"
|
|
64
|
+
},
|
|
65
|
+
"contentHash": "66961c2a111d56cc6c14482f97400e57cd3994d0c106494cd79a9e0b9bb90e20",
|
|
66
|
+
"files": [
|
|
67
|
+
{
|
|
68
|
+
"path": "ai/assets.ts",
|
|
69
|
+
"content": "// ИИ-ассеты по плану режиссёра (ступень ai-asset). Каждый вид — своя скрытая фича тайтла со своей\n// ценой; результат становится обычным ассетом «Моих ассетов» (own:…) по тому же контракту, что и\n// библиотека: воксельный герой — рецепт CharacterSpec, 2D-герой — рецепт рига (детали, цвета, свои\n// SVG), существо или предмет — картинка-вырезка, фон — сцена с картинкой, музыка — петля.\n// Стиль студии (style lock) уходит в фичи картинок переменной, игрок его не пишет.\nimport type {\n IDosGamesClient,\n AIGenerationView,\n OperationResult,\n} from \"@idosgames/core\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { parseCharacterSpec } from \"@idosgames/characters/voxel\";\nimport { heroRig, parseRig2dRecipe } from \"@idosgames/characters/rig2d\";\nimport type {\n ActorAsset,\n AIAssetKind,\n Asset,\n MusicAsset,\n PropAsset,\n SetAsset,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport { newId } from \"../model/store\";\nimport { STYLE_PROMPTS } from \"./prompts\";\nimport { ASSET_GENERATORS as G } from \"./generators\";\n\nexport type AssetResult =\n { ok: true; asset: Asset } | { ok: false; error: string; refunded?: boolean };\n\nexport interface AssetOptions {\n style: StyleFamily;\n /** Подпись ассета в «Моих» (имя героя, «Фон: кухня»). */\n title?: string;\n optionID?: string;\n signal?: AbortSignal;\n onProgress?: (u: number) => void;\n /** Сцена-основа для фона: её якоря и планы камеры, свой только фон. */\n baseSet?: SetAsset;\n /** Длительность музыки по адресу (по умолчанию — декодировать). */\n measure?: (url: string) => Promise<{ duration: number } | null>;\n}\n\nasync function finish(\n client: IDosGamesClient,\n started: OperationResult<AIGenerationView>,\n opts: AssetOptions,\n timeoutMs: number,\n): Promise<\n | { ok: true; view: AIGenerationView }\n | { ok: false; error: string; refunded?: boolean }\n> {\n if (!started.ok) return { ok: false, error: started.error };\n const done = await client.ai.waitForGeneration(started.data, {\n timeoutMs,\n ...(opts.signal ? { signal: opts.signal } : {}),\n onUpdate: (v) => opts.onProgress?.(Number(v.Progress ?? 0)),\n });\n if (!done.ok) return { ok: false, error: done.error };\n if (done.data.Status !== \"completed\")\n return {\n ok: false,\n error: done.data.Error?.Message || \"generation failed\",\n refunded: !!done.data.PlayerRefunded,\n };\n return { ok: true, view: done.data };\n}\n\nconst common = (opts: AssetOptions) => ({\n relatedEntityID: crypto.randomUUID(),\n ...(opts.optionID ? { selectedOptionID: opts.optionID } : {}),\n});\n\nconst meta = (\n kind: Asset[\"kind\"],\n opts: AssetOptions,\n prompt: string,\n tags: string[],\n) => ({\n ref: `own:${newId()}`,\n kind,\n style: opts.style,\n title: (opts.title || prompt).slice(0, 60),\n description: prompt.slice(0, 300),\n tags: [\"ai\", ...tags],\n tier: \"creator\" as const,\n license: \"creator\" as const,\n});\n\nconst firstUrl = (v: AIGenerationView) =>\n v.Assets?.find((a) => a.Url)?.Url ?? null;\n\nasync function measureByDecode(\n url: string,\n): Promise<{ duration: number } | null> {\n try {\n const buf = await (await fetch(url)).arrayBuffer();\n const audio = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(\n buf,\n );\n return { duration: audio.duration };\n } catch {\n return null;\n }\n}\n\n/** Сгенерировать ассет вида `kind` по описанию. */\nexport async function generateAsset(\n client: IDosGamesClient,\n kind: AIAssetKind,\n prompt: string,\n opts: AssetOptions,\n): Promise<AssetResult> {\n const style = STYLE_PROMPTS[opts.style] ?? STYLE_PROMPTS[\"flat-cartoon\"]!;\n switch (kind) {\n case \"voxel-character\": {\n const r = await finish(\n client,\n await client.ai.generateText(\n FEATURES.voxelCharacter,\n { Prompt: prompt },\n common(opts),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const spec = parseCharacterSpec(\n r.view.OutputJson ?? r.view.OutputText ?? \"\",\n );\n if (!spec.ok)\n return { ok: false, error: \"the AI answer is not a character recipe\" };\n const asset: ActorAsset = {\n ...meta(\n \"actor\",\n {\n ...opts,\n style: G[\"voxel-character\"].style!,\n title: opts.title || spec.spec.name || prompt,\n },\n prompt,\n G[\"voxel-character\"].asset.tags,\n ),\n kind: \"actor\",\n affordances: G[\"voxel-character\"].asset[\"affordances\"] as string[],\n body: { type: \"voxel\", spec: spec.spec },\n };\n return { ok: true, asset };\n }\n case \"rig2d-character\": {\n const r = await finish(\n client,\n await client.ai.generateText(\n FEATURES.rig2dCharacter,\n { Prompt: prompt },\n common(opts),\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const recipe = parseRig2dRecipe(\n r.view.OutputJson ?? r.view.OutputText ?? \"\",\n heroRig,\n );\n if (!recipe.ok) return { ok: false, error: recipe.error };\n const asset: ActorAsset = {\n ...meta(\n \"actor\",\n {\n ...opts,\n style: G[\"rig2d-character\"].style!,\n title: opts.title || recipe.recipe.name || prompt,\n },\n recipe.recipe.description || prompt,\n G[\"rig2d-character\"].asset.tags,\n ),\n kind: \"actor\",\n affordances: G[\"rig2d-character\"].asset[\"affordances\"] as string[],\n height: G[\"rig2d-character\"].asset[\"height\"] as number,\n body: {\n type: \"rig2d\",\n look: recipe.recipe.look,\n ...(Object.keys(recipe.recipe.art).length\n ? { art: recipe.recipe.art }\n : {}),\n },\n };\n return { ok: true, asset };\n }\n case \"image-actor\":\n case \"prop\": {\n const r = await finish(\n client,\n await client.ai.generateImage(\n FEATURES.image,\n { Background: \"transparent\", Format: \"png\" },\n {\n ...common(opts),\n variables: { subject: prompt.slice(0, 400), style },\n },\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no picture\" };\n if (kind === \"prop\") {\n const g = G.prop.asset;\n const asset: PropAsset = {\n ...meta(\"prop\", opts, prompt, g.tags),\n kind: \"prop\",\n image: url,\n height: g[\"height\"] as number,\n grip: g[\"grip\"] as { x: number; y: number },\n };\n return { ok: true, asset };\n }\n const asset: ActorAsset = {\n ...meta(\"actor\", opts, prompt, G[\"image-actor\"].asset.tags),\n kind: \"actor\",\n affordances: G[\"image-actor\"].asset[\"affordances\"] as string[],\n height: G[\"image-actor\"].asset[\"height\"] as number,\n body: { type: \"cutout\", image: url, outline: true },\n };\n return { ok: true, asset };\n }\n case \"background\": {\n if (!opts.baseSet)\n return { ok: false, error: \"no scene to put the background into\" };\n const r = await finish(\n client,\n await client.ai.generateImage(\n FEATURES.background,\n { Size: \"1024x1536\", Format: \"jpeg\" },\n {\n ...common(opts),\n variables: { subject: prompt.slice(0, 400), style },\n },\n ),\n opts,\n 5 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no picture\" };\n const base = opts.baseSet.set;\n const asset: SetAsset = {\n ...meta(\"set\", opts, prompt, G.background.asset.tags),\n kind: \"set\",\n set: {\n ...base,\n background: {\n image: url,\n gradient: base.background.gradient ?? [\"#000000\", \"#000000\"],\n },\n props: [],\n },\n };\n return { ok: true, asset };\n }\n case \"music\": {\n const r = await finish(\n client,\n await client.ai.generateMusic(\n FEATURES.music,\n { Format: \"mp3\" },\n { ...common(opts), variables: { mood: prompt.slice(0, 300) } },\n ),\n opts,\n 8 * 60_000,\n );\n if (!r.ok) return r;\n const url = firstUrl(r.view);\n if (!url) return { ok: false, error: \"the AI returned no music\" };\n const m = await (opts.measure ?? measureByDecode)(url);\n const asset: MusicAsset = {\n ...meta(\"music\", opts, prompt, G.music.asset.tags),\n kind: \"music\",\n url,\n duration: m?.duration ?? (G.music.asset[\"duration\"] as number),\n loop: true,\n mood: prompt.slice(0, 60),\n };\n return { ok: true, asset };\n }\n case \"three-d\":\n return { ok: false, error: \"3D generation is not open on this title\" };\n }\n}\n"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"path": "ai/director.ts",
|
|
73
|
+
"content": "// AI-режиссёр: идея игрока → VideoSpec + план по ступеням лестницы. Это скрытая текстовая фича\n// тайтла \"video-director\": системный промпт, схема ответа и цена — в настройках тайтла; игрок\n// заполняет только переменные шаблона (свободного промпта у фичи нет). Ответ не по схеме движок\n// сам возвращает игроку деньгами. Ссылку не из каталога студия чинит САМА — ставит ближайший\n// ассет того же вида: повторный (платный) запрос ради опечатки модели игроку не нужен.\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport {\n CAPTION_STYLES,\n getFormat,\n parseDirectorAnswer,\n type PlanItem,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport type { Access } from \"../model/access\";\nimport { closestAsset, type StudioCatalog } from \"../model/catalog\";\nimport { canPick } from \"../model/access\";\n\nexport type StudioMode = \"quick\" | \"unique\";\n\nexport interface DirectRequest {\n idea: string;\n format: string;\n style: StyleFamily;\n /** Язык реплик словами («Russian», «English»). */\n language: string;\n /** Сколько персонажей хочет игрок; пусто — решит формат. */\n cast?: number;\n /** Свои медиа игрока, ссылками каталога (`own:…`) с подписью. */\n media?: string;\n /** quick — только библиотека (ИИ-ассеты плана отбрасываются), unique — по плану. */\n mode: StudioMode;\n}\n\nexport interface Directed {\n spec: VideoSpec;\n plan: PlanItem[];\n /** Элементы, где ссылку модели пришлось заменить ближайшим из каталога. */\n repaired: string[];\n warnings: string[];\n}\n\nexport type DirectResult =\n | { ok: true; value: Directed }\n | { ok: false; error: string; refunded?: boolean };\n\nexport interface DirectOptions {\n signal?: AbortSignal;\n optionID?: string;\n onProgress?: (u: number) => void;\n}\n\nconst DEFAULT_CAPTIONS = \"karaoke-bold\";\n\nexport async function direct(\n client: IDosGamesClient,\n catalog: StudioCatalog,\n access: Access,\n req: DirectRequest,\n opts: DirectOptions = {},\n): Promise<DirectResult> {\n const variables: Record<string, string> = {\n idea: req.idea.trim().slice(0, 600),\n format: req.format,\n style: req.style,\n language: req.language,\n catalog: catalog.brief(access, req.style),\n };\n if (req.cast) variables[\"cast\"] = String(req.cast);\n if (req.media) variables[\"media\"] = req.media.slice(0, 600);\n\n const started = await client.ai.generateText(\n FEATURES.director,\n {},\n {\n variables,\n relatedEntityID: crypto.randomUUID(),\n ...(opts.optionID ? { selectedOptionID: opts.optionID } : {}),\n },\n );\n if (!started.ok) return { ok: false, error: started.error };\n const done = await client.ai.waitForGeneration(started.data, {\n timeoutMs: 4 * 60_000,\n ...(opts.signal ? { signal: opts.signal } : {}),\n onUpdate: (v) => opts.onProgress?.(Number(v.Progress ?? 0)),\n });\n if (!done.ok) return { ok: false, error: done.error };\n if (done.data.Status !== \"completed\")\n return {\n ok: false,\n error: done.data.Error?.Message || \"director failed\",\n refunded: !!done.data.PlayerRefunded,\n };\n\n const parsed = parseDirectorAnswer(\n done.data.OutputJson ?? done.data.OutputText ?? \"\",\n );\n if (!parsed.ok)\n return {\n ok: false,\n error: parsed.issues.map((i) => `${i.path} ${i.message}`).join(\"; \"),\n };\n return {\n ok: true,\n value: settle(\n parsed.answer.spec,\n parsed.answer.plan,\n catalog,\n access,\n req,\n parsed.issues.map((i) => `${i.path} ${i.message}`),\n ),\n };\n}\n\n/**\n * Довести ответ модели до ролика, который точно соберётся: формат и стиль — как просил игрок,\n * ссылки — только из каталога и открытые игроку, субтитры — известным стилем, сид — новый.\n * Режим quick выбрасывает из плана ИИ-шаги (в спеке уже стоит ближайшее из каталога).\n */\nexport function settle(\n spec: VideoSpec,\n plan: PlanItem[],\n catalog: StudioCatalog,\n access: Access,\n req: Pick<DirectRequest, \"format\" | \"style\" | \"mode\">,\n warnings: string[] = [],\n): Directed {\n const repaired: string[] = [];\n const out: VideoSpec = {\n ...spec,\n format: getFormat(spec.format) ? spec.format : req.format,\n style: req.style,\n aspect: \"9:16\",\n fps: 30,\n seed: Math.floor(Math.random() * 2 ** 31),\n captions: CAPTION_STYLES[spec.captions] ? spec.captions : DEFAULT_CAPTIONS,\n overrides: [],\n assets: {},\n };\n\n const fix = (\n slot: string,\n ref: string | null | undefined,\n kind: \"actor\" | \"set\" | \"music\",\n ): string | undefined => {\n const a = ref ? catalog.get(ref) : undefined;\n if (a && a.kind === kind && canPick(a, access)) return a.ref;\n const near = closestAsset(catalog, access, kind, ref ?? \"\", req.style);\n if (near) repaired.push(slot);\n return near?.ref;\n };\n out.set = fix(\"set\", spec.set, \"set\") ?? spec.set;\n out.cast = spec.cast.map((c, i) => ({\n ...c,\n asset: fix(`cast[${i}]`, c.asset, \"actor\") ?? c.asset,\n }));\n if (spec.music) {\n const m = fix(\"music\", spec.music, \"music\");\n if (m) out.music = m;\n else delete out.music;\n }\n\n // формат решает, сколько актёров: лишних — долой, недостающих — из каталога\n const format = getFormat(out.format);\n if (format) {\n if (out.cast.length > format.maxCast)\n out.cast = out.cast.slice(0, format.maxCast);\n while (out.cast.length < format.minCast) {\n const used = new Set(out.cast.map((c) => c.asset));\n const extra = catalog\n .pickable(access, { kind: \"actor\", style: req.style })\n .find((a) => !used.has(a.ref));\n if (!extra) break;\n out.cast.push({ asset: extra.ref });\n repaired.push(`cast[${out.cast.length - 1}]`);\n }\n out.lines = out.lines.map((l) =>\n l.who >= out.cast.length ? { ...l, who: format.narrator ? -1 : 0 } : l,\n );\n }\n\n const kept =\n req.mode === \"quick\"\n ? plan.filter((p) => p.step !== \"ai-asset\" && p.step !== \"ai-clip\")\n : plan;\n return { spec: out, plan: kept, repaired, warnings };\n}\n"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"path": "ai/generators.ts",
|
|
77
|
+
"content": "// Генераторы ИИ-ассетов (ступень ai-asset) ДАННЫМИ: какая фича, какой промпт, какие настройки модели и\n// какой ассет получается из ответа. Одна таблица на два места: студия игрока (assets.ts — фичи тайтла\n// за валюту студии) и MCP платформы (generate_asset — кредиты издателя; таблица едет в video.json\n// реестра выгрузкой catalog.test.ts). Сервер своей копии не держит: подставляет ответ модели в `asset`.\n//\n// Подстановки в `asset`: \"$url\" — первый файл генерации; \"$out\" — разобранный JSON ответа текстовой\n// фичи; \"$out.<поле>\" — его поле. Ассет потом проходит validateAsset (у игрока — при открытии,\n// у агента — validate_asset из @idosgames/mcp).\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport type { AIAssetKind, AssetKind } from \"@idosgames/timeline\";\nimport { FEATURES, type FeatureKey } from \"../ids\";\nimport {\n BACKGROUND_TEMPLATE,\n IMAGE_ACTOR_TEMPLATE,\n MUSIC_TEMPLATE,\n RIG2D_CHARACTER_SYSTEM_PROMPT,\n STYLE_PROMPTS,\n} from \"./prompts\";\nimport { VOXEL_CHARACTER_SYSTEM_PROMPT } from \"@idosgames/characters/voxel\";\n\nconst ACTS = [\n \"talks\",\n \"walks\",\n \"dances\",\n \"jumps\",\n \"points\",\n \"waves\",\n \"sits\",\n \"falls\",\n];\n\nexport interface AssetGenerator {\n feature: FeatureKey;\n modality: \"text\" | \"image\" | \"music\";\n /** Системный промпт текстовой фичи: запрос — описание как есть. */\n system?: string;\n /** Шаблон промпта медиа: {{var.subject}}, {{var.style}} (подпись стиля), {{var.mood}}. */\n template?: string;\n /** Настройки модели для MCP (`params` генерации; незнакомые уровню ключи шлюз отбрасывает). */\n params?: Record<string, string>;\n /** Стиль задаёт сам генератор (воксели, 2D-риг), иначе — стиль студии. */\n style?: StyleFamily;\n /** Фон кладётся в готовую сцену: её якоря и планы камеры, свой только фон. */\n needsBaseSet?: boolean;\n /** Каким ассетом становится ответ (без ref/title/style/tier — их ставит вызывающий). */\n asset: { kind: AssetKind; tags: string[] } & Record<string, unknown>;\n}\n\nexport const ASSET_GENERATORS: Record<\n Exclude<AIAssetKind, \"three-d\">,\n AssetGenerator\n> = {\n \"voxel-character\": {\n feature: FEATURES.voxelCharacter,\n modality: \"text\",\n system: VOXEL_CHARACTER_SYSTEM_PROMPT,\n style: \"voxel-warm\",\n asset: {\n kind: \"actor\",\n tags: [\"voxel\", \"character\"],\n affordances: ACTS,\n body: { type: \"voxel\", spec: \"$out\" },\n },\n },\n \"rig2d-character\": {\n feature: FEATURES.rig2dCharacter,\n modality: \"text\",\n system: RIG2D_CHARACTER_SYSTEM_PROMPT,\n style: \"chibi-2d\",\n asset: {\n kind: \"actor\",\n tags: [\"2d\", \"hero\", \"character\"],\n affordances: ACTS,\n height: 1.6,\n body: { type: \"rig2d\", look: \"$out.look\", art: \"$out.art\" },\n },\n },\n \"image-actor\": {\n feature: FEATURES.image,\n modality: \"image\",\n template: IMAGE_ACTOR_TEMPLATE,\n params: { aspect_ratio: \"2:3\", output_format: \"png\" },\n asset: {\n kind: \"actor\",\n tags: [\"cutout\", \"character\"],\n affordances: [\"talks\", \"jumps\", \"walks\", \"falls\"],\n height: 1.4,\n body: { type: \"cutout\", image: \"$url\", outline: true },\n },\n },\n prop: {\n feature: FEATURES.image,\n modality: \"image\",\n template: IMAGE_ACTOR_TEMPLATE,\n params: { aspect_ratio: \"1:1\", output_format: \"png\" },\n asset: {\n kind: \"prop\",\n tags: [\"prop\"],\n image: \"$url\",\n height: 0.5,\n grip: { x: 0.5, y: 0.8 },\n },\n },\n background: {\n feature: FEATURES.background,\n modality: \"image\",\n template: BACKGROUND_TEMPLATE,\n params: { aspect_ratio: \"9:16\", output_format: \"jpeg\" },\n needsBaseSet: true,\n asset: { kind: \"set\", tags: [\"scene\", \"background\"] },\n },\n music: {\n feature: FEATURES.music,\n modality: \"music\",\n template: MUSIC_TEMPLATE,\n params: {\n vocals: \"instrumental, no vocals\",\n length: \"as a short seamless loop\",\n },\n asset: {\n kind: \"music\",\n tags: [\"music\", \"loop\"],\n url: \"$url\",\n duration: 30,\n loop: true,\n },\n },\n};\n\n/** Выгрузка для video.json: генераторы + подписи стилей (style lock картинок). */\nexport function generatorsBundle() {\n return { kinds: ASSET_GENERATORS, stylePrompts: STYLE_PROMPTS };\n}\n"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"path": "ai/prompts.ts",
|
|
81
|
+
"content": "// Промпты ИИ-фич студии. Живут они в КОНФИГЕ тайтла (cfg.AI.Features[...].Behavior.SystemInstructions\n// и PromptTemplate — игрок их не видит и не меняет); здесь — эталон, собранный из того, что движок\n// ролика действительно умеет: форматы, биты, эмоции, стили субтитров, ступени лестницы. Его же\n// выгружает ai-prompts.json (тест prompts.test.ts), из которого демо-настройки тайтла берут текст\n// (фикстура движка VideoStudioStarterFixtures) — промпт и движок не расходятся.\nimport { EMOTIONS, STYLE_FAMILIES } from \"@idosgames/characters\";\nimport { heroRig, rig2dRecipeBrief } from \"@idosgames/characters/rig2d\";\nimport { VOXEL_CHARACTER_SYSTEM_PROMPT } from \"@idosgames/characters/voxel\";\nimport {\n AI_ASSET_KINDS,\n BEATS,\n CAPTION_STYLES,\n DIRECTOR_OUTPUT_SCHEMA,\n LADDER_STEPS,\n listFormats,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport { VOICES } from \"../library/voices\";\n\nconst beats = BEATS.map(\n (b) =>\n `${b.id} — ${b.title}${b.needs.includes(\"actor2\") ? \" (two actors)\" : \"\"}`,\n).join(\"\\n\");\nconst formats = listFormats()\n .map(\n (f) =>\n `- ${f.id} — ${f.title}: ${f.description} Cast ${f.minCast}–${f.maxCast}${f.narrator ? \", narrator lines who = -1\" : \"\"}. ${f.directorHint}`,\n )\n .join(\"\\n\");\n\nexport const DIRECTOR_SYSTEM_PROMPT = `You are the director of a short viral vertical video (TikTok / Shorts / Reels, 9:16, 15–60 seconds). From the player's idea you write a VideoSpec and a plan, and answer with ONE JSON object { \"spec\": …, \"plan\": [ … ] } — no markdown, no comments.\n\nTHE SPEC\n- \"format\": one of the formats below; \"style\": the studio style family given in the request (one of ${STYLE_FAMILIES.join(\", \")}); \"aspect\": \"9:16\".\n- \"set\": the ref of ONE scene from the catalog (kind set) that fits the idea. \"cast\": the actors (kind actor), each { \"asset\": ref, \"name\": a short name in the video, \"voice\": a voice preset }.\n- Use ONLY refs that are in the catalog of the request. When the idea needs something the catalog does not have, still put the CLOSEST catalog ref into the spec AND add a plan item that generates the right one (see the ladder) — the studio swaps it in after generating.\n- \"lines\": what is said, in the language of the request; short (≤ 90 characters), punchy, funny or surprising; the first line is the hook that grabs attention in 2 seconds, the last one is the punchline or the twist. Each line: { \"who\": cast index (or -1 for the narrator where the format has one), \"text\", \"emotion\", optional \"beat\" (what the speaker does) and \"react\" (what the listener does) — beat ids from the list below }.\n- \"params\": the format's parameters (top-5 items, quiz questions, the what-if premise…).\n- \"captions\": ${Object.keys(CAPTION_STYLES).join(\" | \")}; \"music\": a music ref from the catalog or omit; \"title\": a short catchy title.\n- Vary beats, reactions and emotions: a video where every line has the same beat looks cheap.\n\nTHE PLAN — the ladder of requests, one item per element of the video (\"cast[0]\", \"set\", \"music\", \"prop:<name>\", \"clip:<line>\"):\n- \"library\" — a ready asset of the catalog fits (free); \"ref\": that asset.\n- \"combine\" — a catalog asset fits after a recolour, an outfit or a prop (free); \"ref\" + \"note\" what to change.\n- \"own\" — the player's own media (photo, voice) given in the request (free); \"ref\": the media ref.\n- \"ai-asset\" — nothing in the catalog fits: { \"generate\": { \"kind\": ${AI_ASSET_KINDS.map((k) => `\"${k}\"`).join(\" | \")}, \"prompt\": a precise description in English } }. Use voxel-character for a character in the voxel style, rig2d-character for a 2D hero, image-actor for any other creature or object that should act, background for a new scene, prop for an object in a hand, music for a soundtrack.\n- \"ai-clip\" — ONLY for a shot the engine cannot draw (photorealism, water, fire close-up): very expensive, use rarely.\nPrefer cheaper steps: library first, AI only where the idea really needs it. Steps: ${LADDER_STEPS.join(\", \")}.\n\nFORMATS\n${formats}\n\nBEATS (id — what happens)\n${beats}\n\nEMOTIONS: ${EMOTIONS.join(\", \")}.\nVOICE PRESETS: ${VOICES.map((v) => `${v.id} (${v.gender})`).join(\", \")}.\n\nNever put real people's names, celebrities' likeness or voices, copyrighted characters, sexual content, hate, harassment or promises of income in the video.`;\n\n/** Шаблон промпта режиссёра: всё, что меняется от запроса к запросу, — переменными. */\nexport const DIRECTOR_TEMPLATE = `Idea: {{var.idea}}\nFormat: {{var.format}}\nStyle family: {{var.style}}\nLanguage of the lines: {{var.language}}\nNumber of characters wanted: {{var.cast}}\nPlayer's own media: {{var.media}}\nCatalog (ref | kind | title | tags | can do):\n{{var.catalog}}`;\n\nexport const DIRECTOR_VARIABLES = [\n { name: \"idea\", type: \"String\", required: true, maxLength: 600 },\n {\n name: \"format\",\n type: \"Enum\",\n required: true,\n enum: listFormats().map((f) => f.id),\n },\n { name: \"style\", type: \"Enum\", required: true, enum: [...STYLE_FAMILIES] },\n { name: \"language\", type: \"String\", required: true, maxLength: 24 },\n { name: \"cast\", type: \"String\", required: false, maxLength: 8 },\n { name: \"media\", type: \"String\", required: false, maxLength: 600 },\n { name: \"catalog\", type: \"String\", required: true, maxLength: 24000 },\n] as const;\n\nexport const RIG2D_CHARACTER_SYSTEM_PROMPT = `You design a 2D cartoon hero for a short video: a layered chibi character on a skeleton, seen from the side. Reply with ONE JSON object — a hero recipe — and nothing else: no markdown, no comments.\n\n{ \"name\": string (≤ 40 chars, in the player's language), \"description\": string (≤ 300 chars),\n \"look\": { \"parts\": { \"<part type>\": \"<part id>\" | null, … }, \"colors\": { \"<colour target>\": \"#rrggbb\", … } },\n \"art\": { \"<picture path>\": { \"svg\": \"<svg markup without the svg wrapper>\" }, … } (optional) }\n\n- Pick parts ONLY from the lists below; null takes a part off where it is allowed. Choose colours that make the hero instantly readable.\n- \"art\" redraws a picture of a part you picked with your own SVG of exactly that canvas (width × height in px, y down): only <path>, <circle>, <ellipse>, <rect>, <line>, <polygon>, <polyline>, <g>, gradients and clipPath — no text, no images, no links, no scripts; ≤ 12000 characters each, ≤ 16 pictures. Draw in the same style: a heavy dark outline (#1f1a21, width 3–4), flat fills with one darker shade. Use it only when no part fits the idea (a chef's apron, a superhero suit).\n\nParts and pictures of the rig:\n${rig2dRecipeBrief(heroRig)}`;\n\n/** Картинки: стиль студии (style lock) — переменной, объект — переменной; свободного текста нет. */\nexport const IMAGE_ACTOR_TEMPLATE = `A single character or object for a cartoon video, full body, centered, facing right, plain flat light background, no text, no frame: {{var.subject}}. Style: {{var.style}}.`;\n\nexport const BACKGROUND_TEMPLATE = `A background scene for a vertical cartoon video, empty stage in the middle for characters, no people, no text, no watermark: {{var.subject}}. Style: {{var.style}}.`;\n\nexport const MUSIC_TEMPLATE = `A short seamless instrumental loop for a funny short video, no vocals: {{var.mood}}.`;\n\nexport const CLIP_TEMPLATE = `A short cinematic shot for a vertical video, no text: {{var.subject}}. Style: {{var.style}}.`;\n\n/** Подписи стиля для картинок (style lock): семейство → как его описать модели. */\nexport const STYLE_PROMPTS: Record<string, string> = {\n \"voxel-warm\":\n \"blocky voxel art, Minecraft-like cubes, warm soft light, pastel colours\",\n \"flat-cartoon\": \"flat 2D cartoon, bold outlines, bright flat colours\",\n \"chibi-2d\":\n \"cute chibi 2D cartoon, heavy dark outlines, cel shading, bright colours\",\n \"low-poly-3d\": \"low-poly 3D, simple faceted shapes, soft studio light\",\n \"photo-sticker\": \"sticker cut-out with a white outline, vivid colours\",\n};\n\n/** Что выгружается в ai-prompts.json для демо-настроек тайтла (фикстура движка читает этот файл). */\nexport function promptsBundle() {\n return {\n $comment:\n \"GENERATED by modules/video-studio (src/ai/prompts.test.ts) — do not edit by hand. The engine fixture VideoStudioStarterFixtures reads it to build the demo title's AI features (title-seed.json).\",\n [FEATURES.director]: {\n system: DIRECTOR_SYSTEM_PROMPT,\n template: DIRECTOR_TEMPLATE,\n variables: DIRECTOR_VARIABLES,\n schema: DIRECTOR_OUTPUT_SCHEMA,\n },\n [FEATURES.voxelCharacter]: { system: VOXEL_CHARACTER_SYSTEM_PROMPT },\n [FEATURES.rig2dCharacter]: { system: RIG2D_CHARACTER_SYSTEM_PROMPT },\n [FEATURES.image]: {\n template: IMAGE_ACTOR_TEMPLATE,\n variables: [\"subject\", \"style\"],\n },\n [FEATURES.background]: {\n template: BACKGROUND_TEMPLATE,\n variables: [\"subject\", \"style\"],\n },\n [FEATURES.music]: { template: MUSIC_TEMPLATE, variables: [\"mood\"] },\n [FEATURES.clip]: {\n template: CLIP_TEMPLATE,\n variables: [\"subject\", \"style\"],\n },\n };\n}\n"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"path": "ai/voice.ts",
|
|
85
|
+
"content": "// Озвучка реплик. Два голоса у каждого пресета: черновой (превью, дешёвый уровень, только английский)\n// и финальный (в MP4, понимает русский). Русский черновик читает сам браузер (Web Speech) —\n// бесплатно, только вживую, в файл не попадает. Повтор — бесплатно дважды:\n// • свой повтор: ключ генерации = голос + текст, движок отдаёт уже оплаченную запись;\n// • чужой повтор: у фич озвучки включено ShareIdenticalResults — та же реплика тем же голосом,\n// озвученная любым игроком тайтла, отдаётся из кеша тайтла за 0 (ремикс, тренд-шаблон).\n// Ремикс берёт озвучку родителя из VideoSpec: переозвучиваются только изменённые реплики.\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport {\n linesNeedingVoice,\n voiceKey,\n type Catalog,\n type Line,\n type VideoSpec,\n type VoiceAsset,\n} from \"@idosgames/timeline\";\nimport { FEATURES } from \"../ids\";\nimport { isCyrillic, NARRATOR_VOICE, voicePreset } from \"../library/voices\";\n\n/** Пресет голоса реплики: свой у реплики → у актёра в ролике → у ассета актёра → рассказчик. */\nexport function voiceOf(spec: VideoSpec, catalog: Catalog, line: Line): string {\n if (line.voice) return line.voice;\n if (line.who < 0) return NARRATOR_VOICE;\n const member = spec.cast[line.who];\n if (member?.voice) return member.voice;\n const asset = member ? catalog.get(member.asset) : undefined;\n return (asset?.kind === \"actor\" ? asset.voice : undefined) ?? NARRATOR_VOICE;\n}\n\n/** Реплики, которые надо озвучить для этого шага (черновик или финал). */\nexport function pendingLines(\n spec: VideoSpec,\n catalog: Catalog,\n final: boolean,\n): number[] {\n const all = linesNeedingVoice(spec, (l) => voiceOf(spec, catalog, l), final);\n // русский черновик читает браузер — серверу его не заказываем\n return final\n ? all\n : all.filter((i) => !isCyrillic(spec.lines[i]?.text ?? \"\"));\n}\n\n/** Реплики, которые в превью прочитает браузер (русский без серверной озвучки). */\nexport function browserLines(spec: VideoSpec): number[] {\n const out: number[] = [];\n spec.lines.forEach((l, i) => {\n if (!spec.assets.voice?.[String(i)] && isCyrillic(l.text)) out.push(i);\n });\n return out;\n}\n\nexport interface VoiceProgress {\n done: number;\n total: number;\n}\n\nexport interface VoiceOptions {\n final: boolean;\n optionID?: string;\n signal?: AbortSignal;\n onProgress?: (p: VoiceProgress) => void;\n /** Сколько реплик озвучивать одновременно. */\n parallel?: number;\n /** Длительность файла по адресу (по умолчанию — декодировать звук). */\n measure?: (url: string) => Promise<{ duration: number } | null>;\n}\n\nexport interface VoiceResult {\n spec: VideoSpec;\n /** Реплики, которые озвучить не вышло (остались с оценкой по длине). */\n failed: number[];\n /** Сколько реплик пришло бесплатно из кеша тайтла. */\n reused: number;\n error?: string;\n}\n\n/** Ключ генерации: один и тот же для того же голоса и текста — повтор не списывается дважды. */\nexport function voiceEntityID(\n final: boolean,\n voice: string,\n text: string,\n): string {\n return `${final ? \"f\" : \"d\"}${voiceKey(voice, text).slice(1)}`;\n}\n\nlet decoder: BaseAudioContext | null = null;\nasync function measureByDecode(\n url: string,\n): Promise<{ duration: number } | null> {\n try {\n const res = await fetch(url);\n if (!res.ok) return null;\n const buf = await res.arrayBuffer();\n decoder ??= new OfflineAudioContext(1, 1, 44100);\n const audio = await decoder.decodeAudioData(buf);\n return { duration: audio.duration };\n } catch {\n return null;\n }\n}\n\n/** Озвучить недостающие реплики. Возвращает НОВЫЙ VideoSpec с адресами и длительностями. */\nexport async function voiceLines(\n client: IDosGamesClient,\n spec: VideoSpec,\n catalog: Catalog,\n opts: VoiceOptions,\n): Promise<VoiceResult> {\n const todo = pendingLines(spec, catalog, opts.final);\n const voice: Record<string, VoiceAsset> = { ...(spec.assets.voice ?? {}) };\n const failed: number[] = [];\n let reused = 0;\n let done = 0;\n let error: string | undefined;\n const measure = opts.measure ?? measureByDecode;\n opts.onProgress?.({ done, total: todo.length });\n\n const one = async (i: number) => {\n const line = spec.lines[i];\n if (!line || opts.signal?.aborted) return;\n const presetId = voiceOf(spec, catalog, line);\n const preset = voicePreset(presetId);\n const name = opts.final ? preset.final : preset.draft;\n const started = await client.ai.generateAudio(\n opts.final ? FEATURES.voice : FEATURES.voiceDraft,\n { Text: line.text, Voice: name, Format: \"mp3\" },\n {\n relatedEntityID: voiceEntityID(opts.final, name, line.text),\n ...(opts.optionID && opts.final\n ? { selectedOptionID: opts.optionID }\n : {}),\n context: { line: i },\n },\n );\n const finished = started.ok\n ? await client.ai.waitForGeneration(started.data, {\n timeoutMs: 3 * 60_000,\n ...(opts.signal ? { signal: opts.signal } : {}),\n })\n : started;\n const url =\n finished.ok && finished.data.Status === \"completed\"\n ? finished.data.Assets?.find((a) => a.Url)?.Url\n : null;\n if (!url) {\n failed.push(i);\n if (!finished.ok) error ??= finished.error;\n else error ??= finished.data.Error?.Message ?? undefined;\n } else {\n if (finished.ok && finished.data.Reused) reused++;\n const m = await measure(url);\n voice[String(i)] = {\n url,\n duration: m?.duration ?? 0,\n key: voiceKey(presetId, line.text),\n ...(opts.final ? {} : { draft: true }),\n };\n }\n done++;\n opts.onProgress?.({ done, total: todo.length });\n };\n\n const queue = [...todo];\n const workers = Array.from(\n { length: Math.min(opts.parallel ?? 3, queue.length) },\n async () => {\n for (let i = queue.shift(); i !== undefined; i = queue.shift())\n await one(i);\n },\n );\n await Promise.all(workers);\n return {\n spec: { ...spec, assets: { ...spec.assets, voice } },\n failed: failed.sort((a, b) => a - b),\n reused,\n ...(error ? { error } : {}),\n };\n}\n\n/** Есть ли у ролика финальный голос на каждой реплике (без этого MP4 не собирается). */\nexport function hasFinalVoice(spec: VideoSpec, catalog: Catalog): boolean {\n return (\n linesNeedingVoice(spec, (l) => voiceOf(spec, catalog, l), true).length === 0\n );\n}\n"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"path": "content.ts",
|
|
89
|
+
"content": "// Ролики и ассеты в Мастерской. Публикация = файл \"main\" (JSON: VideoSpec + свои ассеты, которыми он\n// снят) + файлы \"media\" (то, что лежало у игрока data-адресами: фото, свой голос) + обложка-кадр.\n// Сгенерированное ИИ уже на CDN и едет ссылкой. Открытие — обратно: media → адреса blob, ассеты\n// ролика — в слой «ассеты этого ролика» каталога. Так чужой ролик со своим фото и ИИ-героем\n// играет и ремиксуется у любого игрока.\nimport type { Asset, VideoPackage, VideoSpec } from \"@idosgames/timeline\";\nimport { assetOk, parseVideoSpec, validateAsset } from \"@idosgames/timeline\";\n\nexport const MAIN = \"main\";\nexport const MEDIA = \"media\";\nconst MEDIA_REF = \"media:\";\nconst MAX_MEDIA = 10;\n\nexport interface PackedFile {\n role: string;\n contentType: string;\n data: Blob | string;\n}\n\nfunction dataUrlToBlob(url: string): Blob | null {\n const m = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(url);\n if (!m) return null;\n const type = m[1] ?? \"application/octet-stream\";\n if (!m[2]) return new Blob([decodeURIComponent(m[3] ?? \"\")], { type });\n const bin = atob(m[3] ?? \"\");\n const bytes = new Uint8Array(bin.length);\n for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return new Blob([bytes], { type });\n}\n\n/** Глубокая копия, где каждый data-адрес заменён на `media:N`, а байты собраны в список. */\nexport function extractMedia<T>(value: T, media: Blob[]): T {\n const walk = (v: unknown): unknown => {\n if (typeof v === \"string\" && v.startsWith(\"data:\")) {\n if (media.length >= MAX_MEDIA) return v;\n const blob = dataUrlToBlob(v);\n if (!blob) return v;\n media.push(blob);\n return `${MEDIA_REF}${media.length - 1}`;\n }\n if (Array.isArray(v)) return v.map(walk);\n if (v && typeof v === \"object\")\n return Object.fromEntries(\n Object.entries(v).map(([k, x]) => [k, walk(x)]),\n );\n return v;\n };\n return walk(value) as T;\n}\n\n/** Обратно: `media:N` → адрес N-го файла. */\nexport function restoreMedia<T>(value: T, urls: string[]): T {\n const walk = (v: unknown): unknown => {\n if (typeof v === \"string\" && v.startsWith(MEDIA_REF))\n return urls[Number(v.slice(MEDIA_REF.length))] ?? v;\n if (Array.isArray(v)) return v.map(walk);\n if (v && typeof v === \"object\")\n return Object.fromEntries(\n Object.entries(v).map(([k, x]) => [k, walk(x)]),\n );\n return v;\n };\n return walk(value) as T;\n}\n\n/** Свои ассеты, на которые ссылается ролик (own:/pack: — их нет в библиотеке у другого игрока). */\nexport function ownAssetsOf(\n spec: VideoSpec,\n get: (ref: string) => Asset | undefined,\n): Asset[] {\n const refs = new Set<string>([\n spec.set,\n ...(spec.music ? [spec.music] : []),\n ...spec.cast.map((c) => c.asset),\n ]);\n for (const c of spec.cast)\n for (const p of Object.values(c.props ?? {})) if (p) refs.add(p);\n const out: Asset[] = [];\n for (const ref of refs) {\n if (ref.startsWith(\"lib:\")) continue;\n const a = get(ref);\n if (a) out.push(a);\n }\n return out;\n}\n\nexport type { VideoPackage };\n\nexport function packVideo(pkg: VideoPackage): PackedFile[] {\n const media: Blob[] = [];\n const main = extractMedia(pkg, media);\n return [\n {\n role: MAIN,\n contentType: \"application/json\",\n data: JSON.stringify({ v: 1, ...main }),\n },\n ...media.map((b) => ({\n role: MEDIA,\n contentType: b.type || \"application/octet-stream\",\n data: b,\n })),\n ];\n}\n\nexport interface DownloadedFile {\n role: string;\n contentType: string;\n data: ArrayBuffer;\n}\n\n/**\n * Файлы media → data-адреса. Не blob: — ремикс чужого ролика сохраняется в «Мои ролики», и после\n * перезагрузки blob-адрес был бы мёртв, а data-адрес едет вместе с роликом (и при новой публикации\n * снова уходит файлом).\n */\nfunction mediaUrls(files: DownloadedFile[]): string[] {\n return files\n .filter((f) => f.role === MEDIA)\n .map((f) => {\n const bytes = new Uint8Array(f.data);\n let bin = \"\";\n for (let i = 0; i < bytes.length; i += 0x8000)\n bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));\n return `data:${f.contentType};base64,${btoa(bin)}`;\n });\n}\n\nfunction mainJson(files: DownloadedFile[]): unknown {\n const main = files.find((f) => f.role === MAIN);\n if (!main) return null;\n try {\n return JSON.parse(new TextDecoder().decode(main.data));\n } catch {\n return null;\n }\n}\n\nexport type Unpacked<T> = { ok: true; value: T } | { ok: false; error: string };\n\nexport function unpackVideo(files: DownloadedFile[]): Unpacked<VideoPackage> {\n const raw = mainJson(files) as { spec?: unknown; assets?: unknown } | null;\n if (!raw?.spec) return { ok: false, error: \"the publication has no video\" };\n const restored = restoreMedia(raw, mediaUrls(files));\n const spec = parseVideoSpec(restored.spec);\n if (!spec.ok)\n return { ok: false, error: spec.issues.map((i) => i.message).join(\"; \") };\n const assets = (Array.isArray(restored.assets) ? restored.assets : []).filter(\n (a): a is Asset => assetOk(validateAsset(a as Asset, { inline: true })),\n );\n return { ok: true, value: { spec: spec.spec, assets } };\n}\n\n/** Ассет или пакет ассетов: тот же формат, список. */\nexport function packAssets(title: string, assets: Asset[]): PackedFile[] {\n const media: Blob[] = [];\n const main = extractMedia({ v: 1, title, assets }, media);\n return [\n { role: MAIN, contentType: \"application/json\", data: JSON.stringify(main) },\n ...media.map((b) => ({\n role: MEDIA,\n contentType: b.type || \"application/octet-stream\",\n data: b,\n })),\n ];\n}\n\n/**\n * Ассеты из публикации. Чтобы два автора с одинаковым own:id не перетёрли друг друга, ref\n * переписывается в `pack:<contentID>/<id>` — ролики, снятые с ними, ссылаются на это имя.\n */\nexport function unpackAssets(\n files: DownloadedFile[],\n contentID: string,\n): Unpacked<Asset[]> {\n const raw = mainJson(files) as { assets?: unknown } | null;\n if (!raw || !Array.isArray(raw.assets))\n return { ok: false, error: \"the publication has no assets\" };\n const restored = restoreMedia(raw.assets as unknown[], mediaUrls(files));\n const out: Asset[] = [];\n for (const a of restored) {\n if (!assetOk(validateAsset(a as Asset, { inline: true }))) continue;\n const asset = a as Asset;\n const id = asset.ref.replace(/^[a-z]+:/, \"\").replace(/[^A-Za-z0-9_-]/g, \"\");\n out.push({ ...asset, ref: `pack:${contentID}/${id}`, tier: \"creator\" });\n }\n return out.length\n ? { ok: true, value: out }\n : { ok: false, error: \"no valid assets in the publication\" };\n}\n"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"path": "i18n.ts",
|
|
93
|
+
"content": "import { makeT, pickLanguage } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Video studio\",\n tabTrends: \"Trends\",\n tabCreate: \"Create\",\n tabMine: \"Mine\",\n tabChallenges: \"Challenges\",\n\n // Тренды\n formats: \"Formats\",\n readyVideos: \"Ready videos — free\",\n feed: \"Videos of the app\",\n sortPopular: \"Popular\",\n sortRemixed: \"Most remixed\",\n sortNew: \"New\",\n feedEmpty: \"No videos yet — be the first!\",\n watch: \"Watch\",\n remix: \"Remix\",\n make: \"Make one\",\n likes: \"likes\",\n remixes: \"remixes\",\n\n // Форматы\n \"f_dialogue@1\": \"Dialogue of two\",\n \"f_dialogue@1_d\":\n \"Two characters argue, joke or sort things out — a punchline at the end.\",\n \"f_story@1\": \"Story with a narrator\",\n \"f_story@1_d\":\n \"A narrator tells, the heroes act it out: tales, life hacks, 'how it was'.\",\n \"f_top5@1\": \"Top 5\",\n \"f_top5@1_d\":\n \"A countdown from the fifth to the first, a reaction on every place.\",\n \"f_quiz@1\": \"Quiz\",\n \"f_quiz@1_d\": \"Questions with options, a countdown and the answer.\",\n \"f_whatif@1\": \"What if\",\n \"f_whatif@1_d\":\n \"A crazy premise and growing consequences, a twist at the end.\",\n\n // Создание\n format: \"Format\",\n styleLabel: \"Style\",\n s_voxel: \"Voxels\",\n s_2d: \"2D cartoon\",\n idea: \"Your idea\",\n ideaHint_dialogue: \"A cat asks the boss for a raise…\",\n ideaHint_story: \"How the fox stole the cheese…\",\n ideaHint_top5: \"Top 5 laziest pets…\",\n ideaHint_quiz: \"A quiz about space…\",\n ideaHint_whatif: \"What if cats ruled the world?\",\n modeQuick: \"Quick\",\n modeQuickD: \"Ready characters and scenes — just the lines are new.\",\n modeUnique: \"Unique\",\n modeUniqueD:\n \"AI makes characters, scenes and music for your idea (estimate first).\",\n castCount: \"Characters\",\n castAuto: \"auto\",\n myPhoto: \"Add my photo as a character\",\n photoAdded: \"Photo added — it will be a character\",\n direct: \"Make the video\",\n directing: \"The director is writing the video…\",\n directFailed: \"The director could not make the video. Nothing was charged.\",\n noDirector: \"AI is off in this app — start from a ready video.\",\n needIdea: \"Write the idea first\",\n\n // Редактор\n untitled: \"My video\",\n play: \"Play\",\n pause: \"Pause\",\n slots: \"Slots\",\n tracks: \"Tracks\",\n finish: \"Finish\",\n cast: \"Characters\",\n scene: \"Scene\",\n music: \"Music\",\n noMusic: \"No music\",\n captions: \"Captions\",\n lines: \"Lines\",\n voice: \"Voice\",\n emotion: \"Emotion\",\n action: \"Action\",\n reaction: \"Reaction\",\n narrator: \"Narrator\",\n addLine: \"Add a line\",\n removeLine: \"Remove\",\n recordVoice: \"Record my voice\",\n stopRecord: \"Stop\",\n ownVoice: \"my voice\",\n ownVoiceStale: \"The text changed — record again\",\n change: \"Change\",\n locked: \"In a pack\",\n pickActor: \"Pick a character\",\n pickSet: \"Pick a scene\",\n pickMusic: \"Pick music\",\n addAt: \"Add at {t}\",\n camera: \"Camera\",\n effect: \"Effect\",\n sound: \"Sound\",\n particles: \"Particles\",\n text: \"Text\",\n textHint: \"Text on screen…\",\n add: \"Add\",\n edits: \"Your edits\",\n noEdits: \"No edits yet — the format decides everything.\",\n earlier: \"Earlier\",\n later: \"Later\",\n cantBuild: \"The video cannot be built: {why}\",\n draftVoice: \"Draft voice\",\n draftVoiceD: \"Free preview voice for the lines.\",\n voicing: \"Voicing {done}/{total}…\",\n\n // Смета и финиш\n estimate: \"Estimate\",\n estimateFree: \"Everything is from the library — free.\",\n freeSteps: \"{n} from the library — free\",\n generate: \"Make\",\n generateAll: \"Make everything\",\n generating: \"Making…\",\n generated: \"Ready — it is in My assets\",\n genFailed: \"Could not make it. Nothing was charged.\",\n unavailable: \"not available in this app\",\n k_voice: \"Final voice ×{n}\",\n \"k_voxel-character\": \"Voxel character\",\n \"k_rig2d-character\": \"2D hero\",\n \"k_image-actor\": \"Character from a picture\",\n k_prop: \"Prop\",\n k_background: \"Background\",\n k_music: \"Music\",\n \"k_three-d\": \"3D model\",\n k_clip: \"AI video insert\",\n finalVoice: \"Final voice\",\n finalVoiceD:\n \"The voice that goes into the file. Lines that did not change are not paid again.\",\n voiceReady: \"Final voice is ready\",\n exportMp4: \"Save MP4\",\n exporting: \"Saving the video… {p}%\",\n exported: \"The video is saved\",\n exportUnsupported:\n \"This browser cannot save video — share the live link instead.\",\n exportFailed: \"Could not save the video\",\n needFinalVoice: \"Make the final voice first\",\n watermarkOn: \"Free videos carry a watermark — VIP removes it.\",\n hd: \"HD 1080×1920\",\n publish: \"Publish\",\n publishing: \"Publishing…\",\n published: \"Published!\",\n update: \"Update the publication\",\n publishTitle: \"Title\",\n link: \"Live link\",\n copyLink: \"Copy the link\",\n linkCopied: \"The link is copied\",\n share: \"Share\",\n remixOf: \"A remix\",\n\n // Просмотр\n loadingVideo: \"Loading the video…\",\n videoFailed: \"Could not open the video\",\n like: \"Like\",\n back: \"Back\",\n\n // Мои\n myVideos: \"My videos\",\n myAssets: \"My assets\",\n noVideos: \"No videos yet.\",\n noAssets: \"Characters and scenes you make with AI or from a photo land here.\",\n open: \"Open\",\n delete: \"Delete\",\n deleteVideo: \"Delete this video?\",\n deleteAsset: \"Delete this asset?\",\n publishAsset: \"To the Workshop\",\n priceFree: \"Free\",\n pricePaid: \"For {n} Frames\",\n fromWorkshop: \"From the Workshop\",\n take: \"Take\",\n taken: \"Added to My assets\",\n publishedBadge: \"published\",\n\n // Челленджи\n weekly: \"Challenge of the week\",\n weeklyD:\n \"Every published video is a point. Top authors of the week get Frames.\",\n myScore: \"My videos this week: {n}\",\n boardEmpty: \"No one has published this week yet.\",\n quests: \"Quests\",\n vip: \"Studio VIP\",\n shop: \"Frames and packs\",\n ownStudio: \"Make your own studio\",\n ownStudioD:\n \"Remix this app: your studio with your characters, prices and audience.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Видеостудия\",\n tabTrends: \"Тренды\",\n tabCreate: \"Создать\",\n tabMine: \"Мои\",\n tabChallenges: \"Челленджи\",\n\n formats: \"Форматы\",\n readyVideos: \"Готовые ролики — бесплатно\",\n feed: \"Ролики приложения\",\n sortPopular: \"Популярное\",\n sortRemixed: \"Больше ремиксов\",\n sortNew: \"Новое\",\n feedEmpty: \"Роликов пока нет — будьте первым!\",\n watch: \"Смотреть\",\n remix: \"Ремикс\",\n make: \"Сделать\",\n likes: \"лайков\",\n remixes: \"ремиксов\",\n\n \"f_dialogue@1\": \"Диалог двоих\",\n \"f_dialogue@1_d\":\n \"Двое спорят, шутят или выясняют отношения — панчлайн в конце.\",\n \"f_story@1\": \"История с рассказчиком\",\n \"f_story@1_d\":\n \"Рассказчик ведёт, герои разыгрывают: сказки, лайфхаки, «как это было».\",\n \"f_top5@1\": \"Топ-5\",\n \"f_top5@1_d\": \"Рейтинг от пятого к первому, реакция героя на каждое место.\",\n \"f_quiz@1\": \"Квиз\",\n \"f_quiz@1_d\": \"Вопросы с вариантами, отсчёт и ответ.\",\n \"f_whatif@1\": \"Что если\",\n \"f_whatif@1_d\":\n \"Безумная посылка, нарастающие последствия, поворот в финале.\",\n\n format: \"Формат\",\n styleLabel: \"Стиль\",\n s_voxel: \"Воксели\",\n s_2d: \"2D-мульт\",\n idea: \"Ваша идея\",\n ideaHint_dialogue: \"Кот просит у начальника прибавку…\",\n ideaHint_story: \"Как лис украл сыр…\",\n ideaHint_top5: \"Топ-5 самых ленивых питомцев…\",\n ideaHint_quiz: \"Квиз про космос…\",\n ideaHint_whatif: \"Что если коты захватят мир?\",\n modeQuick: \"Быстро\",\n modeQuickD: \"Готовые герои и сцены — новые только реплики.\",\n modeUnique: \"Уникально\",\n modeUniqueD: \"ИИ сделает героев, сцены и музыку под идею (сначала смета).\",\n castCount: \"Персонажей\",\n castAuto: \"сам\",\n myPhoto: \"Добавить моё фото персонажем\",\n photoAdded: \"Фото добавлено — станет персонажем\",\n direct: \"Сделать ролик\",\n directing: \"Режиссёр пишет ролик…\",\n directFailed: \"Режиссёр не смог сделать ролик. Ничего не списано.\",\n noDirector: \"ИИ в этом приложении выключен — начните с готового ролика.\",\n needIdea: \"Сначала напишите идею\",\n\n untitled: \"Мой ролик\",\n play: \"Играть\",\n pause: \"Пауза\",\n slots: \"Слоты\",\n tracks: \"Дорожки\",\n finish: \"Готово\",\n cast: \"Персонажи\",\n scene: \"Сцена\",\n music: \"Музыка\",\n noMusic: \"Без музыки\",\n captions: \"Субтитры\",\n lines: \"Реплики\",\n voice: \"Голос\",\n emotion: \"Эмоция\",\n action: \"Действие\",\n reaction: \"Реакция\",\n narrator: \"Рассказчик\",\n addLine: \"Добавить реплику\",\n removeLine: \"Убрать\",\n recordVoice: \"Записать свой голос\",\n stopRecord: \"Стоп\",\n ownVoice: \"мой голос\",\n ownVoiceStale: \"Текст изменился — запишите заново\",\n change: \"Сменить\",\n locked: \"В пакете\",\n pickActor: \"Выберите персонажа\",\n pickSet: \"Выберите сцену\",\n pickMusic: \"Выберите музыку\",\n addAt: \"Добавить на {t}\",\n camera: \"Камера\",\n effect: \"Эффект\",\n sound: \"Звук\",\n particles: \"Частицы\",\n text: \"Надпись\",\n textHint: \"Текст на экране…\",\n add: \"Добавить\",\n edits: \"Ваши правки\",\n noEdits: \"Правок нет — всё решает формат.\",\n earlier: \"Раньше\",\n later: \"Позже\",\n cantBuild: \"Ролик не собирается: {why}\",\n draftVoice: \"Черновой голос\",\n draftVoiceD: \"Бесплатный голос для превью.\",\n voicing: \"Озвучка {done}/{total}…\",\n\n estimate: \"Смета\",\n estimateFree: \"Всё из библиотеки — бесплатно.\",\n freeSteps: \"{n} из библиотеки — бесплатно\",\n generate: \"Сделать\",\n generateAll: \"Сделать всё\",\n generating: \"Делаем…\",\n generated: \"Готово — лежит в «Моих ассетах»\",\n genFailed: \"Не получилось. Ничего не списано.\",\n unavailable: \"нет в этом приложении\",\n k_voice: \"Финальный голос ×{n}\",\n \"k_voxel-character\": \"Воксельный персонаж\",\n \"k_rig2d-character\": \"2D-герой\",\n \"k_image-actor\": \"Персонаж по картинке\",\n k_prop: \"Предмет\",\n k_background: \"Фон\",\n k_music: \"Музыка\",\n \"k_three-d\": \"3D-модель\",\n k_clip: \"ИИ-видеовставка\",\n finalVoice: \"Финальный голос\",\n finalVoiceD:\n \"Голос, который уйдёт в файл. Неизменённые реплики второй раз не оплачиваются.\",\n voiceReady: \"Финальный голос готов\",\n exportMp4: \"Сохранить MP4\",\n exporting: \"Сохраняем ролик… {p}%\",\n exported: \"Ролик сохранён\",\n exportUnsupported:\n \"Этот браузер не умеет сохранять видео — поделитесь живой ссылкой.\",\n exportFailed: \"Не удалось сохранить ролик\",\n needFinalVoice: \"Сначала сделайте финальный голос\",\n watermarkOn: \"На бесплатных роликах водяной знак — VIP его убирает.\",\n hd: \"HD 1080×1920\",\n publish: \"Опубликовать\",\n publishing: \"Публикуем…\",\n published: \"Опубликовано!\",\n update: \"Обновить публикацию\",\n publishTitle: \"Название\",\n link: \"Живая ссылка\",\n copyLink: \"Скопировать ссылку\",\n linkCopied: \"Ссылка скопирована\",\n share: \"Поделиться\",\n remixOf: \"Ремикс\",\n\n loadingVideo: \"Загружаем ролик…\",\n videoFailed: \"Не удалось открыть ролик\",\n like: \"Нравится\",\n back: \"Назад\",\n\n myVideos: \"Мои ролики\",\n myAssets: \"Мои ассеты\",\n noVideos: \"Роликов пока нет.\",\n noAssets: \"Сюда попадают персонажи и сцены, сделанные ИИ или из фото.\",\n open: \"Открыть\",\n delete: \"Удалить\",\n deleteVideo: \"Удалить ролик?\",\n deleteAsset: \"Удалить ассет?\",\n publishAsset: \"В Мастерскую\",\n priceFree: \"Бесплатно\",\n pricePaid: \"За {n} Кадров\",\n fromWorkshop: \"Из Мастерской\",\n take: \"Взять\",\n taken: \"Добавлено в «Мои ассеты»\",\n publishedBadge: \"опубликован\",\n\n weekly: \"Челлендж недели\",\n weeklyD:\n \"Каждый опубликованный ролик — очко. Лучшие авторы недели получают Кадры.\",\n myScore: \"Мои ролики за неделю: {n}\",\n boardEmpty: \"На этой неделе ещё никто не публиковал.\",\n quests: \"Квесты\",\n vip: \"VIP студии\",\n shop: \"Кадры и пакеты\",\n ownStudio: \"Сделать свою студию\",\n ownStudioD:\n \"Ремикс этого приложения: ваша студия с вашими героями, ценами и аудиторией.\",\n});\n\n/** Язык игрока для реплик и шаблонов. */\nexport const lang = (): \"en\" | \"ru\" => (pickLanguage() === \"ru\" ? \"ru\" : \"en\");\n\n/** «{n}» и другие подстановки. */\nexport function tf(\n key: keyof typeof EN,\n vars: Record<string, string | number>,\n): string {\n return Object.entries(vars).reduce(\n (s, [k, v]) => s.replaceAll(`{${k}}`, String(v)),\n t(key),\n );\n}\n\nexport type StudioKey = keyof typeof EN;\n"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"path": "ids.ts",
|
|
97
|
+
"content": "// Имена, на которых студия и настройки тайтла договариваются: ИИ-фичи, валюта, типы Мастерской,\n// метрики квестов, доска челленджа. Демо-настройки (title-seed.json, выгрузка фикстуры движка\n// VideoStudioStarterFixtures) называют их так же — меняется одно, меняется и другое.\n\nexport const FEATURES = {\n /** Скрытая текстовая фича: идея → VideoSpec + план по ступеням (JSON по схеме). */\n director: \"video-director\",\n /** Черновой голос превью (дешёвый уровень, общий кеш тайтла). */\n voiceDraft: \"video-voice-draft\",\n /** Финальный голос (понимает русский, общий кеш тайтла). */\n voice: \"video-voice\",\n /** Персонаж, предмет-вырезка. */\n image: \"video-image\",\n /** Фон сцены 9:16. */\n background: \"video-background\",\n /** Воксельный персонаж по описанию и картинкам (рецепт CharacterSpec). */\n voxelCharacter: \"voxel-character\",\n /** 2D-герой по описанию (рецепт рига: детали, цвета, свои картинки). */\n rig2dCharacter: \"rig2d-character\",\n music: \"video-music\",\n /** Кадр, который движок не нарисует (ступень 5, с подтверждением). */\n clip: \"video-clip\",\n} as const;\nexport type FeatureKey = (typeof FEATURES)[keyof typeof FEATURES];\n\n/** Валюта студии: 1 Кадр = цена 1 кредита у платформы (кредит покупки). */\nexport const FRAMES = \"FRAMES\";\n\nexport const CONTENT = {\n video: \"video.spec\",\n asset: \"video.asset\",\n pack: \"video.pack\",\n} as const;\n\n/** Метрики квестов, которые шлёт студия (по одной за раз). */\nexport const METRICS = {\n published: \"video_published\",\n remixed: \"video_remixed\",\n hero: \"hero_created\",\n} as const;\n\n/** Недельный челлендж: очко за опубликованный ролик (ClientScore). */\nexport const CHALLENGE_BOARD = \"weekly_creators\";\n\n/** VIP студии и его льготы (ключи Benefits в настройке Premium). */\nexport const VIP = \"studio_vip\";\nexport const VIP_BENEFITS = {\n noWatermark: \"no_watermark\",\n hd: \"hd_export\",\n premiumVoices: \"premium_voices\",\n} as const;\n\n/** Премиум-пакеты библиотеки — предметы каталога: есть предмет — пакет открыт. */\nexport const PACK_ITEMS = {\n fantasy: \"pack_fantasy\",\n space: \"pack_space\",\n} as const;\nexport const PACK_CATALOG = \"studio_packs\";\n"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"path": "index.ts",
|
|
101
|
+
"content": "// @idosgames/mod-video-studio — «Видеостудия»: игроки делают короткие вирусные ролики с озвучкой,\n// субтитрами по словам и музыкой из готовых персонажей и сцен или из ИИ-ассетов под идею.\n//\n// Главный экспорт — манифест модуля; части экспортируются тоже, чтобы проект мог встроить\n// проигрыватель роликов, режиссёра или смету в свои экраны после копирования в src/modules/.\n\nexport { videoStudioModule, MODE } from \"./module\";\nexport { Studio, type StudioView } from \"./studio\";\nexport * from \"./ids\";\nexport { createLibrary, LIBRARY_ACTORS, LIBRARY_SETS, VOICES } from \"./library\";\nexport {\n direct,\n settle,\n type DirectRequest,\n type Directed,\n} from \"./ai/director\";\nexport { voiceLines, pendingLines, hasFinalVoice, voiceOf } from \"./ai/voice\";\nexport { generateAsset } from \"./ai/assets\";\nexport { estimateVideo, featurePrice, type Estimate } from \"./model/pricing\";\nexport { accessOf, canPick, packOf } from \"./model/access\";\nexport { StudioCatalog } from \"./model/catalog\";\nexport { StudioStore } from \"./model/store\";\nexport { TEMPLATES, templateSpec } from \"./model/templates\";\nexport { createPlayer, captureFrame, exportVideo } from \"./render\";\nexport { PlayerView } from \"./ui/PlayerView\";\n"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"path": "library/actors.ts",
|
|
105
|
+
"content": "// Актёры библиотеки (быстрый режим): воксельные люди и звери (рецепты пакета персонажей) и\n// 2D-герои рига (пресеты со всем гардеробом). У каждого — метаданные, по которым AI-режиссёр выбирает\n// по id: название, теги, что умеет, голос по умолчанию. Премиум-актёры открывает предмет пакета.\nimport type { ActorAsset } from \"@idosgames/timeline\";\nimport {\n creatureCharacter,\n humanSource,\n parseCharacterSpec,\n type CreatureLook,\n type HumanLook,\n} from \"@idosgames/characters/voxel\";\nimport { HERO_PRESETS } from \"@idosgames/characters/rig2d\";\n\nconst TALKS = [\n \"talks\",\n \"walks\",\n \"dances\",\n \"jumps\",\n \"points\",\n \"waves\",\n \"sits\",\n \"falls\",\n];\nconst ANIMAL = [\"talks\", \"walks\", \"jumps\", \"dances\", \"sits\", \"falls\"];\n\nfunction voxelHuman(\n id: string,\n title: string,\n tags: string[],\n look: HumanLook,\n voice: string,\n tier: ActorAsset[\"tier\"] = \"base\",\n): ActorAsset {\n const r = parseCharacterSpec(humanSource({ ...look, name: title, tags }));\n if (!r.ok) throw new Error(`${id}: ${r.error}`);\n return {\n ref: `lib:${id}@1`,\n kind: \"actor\",\n style: \"voxel-warm\",\n title,\n tags,\n affordances: TALKS,\n tier,\n license: \"platform-standard\",\n voice,\n body: { type: \"voxel\", spec: r.spec },\n };\n}\n\n/** Рост зверей рядом с человеком 1.7: мультяшно крупнее жизни, но кот — не с человека. */\nconst CREATURE_HEIGHT: Partial<Record<CreatureLook[\"kind\"], number>> = {\n cat: 0.85,\n fox: 0.9,\n dog: 0.95,\n rabbit: 0.8,\n bear: 1.45,\n bird: 0.9,\n dragon: 1.5,\n slime: 0.8,\n ghost: 1.3,\n robot: 1.05,\n};\n\nfunction voxelCreature(\n id: string,\n title: string,\n tags: string[],\n look: Omit<CreatureLook, \"name\">,\n voice: string,\n tier: ActorAsset[\"tier\"] = \"base\",\n): ActorAsset {\n return {\n ref: `lib:${id}@1`,\n kind: \"actor\",\n style: \"voxel-warm\",\n title,\n tags: [look.kind, ...tags],\n affordances:\n look.kind === \"bird\" || look.kind === \"dragon\"\n ? [...ANIMAL, \"flies\"]\n : ANIMAL,\n tier,\n license: \"platform-standard\",\n voice,\n height: CREATURE_HEIGHT[look.kind] ?? 1,\n body: {\n type: \"voxel\",\n spec: creatureCharacter({ ...look, name: title, tags }),\n },\n };\n}\n\nexport const VOXEL_ACTORS: ActorAsset[] = [\n voxelHuman(\n \"office-guy\",\n \"Офисный работник\",\n [\"office\", \"man\", \"work\", \"офис\", \"мужчина\"],\n {\n top: \"shirt\",\n bottom: \"pants\",\n shoes: \"sneakers\",\n hair: \"short\",\n colors: { shirt: \"#a9b8c4\", pants: \"#4a5563\" },\n },\n \"voice:calm-man\",\n ),\n voxelHuman(\n \"office-girl\",\n \"Коллега\",\n [\"office\", \"woman\", \"work\", \"офис\", \"женщина\"],\n {\n top: \"sweater\",\n bottom: \"pants\",\n shoes: \"sneakers\",\n hair: \"long\",\n colors: { shirt: \"#d9a5b3\", pants: \"#3f4a5a\", hair: \"#6b4a2f\" },\n },\n \"voice:bright-woman\",\n ),\n voxelHuman(\n \"boss\",\n \"Начальник\",\n [\"boss\", \"office\", \"man\", \"начальник\", \"босс\"],\n {\n top: \"shirt\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"short\",\n colors: { shirt: \"#2f3a4a\", pants: \"#222831\", hair: \"#9a9690\" },\n },\n \"voice:deep-man\",\n ),\n voxelHuman(\n \"student\",\n \"Студент\",\n [\"student\", \"school\", \"teen\", \"студент\", \"школа\"],\n {\n top: \"tee\",\n bottom: \"shorts\",\n shoes: \"sneakers\",\n hair: \"curly\",\n colors: { shirt: \"#7fbf7f\", pants: \"#5a6f8a\" },\n },\n \"voice:young-man\",\n ),\n voxelHuman(\n \"grandma\",\n \"Бабушка\",\n [\"grandma\", \"family\", \"old\", \"бабушка\", \"семья\"],\n {\n top: \"sweater\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"curly\",\n colors: { shirt: \"#b48ead\", pants: \"#6b5a6e\", hair: \"#d8d2c4\" },\n },\n \"voice:wise-woman\",\n ),\n voxelHuman(\n \"farmer\",\n \"Фермер\",\n [\"farmer\", \"village\", \"man\", \"фермер\", \"деревня\"],\n {\n top: \"shirt\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"short\",\n hat: true,\n colors: { shirt: \"#c8a878\", pants: \"#5a6f8a\", hat: \"#d9c9a3\" },\n },\n \"voice:calm-man\",\n ),\n voxelHuman(\n \"chef\",\n \"Повар\",\n [\"chef\", \"kitchen\", \"food\", \"повар\", \"кухня\"],\n {\n top: \"tee\",\n bottom: \"pants\",\n shoes: \"sneakers\",\n hair: \"short\",\n hat: true,\n colors: { shirt: \"#f2f2ee\", pants: \"#2f2f35\", hat: \"#ffffff\" },\n },\n \"voice:comedian\",\n ),\n voxelHuman(\n \"astronaut\",\n \"Астронавт\",\n [\"astronaut\", \"space\", \"космонавт\", \"космос\"],\n {\n top: \"sweater\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"none\",\n colors: { shirt: \"#e6e6e6\", pants: \"#cfcfcf\", boots: \"#9a9a9a\" },\n },\n \"voice:narrator\",\n \"premium\",\n ),\n voxelHuman(\n \"knight-v\",\n \"Рыцарь\",\n [\"knight\", \"fantasy\", \"рыцарь\", \"фэнтези\"],\n {\n top: \"sweater\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"short\",\n colors: { shirt: \"#9aa5b1\", pants: \"#6b7480\", boots: \"#5a4636\" },\n },\n \"voice:deep-man\",\n \"premium\",\n ),\n voxelHuman(\n \"girl-hero\",\n \"Героиня\",\n [\"hero\", \"girl\", \"adventure\", \"героиня\", \"приключения\"],\n {\n top: \"tee\",\n bottom: \"shorts\",\n shoes: \"sneakers\",\n hair: \"long\",\n colors: { shirt: \"#ff8a65\", pants: \"#4f6d8a\", hair: \"#c27a48\" },\n },\n \"voice:bright-woman\",\n ),\n voxelCreature(\n \"fox\",\n \"Лис\",\n [\"fox\", \"animal\", \"sly\", \"лис\", \"лиса\", \"животное\"],\n {\n kind: \"fox\",\n body: \"#d9732e\",\n light: \"#f6e4cf\",\n accent: \"#3a2a20\",\n eyes: \"#2f5a2a\",\n },\n \"voice:comedian\",\n ),\n voxelCreature(\n \"cat\",\n \"Кот\",\n [\"cat\", \"animal\", \"pet\", \"кот\", \"кошка\", \"животное\"],\n {\n kind: \"cat\",\n body: \"#9a9690\",\n light: \"#e6e0d6\",\n accent: \"#6a5e58\",\n eyes: \"#7ab84a\",\n },\n \"voice:young-man\",\n ),\n voxelCreature(\n \"ginger-cat\",\n \"Рыжий кот\",\n [\"cat\", \"animal\", \"pet\", \"кот\", \"рыжий\"],\n {\n kind: \"cat\",\n body: \"#e0913a\",\n light: \"#f6dcb4\",\n accent: \"#a85a20\",\n eyes: \"#3f8f4a\",\n },\n \"voice:comedian\",\n ),\n voxelCreature(\n \"dog\",\n \"Пёс\",\n [\"dog\", \"animal\", \"pet\", \"собака\", \"пёс\", \"животное\"],\n {\n kind: \"dog\",\n body: \"#c8a878\",\n light: \"#efe2c8\",\n accent: \"#6a4a30\",\n eyes: \"#4a3020\",\n },\n \"voice:bright-man\",\n ),\n voxelCreature(\n \"bear\",\n \"Медведь\",\n [\"bear\", \"animal\", \"forest\", \"медведь\", \"лес\"],\n {\n kind: \"bear\",\n body: \"#7a5236\",\n light: \"#c89a72\",\n accent: \"#3a2618\",\n eyes: \"#2a1a10\",\n },\n \"voice:deep-man\",\n ),\n voxelCreature(\n \"rabbit\",\n \"Кролик\",\n [\"rabbit\", \"bunny\", \"animal\", \"кролик\", \"заяц\"],\n {\n kind: \"rabbit\",\n body: \"#f2ede6\",\n light: \"#f7c9d2\",\n accent: \"#d9a5b3\",\n eyes: \"#c0392b\",\n },\n \"voice:bright-woman\",\n ),\n voxelCreature(\n \"parrot\",\n \"Попугай\",\n [\"bird\", \"parrot\", \"попугай\", \"птица\"],\n {\n kind: \"bird\",\n body: \"#3fb35a\",\n light: \"#ffd84a\",\n accent: \"#e8462e\",\n eyes: \"#2a1a10\",\n },\n \"voice:comedian\",\n ),\n voxelCreature(\n \"dragon\",\n \"Дракончик\",\n [\"dragon\", \"fantasy\", \"дракон\", \"фэнтези\"],\n {\n kind: \"dragon\",\n body: \"#6a4fb3\",\n light: \"#c7b8f2\",\n accent: \"#ffcf33\",\n eyes: \"#ffb000\",\n },\n \"voice:young-man\",\n \"premium\",\n ),\n voxelCreature(\n \"slime\",\n \"Слизень\",\n [\"slime\", \"cute\", \"слизень\", \"милый\"],\n {\n kind: \"slime\",\n body: \"#5fd18a\",\n light: \"#c9f5d9\",\n accent: \"#2f8f5a\",\n eyes: \"#1f4a2f\",\n },\n \"voice:bright-woman\",\n ),\n voxelCreature(\n \"ghost\",\n \"Призрак\",\n [\"ghost\", \"spooky\", \"halloween\", \"призрак\", \"хэллоуин\"],\n {\n kind: \"ghost\",\n body: \"#eef1f7\",\n light: \"#dfe5f2\",\n accent: \"#9aa6c4\",\n eyes: \"#2a2f45\",\n },\n \"voice:whisper\",\n ),\n voxelCreature(\n \"robot\",\n \"Робот\",\n [\"robot\", \"tech\", \"scifi\", \"робот\", \"техника\"],\n {\n kind: \"robot\",\n body: \"#9aa6b5\",\n light: \"#7ad1ff\",\n accent: \"#ff5a5a\",\n eyes: \"#0b3a5a\",\n },\n \"voice:narrator\",\n ),\n];\n\n/** Русские подписи пресетов героя (метки ассетов — для поиска режиссёра на обоих языках). */\nconst HERO_TITLES: Record<string, string> = {\n knight: \"Рыцарь\",\n quarterback: \"Квотербек\",\n lumberjack: \"Дровосек\",\n viking: \"Викинг\",\n wizard: \"Волшебник\",\n ranger: \"Следопыт\",\n king: \"Король\",\n camper: \"Турист\",\n crusader: \"Крестоносец\",\n};\n\nconst HERO_VOICE: Record<string, string> = {\n wizard: \"voice:wise-man\",\n king: \"voice:deep-man\",\n viking: \"voice:deep-man\",\n quarterback: \"voice:bright-man\",\n};\n\nexport const RIG2D_ACTORS: ActorAsset[] = HERO_PRESETS.map((p, i) => ({\n ref: `lib:hero-${p.id}@1`,\n kind: \"actor\",\n style: \"chibi-2d\",\n title: HERO_TITLES[p.id] ?? p.label,\n tags: [\n p.id,\n p.label.toLowerCase(),\n \"hero\",\n \"2d\",\n (HERO_TITLES[p.id] ?? p.label).toLowerCase(),\n ],\n affordances: TALKS,\n // первые восемь — базовые, остальные — премиум\n tier: i < 8 ? \"base\" : \"premium\",\n license: \"platform-standard\",\n voice: HERO_VOICE[p.id] ?? (i % 2 ? \"voice:calm-man\" : \"voice:bright-man\"),\n height: 1.6,\n body: { type: \"rig2d\", look: p.look },\n}));\n\nexport const LIBRARY_ACTORS: ActorAsset[] = [...VOXEL_ACTORS, ...RIG2D_ACTORS];\n"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"path": "library/cdn.ts",
|
|
109
|
+
"content": "// Файлы библиотеки ПЛАТФОРМЫ: `cloud.idosgames.com/drive/library/{вид}/{id}.v{N}.{расширение}` — общие\n// для всех приложений, не принадлежат ни одному аккаунту. Список и источник каждого файла —\n// `library/files.json` в корне SDK (заливает `npm run push:library`, шаг 0 релиза); тест библиотеки\n// сверяет, что каждый адрес отсюда там есть. Файл версии не меняется: новый вид — новый `.v{N+1}`.\nexport const LIBRARY_CDN = \"https://cloud.idosgames.com/drive/library/\";\n\nexport const libraryFile = (key: string): string => LIBRARY_CDN + key;\n"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"path": "library/index.ts",
|
|
113
|
+
"content": "// Библиотека студии одним каталогом: актёры, сцены, музыка. Поверх неё студия кладёт «Мои ассеты»\n// и пакеты Мастерской (LayeredCatalog).\nimport { MemoryCatalog, type Asset } from \"@idosgames/timeline\";\nimport { LIBRARY_ACTORS } from \"./actors\";\nimport { LIBRARY_SETS } from \"./sets\";\nimport { LIBRARY_MUSIC } from \"./music\";\n\nexport { LIBRARY_ACTORS, VOXEL_ACTORS, RIG2D_ACTORS } from \"./actors\";\nexport { LIBRARY_SETS } from \"./sets\";\nexport { LIBRARY_MUSIC } from \"./music\";\nexport * from \"./voices\";\n\nexport function libraryAssets(): Asset[] {\n return [...LIBRARY_ACTORS, ...LIBRARY_SETS, ...LIBRARY_MUSIC];\n}\n\nexport function createLibrary(): MemoryCatalog {\n return new MemoryCatalog(libraryAssets());\n}\n"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"path": "library/music.ts",
|
|
117
|
+
"content": "// Музыка библиотеки: инструментальные петли ~30 с (сгенерированы 24.10.2026, music-cheap; файлы —\n// библиотека платформы `music/{id}.v1.mp3`). Длительность — настоящая длина файла (по кадрам mp3), настроение — то, по чему\n// режиссёр и редактор подбирают трек к идее.\nimport type { MusicAsset } from \"@idosgames/timeline\";\nimport { libraryFile } from \"./cdn\";\n\ninterface Loop {\n id: string;\n title: string;\n mood: string;\n tags: string[];\n duration: number;\n}\n\nconst LOOPS: Loop[] = [\n {\n id: \"upbeat\",\n title: \"Весёлый бит\",\n mood: \"upbeat, happy\",\n tags: [\"happy\", \"upbeat\", \"fun\", \"весело\", \"позитив\"],\n duration: 29.91,\n },\n {\n id: \"comedy-bounce\",\n title: \"Смешные прыжки\",\n mood: \"comedy, silly\",\n tags: [\"comedy\", \"funny\", \"silly\", \"смешно\", \"комедия\"],\n duration: 30.46,\n },\n {\n id: \"quiz-tension\",\n title: \"Интрига квиза\",\n mood: \"quiz, ticking tension\",\n tags: [\"quiz\", \"tension\", \"game show\", \"квиз\", \"викторина\", \"интрига\"],\n duration: 30.77,\n },\n {\n id: \"suspense\",\n title: \"Напряжение\",\n mood: \"suspense, mysterious\",\n tags: [\n \"suspense\",\n \"mystery\",\n \"detective\",\n \"напряжение\",\n \"тайна\",\n \"детектив\",\n ],\n duration: 28.73,\n },\n {\n id: \"epic\",\n title: \"Эпик\",\n mood: \"epic, orchestral\",\n tags: [\"epic\", \"battle\", \"hero\", \"эпик\", \"битва\", \"герой\"],\n duration: 30.77,\n },\n {\n id: \"chase\",\n title: \"Погоня\",\n mood: \"action, chase\",\n tags: [\"action\", \"chase\", \"fast\", \"экшен\", \"погоня\", \"быстро\"],\n duration: 30.01,\n },\n {\n id: \"romantic\",\n title: \"Романтика\",\n mood: \"romantic, warm\",\n tags: [\"romance\", \"love\", \"date\", \"романтика\", \"любовь\", \"свидание\"],\n duration: 28.71,\n },\n {\n id: \"sad-piano\",\n title: \"Грустное пианино\",\n mood: \"sad, melancholic\",\n tags: [\"sad\", \"drama\", \"tears\", \"грусть\", \"драма\", \"печаль\"],\n duration: 30.77,\n },\n {\n id: \"spooky\",\n title: \"Страшилка\",\n mood: \"spooky, playful\",\n tags: [\"spooky\", \"halloween\", \"horror\", \"страшилка\", \"хэллоуин\", \"ужасы\"],\n duration: 30.75,\n },\n {\n id: \"space-ambient\",\n title: \"Космос\",\n mood: \"space, dreamy ambient\",\n tags: [\"space\", \"dreamy\", \"calm\", \"космос\", \"мечта\", \"спокойно\"],\n duration: 30.22,\n },\n {\n id: \"tavern\",\n title: \"Таверна\",\n mood: \"medieval, folk dance\",\n tags: [\n \"medieval\",\n \"tavern\",\n \"fantasy\",\n \"folk\",\n \"средневековье\",\n \"таверна\",\n \"фэнтези\",\n ],\n duration: 30.77,\n },\n {\n id: \"ukulele\",\n title: \"Милое укулеле\",\n mood: \"cute, cheerful\",\n tags: [\"cute\", \"cheerful\", \"kids\", \"мило\", \"детское\", \"радость\"],\n duration: 30.77,\n },\n {\n id: \"lofi\",\n title: \"Спокойный лоу-фай\",\n mood: \"chill, lo-fi\",\n tags: [\"chill\", \"lofi\", \"calm\", \"study\", \"спокойно\", \"лоуфай\", \"учёба\"],\n duration: 30.77,\n },\n {\n id: \"dramatic-reveal\",\n title: \"Драматичное раскрытие\",\n mood: \"dramatic reveal\",\n tags: [\"dramatic\", \"reveal\", \"twist\", \"драма\", \"поворот\", \"раскрытие\"],\n duration: 28.92,\n },\n {\n id: \"victory\",\n title: \"Победа\",\n mood: \"victory, triumphant\",\n tags: [\"victory\", \"win\", \"celebration\", \"победа\", \"праздник\", \"триумф\"],\n duration: 30.77,\n },\n];\n\nexport const LIBRARY_MUSIC: MusicAsset[] = LOOPS.map((l) => ({\n ref: `lib:${l.id}@1`,\n kind: \"music\",\n // поле обязательно по контракту, но style lock музыку не отсекает (MemoryCatalog.list)\n style: \"flat-cartoon\",\n title: l.title,\n tags: [\"music\", \"loop\", ...l.tags],\n mood: l.mood,\n tier: \"base\",\n license: \"platform-standard\",\n url: libraryFile(`music/${l.id}.v1.mp3`),\n duration: l.duration,\n loop: true,\n}));\n"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"path": "library/sets.ts",
|
|
121
|
+
"content": "// Сцены библиотеки (быстрый режим): декорации данными — небо или стена градиентом, пол (у вокселей —\n// клеткой), пропсы коробками; якоря двух-трёх актёров и планы камеры, общие для всех сцен, поэтому\n// формат кладёт любой диалог на любую сцену. Вертикальный кадр 9:16 — главный, 16:9 тоже помещается.\nimport type { SetAsset, SetProp, SetSpec, V3 } from \"@idosgames/timeline\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { libraryFile } from \"./cdn\";\n\n// Вертикальный кадр узкий: двое стоят близко (±0.62), иначе на плане двоих их режет край кадра.\nconst ANCHORS: SetSpec[\"anchors\"] = {\n left: { x: -0.62, z: 0, facing: 1 },\n right: { x: 0.62, z: 0, facing: -1 },\n center: { x: 0, z: 0.2, facing: 1 },\n \"back-left\": { x: -1.3, z: -1.2, facing: 1 },\n \"back-right\": { x: 1.3, z: -1.2, facing: -1 },\n};\n\n// Планы под героя ~1.7 м с крупной (воксельной, чибишной) головой: средний — в полный рост, крупный —\n// голова и плечи. Камера чуть со стороны взгляда героя — лицо в три четверти, а не профиль и не затылок.\nconst SHOTS: SetSpec[\"shots\"] = {\n wide: { pos: [0, 1.4, 7.2], target: [0, 1.0, 0], fov: 40 },\n \"two-shot\": { pos: [0, 1.3, 5.6], target: [0, 1.0, 0], fov: 38 },\n left: { pos: [0.35, 1.3, 3.9], target: [-0.62, 1.0, 0], fov: 34 },\n right: { pos: [-0.35, 1.3, 3.9], target: [0.62, 1.0, 0], fov: 34 },\n \"close-left\": { pos: [0.2, 1.5, 3.0], target: [-0.62, 1.3, 0], fov: 32 },\n \"close-right\": { pos: [-0.2, 1.5, 3.0], target: [0.62, 1.3, 0], fov: 32 },\n center: { pos: [0.35, 1.3, 4.9], target: [0, 1.0, 0.2], fov: 34 },\n \"close-center\": { pos: [0.3, 1.5, 3.6], target: [0, 1.3, 0.2], fov: 32 },\n top: { pos: [0, 5.5, 3.2], target: [0, 0.6, 0], fov: 42 },\n};\n\nconst SAFE: SetSpec[\"safe\"] = {\n \"9:16\": { top: 0.1, bottom: 0.22 },\n \"16:9\": { top: 0.06, bottom: 0.1 },\n};\n\ninterface SetLook {\n id: string;\n title: string;\n style: StyleFamily;\n tags: string[];\n sky: [string, string];\n /** Нарисованный фон (CDN): пол уже на картинке, поэтому своего пола у такой сцены нет. */\n image?: string;\n ground?: string;\n checker?: string;\n props?: SetProp[];\n sun?: { color: string; intensity: number; dir?: V3 };\n ambient?: number;\n fog?: string;\n tier?: SetAsset[\"tier\"];\n description?: string;\n}\n\nconst box = (pos: V3, size: V3, color: string, rot?: V3): SetProp =>\n rot\n ? { kind: \"box\", pos, size, color, rot }\n : { kind: \"box\", pos, size, color };\n\nfunction makeSet(l: SetLook): SetAsset {\n return {\n ref: `lib:${l.id}@1`,\n kind: \"set\",\n style: l.style,\n title: l.title,\n tags: l.tags,\n ...(l.description ? { description: l.description } : {}),\n ...(l.image ? { preview: l.image } : {}),\n tier: l.tier ?? \"base\",\n license: \"platform-standard\",\n set: {\n background: l.image\n ? { image: l.image, gradient: l.sky }\n : { gradient: l.sky },\n ...(l.ground\n ? {\n ground: {\n color: l.ground,\n voxel: l.style === \"voxel-warm\",\n ...(l.checker ? { checker: l.checker } : {}),\n },\n }\n : {}),\n props: l.props ?? [],\n light: {\n ambient: l.ambient ?? 0.8,\n sun: {\n dir: l.sun?.dir ?? [-0.4, 1, 0.7],\n color: l.sun?.color ?? \"#fff4e0\",\n intensity: l.sun?.intensity ?? 1.1,\n },\n },\n anchors: ANCHORS,\n shots: SHOTS,\n safe: SAFE,\n ...(l.fog ? { fog: { color: l.fog, near: 9, far: 26 } } : {}),\n },\n };\n}\n\n// ── Воксели, тёплый свет ──────────────────────────────────────────────────────────────────────\nconst VOXEL: SetLook[] = [\n {\n id: \"office-day\",\n title: \"Офис днём\",\n style: \"voxel-warm\",\n tags: [\"office\", \"work\", \"indoor\", \"офис\", \"работа\"],\n sky: [\"#d9e6f2\", \"#f4efe4\"],\n ground: \"#b8a58a\",\n checker: \"#a8957a\",\n props: [\n box([-2.6, 0, -2.2], [1.6, 0.9, 0.8], \"#8a6a4a\"),\n box([2.6, 0, -2.2], [1.6, 0.9, 0.8], \"#8a6a4a\"),\n box([-2.6, 0.9, -2.3], [0.5, 0.4, 0.1], \"#2b2f36\"),\n box([0, 0, -3.6], [6, 2.6, 0.2], \"#e8e1d0\"),\n box([1.8, 1.4, -3.45], [1.2, 0.8, 0.05], \"#9fc6e8\"),\n ],\n },\n {\n id: \"office-night\",\n title: \"Офис ночью\",\n style: \"voxel-warm\",\n tags: [\"office\", \"night\", \"indoor\", \"офис\", \"ночь\"],\n sky: [\"#1b2236\", \"#3b3550\"],\n ground: \"#5a5060\",\n checker: \"#4e4556\",\n ambient: 0.5,\n sun: { color: \"#b8c4ff\", intensity: 0.7 },\n props: [\n box([-2.6, 0, -2.2], [1.6, 0.9, 0.8], \"#5a4636\"),\n box([-2.6, 0.9, -2.3], [0.5, 0.4, 0.1], \"#7ad1ff\"),\n box([0, 0, -3.6], [6, 2.6, 0.2], \"#2c2a3c\"),\n ],\n },\n {\n id: \"kitchen\",\n title: \"Кухня\",\n style: \"voxel-warm\",\n tags: [\"kitchen\", \"home\", \"food\", \"кухня\", \"дом\", \"еда\"],\n sky: [\"#fbe7c6\", \"#f6d7a7\"],\n ground: \"#c9a27a\",\n checker: \"#e3d2b8\",\n props: [\n box([0, 0, -2.8], [5, 1, 0.8], \"#e8e4dc\"),\n box([-1.4, 1, -2.9], [0.6, 0.5, 0.5], \"#c0392b\"),\n box([2.2, 0, -2.6], [1, 2.2, 0.9], \"#dfe6ea\"),\n ],\n },\n {\n id: \"forest\",\n title: \"Лес\",\n style: \"voxel-warm\",\n tags: [\"forest\", \"nature\", \"outdoor\", \"лес\", \"природа\"],\n sky: [\"#9fd3ff\", \"#e6f6ff\"],\n ground: \"#6aa84f\",\n checker: \"#5e9947\",\n fog: \"#d4ecf7\",\n props: [\n box([-2.8, 0, -2.5], [0.5, 2, 0.5], \"#7a5030\"),\n box([-2.8, 2, -2.5], [2, 1.6, 2], \"#3f8f3f\"),\n box([2.6, 0, -3.2], [0.5, 2.4, 0.5], \"#7a5030\"),\n box([2.6, 2.4, -3.2], [2.2, 1.8, 2.2], \"#4c9a46\"),\n box([0.6, 0, -1.6], [0.6, 0.3, 0.6], \"#8a8a8a\"),\n ],\n },\n {\n id: \"beach\",\n title: \"Пляж\",\n style: \"voxel-warm\",\n tags: [\"beach\", \"sea\", \"summer\", \"пляж\", \"море\", \"лето\"],\n sky: [\"#7fd0ff\", \"#ffe8b0\"],\n ground: \"#ecd59a\",\n checker: \"#e2c98a\",\n props: [\n box([0, 0, -4], [14, 0.2, 2], \"#3aa6d8\"),\n box([2.4, 0, -2], [0.2, 2.2, 0.2], \"#a0703c\"),\n box([2.4, 2.2, -2], [2.4, 0.2, 2.4], \"#ff6f61\"),\n ],\n },\n {\n id: \"city-street\",\n title: \"Улица города\",\n style: \"voxel-warm\",\n tags: [\"city\", \"street\", \"urban\", \"город\", \"улица\"],\n sky: [\"#b6d4ee\", \"#f2e7da\"],\n ground: \"#8a8f96\",\n checker: \"#7c8188\",\n props: [\n box([-3, 0, -3.5], [2, 4.5, 2], \"#c98f6a\"),\n box([3, 0, -3.8], [2.2, 5.5, 2], \"#9aa9b8\"),\n box([0, 0, -1.8], [8, 0.05, 0.3], \"#f2f2f2\"),\n box([1.6, 0, -1.2], [0.2, 2.4, 0.2], \"#3a3f47\"),\n ],\n },\n {\n id: \"space-station\",\n title: \"Космическая станция\",\n style: \"voxel-warm\",\n tags: [\"space\", \"scifi\", \"station\", \"космос\", \"фантастика\"],\n sky: [\"#05060f\", \"#1a1f40\"],\n ground: \"#5d6470\",\n checker: \"#4e5560\",\n ambient: 0.55,\n sun: { color: \"#9fd0ff\", intensity: 1 },\n props: [\n box([0, 0, -3.6], [6, 3, 0.3], \"#2c3340\"),\n box([0, 1.6, -3.4], [2.4, 1.2, 0.05], \"#0b0f22\"),\n box([-2.5, 0, -2.4], [0.8, 1.2, 0.8], \"#7d8a99\"),\n ],\n tier: \"premium\",\n },\n {\n id: \"mars\",\n title: \"Марс\",\n style: \"voxel-warm\",\n tags: [\"mars\", \"space\", \"planet\", \"марс\", \"космос\", \"планета\"],\n sky: [\"#d9875a\", \"#f2c29b\"],\n ground: \"#b5532c\",\n checker: \"#a74a26\",\n fog: \"#e8a57c\",\n props: [\n box([-2.6, 0, -3], [1.2, 0.8, 1.2], \"#8e3d1f\"),\n box([2.2, 0, -2.6], [0.8, 0.5, 0.8], \"#9a4422\"),\n box([3.4, 0, -4.5], [2.6, 1.6, 2], \"#a34a24\"),\n ],\n },\n {\n id: \"castle-hall\",\n title: \"Зал замка\",\n style: \"voxel-warm\",\n tags: [\"castle\", \"fantasy\", \"medieval\", \"замок\", \"фэнтези\"],\n sky: [\"#3a2f2a\", \"#6b5547\"],\n ground: \"#7a7268\",\n checker: \"#6c645b\",\n ambient: 0.6,\n sun: { color: \"#ffcf8a\", intensity: 1 },\n props: [\n box([0, 0, -3.8], [6, 3.4, 0.4], \"#8a8278\"),\n box([-2.4, 0, -3.2], [0.6, 3, 0.6], \"#9a9288\"),\n box([2.4, 0, -3.2], [0.6, 3, 0.6], \"#9a9288\"),\n box([0, 1.8, -3.55], [1.4, 1.6, 0.05], \"#b0262b\"),\n ],\n tier: \"premium\",\n },\n {\n id: \"school\",\n title: \"Класс\",\n style: \"voxel-warm\",\n tags: [\"school\", \"class\", \"study\", \"школа\", \"урок\"],\n sky: [\"#f5f0e1\", \"#e6dcc4\"],\n ground: \"#b48a5a\",\n checker: \"#a37c50\",\n props: [\n box([0, 0.6, -3.5], [3.2, 1.4, 0.1], \"#2f5d3a\"),\n box([-2.2, 0, -1.6], [1.2, 0.7, 0.7], \"#a0784c\"),\n box([2.2, 0, -1.6], [1.2, 0.7, 0.7], \"#a0784c\"),\n ],\n },\n {\n id: \"stage-show\",\n title: \"Сцена шоу\",\n style: \"voxel-warm\",\n tags: [\"show\", \"stage\", \"quiz\", \"tv\", \"шоу\", \"квиз\", \"тв\"],\n sky: [\"#2a0f3f\", \"#5a1d6e\"],\n ground: \"#3c2a4c\",\n checker: \"#4a3360\",\n ambient: 0.55,\n sun: { color: \"#ffd0ff\", intensity: 1.2 },\n props: [\n box([0, 0, -3.4], [5, 0.3, 1.4], \"#ffcf33\"),\n box([-2.6, 0, -3], [0.4, 3, 0.4], \"#ff4fa3\"),\n box([2.6, 0, -3], [0.4, 3, 0.4], \"#4fd1ff\"),\n ],\n },\n];\n\n// ── Плоский 2D-мульт (герои рига) ────────────────────────────────────────────────────────────\nconst FLAT: SetLook[] = [\n {\n id: \"flat-meadow\",\n title: \"Луг\",\n style: \"chibi-2d\",\n tags: [\"meadow\", \"nature\", \"day\", \"луг\", \"природа\"],\n sky: [\"#8fd3ff\", \"#e8f8ff\"],\n ground: \"#7cc66a\",\n props: [\n box([-2.8, 0, -3], [1.6, 1.6, 0.1], \"#4f9a45\"),\n box([2.8, 0, -3.2], [2, 2.2, 0.1], \"#5aa64f\"),\n ],\n },\n {\n id: \"flat-tavern\",\n title: \"Таверна\",\n style: \"chibi-2d\",\n tags: [\"tavern\", \"fantasy\", \"indoor\", \"таверна\", \"фэнтези\"],\n sky: [\"#5a3a28\", \"#8a5a3a\"],\n ground: \"#6a4a32\",\n ambient: 0.7,\n sun: { color: \"#ffc98a\", intensity: 1 },\n props: [\n box([0, 0, -3.2], [6, 2.8, 0.1], \"#7a5236\"),\n box([-2, 0, -2], [1.4, 0.8, 0.8], \"#5a3a24\"),\n ],\n },\n {\n id: \"flat-dungeon\",\n title: \"Подземелье\",\n style: \"chibi-2d\",\n tags: [\"dungeon\", \"fantasy\", \"dark\", \"подземелье\", \"фэнтези\"],\n sky: [\"#1c1a24\", \"#3a3646\"],\n ground: \"#4a4652\",\n ambient: 0.45,\n sun: { color: \"#9fb0ff\", intensity: 0.8 },\n props: [\n box([-2.4, 0, -3], [0.6, 3, 0.1], \"#5a5662\"),\n box([2.4, 0, -3], [0.6, 3, 0.1], \"#5a5662\"),\n ],\n tier: \"premium\",\n },\n {\n id: \"flat-city\",\n title: \"Город\",\n style: \"chibi-2d\",\n tags: [\"city\", \"street\", \"город\", \"улица\"],\n sky: [\"#a9d6ff\", \"#ffe9d2\"],\n ground: \"#9a9aa2\",\n props: [\n box([-2.8, 0, -3.2], [1.8, 3.6, 0.1], \"#e0a27a\"),\n box([2.8, 0, -3.4], [2, 4.4, 0.1], \"#8ab0d6\"),\n ],\n },\n {\n id: \"flat-sunset\",\n title: \"Закат\",\n style: \"chibi-2d\",\n tags: [\"sunset\", \"romance\", \"outdoor\", \"закат\", \"романтика\"],\n sky: [\"#ff8a5c\", \"#ffd29c\"],\n ground: \"#7a5a7a\",\n ambient: 0.7,\n sun: { color: \"#ffb27a\", intensity: 1 },\n },\n {\n id: \"flat-arena\",\n title: \"Арена\",\n style: \"chibi-2d\",\n tags: [\"arena\", \"fight\", \"battle\", \"арена\", \"битва\"],\n sky: [\"#c9a46a\", \"#f0d9a8\"],\n ground: \"#c2915a\",\n props: [box([0, 0, -3.6], [8, 1.2, 0.1], \"#8a6a44\")],\n },\n {\n id: \"flat-studio\",\n title: \"Студия\",\n style: \"chibi-2d\",\n tags: [\"studio\", \"show\", \"quiz\", \"neutral\", \"студия\", \"шоу\"],\n sky: [\"#7c5cff\", \"#b19cff\"],\n ground: \"#5a44c8\",\n },\n];\n\n// ── Нарисованные фоны (сгенерированы 24.10.2026, image-balanced 1K 9:16; файлы — библиотека платформы)\nconst PAINTED: SetLook[] = [\n {\n id: \"voxel-village\",\n title: \"Деревенская площадь\",\n style: \"voxel-warm\",\n tags: [\n \"village\",\n \"town\",\n \"market\",\n \"outdoor\",\n \"деревня\",\n \"площадь\",\n \"рынок\",\n ],\n sky: [\"#bfe3ff\", \"#f2e6d8\"],\n image: libraryFile(\"img/voxel-village.v1.webp\"),\n },\n {\n id: \"voxel-pirate-ship\",\n title: \"Палуба пиратского корабля\",\n style: \"voxel-warm\",\n tags: [\"pirate\", \"ship\", \"sea\", \"adventure\", \"пират\", \"корабль\", \"море\"],\n sky: [\"#8fd0ff\", \"#e8f4ff\"],\n image: libraryFile(\"img/voxel-pirate-ship.v1.webp\"),\n },\n {\n id: \"voxel-snow-mountain\",\n title: \"Снежные горы\",\n style: \"voxel-warm\",\n tags: [\"snow\", \"mountain\", \"winter\", \"outdoor\", \"снег\", \"горы\", \"зима\"],\n sky: [\"#cfe6ff\", \"#f4f8ff\"],\n image: libraryFile(\"img/voxel-snow-mountain.v1.webp\"),\n },\n {\n id: \"voxel-haunted-house\",\n title: \"Дом с привидениями\",\n style: \"voxel-warm\",\n tags: [\n \"haunted\",\n \"halloween\",\n \"night\",\n \"spooky\",\n \"призрак\",\n \"хэллоуин\",\n \"ночь\",\n \"страшилка\",\n ],\n sky: [\"#3a2f5a\", \"#b9a6d8\"],\n image: libraryFile(\"img/voxel-haunted-house.v1.webp\"),\n },\n {\n id: \"voxel-candy-land\",\n title: \"Конфетная страна\",\n style: \"voxel-warm\",\n tags: [\n \"candy\",\n \"sweets\",\n \"fantasy\",\n \"kids\",\n \"конфеты\",\n \"сладости\",\n \"сказка\",\n ],\n sky: [\"#ffd6ec\", \"#fff1d6\"],\n image: libraryFile(\"img/voxel-candy-land.v1.webp\"),\n },\n {\n id: \"voxel-underwater\",\n title: \"Подводный мир\",\n style: \"voxel-warm\",\n tags: [\"underwater\", \"sea\", \"ocean\", \"fish\", \"под водой\", \"море\", \"океан\"],\n sky: [\"#1f6fa8\", \"#7fd3e8\"],\n image: libraryFile(\"img/voxel-underwater.v1.webp\"),\n },\n {\n id: \"flat-classroom\",\n title: \"Класс\",\n style: \"chibi-2d\",\n tags: [\"school\", \"class\", \"study\", \"школа\", \"урок\", \"класс\"],\n sky: [\"#f5f0e1\", \"#e6c99a\"],\n image: libraryFile(\"img/flat-classroom.v1.webp\"),\n },\n {\n id: \"flat-space\",\n title: \"Космос\",\n style: \"chibi-2d\",\n tags: [\"space\", \"moon\", \"planet\", \"scifi\", \"космос\", \"луна\", \"планета\"],\n sky: [\"#0b0f2e\", \"#3a2f6e\"],\n image: libraryFile(\"img/flat-space.v1.webp\"),\n },\n {\n id: \"flat-jungle\",\n title: \"Джунгли\",\n style: \"chibi-2d\",\n tags: [\n \"jungle\",\n \"nature\",\n \"adventure\",\n \"ruins\",\n \"джунгли\",\n \"природа\",\n \"приключение\",\n ],\n sky: [\"#9fe0a8\", \"#e8f8d8\"],\n image: libraryFile(\"img/flat-jungle.v1.webp\"),\n },\n {\n id: \"flat-throne-hall\",\n title: \"Тронный зал\",\n style: \"chibi-2d\",\n tags: [\n \"castle\",\n \"king\",\n \"throne\",\n \"fantasy\",\n \"medieval\",\n \"замок\",\n \"король\",\n \"трон\",\n \"фэнтези\",\n ],\n sky: [\"#5a3a3a\", \"#c9a46a\"],\n image: libraryFile(\"img/flat-throne-hall.v1.webp\"),\n },\n {\n id: \"flat-kitchen\",\n title: \"Кухня дома\",\n style: \"chibi-2d\",\n tags: [\"kitchen\", \"home\", \"food\", \"cooking\", \"кухня\", \"дом\", \"еда\"],\n sky: [\"#fbe7c6\", \"#f6d7a7\"],\n image: libraryFile(\"img/flat-kitchen.v1.webp\"),\n },\n {\n id: \"flat-night-city\",\n title: \"Ночной город\",\n style: \"chibi-2d\",\n tags: [\n \"city\",\n \"night\",\n \"neon\",\n \"street\",\n \"urban\",\n \"город\",\n \"ночь\",\n \"неон\",\n \"улица\",\n ],\n sky: [\"#1a0f3f\", \"#6a2f9e\"],\n image: libraryFile(\"img/flat-night-city.v1.webp\"),\n },\n];\n\nexport const LIBRARY_SETS: SetAsset[] = [...VOXEL, ...FLAT, ...PAINTED].map(\n makeSet,\n);\n"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"path": "library/voices.ts",
|
|
125
|
+
"content": "// Голоса студии: пресет → голос чернового уровня (audio-cheap, только английский) и финального\n// (audio-balanced, 30+ языков, русский тоже). Черновик русской реплики озвучивает сам браузер\n// (Web Speech) — бесплатно, на устройстве; в MP4 уходит только финальный голос.\n\nexport interface VoicePreset {\n id: string;\n title: string;\n /** Голос уровня audio-cheap. */\n draft: string;\n /** Голос уровня audio-balanced. */\n final: string;\n /** Высота голоса браузерного черновика. */\n pitch: number;\n gender: \"male\" | \"female\" | \"neutral\";\n /** Премиум-голос: льгота VIP студии (premium_voices). */\n premium?: true;\n}\n\nexport const VOICES: VoicePreset[] = [\n {\n id: \"voice:narrator\",\n title: \"Рассказчик\",\n draft: \"am_adam\",\n final: \"English_expressive_narrator\",\n pitch: 1,\n gender: \"male\",\n },\n {\n id: \"voice:storyteller\",\n title: \"Сказочник\",\n draft: \"bm_fable\",\n final: \"English_CaptivatingStoryteller\",\n pitch: 0.95,\n gender: \"male\",\n premium: true,\n },\n {\n id: \"voice:calm-man\",\n title: \"Спокойный мужской\",\n draft: \"am_michael\",\n final: \"English_Trustworth_Man\",\n pitch: 0.95,\n gender: \"male\",\n },\n {\n id: \"voice:deep-man\",\n title: \"Низкий мужской\",\n draft: \"am_onyx\",\n final: \"English_ManWithDeepVoice\",\n pitch: 0.8,\n gender: \"male\",\n },\n {\n id: \"voice:bright-man\",\n title: \"Бодрый мужской\",\n draft: \"am_puck\",\n final: \"English_Jovialman\",\n pitch: 1.1,\n gender: \"male\",\n },\n {\n id: \"voice:young-man\",\n title: \"Молодой мужской\",\n draft: \"am_liam\",\n final: \"English_DecentYoungMan\",\n pitch: 1.05,\n gender: \"male\",\n },\n {\n id: \"voice:wise-man\",\n title: \"Мудрец\",\n draft: \"bm_george\",\n final: \"English_WiseScholar\",\n pitch: 0.85,\n gender: \"male\",\n },\n {\n id: \"voice:comedian\",\n title: \"Комик\",\n draft: \"am_puck\",\n final: \"English_Comedian\",\n pitch: 1.15,\n gender: \"male\",\n premium: true,\n },\n {\n id: \"voice:bright-woman\",\n title: \"Бодрый женский\",\n draft: \"af_bella\",\n final: \"English_Upbeat_Woman\",\n pitch: 1.15,\n gender: \"female\",\n },\n {\n id: \"voice:calm-woman\",\n title: \"Спокойный женский\",\n draft: \"af_sarah\",\n final: \"English_CalmWoman\",\n pitch: 1.05,\n gender: \"female\",\n },\n {\n id: \"voice:wise-woman\",\n title: \"Мудрая\",\n draft: \"af_sarah\",\n final: \"English_Wiselady\",\n pitch: 0.95,\n gender: \"female\",\n },\n {\n id: \"voice:whisper\",\n title: \"Шёпот\",\n draft: \"af_nicole\",\n final: \"English_Whispering_girl\",\n pitch: 1.1,\n gender: \"female\",\n premium: true,\n },\n {\n id: \"voice:anime\",\n title: \"Аниме\",\n draft: \"af_sky\",\n final: \"English_AnimeCharacter\",\n pitch: 1.3,\n gender: \"female\",\n premium: true,\n },\n];\n\nconst BY_ID = new Map(VOICES.map((v) => [v.id, v]));\n\nexport function voicePreset(id: string | undefined): VoicePreset {\n return (id && BY_ID.get(id)) || VOICES[0]!;\n}\n\n/** Голос рассказчика, когда формат читает реплики за кадром. */\nexport const NARRATOR_VOICE = \"voice:narrator\";\n\n/** Есть ли в тексте кириллица — тогда черновик читает браузер (у дешёвого уровня только английский). */\nexport const isCyrillic = (text: string) => /[Ѐ-ӿ]/.test(text);\n"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"path": "media/own.ts",
|
|
129
|
+
"content": "// Свои медиа игрока — ступень лестницы за 0, всё на устройстве: фото (себя, кота, логотип) становится\n// актёром-вырезкой с белой обводкой стикера, свой голос с микрофона — озвучкой реплики. В ролике\n// они лежат data-адресами; при публикации уходят в Мастерскую отдельными файлами (content.ts).\nimport type { ActorAsset, VoiceAsset } from \"@idosgames/timeline\";\nimport { voiceKey } from \"@idosgames/timeline\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { newId } from \"../model/store\";\n\nconst MAX_SIDE = 768;\n\nfunction readFile(file: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.onerror = () => reject(r.error);\n r.readAsDataURL(file);\n });\n}\n\nfunction loadImage(src: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const img = new Image();\n img.onload = () => resolve(img);\n img.onerror = () => reject(new Error(\"not an image\"));\n img.src = src;\n });\n}\n\n/** Картинка → уменьшенный data URL (PNG, если есть прозрачность, иначе JPEG). */\nexport async function shrinkImage(file: Blob): Promise<string> {\n const img = await loadImage(await readFile(file));\n const k = Math.min(\n 1,\n MAX_SIDE / Math.max(img.naturalWidth, img.naturalHeight),\n );\n const c = document.createElement(\"canvas\");\n c.width = Math.max(1, Math.round(img.naturalWidth * k));\n c.height = Math.max(1, Math.round(img.naturalHeight * k));\n const g = c.getContext(\"2d\");\n if (!g) throw new Error(\"no canvas\");\n g.drawImage(img, 0, 0, c.width, c.height);\n const png = file.type === \"image/png\" || file.type === \"image/webp\";\n return png ? c.toDataURL(\"image/png\") : c.toDataURL(\"image/jpeg\", 0.86);\n}\n\n/** Фото игрока → актёр-вырезка «Моих ассетов». */\nexport async function photoActor(\n file: Blob,\n title: string,\n style: StyleFamily,\n): Promise<ActorAsset> {\n const image = await shrinkImage(file);\n return {\n ref: `own:${newId()}`,\n kind: \"actor\",\n style,\n title: title.slice(0, 60) || \"Photo\",\n tags: [\"photo\", \"own\", \"cutout\"],\n affordances: [\"talks\", \"jumps\", \"walks\", \"falls\"],\n tier: \"creator\",\n license: \"creator\",\n height: 1.4,\n body: { type: \"cutout\", image, outline: true },\n };\n}\n\n/** Запись голоса с микрофона, пока не вызвали stop(). */\nexport interface VoiceRecording {\n stop(): Promise<Blob>;\n cancel(): void;\n}\n\nexport async function recordVoice(): Promise<VoiceRecording> {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: { echoCancellation: true, noiseSuppression: true },\n });\n const type =\n [\"audio/webm;codecs=opus\", \"audio/ogg;codecs=opus\", \"audio/webm\"].find(\n (t) => MediaRecorder.isTypeSupported(t),\n ) ?? \"\";\n const rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined);\n const chunks: Blob[] = [];\n rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);\n rec.start();\n const release = () => stream.getTracks().forEach((t) => t.stop());\n return {\n stop: () =>\n new Promise((resolve) => {\n rec.onstop = () => {\n release();\n resolve(new Blob(chunks, { type: rec.mimeType || \"audio/webm\" }));\n };\n rec.stop();\n }),\n cancel: () => {\n rec.onstop = release;\n rec.stop();\n },\n };\n}\n\n/**\n * Голос реплики «свой»: ставится в `line.voice` вместе с записью — ключ озвучки совпадает, и сервер\n * реплику не переозвучивает. Поменял текст — запись больше не подходит, студия просит перезаписать.\n */\nexport const OWN_VOICE = \"own\";\n\n/** Записанный голос → озвучка реплики. */\nexport async function ownVoice(blob: Blob, text: string): Promise<VoiceAsset> {\n const url = await readFile(blob);\n let duration = 0;\n try {\n const audio = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(\n await blob.arrayBuffer(),\n );\n duration = audio.duration;\n } catch {\n duration = 0;\n }\n return { url, duration, key: voiceKey(OWN_VOICE, text) };\n}\n"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"path": "model/access.ts",
|
|
133
|
+
"content": "// Что открыто игроку: VIP студии (без водяного знака, HD, премиум-голоса) и премиум-пакеты\n// библиотеки (предмет каталога в инвентаре — пакет открыт). Решает сервер: VIP и предмет выдаёт\n// он, здесь только читается состояние игрока. Ролик с закрытым ассетом другого автора смотреть и\n// ремиксовать можно — закрыт только ВЫБОР такого ассета в своём ролике.\nimport type { Asset } from \"@idosgames/timeline\";\nimport { PACK_ITEMS, VIP } from \"../ids\";\n\nexport interface PlayerStateView {\n Premium?: {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null | undefined\n > | null;\n } | null;\n InventoryV2?: {\n Items?: Record<string, { TotalAmount?: number } | null | undefined> | null;\n } | null;\n}\n\nexport interface Access {\n vip: boolean;\n packs: ReadonlySet<string>;\n}\n\nexport function accessOf(\n state: PlayerStateView | null | undefined,\n nowMs: number,\n): Access {\n const until = state?.Premium?.Subscriptions?.[VIP]?.ExpirationDate;\n const vip = !!until && new Date(until).getTime() > nowMs;\n const packs = new Set<string>();\n for (const id of Object.values(PACK_ITEMS))\n if (Number(state?.InventoryV2?.Items?.[id]?.TotalAmount ?? 0) > 0)\n packs.add(id);\n return { vip, packs };\n}\n\n/** Каким пакетом открывается премиум-ассет библиотеки: космос — свой пакет, остальное — фэнтези. */\nexport function packOf(asset: Asset): string | null {\n if (asset.tier !== \"premium\") return null;\n return asset.tags.some(\n (t) => t === \"space\" || t === \"scifi\" || t === \"космос\",\n )\n ? PACK_ITEMS.space\n : PACK_ITEMS.fantasy;\n}\n\n/** Можно ли выбрать ассет в своём ролике. Свои, Мастерской и базовые — всегда; премиум — с его пакетом. */\nexport function canPick(asset: Asset, access: Access): boolean {\n const pack = packOf(asset);\n return !pack || access.packs.has(pack);\n}\n"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"path": "model/catalog.ts",
|
|
137
|
+
"content": "// Каталог студии одним объектом: что ролик может назвать по ref. Слои (первый нашедший побеждает):\n// 1. ассеты открытого чужого ролика (пришли с публикацией — его фото, его ИИ-герои);\n// 2. «Мои ассеты» (own:… — сделанное игроком, и pack:… — взятое в Мастерской);\n// 3. библиотека студии (lib:…).\n// Режиссёру уходит только выбираемое игроком: базовое и открытые пакеты, в стиле ролика.\nimport {\n catalogBrief,\n LayeredCatalog,\n MemoryCatalog,\n type Asset,\n type AssetFilter,\n type Catalog,\n} from \"@idosgames/timeline\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { canPick, type Access } from \"./access\";\n\nexport class StudioCatalog implements Catalog {\n readonly video = new MemoryCatalog();\n readonly mine = new MemoryCatalog();\n private readonly all: LayeredCatalog;\n\n constructor(readonly library: MemoryCatalog) {\n this.all = new LayeredCatalog([this.video, this.mine, library]);\n }\n\n get(ref: string): Asset | undefined {\n return this.all.get(ref);\n }\n\n list(filter?: AssetFilter): Asset[] {\n return this.all.list(filter);\n }\n\n /** То, что игрок может поставить в свой ролик (без ассетов чужого ролика и закрытых пакетов). */\n pickable(access: Access, filter?: AssetFilter): Asset[] {\n const own = new LayeredCatalog([this.mine, this.library]);\n return own.list(filter).filter((a) => canPick(a, access));\n }\n\n /** Каталог для промпта режиссёра: выбираемое, в стиле (style lock), одной строкой на ассет. */\n brief(access: Access, style: StyleFamily | null): string {\n const usable = new MemoryCatalog(this.pickable(access));\n return catalogBrief(usable, style, 220);\n }\n\n /** Ассеты чужого ролика: заменить набором, пришедшим с публикацией. */\n setVideoAssets(assets: Asset[]): void {\n for (const a of this.video.list()) this.video.remove(a.ref);\n for (const a of assets) this.video.add(a);\n }\n}\n\n/** Ближайший ассет того же вида: по словам ref и заданного описания, в стиле ролика, открытый игроку. */\nexport function closestAsset(\n catalog: StudioCatalog,\n access: Access,\n kind: Asset[\"kind\"],\n hint: string,\n style: StyleFamily | null,\n): Asset | undefined {\n const words = hint\n .replace(/^[a-z]+:/i, \"\")\n .replace(/@\\d+$/, \"\")\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter((w) => w.length > 2)\n .join(\" \");\n const inStyle = (text?: string) =>\n catalog.pickable(access, {\n kind,\n ...(style ? { style } : {}),\n ...(text ? { text } : {}),\n });\n const found = inStyle(words);\n // при равном счёте — тот, чьё имя (ref) совпало со словом подсказки: «wizard» → hero-wizard\n const named = found.find((a) =>\n words\n .split(\" \")\n .some((w) => w && a.ref.toLowerCase().includes(w.toLowerCase())),\n );\n return (\n named ?? found[0] ?? inStyle()[0] ?? catalog.pickable(access, { kind })[0]\n );\n}\n"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"path": "model/edit.ts",
|
|
141
|
+
"content": "// Правки ролика — чистые функции «старый VideoSpec → новый». Ручные правки игрока (камера, эффект,\n// сдвиг реплики) лежат в overrides поверх формата: сменил формат или героя — правки остались.\n// Реплики адресуются индексом, поэтому вставка и удаление переиндексируют и озвучку, и правки —\n// иначе голос одной реплики поехал бы на соседнюю.\nimport type {\n Line,\n Override,\n VideoSpec,\n VoiceAsset,\n} from \"@idosgames/timeline\";\nimport { SPEC_LIMITS } from \"@idosgames/timeline\";\n\nexport function setCast(\n spec: VideoSpec,\n i: number,\n patch: Partial<VideoSpec[\"cast\"][number]>,\n): VideoSpec {\n const cast = spec.cast.map((c, j) => (j === i ? { ...c, ...patch } : c));\n return { ...spec, cast };\n}\n\nexport function setLine(\n spec: VideoSpec,\n i: number,\n patch: Partial<Line>,\n): VideoSpec {\n return {\n ...spec,\n lines: spec.lines.map((l, j) => (j === i ? clean({ ...l, ...patch }) : l)),\n };\n}\n\n/** Пустые поля реплики не храним: «без бита» — значит формат решает сам. */\nfunction clean(l: Line): Line {\n const out = { ...l };\n for (const k of [\"beat\", \"react\", \"emotion\", \"voice\"] as const)\n if (!out[k]) delete out[k];\n return out;\n}\n\nconst lineOf = (o: Override): number | null => (\"line\" in o ? o.line : null);\n\nfunction reindex(\n spec: VideoSpec,\n map: (i: number) => number | null,\n): Pick<VideoSpec, \"assets\" | \"overrides\"> {\n const voice: Record<string, VoiceAsset> = {};\n for (const [k, a] of Object.entries(spec.assets.voice ?? {})) {\n const to = map(Number(k));\n if (to !== null) voice[String(to)] = a;\n }\n const overrides: Override[] = [];\n for (const o of spec.overrides) {\n const l = lineOf(o);\n if (l === null) overrides.push(o);\n else {\n const to = map(l);\n if (to !== null) overrides.push({ ...o, line: to } as Override);\n }\n }\n return { assets: { ...spec.assets, voice }, overrides };\n}\n\nexport function insertLine(spec: VideoSpec, at: number, line: Line): VideoSpec {\n if (spec.lines.length >= SPEC_LIMITS.lines) return spec;\n const lines = [...spec.lines.slice(0, at), line, ...spec.lines.slice(at)];\n return { ...spec, lines, ...reindex(spec, (i) => (i >= at ? i + 1 : i)) };\n}\n\nexport function removeLine(spec: VideoSpec, at: number): VideoSpec {\n if (spec.lines.length <= 1) return spec;\n const lines = spec.lines.filter((_, i) => i !== at);\n return {\n ...spec,\n lines,\n ...reindex(spec, (i) => (i === at ? null : i > at ? i - 1 : i)),\n };\n}\n\nexport function addOverride(spec: VideoSpec, o: Override): VideoSpec {\n if (spec.overrides.length >= SPEC_LIMITS.overrides) return spec;\n return { ...spec, overrides: [...spec.overrides, o] };\n}\n\nexport function removeOverride(spec: VideoSpec, index: number): VideoSpec {\n return { ...spec, overrides: spec.overrides.filter((_, i) => i !== index) };\n}\n\n/** Сдвиг реплики раньше/позже: одна правка shift на реплику, шаги складываются. */\nexport function shiftLine(\n spec: VideoSpec,\n line: number,\n delta: number,\n): VideoSpec {\n const idx = spec.overrides.findIndex(\n (o) => o.kind === \"shift\" && o.line === line,\n );\n if (idx < 0) return addOverride(spec, { kind: \"shift\", line, delay: delta });\n const cur = spec.overrides[idx] as Extract<Override, { kind: \"shift\" }>;\n const delay = Math.round((cur.delay + delta) * 100) / 100;\n const overrides = spec.overrides\n .map((o, i) => (i === idx ? { ...cur, delay } : o))\n .filter((o) => !(o.kind === \"shift\" && o.delay === 0));\n return { ...spec, overrides };\n}\n\n/** Убрать актёра: его реплики — первому (или рассказчику, если формат умеет). */\nexport function removeCast(\n spec: VideoSpec,\n i: number,\n narrator: boolean,\n): VideoSpec {\n if (spec.cast.length <= 1) return spec;\n const cast = spec.cast.filter((_, j) => j !== i);\n const lines = spec.lines.map((l) =>\n l.who === i\n ? { ...l, who: narrator ? -1 : 0 }\n : l.who > i\n ? { ...l, who: l.who - 1 }\n : l,\n );\n // правки, привязанные к актёру (поза, частицы на нём), едут вместе с индексом\n const overrides: Override[] = [];\n for (const o of spec.overrides) {\n if (\n (o.kind === \"pose\" || o.kind === \"particles\") &&\n o.actor !== undefined\n ) {\n if (o.actor === i) continue;\n overrides.push(o.actor > i ? { ...o, actor: o.actor - 1 } : o);\n } else overrides.push(o);\n }\n return { ...spec, cast, lines, overrides };\n}\n"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"path": "model/pricing.ts",
|
|
145
|
+
"content": "// Смета ролика ДО платных шагов: каждая ступень лестницы — ИИ-фича тайтла со своей ценой\n// (PriceOptions видны клиенту), библиотека, комбинация и свои медиа — бесплатно. Цена берётся из\n// настроек тайтла, не из кода: издатель меняет её в LiveOps, смета следует сама. Окончательно\n// списывает сервер — смета только показывает, сколько это будет.\nimport { costLines, mergeLines, type ResourceLine } from \"@idosgames/react/ui\";\nimport type { AIAssetKind, PlanItem } from \"@idosgames/timeline\";\nimport { FEATURES, type FeatureKey } from \"../ids\";\n\n/** Секция AI тайтла, насколько её видит смета. */\nexport interface AIFeaturesView {\n Enabled?: boolean | null;\n Features?: Record<\n string,\n {\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Какая ИИ-фича делает ассет ступени ai-asset. 3D по описанию — закрытый уровень, его нет. */\nexport const FEATURE_OF_KIND: Record<AIAssetKind, FeatureKey | null> = {\n \"voxel-character\": FEATURES.voxelCharacter,\n \"rig2d-character\": FEATURES.rig2dCharacter,\n \"image-actor\": FEATURES.image,\n prop: FEATURES.image,\n background: FEATURES.background,\n music: FEATURES.music,\n \"three-d\": null,\n};\n\nexport interface FeaturePrice {\n /** Есть ли фича на тайтле вообще. */\n exists: boolean;\n /** Способ оплаты, которым смета считает (первый, где цена в ресурсах). */\n optionID?: string;\n /** Цена одного запуска; пусто — бесплатно игроку. */\n price: ResourceLine[];\n}\n\nexport function featurePrice(\n ai: AIFeaturesView | null | undefined,\n key: string,\n): FeaturePrice {\n const f = ai?.Enabled === false ? null : ai?.Features?.[key];\n if (!f) return { exists: false, price: [] };\n for (const [oid, o] of Object.entries(f.PriceOptions ?? {})) {\n if (!o) continue;\n const price = costLines(o.Cost as never).filter((l) => l.kind !== \"other\");\n if (price.length)\n return { exists: true, optionID: o.OptionID || oid, price };\n }\n return { exists: true, price: [] };\n}\n\nexport type EstimateKind = \"director\" | \"voice\" | \"clip\" | AIAssetKind;\n\nexport interface EstimateRow {\n kind: EstimateKind;\n /** Элемент ролика (`cast[0]`, `set`, `line:3`…). */\n slot: string;\n feature: FeatureKey;\n count: number;\n /** Цена за штуку. */\n unit: ResourceLine[];\n /** Фичи на тайтле нет — шаг невозможен, студия остаётся на ближайшем из библиотеки. */\n unavailable?: boolean;\n optionID?: string;\n}\n\nexport interface Estimate {\n rows: EstimateRow[];\n total: ResourceLine[];\n /** Сколько элементов взято бесплатно (библиотека, комбинация, свои медиа). */\n freeSteps: number;\n}\n\nconst times = (lines: ResourceLine[], n: number): ResourceLine[] =>\n lines.map((l) => ({ ...l, amount: l.amount * n }));\n\n/**\n * Смета: платные пункты плана (ИИ-ассеты, видеовставки) + финальная озвучка `voiceLines` реплик.\n * `includeClips` — видеовставки игрок подтверждает отдельно, без подтверждения их в смете нет.\n */\nexport function estimateVideo(args: {\n ai: AIFeaturesView | null | undefined;\n plan: PlanItem[];\n voiceLines: number;\n includeClips?: boolean;\n}): Estimate {\n const rows: EstimateRow[] = [];\n let freeSteps = 0;\n for (const item of args.plan) {\n if (item.step === \"ai-asset\" && item.generate) {\n const feature = FEATURE_OF_KIND[item.generate.kind];\n const p = feature\n ? featurePrice(args.ai, feature)\n : { exists: false, price: [] };\n rows.push({\n kind: item.generate.kind,\n slot: item.slot,\n feature: feature ?? FEATURES.image,\n count: 1,\n unit: p.price,\n ...(p.exists ? {} : { unavailable: true }),\n ...(p.optionID ? { optionID: p.optionID } : {}),\n });\n } else if (item.step === \"ai-clip\") {\n if (!args.includeClips) continue;\n const p = featurePrice(args.ai, FEATURES.clip);\n rows.push({\n kind: \"clip\",\n slot: item.slot,\n feature: FEATURES.clip,\n count: 1,\n unit: p.price,\n ...(p.exists ? {} : { unavailable: true }),\n ...(p.optionID ? { optionID: p.optionID } : {}),\n });\n } else freeSteps++;\n }\n if (args.voiceLines > 0) {\n const p = featurePrice(args.ai, FEATURES.voice);\n rows.push({\n kind: \"voice\",\n slot: \"lines\",\n feature: FEATURES.voice,\n count: args.voiceLines,\n unit: p.price,\n ...(p.exists ? {} : { unavailable: true }),\n ...(p.optionID ? { optionID: p.optionID } : {}),\n });\n }\n const total = mergeLines(\n rows.filter((r) => !r.unavailable).flatMap((r) => times(r.unit, r.count)),\n );\n return { rows, total, freeSteps };\n}\n\n/** Сколько стоит позвать режиссёра (одна генерация). */\nexport function directorPrice(\n ai: AIFeaturesView | null | undefined,\n): FeaturePrice {\n return featurePrice(ai, FEATURES.director);\n}\n"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"path": "model/store.ts",
|
|
149
|
+
"content": "// «Мои ролики» и «Мои ассеты» на устройстве игрока (IndexedDB; где её нет — в памяти вкладки).\n// Ролик — VideoSpec в несколько килобайт, ассет — рецепт или ссылка на CDN; фото игрока лежит\n// здесь же уменьшенным JPEG. На другое устройство ролик переносит публикация в Мастерской —\n// поэтому хранилище раздельное на тайтл и игрока, и чужой игрок того же браузера его не видит.\nimport type { Asset, VideoSpec } from \"@idosgames/timeline\";\n\nexport interface SavedVideo {\n id: string;\n spec: VideoSpec;\n updatedAt: number;\n /** Публикация в Мастерской (после «Опубликовать»). */\n contentID?: string;\n /** Опубликованная ревизия — новая правка предлагает обновить публикацию. */\n publishedAt?: number;\n /** Превью-кадр (data URL, маленький). */\n thumb?: string;\n}\n\nexport interface SavedAsset {\n asset: Asset;\n updatedAt: number;\n /** Публикация в Мастерской. */\n contentID?: string;\n /** Как сделан: ИИ-фича, фото, пакет Мастерской. */\n origin: \"ai\" | \"photo\" | \"workshop\" | \"combine\" | \"link\";\n}\n\ntype Kind = \"videos\" | \"assets\";\n\ninterface Backend {\n all(kind: Kind): Promise<unknown[]>;\n put(kind: Kind, key: string, value: unknown): Promise<void>;\n remove(kind: Kind, key: string): Promise<void>;\n}\n\nclass MemoryBackend implements Backend {\n private readonly data: Record<Kind, Map<string, unknown>> = {\n videos: new Map(),\n assets: new Map(),\n };\n async all(kind: Kind) {\n return [...this.data[kind].values()];\n }\n async put(kind: Kind, key: string, value: unknown) {\n this.data[kind].set(key, value);\n }\n async remove(kind: Kind, key: string) {\n this.data[kind].delete(key);\n }\n}\n\nclass IdbBackend implements Backend {\n private db: Promise<IDBDatabase>;\n constructor(name: string) {\n this.db = new Promise((resolve, reject) => {\n const req = indexedDB.open(name, 1);\n req.onupgradeneeded = () => {\n for (const k of [\"videos\", \"assets\"])\n if (!req.result.objectStoreNames.contains(k))\n req.result.createObjectStore(k);\n };\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error);\n });\n }\n private async tx<T>(\n kind: Kind,\n mode: IDBTransactionMode,\n run: (s: IDBObjectStore) => IDBRequest<T>,\n ): Promise<T> {\n const db = await this.db;\n return new Promise((resolve, reject) => {\n const req = run(db.transaction(kind, mode).objectStore(kind));\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error);\n });\n }\n all(kind: Kind) {\n return this.tx(kind, \"readonly\", (s) => s.getAll());\n }\n async put(kind: Kind, key: string, value: unknown) {\n await this.tx(kind, \"readwrite\", (s) => s.put(value, key));\n }\n async remove(kind: Kind, key: string) {\n await this.tx(kind, \"readwrite\", (s) => s.delete(key));\n }\n}\n\n/** Хранилище студии. Подписчики узнают о любой записи (списки перерисовываются). */\nexport class StudioStore {\n private backend: Backend;\n private readonly listeners = new Set<() => void>();\n private fallback = false;\n\n constructor(titleId: string, userId: string, backend?: Backend) {\n if (backend) this.backend = backend;\n else {\n try {\n this.backend =\n typeof indexedDB !== \"undefined\"\n ? new IdbBackend(`idos-video-studio:${titleId}:${userId}`)\n : new MemoryBackend();\n } catch {\n this.backend = new MemoryBackend();\n }\n }\n }\n\n /** IndexedDB отказала (приватное окно) — дальше в памяти вкладки, без исключений наружу. */\n private async safe<T>(run: () => Promise<T>, empty: T): Promise<T> {\n try {\n return await run();\n } catch {\n if (!this.fallback) {\n this.fallback = true;\n this.backend = new MemoryBackend();\n }\n try {\n return await run();\n } catch {\n return empty;\n }\n }\n }\n\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n private changed(): void {\n for (const l of this.listeners) l();\n }\n\n async videos(): Promise<SavedVideo[]> {\n const all = (await this.safe(\n () => this.backend.all(\"videos\"),\n [],\n )) as SavedVideo[];\n return all.sort((a, b) => b.updatedAt - a.updatedAt);\n }\n\n async video(id: string): Promise<SavedVideo | undefined> {\n return (await this.videos()).find((v) => v.id === id);\n }\n\n async saveVideo(v: SavedVideo): Promise<void> {\n await this.safe(() => this.backend.put(\"videos\", v.id, v), undefined);\n this.changed();\n }\n\n async removeVideo(id: string): Promise<void> {\n await this.safe(() => this.backend.remove(\"videos\", id), undefined);\n this.changed();\n }\n\n async assets(): Promise<SavedAsset[]> {\n const all = (await this.safe(\n () => this.backend.all(\"assets\"),\n [],\n )) as SavedAsset[];\n return all.sort((a, b) => b.updatedAt - a.updatedAt);\n }\n\n async saveAsset(a: SavedAsset): Promise<void> {\n await this.safe(\n () => this.backend.put(\"assets\", a.asset.ref, a),\n undefined,\n );\n this.changed();\n }\n\n async removeAsset(ref: string): Promise<void> {\n await this.safe(() => this.backend.remove(\"assets\", ref), undefined);\n this.changed();\n }\n}\n\nexport { MemoryBackend };\n\n/** Новый id ролика или ассета: короткий, без спецсимволов (годится в ссылку и в ключ). */\nexport function newId(): string {\n const bytes = new Uint8Array(9);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(36).padStart(2, \"0\"))\n .join(\"\")\n .slice(0, 14);\n}\n"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"path": "model/templates.ts",
|
|
153
|
+
"content": "// Готовые ролики на каждый формат — из библиотеки, с репликами на двух языках. Это бесплатный\n// старт «за одно касание» без ИИ: открыл, поменял слова и героя — ролик твой. Они же — тренды на\n// пустом тайтле (лента ещё пуста) и демо для агента и тестов: собираются всегда, без сервера.\nimport type { VideoSpec } from \"@idosgames/timeline\";\n\nexport interface VideoTemplate {\n id: string;\n format: string;\n title: { en: string; ru: string };\n spec: (lang: \"en\" | \"ru\") => VideoSpec;\n}\n\nconst base = (\n lang: \"en\" | \"ru\",\n over: Partial<VideoSpec> &\n Pick<VideoSpec, \"format\" | \"style\" | \"set\" | \"cast\" | \"lines\">,\n): VideoSpec => ({\n v: 1,\n aspect: \"9:16\",\n fps: 30,\n seed: 20261024,\n captions: \"karaoke-bold\",\n overrides: [],\n assets: {},\n ...over,\n title: over.title ?? (lang === \"ru\" ? \"Мой ролик\" : \"My video\"),\n});\n\ntype L = { en: string; ru: string };\nconst pick = (lang: \"en\" | \"ru\", l: L) => l[lang];\n\nexport const TEMPLATES: VideoTemplate[] = [\n {\n id: \"office-raise\",\n format: \"dialogue@1\",\n title: { en: \"Asking for a raise\", ru: \"Попросил прибавку\" },\n spec: (lang) => {\n const lines: Array<\n [number, L, string, string, VideoSpec[\"lines\"][number][\"emotion\"]]\n > = [\n [\n 0,\n {\n en: \"Boss, I need a raise. Today.\",\n ru: \"Шеф, мне нужна прибавка. Сегодня.\",\n },\n \"talk\",\n \"think\",\n \"neutral\",\n ],\n [\n 1,\n { en: \"Why today?\", ru: \"Почему именно сегодня?\" },\n \"surprised\",\n \"nod\",\n \"surprised\",\n ],\n [\n 0,\n {\n en: \"Three companies are after me!\",\n ru: \"За мной охотятся три компании!\",\n },\n \"talk-excited\",\n \"shrug\",\n \"happy\",\n ],\n [1, { en: \"Which ones?\", ru: \"Какие же?\" }, \"accuse\", \"think\", \"angry\"],\n [\n 0,\n {\n en: \"Electricity, water and the internet.\",\n ru: \"Свет, вода и интернет.\",\n },\n \"punchline\",\n \"laugh\",\n \"sad\",\n ],\n ];\n return base(lang, {\n format: \"dialogue@1\",\n title: pick(lang, {\n en: \"Asking for a raise\",\n ru: \"Попросил прибавку\",\n }),\n style: \"voxel-warm\",\n set: \"lib:office-day@1\",\n music: \"lib:comedy-bounce@1\",\n cast: [{ asset: \"lib:office-guy@1\" }, { asset: \"lib:boss@1\" }],\n lines: lines.map(([who, text, beat, react, emotion]) => ({\n who,\n text: pick(lang, text),\n beat,\n react,\n ...(emotion ? { emotion } : {}),\n })),\n params: { punchline: true, enter: false },\n });\n },\n },\n {\n id: \"fox-cheese\",\n format: \"story@1\",\n title: { en: \"The fox and the cheese\", ru: \"Лис и сыр\" },\n spec: (lang) =>\n base(lang, {\n format: \"story@1\",\n title: pick(lang, { en: \"The fox and the cheese\", ru: \"Лис и сыр\" }),\n style: \"voxel-warm\",\n set: \"lib:forest@1\",\n music: \"lib:ukulele@1\",\n cast: [{ asset: \"lib:fox@1\" }, { asset: \"lib:parrot@1\" }],\n lines: [\n {\n who: -1,\n text: pick(lang, {\n en: \"One morning a fox smelled cheese.\",\n ru: \"Однажды утром лис учуял сыр.\",\n }),\n beat: \"sneak\",\n },\n {\n who: 0,\n text: pick(lang, {\n en: \"Breakfast is served!\",\n ru: \"Завтрак подан!\",\n }),\n beat: \"jump-joy\",\n emotion: \"happy\",\n },\n {\n who: -1,\n text: pick(lang, {\n en: \"But the parrot got there first.\",\n ru: \"Но попугай успел первым.\",\n }),\n beat: \"fly-arc\",\n },\n {\n who: 1,\n text: pick(lang, {\n en: \"Early bird gets the cheese!\",\n ru: \"Кто рано встаёт — тому сыр!\",\n }),\n beat: \"laugh\",\n emotion: \"happy\",\n },\n {\n who: 0,\n text: pick(lang, {\n en: \"Tomorrow I'm setting an alarm.\",\n ru: \"Завтра ставлю будильник.\",\n }),\n beat: \"talk-sad\",\n emotion: \"sad\",\n },\n ],\n params: { focus: [0, 0, 1, 1, 0] },\n }),\n },\n {\n id: \"top-pets\",\n format: \"top5@1\",\n title: { en: \"Top 5 lazy pets\", ru: \"Топ-5 ленивых питомцев\" },\n spec: (lang) => {\n const items: L[] = [\n { en: \"The slime\", ru: \"Слизень\" },\n { en: \"The dog\", ru: \"Пёс\" },\n { en: \"The bear\", ru: \"Медведь\" },\n { en: \"The ghost\", ru: \"Призрак\" },\n { en: \"The cat\", ru: \"Кот\" },\n ];\n const said: L[] = [\n {\n en: \"Fifth: the slime. It doesn't even have legs.\",\n ru: \"Пятое место: слизень. У него даже ног нет.\",\n },\n {\n en: \"Fourth: the dog. Naps between naps.\",\n ru: \"Четвёртое: пёс. Спит между снами.\",\n },\n {\n en: \"Third: the bear. Six months off, every year.\",\n ru: \"Третье: медведь. Полгода отпуска каждый год.\",\n },\n {\n en: \"Second: the ghost. Hasn't left the attic since 1850.\",\n ru: \"Второе: призрак. Не выходил с чердака с 1850 года.\",\n },\n {\n en: \"And first: the cat. Obviously.\",\n ru: \"И первое место: кот. Конечно же.\",\n },\n ];\n return base(lang, {\n format: \"top5@1\",\n title: pick(lang, {\n en: \"Top 5 lazy pets\",\n ru: \"Топ-5 ленивых питомцев\",\n }),\n style: \"voxel-warm\",\n set: \"lib:stage-show@1\",\n music: \"lib:upbeat@1\",\n cast: [{ asset: \"lib:cat@1\" }],\n lines: said.map((s) => ({ who: -1, text: pick(lang, s) })),\n params: { items: items.map((i) => ({ title: pick(lang, i) })) },\n });\n },\n },\n {\n id: \"quiz-space\",\n format: \"quiz@1\",\n title: { en: \"Space quiz\", ru: \"Космический квиз\" },\n spec: (lang) =>\n base(lang, {\n format: \"quiz@1\",\n title: pick(lang, { en: \"Space quiz\", ru: \"Космический квиз\" }),\n style: \"voxel-warm\",\n set: \"lib:stage-show@1\",\n music: \"lib:quiz-tension@1\",\n cast: [{ asset: \"lib:robot@1\" }],\n lines: [\n {\n who: 0,\n text: pick(lang, {\n en: \"Which planet is the hottest?\",\n ru: \"Какая планета самая горячая?\",\n }),\n emotion: \"happy\",\n },\n {\n who: 0,\n text: pick(lang, {\n en: \"How many moons does Mars have?\",\n ru: \"Сколько спутников у Марса?\",\n }),\n },\n {\n who: 0,\n text: pick(lang, {\n en: \"How many did you get right? Write in the comments!\",\n ru: \"Сколько угадал? Пиши в комментариях!\",\n }),\n beat: \"wave\",\n emotion: \"happy\",\n },\n ],\n params: {\n questions: [\n {\n q: pick(lang, {\n en: \"The hottest planet?\",\n ru: \"Самая горячая планета?\",\n }),\n options: pick(lang, {\n en: \"Mercury|Venus|Mars\",\n ru: \"Меркурий|Венера|Марс\",\n }).split(\"|\"),\n answer: 1,\n },\n {\n q: pick(lang, { en: \"Moons of Mars?\", ru: \"Спутников у Марса?\" }),\n options: [\"0\", \"1\", \"2\"],\n answer: 2,\n },\n ],\n },\n }),\n },\n {\n id: \"whatif-cats\",\n format: \"whatif@1\",\n title: {\n en: \"What if cats ruled the world?\",\n ru: \"Что если коты захватят мир?\",\n },\n spec: (lang) =>\n base(lang, {\n format: \"whatif@1\",\n title: pick(lang, {\n en: \"What if cats ruled the world?\",\n ru: \"Что если коты захватят мир?\",\n }),\n style: \"voxel-warm\",\n set: \"lib:city-street@1\",\n music: \"lib:dramatic-reveal@1\",\n cast: [{ asset: \"lib:ginger-cat@1\" }, { asset: \"lib:dog@1\" }],\n lines: [\n {\n who: -1,\n text: pick(lang, {\n en: \"Day one: every chair belongs to a cat.\",\n ru: \"День первый: все стулья принадлежат котам.\",\n }),\n beat: \"sit-down\",\n },\n {\n who: -1,\n text: pick(lang, {\n en: \"Day two: the dogs go on strike.\",\n ru: \"День второй: собаки объявляют забастовку.\",\n }),\n beat: \"angry-steam\",\n },\n {\n who: 1,\n text: pick(lang, {\n en: \"We want our sofa back!\",\n ru: \"Верните нам диван!\",\n }),\n beat: \"shout\",\n emotion: \"angry\",\n },\n {\n who: -1,\n text: pick(lang, {\n en: \"Day three: the economy runs on fish.\",\n ru: \"День третий: экономика держится на рыбе.\",\n }),\n beat: \"money-rain\",\n },\n {\n who: 0,\n text: pick(lang, {\n en: \"Meow. As planned.\",\n ru: \"Мяу. Всё по плану.\",\n }),\n beat: \"victory-dance\",\n emotion: \"happy\",\n },\n ],\n params: {\n premise: pick(lang, {\n en: \"What if cats ruled the world?\",\n ru: \"Что если коты захватят мир?\",\n }),\n finale: \"punchline\",\n },\n }),\n },\n {\n id: \"knight-wizard\",\n format: \"dialogue@1\",\n title: { en: \"The knight and the wizard\", ru: \"Рыцарь и волшебник\" },\n spec: (lang) =>\n base(lang, {\n format: \"dialogue@1\",\n title: pick(lang, {\n en: \"The knight and the wizard\",\n ru: \"Рыцарь и волшебник\",\n }),\n style: \"chibi-2d\",\n set: \"lib:flat-tavern@1\",\n music: \"lib:tavern@1\",\n cast: [{ asset: \"lib:hero-knight@1\" }, { asset: \"lib:hero-wizard@1\" }],\n lines: [\n {\n who: 0,\n text: pick(lang, {\n en: \"I slayed a dragon this morning.\",\n ru: \"Сегодня утром я победил дракона.\",\n }),\n beat: \"talk-excited\",\n react: \"shrug\",\n emotion: \"happy\",\n },\n {\n who: 1,\n text: pick(lang, {\n en: \"That was my pet.\",\n ru: \"Это был мой питомец.\",\n }),\n beat: \"stare\",\n emotion: \"angry\",\n },\n {\n who: 0,\n text: pick(lang, {\n en: \"…a very small dragon.\",\n ru: \"…очень маленького дракона.\",\n }),\n beat: \"talk-scared\",\n emotion: \"scared\",\n },\n {\n who: 1,\n text: pick(lang, { en: \"Run.\", ru: \"Беги.\" }),\n beat: \"shout\",\n react: \"exit-run\",\n emotion: \"angry\",\n },\n ],\n params: { punchline: true },\n }),\n },\n];\n\nexport function templateSpec(\n id: string,\n lang: \"en\" | \"ru\",\n): VideoSpec | undefined {\n const t = TEMPLATES.find((x) => x.id === id);\n return t\n ? { ...t.spec(lang), seed: Math.floor(Math.random() * 2 ** 31) }\n : undefined;\n}\n"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"path": "module.ts",
|
|
157
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { VIDEO_LINK_KEY } from \"@idosgames/timeline\";\nimport { Studio } from \"./studio\";\nimport { CONTENT } from \"./ids\";\nimport { packAssets, packVideo, ownAssetsOf, unpackAssets } from \"./content\";\nimport { captureFrame } from \"./render\";\nimport { TEMPLATES, templateSpec } from \"./model/templates\";\nimport { makeStudioApp } from \"./ui/StudioApp\";\nimport { lang, t } from \"./i18n\";\n\nexport const MODE = \"video-studio\";\n\n// Видеостудия — приложение своего режима ВНУТРИ лобби базы (сверху балансы и профиль, снизу вкладки\n// систем: квесты, магазин, VIP, Мастерская). Свои разделы — Тренды, Создать, Мои, Челленджи — у неё\n// внутри. Ролик — данные (VideoSpec, пакет @idosgames/timeline), персонажи — стандарт платформы\n// (@idosgames/characters); всё платное — ИИ-фичи тайтла с ценой в Кадрах, публикации и ремиксы —\n// Мастерская (типы \"video.spec\", \"video.asset\", \"video.pack\").\nexport const videoStudioModule: Module = defineModule({\n id: \"video-studio\",\n // \"game\" — для лобби это режим «Играть»: приложение стартует в нём и живёт внутри лобби\n meta: {\n name: \"Video studio\",\n type: \"game\",\n genre: \"video\",\n engine: \"three\",\n },\n setup(ctx) {\n const studio = new Studio(ctx.client, ctx.titleId);\n\n ctx.registerPanel({\n id: \"studio\",\n slot: \"overlay\",\n component: makeStudioApp({ studio, features: ctx.features }),\n });\n ctx.registerRoute({\n id: MODE,\n label: t(\"title\"),\n icon: \"🎬\",\n inLobby: true,\n });\n\n // Ролики и ассеты в Мастерской: её браузер публикует и открывает их через эти обработчики.\n ctx.content.registerType({\n type: CONTENT.video,\n label: t(\"title\"),\n icon: \"clapper\",\n modeId: MODE,\n listLocal: async () =>\n (await studio.store.videos()).map((v) => ({\n id: v.id,\n name: v.spec.title || t(\"untitled\"),\n updatedAt: v.updatedAt,\n subtitle: t(`f_${v.spec.format}` as never),\n })),\n capture: async (id) => {\n const v = await studio.store.video(id);\n if (!v) throw new Error(\"no such video\");\n const thumb = await captureFrame(v.spec, studio.catalog);\n return {\n files: packVideo({\n spec: v.spec,\n assets: ownAssetsOf(v.spec, (r) => studio.catalog.get(r)),\n }),\n ...(thumb\n ? { thumbnail: { contentType: \"image/jpeg\" as const, data: thumb } }\n : {}),\n suggestedTitle: v.spec.title ?? t(\"untitled\"),\n tags: [v.spec.format.replace(/@\\d+$/, \"\"), v.spec.style],\n metadata: { format: v.spec.format, style: v.spec.style },\n };\n },\n open: async (content) => {\n studio.go({ screen: \"watch\", contentID: content.contentID });\n },\n });\n const assetType = (type: string, label: string) =>\n ctx.content.registerType({\n type,\n label,\n icon: \"hero\",\n modeId: MODE,\n listLocal: async () =>\n (await studio.store.assets())\n .filter((a) => a.origin !== \"workshop\")\n .map((a) => ({\n id: a.asset.ref,\n name: a.asset.title,\n updatedAt: a.updatedAt,\n })),\n capture: async (ref) => {\n const a = studio.catalog.mine.get(ref);\n if (!a) throw new Error(\"no such asset\");\n return {\n files: packAssets(a.title, [a]),\n suggestedTitle: a.title,\n tags: a.tags.slice(0, 8),\n };\n },\n open: async (content) => {\n const u = unpackAssets(content.files, content.contentID);\n if (!u.ok) throw new Error(u.error);\n for (const a of u.value) await studio.addAsset(a, \"workshop\");\n ctx.notifications.toast({ level: \"success\", message: t(\"taken\") });\n studio.go({ screen: \"home\" });\n },\n });\n assetType(CONTENT.asset, t(\"myAssets\"));\n assetType(CONTENT.pack, \"📦\");\n\n // Живая ссылка: приложение открылось по #video=… — сразу на просмотр этого ролика; по #videodata=… —\n // ролик целиком в ссылке (агент, другое устройство): в «Мои ролики» и в редактор. Хэш после разбора\n // убирается — перезагрузка не заведёт второй копии.\n const fromLink = () => {\n const data = new RegExp(`[#&]${VIDEO_LINK_KEY}=([^&]+)`).exec(\n globalThis.location?.hash ?? \"\",\n );\n if (data?.[1]) {\n globalThis.history?.replaceState(\n null,\n \"\",\n `${globalThis.location.pathname}${globalThis.location.search}`,\n );\n ctx.navigate(MODE);\n void studio.openPackage(decodeURIComponent(data[1])).then((r) => {\n if (!r.ok)\n ctx.notifications.toast({ level: \"error\", message: r.error });\n });\n return;\n }\n const m = /[#&]video=([^&]+)/.exec(globalThis.location?.hash ?? \"\");\n if (!m?.[1]) return;\n studio.go({ screen: \"watch\", contentID: decodeURIComponent(m[1]) });\n ctx.navigate(MODE);\n };\n fromLink();\n ctx.effect(() => {\n if (typeof window === \"undefined\") return;\n window.addEventListener(\"hashchange\", fromLink);\n return () => window.removeEventListener(\"hashchange\", fromLink);\n });\n\n // DOM студии агент читает сам; сцену ролика (canvas) — через это состояние.\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n mode: ctx.modes.current(),\n view: studio.view,\n templates: TEMPLATES.map((x) => x.id),\n }),\n actions: {\n openTrends: () => studio.go({ screen: \"home\" }),\n openCreate: (a) =>\n studio.go({\n screen: \"create\",\n ...(typeof a?.[\"format\"] === \"string\"\n ? { format: a[\"format\"] }\n : {}),\n }),\n newFromTemplate: async (a) => {\n const spec = templateSpec(\n String(a?.[\"id\"] ?? TEMPLATES[0]!.id),\n lang(),\n );\n if (!spec) return { error: \"unknown template\" };\n const saved = await studio.createVideo(spec);\n return { videoId: saved.id };\n },\n watch: (a) =>\n studio.go({\n screen: \"watch\",\n contentID: String(a?.[\"contentID\"] ?? \"\"),\n }),\n },\n describeActions: {\n openTrends:\n \"Show the Trends screen (formats, ready videos, the app's feed).\",\n openCreate:\n \"Open the Create screen; args { format?: 'dialogue@1' | 'story@1' | 'top5@1' | 'quiz@1' | 'whatif@1' }.\",\n newFromTemplate:\n \"Make a new video from a ready template (free, no AI) and open it in the editor; args { id } — see state.templates.\",\n watch: \"Open a published video by its Workshop id; args { contentID }.\",\n },\n });\n },\n});\n"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"path": "render.ts",
|
|
161
|
+
"content": "// Ролик → сцена движка таймлайна: превью в редакторе, кадр-обложка для Мастерской, MP4 на\n// устройстве. Везде один и тот же Stage (детерминированный кадр в момент t) — обложка, превью и\n// файл совпадают кадр в кадр. HD 1080×1920 — льгота VIP, без неё — 720×1280.\nimport {\n compileVideo,\n type Catalog,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { Player, Stage, type PlayerOptions } from \"@idosgames/timeline/three\";\nimport {\n canExportVideo,\n exportMp4,\n type ExportResult,\n} from \"@idosgames/timeline/export\";\n\nexport const WATERMARK = \"iDos Video Studio\";\nexport const ACCENT = \"#ffcf33\";\n\nexport interface StageSize {\n width: number;\n height: number;\n}\n\nexport const SIZE_SD: StageSize = { width: 720, height: 1280 };\nexport const SIZE_HD: StageSize = { width: 1080, height: 1920 };\n\nexport type BuildResult<T> =\n { ok: true; value: T; warnings: string[] } | { ok: false; errors: string[] };\n\nfunction stageOptions(\n spec: VideoSpec,\n catalog: Catalog,\n size: StageSize,\n canvas?: HTMLCanvasElement,\n) {\n const compiled = compileVideo(spec, catalog, { styleLock: false });\n if (!compiled.ok) return compiled;\n return {\n ok: true as const,\n warnings: compiled.warnings,\n opts: {\n ...(canvas ? { canvas } : {}),\n width: size.width,\n height: size.height,\n timeline: compiled.timeline,\n set: compiled.set,\n cast: compiled.cast,\n members: spec.cast,\n catalog,\n overlay: { watermark: spec.watermark ? WATERMARK : null, accent: ACCENT },\n },\n };\n}\n\n/** Живой проигрыватель для превью и живой ссылки. */\nexport async function createPlayer(\n spec: VideoSpec,\n catalog: Catalog,\n canvas: HTMLCanvasElement,\n size: StageSize,\n extra: Pick<PlayerOptions, \"loop\" | \"onTime\" | \"onEnd\" | \"fetcher\"> = {},\n): Promise<BuildResult<Player>> {\n const s = stageOptions(spec, catalog, size, canvas);\n if (!s.ok) return { ok: false, errors: s.errors };\n const player = await Player.create({\n ...s.opts,\n pixelRatio: Math.min(2, globalThis.devicePixelRatio || 1),\n ...extra,\n });\n return { ok: true, value: player, warnings: s.warnings };\n}\n\n/** Кадр ролика картинкой (обложка публикации): по умолчанию треть ролика — там обычно уже действие. */\nexport async function captureFrame(\n spec: VideoSpec,\n catalog: Catalog,\n at = 0.33,\n size: StageSize = { width: 405, height: 720 },\n): Promise<Blob | null> {\n const s = stageOptions(spec, catalog, size);\n if (!s.ok) return null;\n const stage = new Stage(s.opts);\n try {\n await stage.ready();\n stage.renderAt(stage.duration * at);\n return await new Promise<Blob | null>((r) =>\n stage.canvas.toBlob(r, \"image/jpeg\", 0.85),\n );\n } finally {\n release(stage);\n }\n}\n\n/** Сцена на своём холсте: отдать и контекст WebGL — у браузера их ~16 на вкладку. */\nfunction release(stage: Stage): void {\n stage.dispose();\n stage.renderer.forceContextLoss();\n}\n\nexport interface ExportArgs {\n hd: boolean;\n onProgress?: (u: number) => void;\n signal?: AbortSignal;\n}\n\nexport async function exportVideo(\n spec: VideoSpec,\n catalog: Catalog,\n args: ExportArgs,\n): Promise<ExportResult> {\n const size = args.hd ? SIZE_HD : SIZE_SD;\n if (!(await canExportVideo(size.width, size.height)))\n return {\n ok: false,\n reason: \"unsupported\",\n message: \"this browser cannot encode video\",\n };\n const s = stageOptions(spec, catalog, size);\n if (!s.ok)\n return { ok: false, reason: \"failed\", message: s.errors.join(\"; \") };\n const stage = new Stage(s.opts);\n try {\n return await exportMp4(stage, {\n quality: \"high\",\n ...(args.onProgress ? { onProgress: args.onProgress } : {}),\n ...(args.signal ? { signal: args.signal } : {}),\n });\n } finally {\n release(stage);\n }\n}\n\n/** Отдать файл игроку: «Поделиться» телефона (TikTok, галерея), иначе скачивание. */\nexport async function deliverFile(\n blob: Blob,\n name: string,\n): Promise<\"shared\" | \"downloaded\"> {\n const file = new File([blob], name, { type: blob.type });\n const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };\n if (nav.canShare?.({ files: [file] })) {\n try {\n await nav.share({ files: [file], title: name });\n return \"shared\";\n } catch {\n // отменил «Поделиться» — скачаем\n }\n }\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(() => URL.revokeObjectURL(a.href), 30_000);\n return \"downloaded\";\n}\n"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"path": "studio.ts",
|
|
165
|
+
"content": "// Студия целиком: каталог, хранилище, открытый ролик и всё, что с ним делают через платформу —\n// публикация и ремикс (Мастерская), квесты и челлендж (метрики, лидерборд), аналитика промахов\n// библиотеки. Экраны только рисуют её состояние и зовут её методы; агент ИИ-кодера зовёт те же.\nimport type { IDosGamesClient, WorkshopAccessOption } from \"@idosgames/core\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n decodeVideoPackage,\n remixOf,\n type Asset,\n type PlanItem,\n type VideoSpec,\n} from \"@idosgames/timeline\";\nimport { CHALLENGE_BOARD, CONTENT, METRICS } from \"./ids\";\nimport { createLibrary } from \"./library\";\nimport { accessOf, type Access, type PlayerStateView } from \"./model/access\";\nimport { StudioCatalog } from \"./model/catalog\";\nimport type { AIFeaturesView } from \"./model/pricing\";\nimport {\n newId,\n StudioStore,\n type SavedAsset,\n type SavedVideo,\n} from \"./model/store\";\nimport { captureFrame } from \"./render\";\nimport {\n ownAssetsOf,\n packAssets,\n packVideo,\n unpackAssets,\n unpackVideo,\n} from \"./content\";\n\nexport type StudioView =\n | { screen: \"home\" }\n | { screen: \"create\"; format?: string }\n | { screen: \"editor\"; videoId: string; plan?: PlanItem[] }\n | { screen: \"watch\"; contentID: string };\n\nexport type Result<T = void> =\n { ok: true; value: T } | { ok: false; error: string };\n\nexport class Studio {\n readonly catalog = new StudioCatalog(createLibrary());\n readonly store: StudioStore;\n private viewNow: StudioView = { screen: \"home\" };\n private readonly listeners = new Set<() => void>();\n private loaded: Promise<void> | null = null;\n\n constructor(\n readonly client: IDosGamesClient,\n readonly titleId: string,\n store?: StudioStore,\n ) {\n this.store =\n store ?? new StudioStore(titleId, client.auth.context?.userID ?? \"guest\");\n this.store.subscribe(() => this.emit());\n }\n\n // ── состояние экранов ──────────────────────────────────────────────────────────────────────\n get view(): StudioView {\n return this.viewNow;\n }\n\n go(view: StudioView): void {\n this.viewNow = view;\n this.emit();\n }\n\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n private emit(): void {\n for (const l of [...this.listeners]) l();\n }\n\n /** «Мои ассеты» в каталог (один раз за сессию). */\n load(): Promise<void> {\n this.loaded ??= (async () => {\n for (const a of await this.store.assets()) this.catalog.mine.add(a.asset);\n if (!configSection(this.client, \"AI\"))\n await this.client.ai.getDefinitions();\n this.emit();\n })();\n return this.loaded;\n }\n\n access(nowMs = Date.now()): Access {\n return accessOf(\n this.client.data.user.state as PlayerStateView | null,\n nowMs,\n );\n }\n\n ai(): AIFeaturesView | undefined {\n return configSection<AIFeaturesView>(this.client, \"AI\");\n }\n\n // ── ролики ─────────────────────────────────────────────────────────────────────────────────\n async createVideo(spec: VideoSpec, plan?: PlanItem[]): Promise<SavedVideo> {\n const saved: SavedVideo = { id: newId(), spec, updatedAt: Date.now() };\n await this.store.saveVideo(saved);\n this.go({\n screen: \"editor\",\n videoId: saved.id,\n ...(plan?.length ? { plan } : {}),\n });\n void this.refreshThumb(saved.id);\n return saved;\n }\n\n /** Обложка ролика для «Моих роликов» — кадр трети ролика, маленький. */\n async refreshThumb(id: string): Promise<void> {\n const v = await this.store.video(id);\n if (!v) return;\n const blob = await captureFrame(v.spec, this.catalog, 0.33, {\n width: 180,\n height: 320,\n });\n if (!blob) return;\n const thumb = await new Promise<string>((resolve) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.readAsDataURL(blob);\n });\n const now = await this.store.video(id);\n if (now) await this.store.saveVideo({ ...now, thumb });\n }\n\n async updateVideo(id: string, spec: VideoSpec): Promise<void> {\n const v = await this.store.video(id);\n if (!v) return;\n await this.store.saveVideo({ ...v, spec, updatedAt: Date.now() });\n }\n\n async addAsset(asset: Asset, origin: SavedAsset[\"origin\"]): Promise<void> {\n this.catalog.mine.add(asset);\n await this.store.saveAsset({ asset, origin, updatedAt: Date.now() });\n }\n\n async removeAsset(ref: string): Promise<void> {\n this.catalog.mine.remove(ref);\n await this.store.removeAsset(ref);\n }\n\n /** Публикация ролика (ремикс — с родителем: дерево ремиксов и счётчик у родителя). */\n async publish(\n id: string,\n card: { title: string; description?: string; tags?: string[] },\n ): Promise<Result<string>> {\n const v = await this.store.video(id);\n if (!v) return { ok: false, error: \"no such video\" };\n const assets = ownAssetsOf(v.spec, (r) => this.catalog.get(r));\n const thumb = await captureFrame(v.spec, this.catalog);\n if (!thumb) return { ok: false, error: \"could not draw the cover\" };\n const res = await this.client.workshop.publish({\n contentType: CONTENT.video,\n ...(v.contentID ? { contentID: v.contentID } : {}),\n ...(v.spec.parent && !v.contentID\n ? { parentContentID: v.spec.parent }\n : {}),\n files: packVideo({ spec: v.spec, assets }).map((f) => ({\n role: f.role,\n contentType: f.contentType,\n data: f.data,\n })),\n thumbnail: { contentType: \"image/jpeg\", data: thumb },\n title: card.title.slice(0, 80),\n ...(card.description ? { description: card.description } : {}),\n tags: card.tags ?? [],\n metadata: {\n format: v.spec.format,\n style: v.spec.style,\n ...(v.spec.parent ? { parent: v.spec.parent } : {}),\n },\n visibility: \"Public\",\n access: [{ Mode: \"Free\" }],\n });\n if (!res.ok) return { ok: false, error: res.error };\n const contentID = res.data.Content?.ContentID ?? v.contentID ?? \"\";\n const first = !v.contentID;\n await this.store.saveVideo({ ...v, contentID, publishedAt: Date.now() });\n if (first) {\n // квесты и недельный челлендж: очко за опубликованный ролик, отдельная метрика за ремикс\n void this.client.quest.addQuestProgress(METRICS.published, 1);\n if (v.spec.parent)\n void this.client.quest.addQuestProgress(METRICS.remixed, 1);\n void this.client.leaderboard.submitScore(CHALLENGE_BOARD, 1);\n this.client.analytics.logEvent(\"video_published\", {\n format: v.spec.format,\n style: v.spec.style,\n remix: v.spec.parent ? 1 : 0,\n });\n }\n return { ok: true, value: contentID };\n }\n\n /** Скачать опубликованный ролик: VideoSpec + его ассеты (в слой каталога «этого ролика»). */\n async fetchPublished(contentID: string): Promise<Result<VideoSpec>> {\n const got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) {\n // бесплатный ролик сначала «берут» (лицензия Free), потом скачивают\n const acq = await this.client.workshop.acquire(contentID);\n if (!acq.ok) return { ok: false, error: got.error };\n const again = await this.client.workshop.downloadFiles(contentID);\n if (!again.ok) return { ok: false, error: again.error };\n return this.unpack(again.data.files);\n }\n return this.unpack(got.data.files);\n }\n\n private unpack(\n files: Array<{ role: string; contentType: string; data: ArrayBuffer }>,\n ): Result<VideoSpec> {\n const u = unpackVideo(files);\n if (!u.ok) return u;\n this.catalog.setVideoAssets(u.value.assets);\n return { ok: true, value: u.value.spec };\n }\n\n /** Ремикс опубликованного ролика: копия с родителем; озвучка неизменённых реплик остаётся. */\n async remix(contentID: string): Promise<Result<SavedVideo>> {\n const got = await this.fetchPublished(contentID);\n if (!got.ok) return got;\n // ассеты чужого ролика становятся «моими» — иначе ремикс не откроется после перезагрузки\n for (const a of this.catalog.video.list())\n if (!this.catalog.mine.get(a.ref)) await this.addAsset(a, \"workshop\");\n const spec = remixOf(got.value, contentID);\n this.client.analytics.logEvent(\"video_remix_started\", {\n format: spec.format,\n });\n return { ok: true, value: await this.createVideo(spec) };\n }\n\n /**\n * Ролик ССЫЛКОЙ (`#videodata=…` — ролик агента из MCP или с другого устройства): проверка тем же кодом,\n * что у Мастерской, ассеты — в «Мои», сам ролик — новым в редактор. Сервер не нужен.\n */\n async openPackage(text: string): Promise<Result<SavedVideo>> {\n await this.load();\n const got = await decodeVideoPackage(text);\n if (!got.ok) return { ok: false, error: got.error };\n for (const a of got.value.assets)\n if (!this.catalog.mine.get(a.ref)) await this.addAsset(a, \"link\");\n this.client.analytics.logEvent(\"video_link_opened\", {\n format: got.value.spec.format,\n });\n return { ok: true, value: await this.createVideo(got.value.spec) };\n }\n\n /** Живая ссылка на ролик: приложение откроет его сразу на просмотр. */\n liveLink(contentID: string): string {\n const loc = globalThis.location;\n return loc\n ? `${loc.origin}${loc.pathname}#video=${encodeURIComponent(contentID)}`\n : `#video=${contentID}`;\n }\n\n /** Ассет или набор «Моих» — в Мастерскую (бесплатно или по цене, решает игрок). */\n async publishAssets(\n title: string,\n assets: Asset[],\n price?: { currency: string; amount: number },\n ): Promise<Result<string>> {\n if (!assets.length) return { ok: false, error: \"nothing to publish\" };\n const preview = assets.find((a) => a.kind === \"actor\" || a.kind === \"set\");\n const thumb = preview ? await assetThumb(preview, this.catalog) : null;\n if (!thumb) return { ok: false, error: \"could not draw the cover\" };\n const res = await this.client.workshop.publish({\n contentType: assets.length === 1 ? CONTENT.asset : CONTENT.pack,\n files: packAssets(title, assets).map((f) => ({\n role: f.role,\n contentType: f.contentType,\n data: f.data,\n })),\n thumbnail: { contentType: \"image/jpeg\", data: thumb },\n title: title.slice(0, 80),\n tags: [...new Set(assets.flatMap((a) => a.tags))].slice(0, 10),\n metadata: {\n kinds: [...new Set(assets.map((a) => a.kind))].join(\",\"),\n styles: [...new Set(assets.map((a) => a.style))].join(\",\"),\n },\n visibility: \"Public\",\n access: price\n ? [\n {\n Mode: \"Price\",\n Price: {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: price.currency,\n Amount: price.amount,\n },\n ],\n },\n },\n ]\n : [{ Mode: \"Free\" }],\n });\n if (!res.ok) return { ok: false, error: res.error };\n return { ok: true, value: res.data.Content?.ContentID ?? \"\" };\n }\n\n /** Взять ассет или пакет из Мастерской в «Мои ассеты». */\n async takeAssets(\n contentID: string,\n option?: WorkshopAccessOption | null,\n ): Promise<Result<Asset[]>> {\n let got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) {\n // платный — по цене, которую игрок видел на карточке (сервер сверит и не возьмёт иную)\n const acq = await this.client.workshop.acquire(contentID, option);\n if (!acq.ok) return { ok: false, error: acq.error };\n got = await this.client.workshop.downloadFiles(contentID);\n if (!got.ok) return { ok: false, error: got.error };\n }\n const u = unpackAssets(got.data.files, contentID);\n if (!u.ok) return u;\n for (const a of u.value) await this.addAsset(a, \"workshop\");\n return { ok: true, value: u.value };\n }\n\n /** Промах библиотеки (ИИ-шаг плана): что нарисовать в следующий пакет. */\n logMiss(item: PlanItem): void {\n if (item.step !== \"ai-asset\" && item.step !== \"ai-clip\") return;\n this.client.analytics.logEvent(\"library_miss\", {\n kind: item.generate?.kind ?? \"clip\",\n slot: item.slot.slice(0, 40),\n prompt: (item.generate?.prompt ?? \"\").slice(0, 100),\n });\n }\n}\n\n/** Обложка ассета: сцена с первым актёром её стиля, или актёр в первой сцене своего стиля. */\nasync function assetThumb(\n asset: Asset,\n catalog: StudioCatalog,\n): Promise<Blob | null> {\n const first = (kind: \"actor\" | \"set\") =>\n catalog.library.list({ kind, style: asset.style })[0] ??\n catalog.library.list({ kind })[0];\n const set = asset.kind === \"set\" ? asset : first(\"set\");\n const actor = asset.kind === \"actor\" ? asset : first(\"actor\");\n if (!set || !actor) return null;\n return captureFrame(demoSpec(set.ref, actor.ref, asset.style), catalog, 0.5);\n}\n\nfunction demoSpec(\n set: string,\n actor: string,\n style: Asset[\"style\"],\n): VideoSpec {\n return {\n v: 1,\n format: \"story@1\",\n style,\n aspect: \"9:16\",\n fps: 30,\n seed: 7,\n set,\n cast: [{ asset: actor }],\n lines: [{ who: 0, text: \"Hi!\", emotion: \"happy\" }],\n captions: \"clean\",\n overrides: [],\n assets: {},\n };\n}\n"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"path": "ui/AssetPicker.tsx",
|
|
169
|
+
"content": "// Выбор ассета (героя, сцены, музыки): поиск по словам, стиль ролика сверху (style lock), «Мои»\n// первыми, премиум — с замком: нажатие ведёт в магазин за пакетом.\nimport { useMemo, useState, type ReactNode } from \"react\";\nimport type { Asset } from \"@idosgames/timeline\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport {\n Card,\n Columns,\n EmptyState,\n Popup,\n TextInput,\n Toggle,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\nimport { canPick, packOf } from \"../model/access\";\nimport { t } from \"../i18n\";\nimport { useStudio, useStudioEnv } from \"./context\";\n\nexport function assetIcon(a: Asset): string {\n if (a.kind === \"music\") return \"🎵\";\n if (a.kind === \"set\") return a.style === \"voxel-warm\" ? \"🏙️\" : \"🖼️\";\n if (a.kind === \"prop\") return \"🧸\";\n if (a.tags.includes(\"photo\")) return \"📷\";\n if (a.body.type === \"rig2d\") return \"🦸\";\n if (a.body.type === \"gltf\") return \"🧍\";\n if (a.body.type === \"cutout\") return \"✂️\";\n return a.tags.some((x) =>\n [\n \"animal\",\n \"cat\",\n \"dog\",\n \"fox\",\n \"bear\",\n \"rabbit\",\n \"bird\",\n \"dragon\",\n ].includes(x),\n )\n ? \"🐾\"\n : \"🧊\";\n}\n\nexport function AssetPicker({\n kind,\n style,\n title,\n current,\n onPick,\n onClose,\n}: {\n kind: Asset[\"kind\"];\n style: StyleFamily;\n title: string;\n current?: string | null;\n onPick: (a: Asset) => void;\n onClose: () => void;\n}): ReactNode {\n const studio = useStudio();\n const { features } = useStudioEnv();\n const [text, setText] = useState(\"\");\n const [lock, setLock] = useState(kind !== \"music\");\n const access = studio.access();\n const list = useMemo(() => {\n const all = studio.catalog.list({\n kind,\n ...(lock ? { style } : {}),\n ...(text.trim() ? { text } : {}),\n });\n const mine = all.filter((a) => !a.ref.startsWith(\"lib:\"));\n const lib = all.filter((a) => a.ref.startsWith(\"lib:\"));\n // чужие ассеты открытого ролика в выбор не идут\n return [...mine.filter((a) => studio.catalog.mine.get(a.ref)), ...lib];\n }, [studio, kind, style, text, lock]);\n\n return (\n <Popup title={title} onClose={onClose} width={560}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <TextInput\n value={text}\n onChange={(e) => setText(e.target.value)}\n placeholder=\"🔍\"\n style={{ flex: 1, padding: \"8px 10px\" }}\n />\n {kind !== \"music\" ? (\n <label\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n color: v.textDim,\n fontSize: 12,\n }}\n >\n <Toggle value={lock} onChange={setLock} label=\"style\" />\n {t(\"styleLabel\")}\n </label>\n ) : null}\n </div>\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text=\"—\" />\n ) : (\n <Columns min={120} gap={8}>\n {list.map((a) => {\n const open = canPick(a, access);\n return (\n <Card\n key={a.ref}\n highlight={a.ref === current}\n onClick={() => {\n if (open) onPick(a);\n else if (packOf(a)) features.open(\"store\");\n }}\n style={{\n display: \"grid\",\n gap: 4,\n justifyItems: \"center\",\n textAlign: \"center\",\n opacity: open ? 1 : 0.6,\n }}\n >\n <span style={{ fontSize: 28 }}>\n {open ? assetIcon(a) : \"🔒\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, lineHeight: 1.15 }}>\n {a.title}\n </span>\n {!open ? (\n <span style={{ color: v.gold, fontSize: 11 }}>\n {t(\"locked\")}\n </span>\n ) : null}\n </Card>\n );\n })}\n </Columns>\n )}\n </div>\n </Popup>\n );\n}\n"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"path": "ui/Challenges.tsx",
|
|
173
|
+
"content": "// Челленджи: недельный лидерборд авторов (очко за опубликованный ролик), квесты, VIP и магазин\n// студии — экраны систем платформы, студия их только открывает. Внизу — «Сделать свою студию»:\n// ремикс этого приложения целиком на idosgames.com.\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport type { GetLeaderboardResponse } from \"@idosgames/core\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n MenuTile,\n SectionTitle,\n Spinner,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\nimport { CHALLENGE_BOARD } from \"../ids\";\nimport { t, tf } from \"../i18n\";\nimport { useStudio, useStudioEnv } from \"./context\";\n\nexport function Challenges(): ReactNode {\n const studio = useStudio();\n const { features } = useStudioEnv();\n const [board, setBoard] = useState<GetLeaderboardResponse | null | undefined>(\n undefined,\n );\n const me = studio.client.auth.context?.userID;\n\n useEffect(() => {\n let alive = true;\n void studio.client.leaderboard\n .getLeaderboard(CHALLENGE_BOARD)\n .then((r) => alive && setBoard(r.ok ? r.data : null));\n return () => {\n alive = false;\n };\n }, [studio]);\n\n const top = board?.TopUsers ?? [];\n const mine = top.find((u) => u.UserID === me);\n const titleBase = studio.titleId.replace(/-DEV$/i, \"\");\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n <SectionTitle>🏆 {t(\"weekly\")}</SectionTitle>\n <div style={{ color: v.textDim, fontSize: 13 }}>{t(\"weeklyD\")}</div>\n {board === undefined ? (\n <Center>\n <Spinner />\n </Center>\n ) : !top.length ? (\n <EmptyState glyph=\"trophy\" text={t(\"boardEmpty\")} />\n ) : (\n <Card style={{ display: \"grid\", gap: 4, padding: 10 }}>\n {top.slice(0, 20).map((u, i) => (\n <div\n key={u.UserID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"28px 1fr auto\",\n gap: 8,\n alignItems: \"center\",\n padding: \"4px 0\",\n color: u.UserID === me ? v.gold : v.text,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {u.Rank ?? i + 1}\n </span>\n <span\n style={{\n fontSize: 14,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {u.PublicProfile?.Username || u.UserID.slice(0, 8)}\n </span>\n <span style={{ ...outlined, fontSize: 14 }}>\n 🎬 {String(u.Score ?? 0)}\n </span>\n </div>\n ))}\n </Card>\n )}\n {mine ? (\n <div style={{ ...outlined, fontSize: 13 }}>\n {tf(\"myScore\", { n: String(mine.Score ?? 0) })}\n </div>\n ) : null}\n\n <div\n style={{\n display: \"flex\",\n gap: 10,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {features.get(\"quests\")?.available ? (\n <MenuTile\n glyph=\"quest\"\n label={t(\"quests\")}\n onClick={() => features.open(\"quests\")}\n />\n ) : null}\n {features.get(\"premium\")?.available ? (\n <MenuTile\n glyph=\"crown\"\n label={t(\"vip\")}\n onClick={() => features.open(\"premium\")}\n />\n ) : null}\n {features.get(\"store\")?.available ? (\n <MenuTile\n glyph=\"shop\"\n label={t(\"shop\")}\n onClick={() => features.open(\"store\")}\n />\n ) : null}\n </div>\n\n <Card style={{ display: \"grid\", gap: 8, padding: 12 }}>\n <span style={{ ...outlined, fontSize: 16 }}>🎬 {t(\"ownStudio\")}</span>\n <span style={{ color: v.textDim, fontSize: 13 }}>\n {t(\"ownStudioD\")}\n </span>\n <Button\n tone=\"gold\"\n onClick={() =>\n window.open(\n `https://idosgames.com/remix/${encodeURIComponent(titleBase)}`,\n \"_blank\",\n \"noopener\",\n )\n }\n >\n {t(\"ownStudio\")}\n </Button>\n </Card>\n </div>\n );\n}\n"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"path": "ui/context.tsx",
|
|
177
|
+
"content": "import {\n createContext,\n useContext,\n useEffect,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport type { Studio, StudioView } from \"../studio\";\nimport type { SavedAsset, SavedVideo } from \"../model/store\";\n\nexport interface StudioEnv {\n studio: Studio;\n /** Экраны систем платформы (квесты, магазин, VIP) — открываются по id, модуль их не знает. */\n features: FeatureRegistry;\n}\n\nconst Ctx = createContext<StudioEnv | null>(null);\n\nexport function StudioProvider({\n env,\n children,\n}: {\n env: StudioEnv;\n children: ReactNode;\n}): ReactNode {\n return <Ctx.Provider value={env}>{children}</Ctx.Provider>;\n}\n\nexport function useStudioEnv(): StudioEnv {\n const env = useContext(Ctx);\n if (!env) throw new Error(\"useStudio outside StudioProvider\");\n return env;\n}\n\nexport const useStudio = () => useStudioEnv().studio;\n\nexport function useStudioView(): StudioView {\n const studio = useStudio();\n return useSyncExternalStore(\n (l) => studio.subscribe(l),\n () => studio.view,\n () => studio.view,\n );\n}\n\n/** Списки хранилища, живые (перечитываются на каждую запись). */\nexport function useSaved(): {\n videos: SavedVideo[] | null;\n assets: SavedAsset[] | null;\n} {\n const studio = useStudio();\n const [videos, setVideos] = useState<SavedVideo[] | null>(null);\n const [assets, setAssets] = useState<SavedAsset[] | null>(null);\n useEffect(() => {\n let alive = true;\n const read = () => {\n void studio.store.videos().then((v) => alive && setVideos(v));\n void studio.store.assets().then((a) => alive && setAssets(a));\n };\n read();\n const off = studio.store.subscribe(read);\n return () => {\n alive = false;\n off();\n };\n }, [studio]);\n return { videos, assets };\n}\n"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"path": "ui/Create.tsx",
|
|
181
|
+
"content": "// Создание: формат → стиль → идея (+ своё фото) → AI-режиссёр. «Быстро» — только библиотека, ролик\n// готов сразу; «Уникально» — режиссёр ещё и предлагает ИИ-ассеты под идею, их смета откроется в\n// редакторе ДО оплаты. Цена режиссёра — на кнопке (из настроек тайтла). Без ИИ на тайтле — готовые\n// ролики из «Трендов».\nimport { useRef, useState, type ReactNode } from \"react\";\nimport { listFormats } from \"@idosgames/timeline\";\nimport type { StyleFamily } from \"@idosgames/characters\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n EmptyState,\n Field,\n ResourceList,\n Select,\n TextArea,\n outlined,\n useToast,\n v,\n errorText,\n} from \"@idosgames/react/ui\";\nimport { direct, type StudioMode } from \"../ai/director\";\nimport { directorPrice } from \"../model/pricing\";\nimport { photoActor } from \"../media/own\";\nimport { lang, t, type StudioKey } from \"../i18n\";\nimport { useStudio } from \"./context\";\n\nconst STYLES: Array<{ id: StyleFamily; label: StudioKey }> = [\n { id: \"voxel-warm\", label: \"s_voxel\" },\n { id: \"chibi-2d\", label: \"s_2d\" },\n];\n\nconst HINT: Record<string, StudioKey> = {\n \"dialogue@1\": \"ideaHint_dialogue\",\n \"story@1\": \"ideaHint_story\",\n \"top5@1\": \"ideaHint_top5\",\n \"quiz@1\": \"ideaHint_quiz\",\n \"whatif@1\": \"ideaHint_whatif\",\n};\n\nexport function Create({\n initialFormat,\n}: {\n initialFormat?: string;\n}): ReactNode {\n const studio = useStudio();\n const state = useUserState();\n const toast = useToast();\n const formats = listFormats();\n const [format, setFormat] = useState(\n initialFormat && formats.some((f) => f.id === initialFormat)\n ? initialFormat\n : formats[0]!.id,\n );\n const [style, setStyle] = useState<StyleFamily>(\"voxel-warm\");\n const [idea, setIdea] = useState(\"\");\n const [mode, setMode] = useState<StudioMode>(\"quick\");\n const [cast, setCast] = useState(0);\n const [photo, setPhoto] = useState<{ ref: string; title: string } | null>(\n null,\n );\n const [busy, setBusy] = useState(false);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const ai = studio.ai();\n const price = directorPrice(ai);\n const fmt = formats.find((f) => f.id === format)!;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(price.price, balances);\n\n if (!price.exists)\n return (\n <EmptyState\n glyph=\"clapper\"\n text={t(\"noDirector\")}\n action={\n <Button tone=\"gold\" onClick={() => studio.go({ screen: \"home\" })}>\n {t(\"tabTrends\")}\n </Button>\n }\n />\n );\n\n const go = async () => {\n if (!idea.trim()) return toast(t(\"needIdea\"), \"info\");\n setBusy(true);\n const r = await direct(\n studio.client,\n studio.catalog,\n studio.access(),\n {\n idea: idea.trim(),\n format,\n style,\n language: lang() === \"ru\" ? \"Russian\" : \"English\",\n mode,\n ...(cast ? { cast } : {}),\n ...(photo\n ? {\n media: `${photo.ref} — the player's photo \"${photo.title}\": cast it as one of the characters`,\n }\n : {}),\n },\n price.optionID ? { optionID: price.optionID } : {},\n );\n setBusy(false);\n if (!r.ok)\n return toast(r.error ? errorText(r.error) : t(\"directFailed\"), \"error\");\n for (const p of r.value.plan) studio.logMiss(p);\n // фото игрока — в первого персонажа, если режиссёр его не поставил\n const spec = r.value.spec;\n if (photo && !spec.cast.some((c) => c.asset === photo.ref) && spec.cast[0])\n spec.cast[0] = { ...spec.cast[0], asset: photo.ref };\n await studio.createVideo(\n { ...spec, title: spec.title || idea.trim().slice(0, 60) },\n r.value.plan,\n );\n };\n\n const addPhoto = async (file: File | undefined) => {\n if (!file) return;\n try {\n const actor = await photoActor(\n file,\n file.name.replace(/\\.[a-z0-9]+$/i, \"\"),\n style,\n );\n await studio.addAsset(actor, \"photo\");\n setPhoto({ ref: actor.ref, title: actor.title });\n toast(t(\"photoAdded\"), \"success\");\n } catch (e) {\n toast(errorText(e instanceof Error ? e.message : String(e)), \"error\");\n }\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Field label={t(\"format\")}>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {formats.map((f) => (\n <Button\n key={f.id}\n size=\"sm\"\n tone={f.id === format ? \"gold\" : \"grey\"}\n onClick={() => setFormat(f.id)}\n >\n {t(`f_${f.id}` as StudioKey)}\n </Button>\n ))}\n </div>\n </Field>\n <div style={{ color: v.textDim, fontSize: 13 }}>\n {t(`f_${format}_d` as StudioKey)}\n </div>\n\n <Field label={t(\"styleLabel\")}>\n <div style={{ display: \"flex\", gap: 6 }}>\n {STYLES.map((s) => (\n <Button\n key={s.id}\n size=\"sm\"\n tone={s.id === style ? \"gold\" : \"grey\"}\n onClick={() => setStyle(s.id)}\n >\n {t(s.label)}\n </Button>\n ))}\n </div>\n </Field>\n\n <Field label={t(\"idea\")}>\n <TextArea\n value={idea}\n onChange={(e) => setIdea(e.target.value)}\n maxLength={600}\n rows={4}\n placeholder={t(HINT[format] ?? \"ideaHint_dialogue\")}\n style={{ width: \"100%\", padding: 12 }}\n />\n </Field>\n\n <div style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}>\n {([\"quick\", \"unique\"] as const).map((m) => (\n <Card\n key={m}\n highlight={mode === m}\n onClick={() => setMode(m)}\n style={{ display: \"grid\", gap: 4 }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {m === \"quick\" ? `⚡ ${t(\"modeQuick\")}` : `✨ ${t(\"modeUnique\")}`}\n </span>\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {m === \"quick\" ? t(\"modeQuickD\") : t(\"modeUniqueD\")}\n </span>\n </Card>\n ))}\n </div>\n\n {fmt.maxCast > fmt.minCast ? (\n <Field label={t(\"castCount\")}>\n <Select\n value={String(cast)}\n onChange={(e) => setCast(Number(e.target.value))}\n >\n <option value=\"0\">{t(\"castAuto\")}</option>\n {Array.from(\n { length: fmt.maxCast - fmt.minCast + 1 },\n (_, i) => fmt.minCast + i,\n ).map((n) => (\n <option key={n} value={String(n)}>\n {n}\n </option>\n ))}\n </Select>\n </Field>\n ) : null}\n\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n hidden\n onChange={(e) => void addPhoto(e.target.files?.[0])}\n />\n <Button tone=\"blue\" size=\"sm\" onClick={() => fileRef.current?.click()}>\n 📷 {photo ? t(\"photoAdded\") : t(\"myPhoto\")}\n </Button>\n\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={!!idea.trim() && affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void go()}\n data-tutorial-anchor=\"video-studio:direct\"\n >\n {busy ? t(\"directing\") : t(\"direct\")}{\" \"}\n {price.price.length ? (\n <ResourceList lines={price.price} size={18} />\n ) : null}\n </Button>\n </div>\n );\n}\n"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"path": "ui/Editor.tsx",
|
|
185
|
+
"content": "// Редактор ролика: превью сверху, под ним три уровня правки — Слоты (L1: герои, сцена, голоса,\n// музыка, реплики), Дорожки (L2: камера, эффекты, звуки, надписи, сдвиги — правками поверх\n// формата) и Готово (смета ИИ-шагов, финальный голос, MP4, публикация, живая ссылка). Каждая\n// правка сохраняется в «Мои ролики» сама; превью пересобирается чуть позже последней правки.\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { PlanItem, VideoSpec } from \"@idosgames/timeline\";\nimport {\n Center,\n Spinner,\n Tabs,\n TextInput,\n ScreenHeader,\n v,\n} from \"@idosgames/react/ui\";\nimport type { SavedVideo } from \"../model/store\";\nimport { t } from \"../i18n\";\nimport { useStudio } from \"./context\";\nimport { PlayerView, type PlayerHandle } from \"./PlayerView\";\nimport { Slots } from \"./Slots\";\nimport { Tracks } from \"./Tracks\";\nimport { Finish } from \"./Finish\";\n\ntype Tab = \"slots\" | \"tracks\" | \"finish\";\n\nexport interface EditorApi {\n spec: VideoSpec;\n change(next: VideoSpec | ((s: VideoSpec) => VideoSpec)): void;\n player: PlayerHandle | null;\n saved: SavedVideo;\n plan: PlanItem[];\n setPlan(p: PlanItem[]): void;\n}\n\nexport function Editor({\n videoId,\n plan: initialPlan,\n}: {\n videoId: string;\n plan?: PlanItem[];\n}): ReactNode {\n const studio = useStudio();\n const [saved, setSaved] = useState<SavedVideo | null>(null);\n const [spec, setSpec] = useState<VideoSpec | null>(null);\n const [live, setLive] = useState<VideoSpec | null>(null);\n const [tab, setTab] = useState<Tab>(initialPlan?.length ? \"finish\" : \"slots\");\n const [plan, setPlan] = useState<PlanItem[]>(initialPlan ?? []);\n const [player, setPlayer] = useState<PlayerHandle | null>(null);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const latest = useRef<VideoSpec | null>(null);\n\n useEffect(() => {\n let alive = true;\n void studio.store.video(videoId).then((v) => {\n if (!alive || !v) return;\n setSaved(v);\n setSpec(v.spec);\n setLive(v.spec);\n latest.current = v.spec;\n });\n return () => {\n alive = false;\n };\n }, [studio, videoId]);\n\n // уход из редактора: сохранить и снять обложку для «Моих роликов»\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n const s = latest.current;\n if (s)\n void studio\n .updateVideo(videoId, s)\n .then(() => studio.refreshThumb(videoId));\n },\n [studio, videoId],\n );\n\n const api = useMemo<EditorApi | null>(() => {\n if (!spec || !saved) return null;\n return {\n spec,\n saved,\n player,\n plan,\n setPlan,\n change(next) {\n setSpec((cur) => {\n if (!cur) return cur;\n const value = typeof next === \"function\" ? next(cur) : next;\n latest.current = value;\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n setLive(value);\n void studio.updateVideo(videoId, value);\n void studio.store.video(videoId).then((v) => v && setSaved(v));\n }, 450);\n return value;\n });\n },\n };\n }, [spec, saved, player, plan, studio, videoId]);\n\n if (!api || !live)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <ScreenHeader\n title={\n <TextInput\n value={api.spec.title ?? \"\"}\n placeholder={t(\"untitled\")}\n maxLength={80}\n onChange={(e) =>\n api.change((s) => ({ ...s, title: e.target.value }))\n }\n style={{ width: \"100%\", padding: \"6px 10px\" }}\n />\n }\n icon=\"clapper\"\n onBack={() => studio.go({ screen: \"home\" })}\n />\n {api.spec.parent ? (\n <div style={{ color: v.textDim, fontSize: 12, textAlign: \"center\" }}>\n ↻ {t(\"remixOf\")}\n </div>\n ) : null}\n <PlayerView spec={live} catalog={studio.catalog} onReady={setPlayer} />\n <Tabs\n tabs={[\n { id: \"slots\", label: t(\"slots\") },\n { id: \"tracks\", label: t(\"tracks\") },\n { id: \"finish\", label: t(\"finish\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"slots\" ? (\n <Slots api={api} />\n ) : tab === \"tracks\" ? (\n <Tracks api={api} />\n ) : (\n <Finish api={api} />\n )}\n </div>\n );\n}\n"
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"path": "ui/Finish.tsx",
|
|
189
|
+
"content": "// «Готово»: всё, что стоит денег, — здесь и только с ценой на кнопке. Смета плана режиссёра\n// (ИИ-ассеты под идею), черновой голос (бесплатно), финальный голос (платятся только новые и\n// изменённые реплики), MP4 на устройстве (водяной знак без VIP, HD с VIP), публикация в ленту и\n// живая ссылка. Отказ генерации ничего не списывает — движок возвращает сам.\nimport { useState, type ReactNode } from \"react\";\nimport type { PlanItem, VideoSpec } from \"@idosgames/timeline\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n Field,\n ProgressBar,\n ResourceList,\n SectionTitle,\n TextInput,\n outlined,\n useToast,\n v,\n errorText,\n} from \"@idosgames/react/ui\";\nimport { generateAsset } from \"../ai/assets\";\nimport { hasFinalVoice, pendingLines, voiceLines } from \"../ai/voice\";\nimport { estimateVideo, featurePrice, FEATURE_OF_KIND } from \"../model/pricing\";\nimport { setCast } from \"../model/edit\";\nimport { FEATURES } from \"../ids\";\nimport { deliverFile, exportVideo } from \"../render\";\nimport { t, tf, type StudioKey } from \"../i18n\";\nimport { useStudio } from \"./context\";\nimport type { EditorApi } from \"./Editor\";\n\n/** Куда ложится сгенерированное: слот плана → правка ролика. */\nfunction applyToSlot(\n spec: VideoSpec,\n slot: string,\n ref: string,\n kind: string,\n): VideoSpec {\n const cast = /^cast\\[(\\d+)\\]$/.exec(slot);\n if (cast) {\n const i = Number(cast[1]);\n return i < spec.cast.length\n ? setCast(spec, i, { asset: ref })\n : { ...spec, cast: [...spec.cast, { asset: ref }] };\n }\n if (slot === \"set\" || kind === \"background\") return { ...spec, set: ref };\n if (slot === \"music\" || kind === \"music\") return { ...spec, music: ref };\n if (slot.startsWith(\"prop:\") && spec.cast[0])\n return setCast(spec, 0, {\n props: { ...(spec.cast[0].props ?? {}), handR: ref },\n });\n return spec;\n}\n\nexport function Finish({ api }: { api: EditorApi }): ReactNode {\n const studio = useStudio();\n const state = useUserState();\n const toast = useToast();\n const { spec } = api;\n const ai = studio.ai();\n const access = studio.access();\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const [busy, setBusy] = useState<string | null>(null);\n const [voicing, setVoicing] = useState<{\n done: number;\n total: number;\n } | null>(null);\n const [progress, setProgress] = useState<number | null>(null);\n const [title, setTitle] = useState(spec.title ?? \"\");\n\n const finalPending = pendingLines(spec, studio.catalog, true);\n const draftPending = pendingLines(spec, studio.catalog, false);\n const estimate = estimateVideo({\n ai,\n plan: api.plan,\n voiceLines: finalPending.length,\n });\n const voicePrice = featurePrice(ai, FEATURES.voice);\n const draftPrice = featurePrice(ai, FEATURES.voiceDraft);\n const voiced = hasFinalVoice(spec, studio.catalog);\n // без фичи озвучки на тайтле ролик выходит с субтитрами без голоса — ждать нечего\n const needVoice = voicePrice.exists && !voiced;\n\n const make = async (item: PlanItem): Promise<boolean> => {\n if (!item.generate) return false;\n const feature = FEATURE_OF_KIND[item.generate.kind];\n const price = feature ? featurePrice(ai, feature) : null;\n const baseSet = studio.catalog.get(spec.set);\n const r = await generateAsset(\n studio.client,\n item.generate.kind,\n item.generate.prompt,\n {\n style: spec.style,\n ...(price?.optionID ? { optionID: price.optionID } : {}),\n ...(baseSet?.kind === \"set\" ? { baseSet } : {}),\n },\n );\n if (!r.ok) {\n toast(r.refunded ? t(\"genFailed\") : errorText(r.error), \"error\");\n return false;\n }\n await studio.addAsset(r.asset, \"ai\");\n api.change((s) =>\n applyToSlot(s, item.slot, r.asset.ref, item.generate!.kind),\n );\n api.setPlan(api.plan.filter((p) => p !== item));\n // квест «свой герой» засчитывает сервер (QuestMetricOnCompleted у фич персонажей)\n return true;\n };\n\n const makeOne = async (item: PlanItem) => {\n setBusy(item.slot);\n const ok = await make(item);\n setBusy(null);\n if (ok) toast(t(\"generated\"), \"success\");\n };\n\n const makeAll = async () => {\n setBusy(\"all\");\n for (const item of estimate.rows.filter(\n (r) => r.kind !== \"voice\" && r.kind !== \"clip\" && !r.unavailable,\n )) {\n const plan = api.plan.find(\n (p) => p.slot === item.slot && p.generate?.kind === item.kind,\n );\n if (plan && !(await make(plan))) break;\n }\n setBusy(null);\n };\n\n const voice = async (final: boolean) => {\n setBusy(final ? \"final\" : \"draft\");\n const price = final ? voicePrice : draftPrice;\n const r = await voiceLines(studio.client, spec, studio.catalog, {\n final,\n ...(price.optionID ? { optionID: price.optionID } : {}),\n onProgress: setVoicing,\n });\n setVoicing(null);\n setBusy(null);\n // в ролик — только озвучка: пока шла генерация, игрок мог поправить что-то ещё\n api.change((s) => ({\n ...s,\n assets: {\n ...s.assets,\n voice: { ...(s.assets.voice ?? {}), ...(r.spec.assets.voice ?? {}) },\n },\n }));\n if (r.failed.length)\n toast(r.error ? errorText(r.error) : t(\"genFailed\"), \"error\");\n else if (final) toast(t(\"voiceReady\"), \"success\");\n };\n\n const exportMp4 = async () => {\n setBusy(\"export\");\n setProgress(0);\n const watermarked: VideoSpec = { ...spec, watermark: !access.vip };\n const r = await exportVideo(watermarked, studio.catalog, {\n hd: access.vip,\n onProgress: setProgress,\n });\n setProgress(null);\n setBusy(null);\n if (!r.ok)\n return toast(\n r.reason === \"unsupported\"\n ? t(\"exportUnsupported\")\n : `${t(\"exportFailed\")}: ${r.message}`,\n \"error\",\n );\n await deliverFile(\n r.blob,\n `${(spec.title || \"video\").replace(/[^\\p{L}\\p{N}_-]+/gu, \"_\").slice(0, 40)}.mp4`,\n );\n toast(t(\"exported\"), \"success\");\n };\n\n const publish = async () => {\n setBusy(\"publish\");\n // в ленту ролик уходит с финальным голосом: черновой не проходит\n const name = title.trim() || spec.title || t(\"untitled\");\n if (name !== spec.title) {\n api.change((s) => ({ ...s, title: name }));\n await studio.updateVideo(api.saved.id, { ...spec, title: name });\n }\n const r = await studio.publish(api.saved.id, {\n title: name,\n tags: [spec.format.replace(/@\\d+$/, \"\"), spec.style],\n });\n setBusy(null);\n if (!r.ok) return toast(errorText(r.error), \"error\");\n toast(t(\"published\"), \"success\");\n };\n\n const contentID = api.saved.contentID;\n const link = contentID ? studio.liveLink(contentID) : null;\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <SectionTitle>{t(\"estimate\")}</SectionTitle>\n <Card style={{ display: \"grid\", gap: 8, padding: 10 }}>\n {estimate.freeSteps ? (\n <div style={{ color: v.green, fontSize: 13 }}>\n ✓ {tf(\"freeSteps\", { n: estimate.freeSteps })}\n </div>\n ) : null}\n {estimate.rows.every((r) => r.kind === \"voice\") ? (\n <div style={{ color: v.textDim, fontSize: 13 }}>\n {t(\"estimateFree\")}\n </div>\n ) : null}\n {estimate.rows\n .filter((r) => r.kind !== \"voice\")\n .map((r) => {\n const item = api.plan.find(\n (p) =>\n p.slot === r.slot &&\n (p.generate?.kind === r.kind || r.kind === \"clip\"),\n );\n return (\n <div\n key={r.slot + r.kind}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 6,\n alignItems: \"center\",\n }}\n >\n <div style={{ display: \"grid\" }}>\n <span style={{ ...outlined, fontSize: 13 }}>\n {t(`k_${r.kind}` as StudioKey)}\n </span>\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {item?.generate?.prompt ?? item?.note ?? r.slot}\n </span>\n </div>\n {r.unavailable || r.kind === \"clip\" ? (\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {t(\"unavailable\")}\n </span>\n ) : (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === r.slot}\n disabled={!!busy || !canAfford(r.unit, balances)}\n onClick={() => item && void makeOne(item)}\n >\n {t(\"generate\")}{\" \"}\n {r.unit.length ? (\n <ResourceList lines={r.unit} size={14} />\n ) : null}\n </Button>\n )}\n </div>\n );\n })}\n {estimate.rows.filter(\n (r) => r.kind !== \"voice\" && r.kind !== \"clip\" && !r.unavailable,\n ).length > 1 ? (\n <Button\n tone=\"gold\"\n busy={busy === \"all\"}\n disabled={!!busy || !canAfford(estimate.total, balances)}\n onClick={() => void makeAll()}\n >\n {t(\"generateAll\")} <ResourceList lines={estimate.total} size={16} />\n </Button>\n ) : null}\n </Card>\n\n <Field label={t(\"voice\")}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {draftPending.length && draftPrice.exists ? (\n <Card\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 6,\n alignItems: \"center\",\n padding: 10,\n }}\n >\n <div style={{ display: \"grid\" }}>\n <span style={{ ...outlined, fontSize: 13 }}>\n {t(\"draftVoice\")}\n </span>\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {t(\"draftVoiceD\")}\n </span>\n </div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n busy={busy === \"draft\"}\n disabled={!!busy}\n onClick={() => void voice(false)}\n >\n 🎙 ×{draftPending.length}\n </Button>\n </Card>\n ) : null}\n <Card\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 6,\n alignItems: \"center\",\n padding: 10,\n }}\n >\n <div style={{ display: \"grid\" }}>\n <span style={{ ...outlined, fontSize: 13 }}>\n {voiced ? `✓ ${t(\"voiceReady\")}` : t(\"finalVoice\")}\n </span>\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {t(\"finalVoiceD\")}\n </span>\n </div>\n {!voiced && voicePrice.exists ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === \"final\"}\n disabled={\n !!busy ||\n !canAfford(\n voicePrice.price.map((l) => ({\n ...l,\n amount: l.amount * finalPending.length,\n })),\n balances,\n )\n }\n onClick={() => void voice(true)}\n >\n {tf(\"k_voice\", { n: finalPending.length })}{\" \"}\n {voicePrice.price.length ? (\n <ResourceList\n lines={voicePrice.price.map((l) => ({\n ...l,\n amount: l.amount * finalPending.length,\n }))}\n size={14}\n />\n ) : null}\n </Button>\n ) : null}\n </Card>\n {voicing ? (\n <ProgressBar\n value={voicing.done}\n max={Math.max(1, voicing.total)}\n label={tf(\"voicing\", voicing)}\n tone=\"blue\"\n />\n ) : null}\n </div>\n </Field>\n\n <Field label={t(\"exportMp4\")}>\n <div style={{ display: \"grid\", gap: 6 }}>\n {!access.vip ? (\n <div style={{ color: v.textDim, fontSize: 12 }}>\n {t(\"watermarkOn\")}\n </div>\n ) : (\n <div style={{ color: v.green, fontSize: 12 }}>\n ✓ VIP · {t(\"hd\")}\n </div>\n )}\n <Button\n tone=\"green\"\n busy={busy === \"export\"}\n disabled={!!busy || needVoice}\n onClick={() => void exportMp4()}\n >\n ⬇ {needVoice ? t(\"needFinalVoice\") : t(\"exportMp4\")}\n </Button>\n {progress !== null ? (\n <ProgressBar\n value={Math.round(progress * 100)}\n max={100}\n label={tf(\"exporting\", { p: Math.round(progress * 100) })}\n />\n ) : null}\n </div>\n </Field>\n\n <Field label={contentID ? t(\"update\") : t(\"publish\")}>\n <div style={{ display: \"grid\", gap: 6 }}>\n <TextInput\n value={title}\n maxLength={80}\n placeholder={t(\"publishTitle\")}\n onChange={(e) => setTitle(e.target.value)}\n style={{ padding: \"8px 10px\" }}\n />\n <Button\n tone=\"gold\"\n size=\"lg\"\n busy={busy === \"publish\"}\n disabled={!!busy || needVoice}\n attract={!needVoice && !contentID}\n onClick={() => void publish()}\n >\n 🚀{\" \"}\n {needVoice\n ? t(\"needFinalVoice\")\n : contentID\n ? t(\"update\")\n : t(\"publish\")}\n </Button>\n {link ? (\n <Card style={{ display: \"grid\", gap: 6, padding: 10 }}>\n <span style={{ ...outlined, fontSize: 13 }}>🔗 {t(\"link\")}</span>\n <span\n style={{\n color: v.textDim,\n fontSize: 11,\n wordBreak: \"break-all\",\n }}\n >\n {link}\n </span>\n <div style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => {\n void navigator.clipboard?.writeText(link);\n toast(t(\"linkCopied\"), \"success\");\n }}\n >\n {t(\"copyLink\")}\n </Button>\n {typeof navigator.share === \"function\" ? (\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() =>\n void navigator\n .share({ url: link, title: spec.title ?? \"\" })\n .catch(() => undefined)\n }\n >\n {t(\"share\")}\n </Button>\n ) : null}\n </div>\n </Card>\n ) : null}\n </div>\n </Field>\n </div>\n );\n}\n"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"path": "ui/labels.ts",
|
|
193
|
+
"content": "// Подписи того, что движок таймлайна называет по id: биты, движения камеры, эффекты, звуки,\n// частицы, эмоции, стили субтитров. По-русски — названия из пакета, по-английски — сам id словами.\nimport { EMOTIONS, type Emotion } from \"@idosgames/characters\";\nimport {\n BEATS,\n CAMERA_FX,\n CAMERA_MOVES,\n CAPTION_STYLES,\n PARTICLES,\n SFX,\n} from \"@idosgames/timeline\";\nimport { lang } from \"../i18n\";\n\nconst words = (id: string) => {\n const s = id\n .replace(/^beat:/, \"\")\n .replace(/[-_]+/g, \" \")\n .replace(/@\\d+$/, \"\");\n return s.charAt(0).toUpperCase() + s.slice(1);\n};\n\nconst EMOTION_RU: Record<Emotion, string> = {\n neutral: \"Спокойно\",\n happy: \"Радость\",\n sad: \"Грусть\",\n angry: \"Злость\",\n surprised: \"Удивление\",\n scared: \"Страх\",\n tired: \"Усталость\",\n hurt: \"Больно\",\n};\n\nconst FX_RU: Record<string, string> = {\n shake: \"Тряска\",\n \"zoom-punch\": \"Зум-удар\",\n flash: \"Вспышка\",\n \"flash-black\": \"Затемнение\",\n vignette: \"Виньетка\",\n slowmo: \"Замедление\",\n \"fade-in\": \"Появление\",\n \"fade-out\": \"Затухание\",\n whip: \"Вжух\",\n tilt: \"Наклон\",\n};\n\nconst POSE_RU: Record<string, string> = {\n idle: \"Стоит\",\n talk: \"Говорит\",\n walk: \"Идёт\",\n run: \"Бежит\",\n surprised: \"Удивлён\",\n point: \"Показывает\",\n sit: \"Сидит\",\n wave: \"Машет\",\n jump: \"Прыгает\",\n fall: \"Падает\",\n dance: \"Танцует\",\n};\n\nconst BEAT_TITLES = new Map(BEATS.map((b) => [b.id, b.title]));\n/** Бит по id в любой записи («talk» и «beat:talk» — один бит) → как он лежит в библиотеке. */\nexport const beatId = (id: string | undefined) =>\n !id ? \"\" : id.startsWith(\"beat:\") ? id : `beat:${id}`;\nconst ru = () => lang() === \"ru\";\n\nexport const beatLabel = (id: string) =>\n ru() ? (BEAT_TITLES.get(beatId(id)) ?? id) : words(id);\nexport const moveLabel = (id: string) =>\n ru() ? (CAMERA_MOVES[id]?.title ?? id) : words(id);\nexport const fxLabel = (id: string) => (ru() ? (FX_RU[id] ?? id) : words(id));\nexport const sfxLabel = (id: string) =>\n ru() ? (SFX[id]?.title ?? id) : words(id);\nexport const particlesLabel = (id: string) =>\n ru() ? (PARTICLES[id]?.title ?? id) : words(id);\nexport const poseLabel = (id: string) =>\n ru() ? (POSE_RU[id] ?? id) : words(id);\nexport const emotionLabel = (e: Emotion) => (ru() ? EMOTION_RU[e] : words(e));\nexport const captionLabel = (id: string) =>\n ru() ? (CAPTION_STYLES[id]?.title ?? id) : words(id);\n\nexport const BEAT_IDS = BEATS.map((b) => b.id);\n/** Биты двоих (спор, объятие) в списке реакции не нужны. */\nexport const SOLO_BEATS = BEATS.filter((b) => !b.needs.includes(\"actor2\")).map(\n (b) => b.id,\n);\nexport const MOVE_IDS = Object.keys(CAMERA_MOVES);\nexport const FX_IDS: readonly string[] = CAMERA_FX;\nexport const SFX_IDS = Object.keys(SFX);\nexport const PARTICLE_IDS = Object.keys(PARTICLES);\nexport const CAPTION_IDS = Object.keys(CAPTION_STYLES);\nexport { EMOTIONS };\n\nexport function formatTime(t: number): string {\n const s = Math.max(0, t);\n return `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, \"0\")}`;\n}\n"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"path": "ui/Mine.tsx",
|
|
197
|
+
"content": "// «Мои»: ролики (черновики и опубликованные) и ассеты — сделанные ИИ, из фото, взятые в Мастерской.\n// Свой ассет можно выложить в Мастерскую бесплатно или за Кадры (доля автору — по правилам типа\n// \"video.asset\" тайтла), чужие ассеты и пакеты — взять оттуда же.\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport type { WorkshopContentView } from \"@idosgames/core\";\nimport {\n Button,\n Card,\n Center,\n Columns,\n ConfirmPopup,\n EmptyState,\n Popup,\n Select,\n Spinner,\n Tabs,\n TextInput,\n outlined,\n useStagger,\n useToast,\n v,\n errorText,\n} from \"@idosgames/react/ui\";\nimport { CONTENT, FRAMES } from \"../ids\";\nimport type { SavedAsset } from \"../model/store\";\nimport { t, tf } from \"../i18n\";\nimport { useSaved, useStudio } from \"./context\";\nimport { assetIcon } from \"./AssetPicker\";\n\ntype Tab = \"videos\" | \"assets\" | \"workshop\";\n\nexport function Mine(): ReactNode {\n const [tab, setTab] = useState<Tab>(\"videos\");\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={[\n { id: \"videos\", label: t(\"myVideos\") },\n { id: \"assets\", label: t(\"myAssets\") },\n { id: \"workshop\", label: t(\"fromWorkshop\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"videos\" ? (\n <Videos />\n ) : tab === \"assets\" ? (\n <Assets />\n ) : (\n <FromWorkshop />\n )}\n </div>\n );\n}\n\nfunction Videos(): ReactNode {\n const studio = useStudio();\n const stagger = useStagger();\n const { videos } = useSaved();\n const [removing, setRemoving] = useState<string | null>(null);\n if (!videos)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!videos.length)\n return (\n <EmptyState\n glyph=\"clapper\"\n text={t(\"noVideos\")}\n action={\n <Button tone=\"gold\" onClick={() => studio.go({ screen: \"create\" })}>\n {t(\"tabCreate\")}\n </Button>\n }\n />\n );\n return (\n <>\n <Columns min={150} gap={8}>\n {videos.map((vd, i) => (\n <Card\n key={vd.id}\n style={{ display: \"grid\", gap: 6, padding: 8, ...stagger(i) }}\n >\n <div\n onClick={() => studio.go({ screen: \"editor\", videoId: vd.id })}\n style={{\n aspectRatio: \"9 / 16\",\n borderRadius: 10,\n overflow: \"hidden\",\n background: \"#111\",\n cursor: \"pointer\",\n }}\n >\n {vd.thumb ? (\n <img\n src={vd.thumb}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : null}\n </div>\n <span style={{ ...outlined, fontSize: 13 }}>\n {vd.spec.title || t(\"untitled\")}\n </span>\n <span\n style={{\n color: vd.contentID ? v.green : v.textDim,\n fontSize: 11,\n }}\n >\n {vd.contentID\n ? `✓ ${t(\"publishedBadge\")}`\n : t(`f_${vd.spec.format}` as never)}\n {vd.spec.parent ? ` · ↻ ${t(\"remixOf\")}` : \"\"}\n </span>\n <div style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n style={{ flex: 1 }}\n onClick={() => studio.go({ screen: \"editor\", videoId: vd.id })}\n >\n {t(\"open\")}\n </Button>\n <Button size=\"sm\" tone=\"grey\" onClick={() => setRemoving(vd.id)}>\n ✕\n </Button>\n </div>\n </Card>\n ))}\n </Columns>\n {removing ? (\n <ConfirmPopup\n title={t(\"deleteVideo\")}\n tone=\"red\"\n confirmLabel={t(\"delete\")}\n onConfirm={() => studio.store.removeVideo(removing)}\n onClose={() => setRemoving(null)}\n />\n ) : null}\n </>\n );\n}\n\nfunction Assets(): ReactNode {\n const studio = useStudio();\n const toast = useToast();\n const stagger = useStagger();\n const { assets } = useSaved();\n const [removing, setRemoving] = useState<string | null>(null);\n const [publishing, setPublishing] = useState<SavedAsset | null>(null);\n if (!assets)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!assets.length) return <EmptyState glyph=\"hero\" text={t(\"noAssets\")} />;\n return (\n <>\n <Columns min={150} gap={8}>\n {assets.map((a, i) => (\n <Card\n key={a.asset.ref}\n style={{\n display: \"grid\",\n gap: 6,\n justifyItems: \"center\",\n textAlign: \"center\",\n ...stagger(i),\n }}\n >\n <span style={{ fontSize: 34 }}>{assetIcon(a.asset)}</span>\n <span style={{ ...outlined, fontSize: 13 }}>{a.asset.title}</span>\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {a.origin === \"ai\"\n ? \"✨ AI\"\n : a.origin === \"photo\"\n ? \"📷\"\n : a.origin === \"workshop\"\n ? \"🛠\"\n : \"\"}{\" \"}\n {a.contentID ? `· ✓ ${t(\"publishedBadge\")}` : \"\"}\n </span>\n <div style={{ display: \"flex\", gap: 6 }}>\n {a.origin !== \"workshop\" && !a.contentID ? (\n <Button size=\"sm\" tone=\"gold\" onClick={() => setPublishing(a)}>\n {t(\"publishAsset\")}\n </Button>\n ) : null}\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setRemoving(a.asset.ref)}\n >\n ✕\n </Button>\n </div>\n </Card>\n ))}\n </Columns>\n {removing ? (\n <ConfirmPopup\n title={t(\"deleteAsset\")}\n tone=\"red\"\n confirmLabel={t(\"delete\")}\n onConfirm={() => studio.removeAsset(removing)}\n onClose={() => setRemoving(null)}\n />\n ) : null}\n {publishing ? (\n <PublishAsset\n item={publishing}\n onClose={() => setPublishing(null)}\n onDone={async (contentID) => {\n await studio.store.saveAsset({ ...publishing, contentID });\n setPublishing(null);\n toast(t(\"published\"), \"success\");\n }}\n />\n ) : null}\n </>\n );\n}\n\nfunction PublishAsset({\n item,\n onClose,\n onDone,\n}: {\n item: SavedAsset;\n onClose: () => void;\n onDone: (contentID: string) => Promise<void>;\n}): ReactNode {\n const studio = useStudio();\n const toast = useToast();\n const [title, setTitle] = useState(item.asset.title);\n const [price, setPrice] = useState(0);\n const [busy, setBusy] = useState(false);\n return (\n <Popup title={t(\"publishAsset\")} onClose={onClose} width={380}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <TextInput\n value={title}\n maxLength={80}\n onChange={(e) => setTitle(e.target.value)}\n style={{ padding: \"8px 10px\" }}\n />\n <Select\n value={String(price)}\n onChange={(e) => setPrice(Number(e.target.value))}\n >\n {[0, 10, 30, 60, 120].map((n) => (\n <option key={n} value={String(n)}>\n {n ? tf(\"pricePaid\", { n }) : t(\"priceFree\")}\n </option>\n ))}\n </Select>\n <Button\n tone=\"gold\"\n busy={busy}\n onClick={async () => {\n setBusy(true);\n const r = await studio.publishAssets(\n title,\n [item.asset],\n price ? { currency: FRAMES, amount: price } : undefined,\n );\n setBusy(false);\n if (!r.ok) return toast(errorText(r.error), \"error\");\n await onDone(r.value);\n }}\n >\n 🚀 {t(\"publish\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction FromWorkshop(): ReactNode {\n const studio = useStudio();\n const toast = useToast();\n const stagger = useStagger();\n const [items, setItems] = useState<WorkshopContentView[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n let alive = true;\n void Promise.all([\n studio.client.workshop.browse({\n contentType: CONTENT.asset,\n sort: \"Popular\",\n pageSize: 24,\n }),\n studio.client.workshop.browse({\n contentType: CONTENT.pack,\n sort: \"Popular\",\n pageSize: 12,\n }),\n ]).then(([a, p]) => {\n if (alive)\n setItems(\n [\n ...(p.ok ? (p.data.Items ?? []) : []),\n ...(a.ok ? (a.data.Items ?? []) : []),\n ].filter((x) => !x.IsMine),\n );\n });\n return () => {\n alive = false;\n };\n }, [studio]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!items.length) return <EmptyState glyph=\"market\" text={t(\"feedEmpty\")} />;\n return (\n <Columns min={150} gap={8}>\n {items.map((c, i) => {\n const option =\n c.Access?.find((o) => o.Mode === \"Free\") ??\n c.Access?.find((o) => o.Mode === \"Price\") ??\n null;\n const price =\n option?.Mode === \"Price\"\n ? option.Price?.Entries?.[0]?.Amount\n : undefined;\n return (\n <Card\n key={c.ContentID}\n style={{ display: \"grid\", gap: 6, padding: 8, ...stagger(i) }}\n >\n <div\n style={{\n aspectRatio: \"9 / 16\",\n borderRadius: 10,\n overflow: \"hidden\",\n background: \"#111\",\n }}\n >\n {c.ThumbnailUrl ? (\n <img\n src={c.ThumbnailUrl}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : null}\n </div>\n <span style={{ ...outlined, fontSize: 13 }}>\n {c.ContentType === CONTENT.pack ? \"📦 \" : \"\"}\n {c.Title}\n </span>\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === c.ContentID}\n onClick={async () => {\n setBusy(c.ContentID ?? \"\");\n const r = await studio.takeAssets(c.ContentID ?? \"\", option);\n setBusy(null);\n if (!r.ok) return toast(errorText(r.error), \"error\");\n toast(t(\"taken\"), \"success\");\n }}\n >\n {t(\"take\")} {price ? `· ${price}` : \"\"}\n </Button>\n </Card>\n );\n })}\n </Columns>\n );\n}\n"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"path": "ui/PlayerView.tsx",
|
|
201
|
+
"content": "// Ролик на холсте 9:16 с управлением. Сцена собирается заново, когда меняется VideoSpec (правка в\n// редакторе), и продолжает с того же места. Русские реплики без серверной озвучки читает браузер\n// (Web Speech) — в такт часам проигрывателя, только вживую.\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { Catalog, VideoSpec } from \"@idosgames/timeline\";\nimport type { Player } from \"@idosgames/timeline/three\";\nimport { Button, Spinner, v, outlined } from \"@idosgames/react/ui\";\nimport { createPlayer, SIZE_SD } from \"../render\";\nimport { browserLines, voiceOf } from \"../ai/voice\";\nimport { voicePreset } from \"../library/voices\";\nimport { t, tf } from \"../i18n\";\nimport { formatTime } from \"./labels\";\n\nexport interface PlayerHandle {\n time(): number;\n seek(t: number): void;\n play(): void;\n pause(): void;\n duration(): number;\n}\n\nexport function PlayerView({\n spec,\n catalog,\n autoplay = false,\n onReady,\n height = \"min(62vh, 640px)\",\n}: {\n spec: VideoSpec;\n catalog: Catalog;\n autoplay?: boolean;\n onReady?: (h: PlayerHandle | null) => void;\n height?: string;\n}): ReactNode {\n const canvasRef = useRef<HTMLCanvasElement>(null);\n const playerRef = useRef<Player | null>(null);\n const resume = useRef(0);\n const [time, setTime] = useState(0);\n const [duration, setDuration] = useState(0);\n const [playing, setPlaying] = useState(false);\n const [busy, setBusy] = useState(true);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n let alive = true;\n let player: Player | null = null;\n setBusy(true);\n setError(null);\n const speech = new BrowserSpeech(spec, catalog);\n void createPlayer(spec, catalog, canvas, SIZE_SD, {\n onTime: (tt) => {\n setTime(tt);\n speech.at(tt, player);\n },\n onEnd: () => {\n setPlaying(false);\n speech.stop();\n },\n }).then((r) => {\n if (!alive) {\n if (r.ok) r.value.dispose();\n return;\n }\n setBusy(false);\n if (!r.ok) {\n setError(r.errors.join(\"; \"));\n onReady?.(null);\n return;\n }\n player = r.value;\n playerRef.current = player;\n setDuration(player.duration);\n const from = Math.min(resume.current, player.duration);\n player.seek(from);\n speech.reset(from);\n const handle: PlayerHandle = {\n time: () => player?.time ?? 0,\n seek: (x) => {\n speech.reset(x);\n player?.seek(x);\n },\n play: () => {\n player?.play();\n setPlaying(true);\n },\n pause: () => {\n player?.pause();\n speech.stop();\n setPlaying(false);\n },\n duration: () => player?.duration ?? 0,\n };\n onReady?.(handle);\n if (autoplay) handle.play();\n });\n return () => {\n alive = false;\n speech.stop();\n if (player) {\n resume.current = player.time;\n player.dispose();\n }\n playerRef.current = null;\n setPlaying(false);\n };\n // новая сцена на каждую правку ролика (onReady и autoplay — только при её сборке)\n }, [spec, catalog]);\n\n const toggle = () => {\n const p = playerRef.current;\n if (!p) return;\n if (p.isPlaying) {\n p.pause();\n setPlaying(false);\n } else {\n p.play();\n setPlaying(true);\n }\n };\n\n return (\n <div style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n <div\n style={{\n position: \"relative\",\n height,\n aspectRatio: \"9 / 16\",\n maxWidth: \"100%\",\n borderRadius: 14,\n overflow: \"hidden\",\n background: \"#111\",\n border: `${v.outline} solid ${v.ink}`,\n }}\n onClick={toggle}\n >\n <canvas\n ref={canvasRef}\n style={{ width: \"100%\", height: \"100%\", display: \"block\" }}\n />\n {busy ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Spinner />\n </div>\n ) : null}\n {error ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n padding: 16,\n textAlign: \"center\",\n ...outlined,\n fontSize: 14,\n }}\n >\n {tf(\"cantBuild\", { why: error })}\n </div>\n ) : null}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n width: \"100%\",\n maxWidth: 420,\n }}\n >\n <Button\n size=\"sm\"\n tone={playing ? \"grey\" : \"gold\"}\n onClick={toggle}\n disabled={busy || !!error}\n >\n {playing ? t(\"pause\") : t(\"play\")}\n </Button>\n <input\n type=\"range\"\n min={0}\n max={Math.max(0.1, duration)}\n step={0.05}\n value={time}\n onChange={(e) => {\n const x = Number(e.target.value);\n playerRef.current?.seek(x);\n setTime(x);\n }}\n style={{ flex: 1, accentColor: v.gold }}\n aria-label=\"time\"\n />\n <span\n style={{\n ...outlined,\n fontSize: 12,\n minWidth: 74,\n textAlign: \"right\",\n }}\n >\n {formatTime(time)} / {formatTime(duration)}\n </span>\n </div>\n </div>\n );\n}\n\n/** Браузерный черновой голос: реплика звучит, когда часы проигрывателя пересекают её начало. */\nclass BrowserSpeech {\n private readonly lines: Set<number>;\n private spoken = new Set<number>();\n private last = 0;\n\n constructor(\n private readonly spec: VideoSpec,\n private readonly catalog: Catalog,\n ) {\n this.lines = new Set(\n typeof speechSynthesis === \"undefined\" ? [] : browserLines(spec),\n );\n }\n\n reset(t: number): void {\n this.stop();\n this.last = t;\n this.spoken = new Set();\n }\n\n stop(): void {\n if (typeof speechSynthesis !== \"undefined\" && this.lines.size)\n speechSynthesis.cancel();\n }\n\n at(t: number, player: Player | null): void {\n if (!this.lines.size || !player) return;\n // перемотка (назад или скачком вперёд) — пропущенное не проговаривается\n if (t < this.last - 0.2 || t > this.last + 0.5) {\n this.reset(t);\n return;\n }\n for (const s of player.stage.timeline.speech) {\n if (!this.lines.has(s.line) || this.spoken.has(s.line)) continue;\n if (s.start >= this.last - 0.01 && s.start <= t + 0.05) {\n this.spoken.add(s.line);\n const line = this.spec.lines[s.line];\n if (!line) continue;\n const u = new SpeechSynthesisUtterance(line.text);\n u.lang = \"ru-RU\";\n u.pitch = voicePreset(voiceOf(this.spec, this.catalog, line)).pitch;\n // уложиться в длину реплики: ~13 букв в секунду у оценки, у браузера ~15 при rate 1\n u.rate = Math.min(\n 1.6,\n Math.max(0.8, line.text.length / 15 / Math.max(0.6, s.end - s.start)),\n );\n speechSynthesis.speak(u);\n }\n }\n this.last = t;\n }\n}\n"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"path": "ui/Slots.tsx",
|
|
205
|
+
"content": "// Слоты (L1): кто играет и где, кем озвучен, какая музыка и субтитры, что говорят. Смена героя или\n// сцены не трогает реплики и ручные правки — формат перекладывает их сам.\nimport { useState, type ReactNode } from \"react\";\nimport { getFormat, type Asset, type Line } from \"@idosgames/timeline\";\nimport {\n Button,\n Card,\n Field,\n IconButton,\n Select,\n TextArea,\n TextInput,\n outlined,\n useToast,\n v,\n errorText,\n} from \"@idosgames/react/ui\";\nimport {\n insertLine,\n removeCast,\n removeLine,\n setCast,\n setLine,\n} from \"../model/edit\";\nimport { VOICES, voicePreset } from \"../library/voices\";\nimport { voiceOf } from \"../ai/voice\";\nimport {\n OWN_VOICE,\n ownVoice,\n recordVoice,\n type VoiceRecording,\n} from \"../media/own\";\nimport { voiceKey } from \"@idosgames/timeline\";\nimport { t } from \"../i18n\";\nimport { useStudio } from \"./context\";\nimport { AssetPicker, assetIcon } from \"./AssetPicker\";\nimport type { EditorApi } from \"./Editor\";\nimport {\n beatId,\n beatLabel,\n captionLabel,\n CAPTION_IDS,\n emotionLabel,\n EMOTIONS,\n SOLO_BEATS,\n} from \"./labels\";\n\ntype Picking =\n { kind: \"actor\"; index: number } | { kind: \"set\" } | { kind: \"music\" } | null;\n\nexport function Slots({ api }: { api: EditorApi }): ReactNode {\n const studio = useStudio();\n const { spec } = api;\n const [picking, setPicking] = useState<Picking>(null);\n const format = getFormat(spec.format);\n const vip = studio.access().vip;\n\n const set = studio.catalog.get(spec.set);\n const music = spec.music ? studio.catalog.get(spec.music) : undefined;\n\n const pick = (a: Asset) => {\n if (!picking) return;\n if (picking.kind === \"actor\")\n api.change((s) =>\n picking.index < s.cast.length\n ? setCast(s, picking.index, { asset: a.ref })\n : { ...s, cast: [...s.cast, { asset: a.ref }] },\n );\n else if (picking.kind === \"set\") api.change((s) => ({ ...s, set: a.ref }));\n else api.change((s) => ({ ...s, music: a.ref }));\n setPicking(null);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Field label={t(\"cast\")}>\n <div style={{ display: \"grid\", gap: 6 }}>\n {spec.cast.map((c, i) => {\n const a = studio.catalog.get(c.asset);\n const voice =\n c.voice ?? (a?.kind === \"actor\" ? a.voice : undefined) ?? \"\";\n return (\n <Card\n key={i}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n gap: 8,\n alignItems: \"center\",\n padding: 8,\n }}\n >\n <span style={{ fontSize: 26 }}>{a ? assetIcon(a) : \"❔\"}</span>\n <div style={{ display: \"grid\", gap: 4 }}>\n <TextInput\n value={c.name ?? \"\"}\n placeholder={a?.title ?? \"\"}\n maxLength={30}\n onChange={(e) =>\n api.change((s) =>\n setCast(s, i, { name: e.target.value || undefined }),\n )\n }\n style={{ padding: \"6px 8px\" }}\n />\n <Select\n value={voice}\n onChange={(e) =>\n api.change((s) =>\n setCast(s, i, { voice: e.target.value || undefined }),\n )\n }\n >\n {VOICES.map((p) => (\n <option\n key={p.id}\n value={p.id}\n disabled={!!p.premium && !vip && p.id !== voice}\n >\n 🎙 {p.title}\n {p.premium && !vip ? \" · VIP\" : \"\"}\n </option>\n ))}\n </Select>\n </div>\n <div style={{ display: \"grid\", gap: 4 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setPicking({ kind: \"actor\", index: i })}\n >\n {t(\"change\")}\n </Button>\n {format && spec.cast.length > format.minCast ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n api.change((s) => removeCast(s, i, format.narrator))\n }\n >\n ✕\n </Button>\n ) : null}\n </div>\n </Card>\n );\n })}\n {format && spec.cast.length < format.maxCast ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setPicking({ kind: \"actor\", index: spec.cast.length })\n }\n >\n + {t(\"cast\")}\n </Button>\n ) : null}\n </div>\n </Field>\n\n <div style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}>\n <Card\n onClick={() => setPicking({ kind: \"set\" })}\n style={{ display: \"grid\", gap: 2, padding: 10 }}\n >\n <span style={{ color: v.textDim, fontSize: 12 }}>{t(\"scene\")}</span>\n <span style={{ ...outlined, fontSize: 14 }}>\n {set ? assetIcon(set) : \"\"} {set?.title ?? spec.set}\n </span>\n </Card>\n <Card\n onClick={() => setPicking({ kind: \"music\" })}\n style={{ display: \"grid\", gap: 2, padding: 10 }}\n >\n <span style={{ color: v.textDim, fontSize: 12 }}>{t(\"music\")}</span>\n <span style={{ ...outlined, fontSize: 14 }}>\n 🎵 {music?.title ?? t(\"noMusic\")}\n </span>\n </Card>\n </div>\n {spec.music ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => api.change((s) => ({ ...s, music: null }))}\n >\n {t(\"noMusic\")}\n </Button>\n ) : null}\n\n <Field label={t(\"captions\")}>\n <Select\n value={spec.captions}\n onChange={(e) =>\n api.change((s) => ({ ...s, captions: e.target.value }))\n }\n >\n {CAPTION_IDS.map((id) => (\n <option key={id} value={id}>\n {captionLabel(id)}\n </option>\n ))}\n </Select>\n </Field>\n\n <Field label={t(\"lines\")}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {spec.lines.map((l, i) => (\n <LineRow\n key={i}\n api={api}\n index={i}\n line={l}\n narrator={!!format?.narrator}\n />\n ))}\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n api.change((s) =>\n insertLine(s, s.lines.length, {\n who:\n s.lines[s.lines.length - 1]?.who === 0 && s.cast.length > 1\n ? 1\n : 0,\n text: \"…\",\n }),\n )\n }\n >\n + {t(\"addLine\")}\n </Button>\n </div>\n </Field>\n\n {picking ? (\n <AssetPicker\n kind={picking.kind}\n style={spec.style}\n title={\n picking.kind === \"actor\"\n ? t(\"pickActor\")\n : picking.kind === \"set\"\n ? t(\"pickSet\")\n : t(\"pickMusic\")\n }\n current={\n picking.kind === \"actor\"\n ? spec.cast[picking.index]?.asset\n : picking.kind === \"set\"\n ? spec.set\n : spec.music\n }\n onPick={pick}\n onClose={() => setPicking(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction LineRow({\n api,\n index,\n line,\n narrator,\n}: {\n api: EditorApi;\n index: number;\n line: Line;\n narrator: boolean;\n}): ReactNode {\n const studio = useStudio();\n const toast = useToast();\n const { spec } = api;\n const [rec, setRec] = useState<VoiceRecording | null>(null);\n const own = line.voice === OWN_VOICE;\n const ownStale =\n own &&\n spec.assets.voice?.[String(index)]?.key !== voiceKey(OWN_VOICE, line.text);\n\n const startRec = async () => {\n try {\n setRec(await recordVoice());\n } catch (e) {\n toast(errorText(e instanceof Error ? e.message : String(e)), \"error\");\n }\n };\n const stopRec = async () => {\n if (!rec) return;\n const blob = await rec.stop();\n setRec(null);\n const asset = await ownVoice(blob, line.text);\n api.change((s) => {\n const next = setLine(s, index, { voice: OWN_VOICE });\n return {\n ...next,\n assets: {\n ...next.assets,\n voice: { ...(next.assets.voice ?? {}), [String(index)]: asset },\n },\n };\n });\n };\n\n return (\n <Card style={{ display: \"grid\", gap: 6, padding: 8 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <Select\n value={String(line.who)}\n onChange={(e) =>\n api.change((s) =>\n setLine(s, index, { who: Number(e.target.value) }),\n )\n }\n style={{ flex: 1 }}\n >\n {narrator ? <option value=\"-1\">🎙 {t(\"narrator\")}</option> : null}\n {spec.cast.map((c, i) => (\n <option key={i} value={String(i)}>\n {c.name || studio.catalog.get(c.asset)?.title || `#${i + 1}`}\n </option>\n ))}\n </Select>\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {own\n ? `🎤 ${t(\"ownVoice\")}`\n : voicePreset(voiceOf(spec, studio.catalog, line)).title}\n </span>\n <IconButton\n glyph=\"trash\"\n label={t(\"removeLine\")}\n size={32}\n onClick={() => api.change((s) => removeLine(s, index))}\n />\n </div>\n <TextArea\n value={line.text}\n maxLength={220}\n rows={2}\n onChange={(e) =>\n api.change((s) => setLine(s, index, { text: e.target.value }))\n }\n style={{ width: \"100%\", padding: 8 }}\n />\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr 1fr\", gap: 6 }}\n >\n <Select\n aria-label={t(\"emotion\")}\n value={line.emotion ?? \"\"}\n onChange={(e) =>\n api.change((s) =>\n setLine(s, index, {\n emotion: (e.target.value || undefined) as Line[\"emotion\"],\n }),\n )\n }\n >\n <option value=\"\">{t(\"emotion\")}</option>\n {EMOTIONS.map((em) => (\n <option key={em} value={em}>\n {emotionLabel(em)}\n </option>\n ))}\n </Select>\n <Select\n aria-label={t(\"action\")}\n value={beatId(line.beat)}\n onChange={(e) =>\n api.change((s) =>\n setLine(s, index, { beat: e.target.value || undefined }),\n )\n }\n >\n <option value=\"\">{t(\"action\")}</option>\n {SOLO_BEATS.map((b) => (\n <option key={b} value={b}>\n {beatLabel(b)}\n </option>\n ))}\n </Select>\n <Select\n aria-label={t(\"reaction\")}\n value={beatId(line.react)}\n onChange={(e) =>\n api.change((s) =>\n setLine(s, index, { react: e.target.value || undefined }),\n )\n }\n >\n <option value=\"\">{t(\"reaction\")}</option>\n {SOLO_BEATS.map((b) => (\n <option key={b} value={b}>\n {beatLabel(b)}\n </option>\n ))}\n </Select>\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n {rec ? (\n <Button size=\"sm\" tone=\"red\" attract onClick={() => void stopRec()}>\n ⏹ {t(\"stopRecord\")}\n </Button>\n ) : (\n <Button size=\"sm\" tone=\"grey\" onClick={() => void startRec()}>\n 🎤 {t(\"recordVoice\")}\n </Button>\n )}\n {own ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n api.change((s) => setLine(s, index, { voice: undefined }))\n }\n >\n ✕ {t(\"ownVoice\")}\n </Button>\n ) : null}\n {ownStale ? (\n <span style={{ color: v.red, fontSize: 12 }}>\n {t(\"ownVoiceStale\")}\n </span>\n ) : null}\n </div>\n </Card>\n );\n}\n"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"path": "ui/StudioApp.tsx",
|
|
209
|
+
"content": "// Приложение студии на экране своего режима (внутри лобби: сверху балансы, снизу вкладки систем).\n// Свои разделы — вкладками сверху: Тренды, Создать, Мои, Челленджи; редактор и просмотр ролика\n// открываются поверх них со своей кнопкой «Назад».\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport { Tabs, screenBackground } from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\nimport {\n StudioProvider,\n useStudio,\n useStudioView,\n type StudioEnv,\n} from \"./context\";\nimport { Trends } from \"./Trends\";\nimport { Create } from \"./Create\";\nimport { Editor } from \"./Editor\";\nimport { Watch } from \"./Watch\";\nimport { Mine } from \"./Mine\";\nimport { Challenges } from \"./Challenges\";\n\ntype Section = \"trends\" | \"create\" | \"mine\" | \"challenges\";\n\nexport function makeStudioApp(env: StudioEnv): () => ReactNode {\n return function StudioPanel(): ReactNode {\n return (\n <StudioProvider env={env}>\n <StudioRoot />\n </StudioProvider>\n );\n };\n}\n\nfunction StudioRoot(): ReactNode {\n const studio = useStudio();\n const view = useStudioView();\n const [section, setSection] = useState<Section>(\"trends\");\n\n useEffect(() => {\n void studio.load();\n }, [studio]);\n\n // «Создать» из карточки формата — переключить раздел\n useEffect(() => {\n if (view.screen === \"create\") setSection(\"create\");\n }, [view]);\n\n return (\n <div\n style={{\n background: screenBackground,\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n WebkitOverflowScrolling: \"touch\",\n }}\n >\n <div\n style={{\n maxWidth: 760,\n margin: \"0 auto\",\n padding: \"10px 12px 24px\",\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {view.screen === \"editor\" ? (\n <Editor\n key={view.videoId}\n videoId={view.videoId}\n {...(view.plan ? { plan: view.plan } : {})}\n />\n ) : view.screen === \"watch\" ? (\n <Watch key={view.contentID} contentID={view.contentID} />\n ) : (\n <>\n <Tabs\n tabs={[\n { id: \"trends\", label: t(\"tabTrends\"), glyph: \"flame\" },\n { id: \"create\", label: t(\"tabCreate\"), glyph: \"plus\" },\n { id: \"mine\", label: t(\"tabMine\"), glyph: \"clapper\" },\n {\n id: \"challenges\",\n label: t(\"tabChallenges\"),\n glyph: \"trophy\",\n },\n ]}\n value={section}\n onChange={(s) => {\n setSection(s);\n studio.go(\n s === \"create\" ? { screen: \"create\" } : { screen: \"home\" },\n );\n }}\n />\n {section === \"trends\" ? (\n <Trends />\n ) : section === \"create\" ? (\n <Create\n key={view.screen === \"create\" ? (view.format ?? \"\") : \"\"}\n {...(view.screen === \"create\" && view.format\n ? { initialFormat: view.format }\n : {})}\n />\n ) : section === \"mine\" ? (\n <Mine />\n ) : (\n <Challenges />\n )}\n </>\n )}\n </div>\n </div>\n );\n}\n"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"path": "ui/Tracks.tsx",
|
|
213
|
+
"content": "// Дорожки (L2): реплики на шкале времени (касание — перемотать, «раньше/позже» — сдвиг), ручные\n// правки поверх формата (камера, эффект, звук, частицы, надпись, поза) в текущий момент превью.\n// Всё ложится в overrides: формат и герои меняются — правки остаются на своих местах.\nimport { useEffect, useMemo, useState, type ReactNode } from \"react\";\nimport { ACTOR_POSES } from \"@idosgames/characters\";\nimport { compileVideo, type Override } from \"@idosgames/timeline\";\nimport {\n Button,\n Card,\n Field,\n IconButton,\n Select,\n TextInput,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\nimport { addOverride, removeOverride, shiftLine } from \"../model/edit\";\nimport { t, tf } from \"../i18n\";\nimport { useStudio } from \"./context\";\nimport type { EditorApi } from \"./Editor\";\nimport {\n FX_IDS,\n fxLabel,\n formatTime,\n poseLabel,\n MOVE_IDS,\n moveLabel,\n PARTICLE_IDS,\n particlesLabel,\n SFX_IDS,\n sfxLabel,\n} from \"./labels\";\n\nconst LANE_COLORS = [\n \"#ffcf33\",\n \"#4cc9f0\",\n \"#ff8fa3\",\n \"#7bd389\",\n \"#b388eb\",\n \"#ffb03a\",\n];\n\nexport function Tracks({ api }: { api: EditorApi }): ReactNode {\n const studio = useStudio();\n const { spec } = api;\n const compiled = useMemo(\n () => compileVideo(spec, studio.catalog),\n [spec, studio],\n );\n const [selected, setSelected] = useState<number | null>(null);\n const [move, setMove] = useState(MOVE_IDS[1] ?? \"push-in\");\n const [fx, setFx] = useState(FX_IDS[0] ?? \"shake\");\n const [sfx, setSfx] = useState(SFX_IDS[0] ?? \"whoosh\");\n const [parts, setParts] = useState(PARTICLE_IDS[0] ?? \"sparks\");\n const [actor, setActor] = useState(0);\n const [pose, setPose] = useState<string>(\"wave\");\n const [text, setText] = useState(\"\");\n const now = () => Math.round((api.player?.time() ?? 0) * 100) / 100;\n // подпись «добавить на …» идёт за проигрывателем\n const [, tick] = useState(0);\n useEffect(() => {\n const id = setInterval(() => tick((x) => x + 1), 250);\n return () => clearInterval(id);\n }, []);\n\n if (!compiled.ok)\n return (\n <div style={{ color: v.red }}>\n {tf(\"cantBuild\", { why: compiled.errors.join(\"; \") })}\n </div>\n );\n const tl = compiled.timeline;\n const dur = Math.max(1, tl.duration);\n const pct = (x: number) => `${(x / dur) * 100}%`;\n const add = (o: Override) => api.change((s) => addOverride(s, o));\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card style={{ display: \"grid\", gap: 6, padding: 10 }}>\n {[-1, ...spec.cast.map((_, i) => i)].map((who) => {\n const spans = tl.speech.filter((s) => s.actor === who);\n if (who === -1 && !spans.length) return null;\n const name =\n who < 0\n ? t(\"narrator\")\n : spec.cast[who]?.name ||\n studio.catalog.get(spec.cast[who]?.asset ?? \"\")?.title ||\n `#${who + 1}`;\n return (\n <div\n key={who}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"80px 1fr\",\n gap: 6,\n alignItems: \"center\",\n }}\n >\n <span\n style={{\n ...outlined,\n fontSize: 11,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n </span>\n <div\n style={{\n position: \"relative\",\n height: 26,\n background: \"rgba(0,0,0,0.25)\",\n borderRadius: 6,\n }}\n >\n {spans.map((s) => (\n <button\n key={s.line}\n type=\"button\"\n onClick={() => {\n setSelected(s.line);\n api.player?.seek(s.start);\n }}\n title={spec.lines[s.line]?.text}\n style={{\n position: \"absolute\",\n left: pct(s.start),\n width: pct(Math.max(0.15, s.end - s.start)),\n top: 2,\n bottom: 2,\n borderRadius: 5,\n border:\n selected === s.line ? `2px solid ${v.text}` : \"none\",\n background: LANE_COLORS[(who + 1) % LANE_COLORS.length],\n color: \"#1b1b1b\",\n fontSize: 10,\n fontWeight: 800,\n cursor: \"pointer\",\n overflow: \"hidden\",\n }}\n >\n {s.line + 1}\n </button>\n ))}\n </div>\n </div>\n );\n })}\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"80px 1fr\",\n gap: 6,\n alignItems: \"center\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11 }}>{t(\"edits\")}</span>\n <div\n style={{\n position: \"relative\",\n height: 16,\n background: \"rgba(0,0,0,0.25)\",\n borderRadius: 6,\n }}\n >\n {spec.overrides.map((o, i) =>\n \"at\" in o ? (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: pct(o.at),\n top: 2,\n width: 4,\n bottom: 2,\n background: v.gold,\n borderRadius: 2,\n }}\n />\n ) : null,\n )}\n </div>\n </div>\n {selected !== null ? (\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, flex: 1 }}>\n #{selected + 1} · {spec.lines[selected]?.text.slice(0, 40)}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => api.change((s) => shiftLine(s, selected, -0.25))}\n >\n ⟵ {t(\"earlier\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => api.change((s) => shiftLine(s, selected, 0.25))}\n >\n {t(\"later\")} ⟶\n </Button>\n </div>\n ) : null}\n </Card>\n\n <Field label={tf(\"addAt\", { t: formatTime(api.player?.time() ?? 0) })}>\n <div style={{ display: \"grid\", gap: 6 }}>\n <Row\n label={`🎥 ${t(\"camera\")}`}\n value={move}\n options={MOVE_IDS}\n labelOf={moveLabel}\n onChange={setMove}\n onAdd={() => add({ kind: \"camera\", at: now(), move, duration: 2 })}\n />\n <Row\n label={`✨ ${t(\"effect\")}`}\n value={fx}\n options={FX_IDS}\n labelOf={fxLabel}\n onChange={setFx}\n onAdd={() =>\n add({ kind: \"fx\", at: now(), fx, duration: 0.5, strength: 1 })\n }\n />\n <Row\n label={`🔊 ${t(\"sound\")}`}\n value={sfx}\n options={SFX_IDS}\n labelOf={sfxLabel}\n onChange={setSfx}\n onAdd={() => add({ kind: \"sfx\", at: now(), sfx })}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr 1fr auto\",\n gap: 6,\n }}\n >\n <Select value={parts} onChange={(e) => setParts(e.target.value)}>\n {PARTICLE_IDS.map((p) => (\n <option key={p} value={p}>\n 🎉 {particlesLabel(p)}\n </option>\n ))}\n </Select>\n <ActorSelect api={api} value={actor} onChange={setActor} />\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() =>\n add({\n kind: \"particles\",\n at: now(),\n preset: parts,\n actor,\n duration: 1,\n })\n }\n >\n {t(\"add\")}\n </Button>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr 1fr auto\",\n gap: 6,\n }}\n >\n <Select value={pose} onChange={(e) => setPose(e.target.value)}>\n {ACTOR_POSES.map((p) => (\n <option key={p} value={p}>\n 🕺 {poseLabel(p)}\n </option>\n ))}\n </Select>\n <ActorSelect api={api} value={actor} onChange={setActor} />\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => add({ kind: \"pose\", actor, at: now(), pose })}\n >\n {t(\"add\")}\n </Button>\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr auto\", gap: 6 }}\n >\n <TextInput\n value={text}\n maxLength={60}\n placeholder={t(\"textHint\")}\n onChange={(e) => setText(e.target.value)}\n style={{ padding: \"8px 10px\" }}\n />\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!text.trim()}\n onClick={() => {\n add({\n kind: \"text\",\n at: now(),\n duration: 2,\n text: text.trim(),\n y: 0.3,\n });\n setText(\"\");\n }}\n >\n {t(\"add\")}\n </Button>\n </div>\n </div>\n </Field>\n\n <Field label={t(\"edits\")}>\n {spec.overrides.length === 0 ? (\n <div style={{ color: v.textDim, fontSize: 13 }}>{t(\"noEdits\")}</div>\n ) : (\n <div style={{ display: \"grid\", gap: 4 }}>\n {spec.overrides.map((o, i) => (\n <div\n key={i}\n style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}\n >\n <span style={{ ...outlined, fontSize: 12, flex: 1 }}>\n {describe(o)}\n </span>\n {\"at\" in o ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => api.player?.seek(o.at)}\n >\n {formatTime(o.at)}\n </Button>\n ) : null}\n <IconButton\n glyph=\"trash\"\n label={t(\"delete\")}\n size={30}\n onClick={() => api.change((s) => removeOverride(s, i))}\n />\n </div>\n ))}\n </div>\n )}\n </Field>\n </div>\n );\n}\n\nfunction Row({\n label,\n value,\n options,\n labelOf,\n onChange,\n onAdd,\n}: {\n label: string;\n value: string;\n options: readonly string[];\n labelOf: (id: string) => string;\n onChange: (v: string) => void;\n onAdd: () => void;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gridTemplateColumns: \"1fr auto\", gap: 6 }}>\n <Select value={value} onChange={(e) => onChange(e.target.value)}>\n {options.map((o) => (\n <option key={o} value={o}>\n {label} · {labelOf(o)}\n </option>\n ))}\n </Select>\n <Button size=\"sm\" tone=\"blue\" onClick={onAdd}>\n {t(\"add\")}\n </Button>\n </div>\n );\n}\n\nfunction ActorSelect({\n api,\n value,\n onChange,\n}: {\n api: EditorApi;\n value: number;\n onChange: (i: number) => void;\n}): ReactNode {\n const studio = useStudio();\n return (\n <Select\n value={String(value)}\n onChange={(e) => onChange(Number(e.target.value))}\n >\n {api.spec.cast.map((c, i) => (\n <option key={i} value={String(i)}>\n {c.name || studio.catalog.get(c.asset)?.title || `#${i + 1}`}\n </option>\n ))}\n </Select>\n );\n}\n\nfunction describe(o: Override): string {\n switch (o.kind) {\n case \"camera\":\n return `🎥 ${moveLabel(o.move)}`;\n case \"cut\":\n return `✂ ${o.shot}`;\n case \"fx\":\n return `✨ ${fxLabel(o.fx)}`;\n case \"sfx\":\n return `🔊 ${sfxLabel(o.sfx)}`;\n case \"particles\":\n return `🎉 ${particlesLabel(o.preset)}`;\n case \"pose\":\n return `🕺 ${poseLabel(o.pose)} #${o.actor + 1}`;\n case \"text\":\n return `🔤 ${o.text}`;\n case \"shift\":\n return `⇄ #${o.line + 1} ${o.delay > 0 ? \"+\" : \"\"}${o.delay}s`;\n case \"beat\":\n return `🎬 #${o.line + 1} ${o.beat}`;\n case \"react\":\n return `↩ #${o.line + 1} ${o.beat}`;\n case \"emotion\":\n return `😶 #${o.line + 1} ${o.emotion}`;\n }\n}\n"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"path": "ui/Trends.tsx",
|
|
217
|
+
"content": "// Тренды: форматы (с чего начать), готовые ролики (бесплатный старт за одно касание) и лента\n// роликов приложения из Мастерской — популярное, больше ремиксов, новое. Из ленты — «Смотреть» и\n// «Ремикс»; ремикс сохраняет озвучку неизменённых реплик и ссылку на родителя.\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport type { WorkshopContentView } from \"@idosgames/core\";\nimport { listFormats } from \"@idosgames/timeline\";\nimport {\n Button,\n Card,\n Center,\n Columns,\n EmptyState,\n SectionTitle,\n Spinner,\n Tabs,\n outlined,\n useStagger,\n useToast,\n v,\n errorText,\n} from \"@idosgames/react/ui\";\nimport { CONTENT } from \"../ids\";\nimport { TEMPLATES, templateSpec } from \"../model/templates\";\nimport { lang, t, type StudioKey } from \"../i18n\";\nimport { useStudio } from \"./context\";\n\nconst FORMAT_ICON: Record<string, string> = {\n \"dialogue@1\": \"💬\",\n \"story@1\": \"📖\",\n \"top5@1\": \"🏆\",\n \"quiz@1\": \"❓\",\n \"whatif@1\": \"🤯\",\n};\n\ntype Sort = \"Popular\" | \"MostRemixed\" | \"New\";\n\nexport function Trends(): ReactNode {\n const studio = useStudio();\n const toast = useToast();\n const stagger = useStagger();\n const [sort, setSort] = useState<Sort>(\"Popular\");\n const [feed, setFeed] = useState<WorkshopContentView[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n useEffect(() => {\n let alive = true;\n setFeed(null);\n void studio.client.workshop\n .browse({ contentType: CONTENT.video, sort, pageSize: 24 })\n .then((r) => {\n if (alive) setFeed(r.ok ? (r.data.Items ?? []) : []);\n });\n return () => {\n alive = false;\n };\n }, [studio, sort]);\n\n const remix = async (id: string) => {\n setBusy(id);\n const r = await studio.remix(id);\n setBusy(null);\n if (!r.ok) toast(errorText(r.error), \"error\");\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n <SectionTitle>{t(\"formats\")}</SectionTitle>\n <Columns min={150} gap={8}>\n {listFormats().map((f, i) => (\n <Card\n key={f.id}\n onClick={() => studio.go({ screen: \"create\", format: f.id })}\n style={{ display: \"grid\", gap: 4, ...stagger(i) }}\n >\n <span style={{ fontSize: 28 }}>{FORMAT_ICON[f.id] ?? \"🎬\"}</span>\n <span style={{ ...outlined, fontSize: 15 }}>\n {t(`f_${f.id}` as StudioKey)}\n </span>\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {t(`f_${f.id}_d` as StudioKey)}\n </span>\n </Card>\n ))}\n </Columns>\n\n <SectionTitle>{t(\"readyVideos\")}</SectionTitle>\n <Columns min={150} gap={8}>\n {TEMPLATES.map((tpl, i) => (\n <Card\n key={tpl.id}\n onClick={() => {\n const spec = templateSpec(tpl.id, lang());\n if (spec) void studio.createVideo(spec);\n }}\n style={{ display: \"grid\", gap: 4, ...stagger(i) }}\n >\n <span style={{ fontSize: 22 }}>\n {FORMAT_ICON[tpl.format] ?? \"🎬\"}\n </span>\n <span style={{ ...outlined, fontSize: 14 }}>\n {tpl.title[lang()]}\n </span>\n <span style={{ color: v.textDim, fontSize: 12 }}>\n {t(`f_${tpl.format}` as StudioKey)}\n </span>\n </Card>\n ))}\n </Columns>\n\n <SectionTitle>{t(\"feed\")}</SectionTitle>\n <Tabs\n tabs={[\n { id: \"Popular\", label: t(\"sortPopular\") },\n { id: \"MostRemixed\", label: t(\"sortRemixed\") },\n { id: \"New\", label: t(\"sortNew\") },\n ]}\n value={sort}\n onChange={setSort}\n />\n {feed === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <EmptyState glyph=\"clapper\" text={t(\"feedEmpty\")} />\n ) : (\n <Columns min={160} gap={8}>\n {feed.map((c, i) => (\n <VideoCard\n key={c.ContentID}\n item={c}\n index={i}\n busy={busy === c.ContentID}\n onRemix={() => void remix(c.ContentID ?? \"\")}\n />\n ))}\n </Columns>\n )}\n </div>\n );\n}\n\nexport function VideoCard({\n item,\n index,\n busy,\n onRemix,\n}: {\n item: WorkshopContentView;\n index: number;\n busy: boolean;\n onRemix: () => void;\n}): ReactNode {\n const studio = useStudio();\n const stagger = useStagger();\n const id = item.ContentID ?? \"\";\n return (\n <Card style={{ display: \"grid\", gap: 6, padding: 8, ...stagger(index) }}>\n <div\n onClick={() => studio.go({ screen: \"watch\", contentID: id })}\n style={{\n aspectRatio: \"9 / 16\",\n borderRadius: 10,\n overflow: \"hidden\",\n background: \"#111\",\n cursor: \"pointer\",\n }}\n >\n {item.ThumbnailUrl ? (\n <img\n src={item.ThumbnailUrl}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : null}\n </div>\n <span style={{ ...outlined, fontSize: 13, lineHeight: 1.2 }}>\n {item.Title || t(\"untitled\")}\n </span>\n <span style={{ color: v.textDim, fontSize: 11 }}>\n {item.CreatorPublicData?.Username ?? \"\"} · ♥ {item.Stats?.Likes ?? 0} ·\n ↻ {item.Stats?.Remixes ?? 0}\n </span>\n <div style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => studio.go({ screen: \"watch\", contentID: id })}\n style={{ flex: 1 }}\n >\n {t(\"watch\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy}\n onClick={onRemix}\n style={{ flex: 1 }}\n >\n {t(\"remix\")}\n </Button>\n </div>\n </Card>\n );\n}\n"
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"path": "ui/Watch.tsx",
|
|
221
|
+
"content": "// Просмотр опубликованного ролика — из ленты или по живой ссылке (#video=…): ролик играет на\n// устройстве из своих данных, ниже — «Ремикс», «Нравится», «Поделиться» и ремиксы этого ролика.\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport type { WorkshopContentView } from \"@idosgames/core\";\nimport type { VideoSpec } from \"@idosgames/timeline\";\nimport {\n Button,\n Center,\n Columns,\n ScreenHeader,\n Spinner,\n EmptyState,\n SectionTitle,\n outlined,\n useToast,\n v,\n errorText,\n} from \"@idosgames/react/ui\";\nimport { CONTENT } from \"../ids\";\nimport { t } from \"../i18n\";\nimport { useStudio } from \"./context\";\nimport { PlayerView } from \"./PlayerView\";\nimport { VideoCard } from \"./Trends\";\n\nexport function Watch({ contentID }: { contentID: string }): ReactNode {\n const studio = useStudio();\n const toast = useToast();\n const [spec, setSpec] = useState<VideoSpec | null>(null);\n const [card, setCard] = useState<WorkshopContentView | null>(null);\n const [remixes, setRemixes] = useState<WorkshopContentView[]>([]);\n const [error, setError] = useState<string | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [liked, setLiked] = useState(false);\n\n useEffect(() => {\n let alive = true;\n setSpec(null);\n setError(null);\n void studio.fetchPublished(contentID).then((r) => {\n if (!alive) return;\n if (r.ok) setSpec(r.value);\n else setError(r.error);\n });\n void studio.client.workshop.getContent(contentID).then((r) => {\n if (alive && r.ok) {\n setCard(r.data.Content ?? null);\n setLiked(!!r.data.Content?.Liked);\n }\n });\n void studio.client.workshop\n .browse({\n contentType: CONTENT.video,\n parentContentID: contentID,\n sort: \"Popular\",\n pageSize: 12,\n })\n .then((r) => {\n if (alive && r.ok) setRemixes(r.data.Items ?? []);\n });\n return () => {\n alive = false;\n studio.catalog.setVideoAssets([]);\n };\n }, [studio, contentID]);\n\n const remix = async (id: string) => {\n setBusy(id);\n const r = await studio.remix(id);\n setBusy(null);\n if (!r.ok) toast(errorText(r.error), \"error\");\n };\n\n const like = async () => {\n const r = liked\n ? await studio.client.social.unlike(\"WorkshopItem\", contentID)\n : await studio.client.social.like(\"WorkshopItem\", contentID);\n if (r.ok) setLiked(!liked);\n else toast(errorText(r.error), \"error\");\n };\n\n const link = studio.liveLink(contentID);\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <ScreenHeader\n title={card?.Title || t(\"title\")}\n icon=\"clapper\"\n onBack={() => studio.go({ screen: \"home\" })}\n />\n {error ? (\n <EmptyState\n glyph=\"clapper\"\n text={`${t(\"videoFailed\")}: ${errorText(error)}`}\n />\n ) : !spec ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n <PlayerView spec={spec} catalog={studio.catalog} autoplay />\n )}\n {card ? (\n <div style={{ color: v.textDim, fontSize: 12, textAlign: \"center\" }}>\n {card.CreatorPublicData?.Username ?? \"\"} · ♥ {card.Stats?.Likes ?? 0}{\" \"}\n · ↻ {card.Stats?.Remixes ?? 0}\n </div>\n ) : null}\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr 1fr\", gap: 6 }}\n >\n <Button\n tone=\"gold\"\n busy={busy === contentID}\n disabled={!spec}\n onClick={() => void remix(contentID)}\n >\n ↻ {t(\"remix\")}\n </Button>\n <Button tone={liked ? \"red\" : \"grey\"} onClick={() => void like()}>\n ♥ {t(\"like\")}\n </Button>\n <Button\n tone=\"blue\"\n onClick={() => {\n if (typeof navigator.share === \"function\")\n void navigator\n .share({ url: link, title: card?.Title ?? \"\" })\n .catch(() => undefined);\n else {\n void navigator.clipboard?.writeText(link);\n toast(t(\"linkCopied\"), \"success\");\n }\n }}\n >\n 🔗 {t(\"share\")}\n </Button>\n </div>\n {remixes.length ? (\n <>\n <SectionTitle>\n ↻ {t(\"remixes\")}{\" \"}\n <span style={{ ...outlined, fontSize: 12 }}>{remixes.length}</span>\n </SectionTitle>\n <Columns min={150} gap={8}>\n {remixes.map((c, i) => (\n <VideoCard\n key={c.ContentID}\n item={c}\n index={i}\n busy={busy === c.ContentID}\n onRemix={() => void remix(c.ContentID ?? \"\")}\n />\n ))}\n </Columns>\n </>\n ) : null}\n </div>\n );\n}\n"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"path": "module.meta.json",
|
|
225
|
+
"content": "{\n \"id\": \"video-studio\",\n \"type\": \"template\",\n \"uses\": [\"workshop\", \"premium\", \"store\", \"leaderboard\"],\n \"summary\": \"Video studio: players make viral short videos with voice-over, captions and music — from ready characters and scenes in seconds, or with AI characters, backgrounds and props made for their idea.\",\n \"description\": \"A whole app of its own mode (React + a three.js stage), the 'Video studio' template. A player picks a trending format (dialogue of two, a story with a narrator, top-5, a quiz, 'what if'), writes an idea (optionally a photo or their own voice) and gets a video in one tap: a hidden AI director (title feature \\\"video-director\\\", JSON answer) writes the VideoSpec — cast, scene, lines, beats, emotions, music, captions — and a plan by the ladder of requests for every element: from the library (free), combined from what exists (recolour, outfit, a prop — free), the player's own media (free), an AI asset (a voxel character, a 2D hero recipe, a background, a prop, music) or an AI video insert. The estimate in the studio currency is shown BEFORE anything paid. The timeline engine (@idosgames/timeline) plays the video right away with a draft voice; the player edits it in slots (hero, scene, voice, music, lines) or on the tracks (move clips, camera, beats, captions — kept as overrides on top of the format), pays for the final voice, and exports MP4 on the device (WebCodecs), shares a live link, or publishes to the app's feed (Workshop type \\\"video.spec\\\"). Anyone can press 'Remix' — a copy with the parent (the remix tree): unchanged lines keep their voice and cost nothing. Characters are the platform's standard (@idosgames/characters): voxel recipes (CharacterSpec), the 2D hero with the full wardrobe, GLB on the humanoid-v1 skeleton and cut-outs (photos, logos, AI creatures) with procedural animation and a mouth insert; every one speaks with lip-sync and plays the six emotions. Own assets go to 'My assets' and to the Workshop (\\\"video.asset\\\", packs \\\"video.pack\\\" free or for a price). Screens: Trends, Editor, My videos, My assets, Feed, Challenges (weekly leaderboard), Author profile. Money: every AI step is a title AI feature with its price in the studio currency (\\\"FRAMES\\\"), daily limits and the title's credit budget; the first video is free with a watermark, VIP removes it.\",\n \"provides\": [\n \"short videos with voice-over, word-by-word captions and music\",\n \"video formats: dialogue of two, story with a narrator, top-5, quiz, what-if\",\n \"AI director: idea → VideoSpec and an estimate by the ladder of requests\",\n \"estimate in the studio currency before paid steps\",\n \"live preview on the device with a draft voice\",\n \"editing in slots (hero, scene, voice, music, lines) and on tracks (camera, beats, captions)\",\n \"lip-sync and emotions for voxel, 2D, GLB and cut-out characters\",\n \"AI characters (voxel and 2D), backgrounds, props, music and video inserts for a video\",\n \"the player's photo as a cut-out actor (sticker with an outline)\",\n \"MP4 export on the device (WebCodecs) and a live link\",\n \"publishing videos and remixes in the Workshop (remix tree)\",\n \"My assets: own characters and scenes reused for free, published as packs\",\n \"weekly challenge with a leaderboard, quests for videos\",\n \"watermark on free videos, VIP without it\"\n ],\n \"tags\": [\n \"video\",\n \"shorts\",\n \"tiktok\",\n \"reels\",\n \"viral\",\n \"remix\",\n \"ugc\",\n \"ai\",\n \"voice\",\n \"captions\",\n \"animation\",\n \"studio\",\n \"three\"\n ],\n \"author\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.1\"\n}\n"
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|