@idosgames/mcp 0.1.43 → 0.1.45

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.
Files changed (40) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +144 -144
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +15 -15
  7. package/registry/modules/boosts.json +5 -5
  8. package/registry/modules/character.json +11 -11
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +5 -5
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +5 -5
  13. package/registry/modules/deals.json +6 -6
  14. package/registry/modules/events.json +7 -7
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +21 -17
  17. package/registry/modules/idle-rpg.json +20 -16
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +7 -7
  21. package/registry/modules/lootboxes.json +7 -7
  22. package/registry/modules/mailbox.json +7 -7
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +7 -7
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +5 -5
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +5 -5
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +9 -9
  33. package/registry/modules/tutorials.json +7 -7
  34. package/registry/modules/video-studio.json +9 -9
  35. package/registry/modules/voxelcraft.json +37 -29
  36. package/registry/modules/workshop.json +6 -6
  37. package/registry/skills/ai-generation-system.json +2 -2
  38. package/registry/skills/idosgames-ui-kit.json +1 -1
  39. package/registry/skills/rewarded-ads.json +1 -1
  40. package/registry/skills/store-system.json +1 -1
@@ -54,23 +54,23 @@
54
54
  "version": "0.1.1"
55
55
  },
56
56
  "dependencies": {
57
- "@idosgames/characters": "0.1.1",
58
- "@idosgames/core": "0.24.3",
59
- "@idosgames/module-sdk": "0.13.1",
60
- "@idosgames/react": "0.6.2",
57
+ "@idosgames/characters": "0.1.2",
58
+ "@idosgames/core": "0.26.0",
59
+ "@idosgames/module-sdk": "0.13.3",
60
+ "@idosgames/react": "0.7.0",
61
61
  "@idosgames/timeline": "0.1.0",
62
62
  "react": "19.2.7",
63
63
  "three": "0.185.1"
64
64
  },
65
- "contentHash": "66961c2a111d56cc6c14482f97400e57cd3994d0c106494cd79a9e0b9bb90e20",
65
+ "contentHash": "f21269891c1f334358e49c1926f8fd343a3f32dae1ef373b15d7b4c193cf4b9b",
66
66
  "files": [
67
67
  {
68
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"
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 { payWithCredits } from \"@idosgames/react/ui\";\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\n/**\n * Запуск генерации с ценой, которая может быть в Aspis: первый вызов только просит игрока подтвердить на idosgames.com\n * (окно — кит), Aspis списываются только за готовую генерацию. Ключ один на оба вызова.\n */\nfunction start(\n client: IDosGamesClient,\n opts: AssetOptions,\n call: (\n o: ReturnType<typeof common> & { creditsConfirmationID?: string },\n ) => Promise<OperationResult<AIGenerationView>>,\n): Promise<OperationResult<AIGenerationView>> {\n const base = common(opts);\n return payWithCredits(\n client,\n (creditsConfirmationID) => call({ ...base, creditsConfirmationID }),\n opts.signal ? { signal: opts.signal } : {},\n );\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 start(client, opts, (o) =>\n client.ai.generateText(\n FEATURES.voxelCharacter,\n { Prompt: prompt },\n o,\n ),\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 start(client, opts, (o) =>\n client.ai.generateText(\n FEATURES.rig2dCharacter,\n { Prompt: prompt },\n o,\n ),\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 start(client, opts, (o) =>\n client.ai.generateImage(\n FEATURES.image,\n { Background: \"transparent\", Format: \"png\" },\n {\n ...o,\n variables: { subject: prompt.slice(0, 400), style },\n },\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 start(client, opts, (o) =>\n client.ai.generateImage(\n FEATURES.background,\n { Size: \"1024x1536\", Format: \"jpeg\" },\n {\n ...o,\n variables: { subject: prompt.slice(0, 400), style },\n },\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 start(client, opts, (o) =>\n client.ai.generateMusic(\n FEATURES.music,\n { Format: \"mp3\" },\n { ...o, variables: { mood: prompt.slice(0, 300) } },\n ),\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
70
  },
71
71
  {
72
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"
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 { payWithCredits } from \"@idosgames/react/ui\";\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 // Цена в Aspis: первый вызов только просит игрока подтвердить на idosgames.com (окно — кит); ключ один на оба.\n const relatedEntityID = crypto.randomUUID();\n const started = await payWithCredits(\n client,\n (creditsConfirmationID) =>\n client.ai.generateText(\n FEATURES.director,\n {},\n {\n variables,\n relatedEntityID,\n creditsConfirmationID,\n ...(opts.optionID ? { selectedOptionID: opts.optionID } : {}),\n },\n ),\n opts.signal ? { signal: opts.signal } : {},\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
74
  },
75
75
  {
76
76
  "path": "ai/generators.ts",
@@ -82,7 +82,7 @@
82
82
  },
83
83
  {
84
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"
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 { payWithCredits } from \"@idosgames/react/ui\";\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 // Цена в Aspis: первый вызов только просит игрока подтвердить на idosgames.com (окно — кит).\n const started = await payWithCredits(\n client,\n (creditsConfirmationID) =>\n 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 creditsConfirmationID,\n ...(opts.optionID && opts.final\n ? { selectedOptionID: opts.optionID }\n : {}),\n context: { line: i },\n },\n ),\n opts.signal ? { signal: opts.signal } : {},\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
86
  },
87
87
  {
88
88
  "path": "content.ts",
@@ -162,7 +162,7 @@
162
162
  },
163
163
  {
164
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"
165
+ "content": "// Студия целиком: каталог, хранилище, открытый ролик и всё, что с ним делают через платформу —\n// публикация и ремикс (Мастерская), квесты и челлендж (метрики, лидерборд), аналитика промахов\n// библиотеки. Экраны только рисуют её состояние и зовут её методы; агент ИИ-кодера зовёт те же.\nimport type { IDosGamesClient, WorkshopAccessOption } from \"@idosgames/core\";\nimport { configSection, kitCreditsPrompt } 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 // A publish fee in Aspis: the player confirms it on idosgames.com (the kit shows it); the upload is done once.\n const credits = kitCreditsPrompt(this.client);\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 credits: credits.options,\n });\n await credits.done(res);\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 // A publish fee in Aspis: the player confirms it on idosgames.com (the kit shows it); the upload is done once.\n const credits = kitCreditsPrompt(this.client);\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 credits: credits.options,\n });\n await credits.done(res);\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
166
  },
167
167
  {
168
168
  "path": "ui/AssetPicker.tsx",