yesilcam-studios 0.1.0 → 0.3.0

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/src/files.js ADDED
@@ -0,0 +1,87 @@
1
+ // Dosya yükleme / indirme. Küçük dosyalar base64 (aistudio-upload-asset, ≤14 MB), büyükler imzalı PUT (ys-upload-init → PUT → ys-upload-complete).
2
+ // Kullanıcı medyası özeldir: indirme bağlantıları süreli imzalıdır; süresi geçmişse ys-media-resign ile yenilenir.
3
+ import { readFileSync, writeFileSync, statSync, mkdirSync, existsSync } from 'node:fs'
4
+ import { basename, extname, dirname, join, resolve } from 'node:path'
5
+ import { ApiError } from './client.js'
6
+
7
+ export const MIME = {
8
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.heic': 'image/heic',
9
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
10
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg',
11
+ '.json': 'application/json', '.dxf': 'application/dxf', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
12
+ }
13
+ export const mimeOf = (p) => MIME[extname(String(p)).toLowerCase()] || 'application/octet-stream'
14
+ export const kindOf = (mime) => (mime.startsWith('video') ? 'video' : mime.startsWith('audio') ? 'audio' : 'image')
15
+ const BASE64_MAX = 14 * 1024 * 1024
16
+
17
+ export function readLocal(path) {
18
+ const p = resolve(String(path))
19
+ if (!existsSync(p)) throw new ApiError('file_not_found', { path: p })
20
+ return { path: p, buf: readFileSync(p), size: statSync(p).size, mime: mimeOf(p), name: basename(p) }
21
+ }
22
+
23
+ export async function putPresigned(url, headers, buf, fetchImpl = globalThis.fetch) {
24
+ const r = await fetchImpl(url, { method: 'PUT', headers: headers || {}, body: buf })
25
+ if (!r.ok) throw new ApiError('upload_failed', { status: r.status })
26
+ }
27
+
28
+ /** Yerel dosya → kütüphane varlığı. → { asset_id, url, kind, thumb_url?, duration? } */
29
+ export async function uploadAsset(c, path, { kind, label } = {}, fetchImpl) {
30
+ const f = readLocal(path)
31
+ const k = kind || kindOf(f.mime)
32
+ if (f.size <= BASE64_MAX && /^(image|video|audio)\//.test(f.mime) && f.mime !== 'image/heic') {
33
+ const r = await c.call('aistudio-upload-asset', { file_base64: `data:${f.mime};base64,${f.buf.toString('base64')}`, kind: k, label: (label || f.name).slice(0, 80) })
34
+ return { asset_id: String(r.asset_id ?? r.id), url: r.spaces_url || r.url || null, kind: r.kind || k }
35
+ }
36
+ const init = await c.call('ys-upload-init', { kind: k, mime: f.mime, size: f.size, name: f.name })
37
+ await putPresigned(init.url, init.headers || { 'Content-Type': f.mime }, f.buf, fetchImpl)
38
+ const done = await c.call('ys-upload-complete', { key: init.key, kind: k, label: (label || f.name).slice(0, 80) })
39
+ return { asset_id: String(done.asset_id), url: done.url || null, kind: done.kind || k, thumb_url: done.thumb_url || null, duration: done.duration_sec ?? null }
40
+ }
41
+
42
+ /** AI Influencer: kendi fotoğrafı (özel nesne) + açık onay + güvenlik denetimi. → { media_id, status, reason } */
43
+ export async function uploadInfluencerPhoto(c, path, consent, fetchImpl) {
44
+ if (!consent) throw new ApiError('consent_required')
45
+ const f = readLocal(path)
46
+ if (!/^image\/(jpeg|png|webp)$/.test(f.mime)) throw new ApiError('unsupported_type', { mime: f.mime })
47
+ const init = await c.call('ys-influencer-photo-init', { mime: f.mime, size: f.size })
48
+ await putPresigned(init.url, init.headers || { 'Content-Type': f.mime }, f.buf, fetchImpl)
49
+ return c.call('ys-influencer-photo-check', { key: init.key, consent: 1 })
50
+ }
51
+
52
+ /** AI Influencer: kendi videosu (3–30 sn) → yükle + onay + denetim. → { asset_id, check } */
53
+ export async function uploadInfluencerVideo(c, path, consent, fetchImpl) {
54
+ if (!consent) throw new ApiError('consent_required')
55
+ const up = await uploadAsset(c, path, { kind: 'video' }, fetchImpl)
56
+ const check = await c.call('ys-influencer-video-check', { asset_id: Number(up.asset_id), consent: 1 })
57
+ return { asset_id: up.asset_id, check }
58
+ }
59
+
60
+ /** Ses örneği (ses klonu): presigned PUT. → key */
61
+ export async function uploadVoiceSample(c, path, fetchImpl) {
62
+ const f = readLocal(path)
63
+ const init = await c.call('ys-influencer-voice-sample-init', { filename: f.name, size: f.size })
64
+ await putPresigned(init.put_url, { 'Content-Type': init.content_type || f.mime }, f.buf, fetchImpl)
65
+ return init.key
66
+ }
67
+
68
+ const fileNameFromUrl = (url, fallback = 'download') => {
69
+ try { const b = basename(new URL(url).pathname); return b && b.includes('.') ? decodeURIComponent(b) : fallback } catch { return fallback }
70
+ }
71
+
72
+ /** URL → dosya. `out` bir dizinse adı URL'den alınır. 403/400 (süresi dolmuş imza) → ys-media-resign ile bir kez yenilenir. */
73
+ export async function downloadUrl(c, url, out, fetchImpl = globalThis.fetch) {
74
+ if (!url) throw new ApiError('nothing_to_download')
75
+ let target = resolve(String(out || fileNameFromUrl(url)))
76
+ if (existsSync(target) && statSync(target).isDirectory()) target = join(target, fileNameFromUrl(url))
77
+ let res = await fetchImpl(url)
78
+ if ((res.status === 403 || res.status === 400) && c) {
79
+ const r = await c.call('ys-media-resign', { urls: [url] }).catch(() => null)
80
+ const fresh = r?.urls?.[0]
81
+ if (fresh) res = await fetchImpl(fresh)
82
+ }
83
+ if (!res.ok) throw new ApiError('download_failed', { status: res.status })
84
+ mkdirSync(dirname(target), { recursive: true })
85
+ writeFileSync(target, Buffer.from(await res.arrayBuffer()))
86
+ return target
87
+ }
package/src/mcp.js CHANGED
@@ -1,64 +1,102 @@
1
- // Claude için MCP sunucusu (stdio). Araçlar CLI ile aynı istemciyi kullanır.
1
+ // Claude (ve diğer MCP istemcileri) için stdio MCP sunucusu. Araçlar işlem kataloğundan türetilir (CLI ile birebir).
2
+ // Jeton harcayan araçlar confirm=true olmadan YALNIZ teklif döner; istemci kullanıcı onayı olmadan jeton harcayamaz.
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
5
  import { z } from 'zod'
5
- import { readFileSync } from 'node:fs'
6
- import { basename, extname } from 'node:path'
7
- import { makeClient } from './client.js'
8
- import { getUserToken } from './config.js'
9
- import { imageJob, videoJob, tryonJob, waitForJob, describeJob, TIERS, ASPECTS } from './jobs.js'
10
- import { analyzePlanText } from './maket/analyze.js'
11
- import { SITE_URL } from './constants.js'
6
+ import { makeClient, friendlyError, ApiError } from './client.js'
7
+ import { getCredentials } from './config.js'
8
+ import { selectOps, STUDIOS } from './registry.js'
9
+ import { execute } from './run.js'
10
+ import { VERSION, KEYS_URL } from './constants.js'
12
11
 
13
12
  const text = (data) => ({ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] })
14
- const fail = (e) => ({ isError: true, content: [{ type: 'text', text: `Error: ${e?.message || e}` }] })
15
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }
13
+ const fail = (e) => ({ isError: true, content: [{ type: 'text', text: `Error: ${friendlyError(e)}` }] })
16
14
 
17
- export function createServer({ client: injected } = {}) {
18
- const server = new McpServer({ name: 'yesilcam', version: '0.1.0' })
19
- const client = () => injected || makeClient(getUserToken())
20
- const guarded = (fn) => async (args) => { try { return await fn(args) } catch (e) { return fail(e) } }
15
+ export const INSTRUCTIONS = `Yeşilçam Studios tools: AI film, image, animation, design, website, fashion try-on, Maket and AI Influencer studios on the user's own account and token wallet.
16
+ Rules:
17
+ - Tools that spend tokens return a QUOTE first and charge nothing. Show the price to the user and only after they agree call the same tool again with the same arguments plus confirm=true. Never set confirm=true on your own. Use max_tokens as a safety limit.
18
+ - Long jobs: pass wait=true (and save_to to download), or call yesilcam_wait with the returned kind and id. Progress is reported while waiting.
19
+ - Media is private; links are signed and expire after a few hours (yesilcam_download refreshes them). Sharing publicly goes through yesilcam_share (may need approval).
20
+ - Characters, influencers and people must be adults; real public figures are not allowed.
21
+ - Account, payment, API key and social-account connection settings are only in the web app (https://yesilcamstudios.com/app).`
21
22
 
22
- server.registerTool('yesilcam_balance', { description: 'Token balance of the signed-in Yeşilçam Studios account.', inputSchema: {} },
23
- guarded(async () => text(await client().balance())))
24
- server.registerTool('yesilcam_account', { description: 'Account name, email and subscription plan.', inputSchema: {} },
25
- guarded(async () => text(await client().me())))
26
- server.registerTool('yesilcam_pricing', { description: 'Production costs in tokens (Film Studio and Fashion Try-On operations) and token packs with prices.', inputSchema: {} },
27
- guarded(async () => { const c = client(); const [ops, packs] = await Promise.all([c.modulePricing(), c.tokenPackages()]); return text({ operations: ops.filter((r) => ['ai-studio', 'fashion-tryon'].includes(r.module_key)).map((r) => ({ module: r.module_key, operation: r.operation_key, tokens: Number(r.base_cost_tokens), free_per_month: Number(r.monthly_free_limit || 0) })), packs }) }))
28
- server.registerTool('yesilcam_list_assets', { description: 'List images/videos in the user library. Returns asset ids and URLs.', inputSchema: { kind: z.enum(['image', 'video']).optional(), limit: z.number().int().min(1).max(100).optional() } },
29
- guarded(async ({ kind, limit }) => text(await client().assets({ kind, limit: limit || 40 }))))
30
- server.registerTool('yesilcam_upload_asset', { description: 'Upload a local image/video/audio file to the library. Returns the asset id to use in other tools.', inputSchema: { path: z.string(), label: z.string().optional() } },
31
- guarded(async ({ path, label }) => { const ext = extname(path).toLowerCase(); const mime = MIME[ext] || 'application/octet-stream'; const kind = mime.startsWith('video') ? 'video' : mime.startsWith('audio') ? 'audio' : 'image'; return text(await client().uploadAsset(`data:${mime};base64,${readFileSync(path).toString('base64')}`, kind, label || basename(path))) }))
32
- server.registerTool('yesilcam_create_image', { description: 'Generate an image from a prompt (Film Studio). Costs tokens. Set wait=true to block until the result URL is ready.', inputSchema: { prompt: z.string().min(3), tier: z.enum(TIERS).optional(), aspect: z.enum(ASPECTS).optional(), framing: z.enum(['waist_up', 'waist_down', 'full_body']).optional(), wait: z.boolean().optional() } },
33
- guarded(async ({ prompt, tier, aspect, framing, wait }) => run(client(), imageJob({ prompt, tier: tier || 'draft', aspect: aspect || '16:9', framing }), wait)))
34
- server.registerTool('yesilcam_create_video', { description: 'Generate a video from a prompt (text2video) or animate an image asset (image2video, give source_asset_id). Costs tokens.', inputSchema: { prompt: z.string().optional(), source_asset_id: z.string().optional(), tier: z.enum(TIERS).optional(), aspect: z.enum(ASPECTS).optional(), duration: z.number().int().optional(), resolution: z.enum(['480p', '720p', '1080p']).optional(), generate_audio: z.boolean().optional(), wait: z.boolean().optional() } },
35
- guarded(async (a) => run(client(), videoJob({ prompt: a.prompt || '', tier: a.tier || 'draft', aspect: a.aspect || '16:9', sourceAssetId: a.source_asset_id, duration: a.duration, resolution: a.resolution, generateAudio: a.generate_audio }), a.wait)))
36
- server.registerTool('yesilcam_tryon', { description: 'Fashion Try-On: put a garment (image asset) on a person (image asset) or a saved character. Costs tokens.', inputSchema: { garment_asset_id: z.string(), person_asset_id: z.string().optional(), character_id: z.string().optional(), prompt: z.string().optional(), wait: z.boolean().optional() } },
37
- guarded(async (a) => { if (!a.person_asset_id && !a.character_id) throw new Error('person_asset_id or character_id is required'); return run(client(), tryonJob({ garmentAssetId: a.garment_asset_id, personAssetId: a.person_asset_id, characterId: a.character_id, prompt: a.prompt }), a.wait) }))
38
- server.registerTool('yesilcam_job_status', { description: 'Status and result URL of a generation job.', inputSchema: { job_id: z.string(), wait: z.boolean().optional() } },
39
- guarded(async ({ job_id, wait }) => { const c = client(); const job = wait ? await waitForJob(c, job_id) : (await c.jobStatus(Number(job_id))).job; return text(describeJob(job)) }))
40
- server.registerTool('yesilcam_list_jobs', { description: 'Recent generation jobs.', inputSchema: { limit: z.number().int().min(1).max(100).optional() } },
41
- guarded(async ({ limit }) => text(await client().jobs({ limit: limit || 20 }))))
42
- server.registerTool('yesilcam_list_characters', { description: 'Saved characters (for consistent faces across scenes).', inputSchema: {} },
43
- guarded(async () => text(await client().characters())))
44
- server.registerTool('yesilcam_maket_analyze', { description: 'Analyze a local DXF or JSON floor plan (Maket): walls, doors, windows, rooms, areas, layer roles. Free, runs locally.', inputSchema: { path: z.string() } },
45
- guarded(async ({ path }) => text(analyzePlanText(readFileSync(path, 'utf8'), basename(path)))))
46
- server.registerTool('yesilcam_maket_projects', { description: 'Saved Maket projects of the account.', inputSchema: {} },
47
- guarded(async () => text(await client().maketList())))
48
- server.registerTool('yesilcam_maket_save', { description: `Save a local floor plan as a Maket project; open it at ${SITE_URL}/app/maket.`, inputSchema: { path: z.string(), title: z.string().optional() } },
49
- guarded(async ({ path, title }) => { const t = readFileSync(path, 'utf8'); const a = analyzePlanText(t, basename(path)); return text(await client().maketCreate({ title: title || basename(path, extname(path)), source_kind: a.source_kind, source_text: t, settings: { overrides: {}, wallHeight: 2.9 }, stats: a.stats })) }))
23
+ export function buildToolSchema(op) {
24
+ const s = { ...op.input }
25
+ if (op.kind === 'spend') {
26
+ s.confirm = z.boolean().optional().describe('Set true ONLY after the user approved the quoted cost. Without it nothing is charged and a quote is returned.')
27
+ s.max_tokens = z.number().int().min(0).optional().describe('Refuse if the quote is above this many tokens')
28
+ }
29
+ if (op.wait) {
30
+ s.wait = z.boolean().optional().describe('Wait for the result (reports progress)')
31
+ s.max_wait_seconds = z.number().int().min(5).max(3600).optional().describe('How long to wait (default 900)')
32
+ s.save_to = z.string().optional().describe('Local file or folder to save the result to (implies wait)')
33
+ }
34
+ return s
35
+ }
36
+
37
+ export function toolDescription(op) {
38
+ const tag = op.kind === 'spend' ? (op.confirm ? ' [Needs confirm=true after the user approves.]' : ' [Spends tokens: returns a quote first; call again with confirm=true after the user approves.]') : op.kind === 'local' ? ' [Free, runs locally.]' : ''
39
+ return `${op.description}${tag}`
40
+ }
41
+
42
+ export function createServer({ client: injected, studios, readOnly } = {}) {
43
+ const server = new McpServer({ name: 'yesilcam', version: VERSION }, { instructions: INSTRUCTIONS })
44
+ let cached = null
45
+ const client = () => {
46
+ if (injected) return injected
47
+ if (cached) return cached
48
+ const cred = getCredentials()
49
+ if (!cred) throw new ApiError('not_logged_in')
50
+ cached = makeClient(cred)
51
+ return cached
52
+ }
53
+ for (const op of selectOps({ studios, readOnly })) {
54
+ server.registerTool(`yesilcam_${op.name}`, {
55
+ title: op.summary,
56
+ description: toolDescription(op),
57
+ inputSchema: buildToolSchema(op),
58
+ annotations: { title: op.summary, readOnlyHint: op.kind === 'read' || op.kind === 'local', destructiveHint: Boolean(op.destructive), idempotentHint: op.kind === 'read' || op.kind === 'local', openWorldHint: op.kind !== 'local' },
59
+ }, async (args, extra) => {
60
+ try {
61
+ const token = extra?._meta?.progressToken
62
+ let tick = 0
63
+ const onProgress = token != null && extra?.sendNotification ? (st) => {
64
+ tick += 1
65
+ const pct = st?.progress != null ? Math.max(0, Math.min(100, Number(st.progress))) : null
66
+ extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: pct ?? tick, ...(pct != null ? { total: 100 } : {}), message: [st?.status, st?.stage].filter(Boolean).join(' · ') } }).catch(() => {})
67
+ } : undefined
68
+ const r = await execute(op, op.kind === 'local' ? null : client(), args, { confirmed: args?.confirm === true, onProgress })
69
+ if (r.status === 'quote') return text({ status: 'quote_only_nothing_charged', quote: r.quote, next: `Show this price to the user. If they approve, call yesilcam_${op.name} again with the same arguments and confirm=true.` })
70
+ if (r.status !== 'done') return text(r)
71
+ return text({ ...(r.quote ? { quoted_tokens: r.quote.total } : {}), result: r.result, ...(r.final ? { final: r.final } : {}), ...(r.track ? { track: `yesilcam_wait kind=${r.track.kind} id=${r.track.id}` } : {}) })
72
+ } catch (e) { return fail(e) }
73
+ })
74
+ }
50
75
  return server
51
76
  }
52
77
 
53
- async function run(c, payload, wait) {
54
- const r = await c.createJob(payload)
55
- const jobId = r?.job_id || r?.id || r?.job?.id
56
- if (!jobId) return text(r)
57
- if (!wait) return text({ job_id: String(jobId), status: 'queued', hint: 'Call yesilcam_job_status with wait=true to get the result.' })
58
- return text(describeJob(await waitForJob(c, jobId)))
78
+ export function parseArgs(argv) {
79
+ const o = { studios: [], readOnly: false }
80
+ for (let i = 0; i < argv.length; i++) {
81
+ const v = argv[i]
82
+ if (v === '--read-only') o.readOnly = true
83
+ else if (v === '--studios' && argv[i + 1]) o.studios = argv[++i].split(',').map((s) => s.trim()).filter(Boolean)
84
+ else if (v.startsWith('--studios=')) o.studios = v.slice(10).split(',').map((s) => s.trim()).filter(Boolean)
85
+ else if (v === '--help' || v === '-h') o.help = true
86
+ else if (v === '--version' || v === '-v') o.version = true
87
+ }
88
+ if (process.env.YESILCAM_MCP_STUDIOS && !o.studios.length) o.studios = process.env.YESILCAM_MCP_STUDIOS.split(',').map((s) => s.trim()).filter(Boolean)
89
+ if (process.env.YESILCAM_MCP_READ_ONLY === '1') o.readOnly = true
90
+ const bad = o.studios.filter((s) => !STUDIOS.includes(s))
91
+ if (bad.length) throw new Error(`unknown studio: ${bad.join(', ')} (use: ${STUDIOS.filter((s) => s !== 'core').join(', ')})`)
92
+ return o
59
93
  }
60
94
 
61
- export async function startStdio() {
62
- const server = createServer()
95
+ export async function startStdio(argv = process.argv.slice(2)) {
96
+ const o = parseArgs(argv)
97
+ if (o.version) { process.stdout.write(`${VERSION}\n`); return }
98
+ if (o.help) { process.stdout.write(`yesilcam-mcp ${VERSION}: Yeşilçam Studios MCP server (stdio).\nOptions: --studios image,film,animation,cinema,design,website,fashion,maket,influencer --read-only\nSign in first with: yesilcam login (API key from ${KEYS_URL})\n`); return }
99
+ if (!getCredentials()) process.stderr.write(`yesilcam-mcp: not signed in. Run "yesilcam login" (API key from ${KEYS_URL}) or set YESILCAM_API_KEY.\n`)
100
+ const server = createServer(o)
63
101
  await server.connect(new StdioServerTransport())
64
102
  }
@@ -0,0 +1,43 @@
1
+ // İşlem kataloğu yardımcıları. Her işlem:
2
+ // { name, group, action, summary, description, kind: 'read'|'write'|'spend'|'local', destructive?, input:{zod},
3
+ // positional?: alan adı (CLI ilk argüman), quote?(c,a) (spend), run(c,a,ctx), wait?: (sonuç, a) => { kind, id } }
4
+ // kind=spend: MCP'de confirm=true olmadan yalnız teklif döner (jeton harcanmaz); CLI'da --yes ya da etkileşimli onay.
5
+ import { z } from 'zod'
6
+
7
+ export { z }
8
+ export const ID = (d) => z.union([z.string(), z.number()]).describe(d)
9
+ export const IDS = (d, max = 10) => z.array(z.union([z.string(), z.number()])).max(max).describe(d)
10
+ export const NEG = z.string().max(500).optional().describe('Things to avoid (negative prompt), up to 500 characters')
11
+ export const ASPECT3 = z.enum(['16:9', '9:16', '1:1'])
12
+ export const n = (v) => (v == null || v === '' ? undefined : Number(v))
13
+ export const ints = (arr) => (Array.isArray(arr) ? arr.map((x) => Number(x)).filter((x) => Number.isFinite(x)) : undefined)
14
+ export const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ''))
15
+
16
+ export const op = (o) => o
17
+
18
+ /** Tek işlem anahtarının bu plandaki bedeli (ai-studio modülü). */
19
+ export async function opCost(c, key) {
20
+ const r = await c.call('aistudio-operation-cost', { operation_key: key })
21
+ // Ücretli planda aylık ücretsiz hakkı kalan işlemler 0'a düşer (sunucu da ücret almaz)
22
+ const free = Number(r?.monthlyFreeRemaining || 0) > 0 || Number(r?.dailyFreeRemaining || 0) > 0 || r?.isFreeForAll === true
23
+ return { key: free ? `${key} (free monthly use, ${r.monthlyFreeRemaining ?? r.dailyFreeRemaining ?? ''} left)` : key, tokens: free ? 0 : Number(r?.effectiveCost ?? r?.effective_cost ?? 0), balance: r?.currentBalance != null ? Number(r.currentBalance) : null }
24
+ }
25
+ /** Başka modüller için (fashion-tryon, maket …). */
26
+ export async function moduleCost(c, moduleKey, operationKey) {
27
+ const r = await c.call('check-module-cost', { moduleKey, operationKey })
28
+ return { key: operationKey, tokens: Number(r?.effectiveCost ?? 0) }
29
+ }
30
+ export async function balanceOf(c) {
31
+ const r = await c.call('get-token-balance').catch(() => null)
32
+ return r && r.balance != null ? Number(r.balance) : null
33
+ }
34
+ /** Teklif nesnesi: { total, items:[{item, tokens}], balance, can_afford, notes? } */
35
+ export async function quote(c, items, { balance, notes, approximate } = {}) {
36
+ const list = items.filter(Boolean).map((i) => ({ item: i.item, tokens: Math.ceil(Number(i.tokens) || 0) }))
37
+ const total = list.reduce((s, i) => s + i.tokens, 0)
38
+ const bal = balance != null ? Number(balance) : await balanceOf(c)
39
+ return clean({ total, items: list, balance: bal, can_afford: bal == null ? undefined : bal >= total, approximate: approximate || undefined, notes })
40
+ }
41
+
42
+ /** CLI/MCP'de dosya yolu gibi davranan alanlar (CLI ~ genişletmesi vb. için işaret). */
43
+ export const PATH = (d) => z.string().min(1).describe(d)
@@ -0,0 +1,62 @@
1
+ import { z, op, n, clean } from './_util.js'
2
+ import { checkOnce, waitFor, WAIT_KINDS, waitKindHelp } from '../wait.js'
3
+ import { downloadUrl } from '../files.js'
4
+
5
+ export default [
6
+ op({ name: 'account', group: 'account', action: 'show', kind: 'read', summary: 'Account, plan and token balance',
7
+ description: 'Shows the signed-in Yeşilçam Studios account: name, email, plan (Free, Basic, Pro), plan expiry, token balance and, when signed in with an API key, the key scopes.',
8
+ input: {},
9
+ run: async (c) => {
10
+ const [sub, bal, bill, ses] = await Promise.all([c.call('get-user-subscription-infos').catch(() => ({})), c.call('get-token-balance').catch(() => ({})), c.call('ys-billing-info').catch(() => null), c.session().catch(() => null)])
11
+ const plan = sub?.subscriptionPackageType || 'social-hive'
12
+ const label = { 'social-hive': 'Free', basic: 'Basic', professional: 'Pro' }
13
+ return clean({ name: [sub?.name, sub?.lastname].filter(Boolean).join(' ') || ses?.user?.name, email: sub?.email || ses?.user?.email, plan: label[bill?.effective_plan || plan] || plan,
14
+ plan_expires: (() => { const t = Number(sub?.subscriptionExpiredDate || bill?.expires_at); return t > 1e9 ? new Date(t * 1000).toISOString().slice(0, 10) : undefined })(), balance: bal?.balance != null ? Number(bal.balance) : undefined,
15
+ auth: c.kind, api_key: ses?.key ? { name: ses.key.name, prefix: ses.key.prefix, scopes: ses.scopes } : undefined })
16
+ } }),
17
+ op({ name: 'balance', group: 'account', action: 'balance', kind: 'read', summary: 'Token balance',
18
+ description: 'Current token balance of the account.', input: {},
19
+ run: async (c) => { const r = await c.call('get-token-balance'); return { balance: Number(r.balance) } } }),
20
+ op({ name: 'pricing', group: 'account', action: 'pricing', kind: 'read', summary: 'Token prices of every operation and token packs',
21
+ description: 'Lists base token prices per operation for all studios (your plan may apply a multiplier: Pro x0.8, Free x1.3; use the price tool for your exact price) and the token packs you can buy.',
22
+ input: { search: z.string().optional().describe('Filter operations by text, e.g. "video" or "inf_"') },
23
+ run: async (c, a) => {
24
+ const [ops, packs] = await Promise.all([c.call('get-all-module-pricing'), c.call('get-token-packages', { app: 'yesilcam' }).catch(() => [])])
25
+ const rows = (Array.isArray(ops) ? ops : []).filter((r) => ['ai-studio', 'fashion-tryon', 'maket'].includes(r.module_key) && String(r.status ?? '1') === '1')
26
+ .map((r) => ({ module: r.module_key, operation: r.operation_key, tokens: Number(r.base_cost_tokens) }))
27
+ .filter((r) => !a.search || `${r.module} ${r.operation}`.toLowerCase().includes(String(a.search).toLowerCase())).sort((x, y) => x.tokens - y.tokens)
28
+ const pk = (Array.isArray(packs) ? packs : packs?.items || []).map((p) => clean({ id: p.id, title: p.title, tokens: Number(p.token_amount), bonus_percent: Number(p.bonus_percentage || 0), usd: n(p.price_usd), usd_without_plan: n(p.price_usd_standard), try: n(p.price_tr), try_without_plan: n(p.price_tr_standard) }))
29
+ return { operations: rows, packs: pk, note: 'Base prices. Your plan multiplier applies (Pro x0.8, Basic x1.0, Free x1.3). Failed jobs are refunded.' }
30
+ } }),
31
+ op({ name: 'price', group: 'account', action: 'price', kind: 'read', summary: 'Exact token price of one operation for your plan',
32
+ description: 'Exact token price of one operation key for the signed-in plan (keys come from the pricing tool, e.g. image_standard, video_draft_5s, inf_talk_veo_sec).',
33
+ input: { operation: z.string().min(2).describe('Operation key, e.g. image_standard'), module: z.enum(['ai-studio', 'fashion-tryon', 'maket']).optional().describe('Module (default ai-studio)') },
34
+ positional: 'operation',
35
+ run: async (c, a) => { const r = await c.call('check-module-cost', { moduleKey: a.module || 'ai-studio', operationKey: a.operation }); return clean({ operation: a.operation, tokens: n(r.effectiveCost), base_tokens: n(r.baseCost), multiplier: n(r.multiplier), balance: n(r.currentBalance) }) } }),
36
+ op({ name: 'transactions', group: 'account', action: 'transactions', kind: 'read', summary: 'Recent token transactions',
37
+ description: 'Recent token transactions (charges, refunds, grants, purchases).',
38
+ input: { page: z.number().int().min(1).optional(), limit: z.number().int().min(1).max(100).optional() },
39
+ run: (c, a) => c.call('get-token-transactions', { page: a.page || 1, limit: a.limit || 20 }) }),
40
+
41
+ // ── ortak bekleme / durum / indirme
42
+ op({ name: 'status', group: 'wait', action: 'status', kind: 'read', summary: 'Status of any job or project',
43
+ description: `One status read of a running job or project. kind: ${waitKindHelp()}.`,
44
+ input: { kind: z.enum(WAIT_KINDS).describe('What to check'), id: z.union([z.string(), z.number()]).describe('Job / project id (renders: projectId:renderId)') },
45
+ positional: ['kind', 'id'],
46
+ run: (c, a) => checkOnce(c, a.kind, a.id) }),
47
+ op({ name: 'wait', group: 'wait', action: 'until', kind: 'read', summary: 'Wait until a job or project finishes (optionally download the result)',
48
+ description: `Polls a job or project until it finishes, needs your action, or max_wait_seconds passes; reports progress. Optionally saves the result file. kind: ${waitKindHelp()}.`,
49
+ input: { kind: z.enum(WAIT_KINDS), id: z.union([z.string(), z.number()]), max_wait_seconds: z.number().int().min(5).max(3600).optional().describe('Default 600'),
50
+ save_to: z.string().optional().describe('Local file or folder to save the result to') },
51
+ positional: ['kind', 'id'],
52
+ run: async (c, a, ctx) => {
53
+ const st = await waitFor(c, a.kind, a.id, { timeoutMs: (a.max_wait_seconds || 600) * 1000, onTick: ctx?.onProgress })
54
+ if (a.save_to && st.ok && st.url) st.saved = await downloadUrl(c, st.url, a.save_to)
55
+ return st
56
+ } }),
57
+ op({ name: 'download', group: 'library', action: 'download', kind: 'read', summary: 'Download a result or library file',
58
+ description: 'Downloads a file from a Yeşilçam Studios URL (private signed links are refreshed automatically) to a local path.',
59
+ input: { url: z.string().url().describe('File URL from a result or the library'), save_to: z.string().optional().describe('Local file or folder (default: current folder)') },
60
+ positional: 'url',
61
+ run: async (c, a) => ({ saved: await downloadUrl(c, a.url, a.save_to || '.') }) }),
62
+ ]
@@ -0,0 +1,126 @@
1
+ // Animation Studio: 25 tarz (tek motor ailesi aistudio-anim-*), Inkmation (aistudio-inkmation-*), Custom (aistudio-motion-*).
2
+ // Ink Landscape = engine "inkland" (palet + mevsim).
3
+ import { z, op, ID, NEG, ASPECT3, n, clean, quote } from './_util.js'
4
+ import { uploadAsset } from '../files.js'
5
+
6
+ // Uygulama vitrinindeki ad + sunucu motoru (+ varyant). Ad çevrilmez.
7
+ export const STYLES = [
8
+ ['foliomation', 'Foliomation'], ['papercraft', 'Papercraft'], ['dioramation', 'Dioramation'], ['typemation', 'Typemation'], ['fluxmation', 'Fluxmation'],
9
+ ['puppetmation', 'Puppetmation'], ['inkland', 'Ink Landscape'], ['popbook', 'Popbook (interactive book)'], ['labmation', 'Labmation (interactive lab)'],
10
+ ['wuxiamation-legend2d', 'Wuxiamation · Legend 2D'], ['wuxiamation-cinematic3d', 'Wuxiamation · Cinematic 3D'], ['celmation', 'Celmation'], ['neonmation', 'Neonmation'],
11
+ ['toymation', 'Toymation'], ['toonmation', 'Toonmation'], ['shadowmation', 'Shadowmation'], ['pixelmation', 'Pixelmation'], ['panelmation', 'Panelmation'],
12
+ ['claymation', 'Claymation'], ['knitmation', 'Knitmation'], ['sumimation', 'Sumimation'], ['postermation', 'Postermation'], ['epicmation', 'Epicmation'],
13
+ ]
14
+ const STYLE_IDS = STYLES.map((s) => s[0])
15
+ const MOTION = ['wuxiamation', 'celmation', 'neonmation', 'toymation', 'toonmation', 'pixelmation', 'panelmation', 'claymation', 'knitmation', 'sumimation', 'postermation', 'epicmation']
16
+ const engineOf = (style) => { const [e, v] = String(style).split('-'); return { engine: e, variant: v } }
17
+
18
+ const animInput = {
19
+ style: z.enum(STYLE_IDS).describe('Animation style (see yesilcam_animation_styles)'),
20
+ prompt: z.string().min(12).max(8000).describe('What the film is about (story, topic or script)'),
21
+ title: z.string().max(200).optional(),
22
+ lang: z.enum(['tr', 'en', 'de', 'fr', 'es', 'it']).optional().describe('Narration language'),
23
+ aspect: ASPECT3.optional(), seconds: z.number().int().min(10).max(120).optional().describe('Target length (default 30)'),
24
+ voice: z.string().optional().describe('Voice id (yesilcam_animation_voices); empty = automatic'),
25
+ music: z.string().max(120).optional().describe('auto, none, or a music description'),
26
+ captions: z.enum(['bottom', 'top', 'none']).optional(), narrate: z.boolean().optional(),
27
+ palette: z.string().optional().describe('postermation: auto|bosphorus|riviera|noir|pastel · inkland: auto|ink|sepia|qinglu|night|yesilcam'),
28
+ season: z.enum(['auto', 'spring', 'summer', 'autumn', 'winter']).optional().describe('Ink Landscape only'),
29
+ note: z.string().max(300).optional().describe('Extra style note'),
30
+ negative_prompt: NEG,
31
+ }
32
+ const animOptions = (a) => { const { variant } = engineOf(a.style); return clean({ lang: a.lang, aspect: a.aspect, maxSeconds: a.seconds, voice: a.voice, music: a.music, captions: a.captions, narrate: a.narrate, palette: a.palette, season: a.season, style: a.note, variant, negative_prompt: a.negative_prompt }) }
33
+ const TIERS = z.enum(['premium', 'professional', 'cinema'])
34
+
35
+ const renderInput = { project_id: ID('Animation project id'), format: z.enum(['mp4', 'webp', 'gif']).optional(), quality: z.enum(['hd', '4k']).optional().describe('mp4 only'), aspect: ASPECT3.optional() }
36
+ const renderBody = (a) => clean({ project_id: Number(a.project_id), format: a.format || 'mp4', quality: (a.format || 'mp4') === 'mp4' ? (a.quality || 'hd') : undefined, aspect: a.aspect })
37
+
38
+ export default [
39
+ op({ name: 'animation_styles', group: 'animation', action: 'styles', kind: 'local', summary: 'The 25 animation styles',
40
+ description: 'Lists Animation Studio styles: 23 engine styles (including Ink Landscape and two Wuxiamation variants), plus Inkmation (yesilcam_inkmation_create) and Custom (yesilcam_custom_animation_create). "cinema" tier is available for moving styles on Basic/Pro.',
41
+ input: {},
42
+ run: () => ({ styles: STYLES.map(([id, name]) => ({ id, name, cinema_tier: MOTION.includes(engineOf(id).engine), interactive: ['popbook', 'labmation'].includes(id) || undefined })), also: [{ id: 'inkmation', name: 'Inkmation', tool: 'inkmation_create' }, { id: 'custom', name: 'Custom', tool: 'custom_animation_create' }] }) }),
43
+ op({ name: 'animation_voices', group: 'animation', action: 'voices', kind: 'read', summary: 'Narration voices for Animation and Inkmation',
44
+ description: 'Voices for Animation Studio and Inkmation narration (id, name, language, gender, preview).', input: {},
45
+ run: (c) => c.call('aistudio-inkmation-voices') }),
46
+ op({ name: 'animation_create', group: 'animation', action: 'create', kind: 'spend', summary: 'Animation Studio: make an animated film in one of 25 styles',
47
+ description: 'Creates an animated film in a chosen style: the director writes scenes, images, narration and music are made, then you render MP4/WebP/GIF (yesilcam_animation_render). Tiers: premium (best story craft), professional (fast, economical), cinema (moving styles, Basic/Pro). The estimate is charged up front and unused tokens are refunded at the end.',
48
+ positional: 'prompt',
49
+ input: { ...animInput, tier: TIERS.optional().describe('premium (default), professional, cinema') },
50
+ quote: async (c, a) => { const { engine } = engineOf(a.style); const r = await c.call('aistudio-anim-quote', { engine, prompt: a.prompt, options: animOptions(a) })
51
+ const t = r.tiers?.[a.tier || 'premium']; if (!t) throw new Error(`tier ${a.tier} is not available for ${a.style}`)
52
+ return quote(c, Object.entries(t.parts || { total: t.tokens }).map(([k, v]) => ({ item: k, tokens: v })), { balance: n(r.balance), notes: 'Estimate charged up front; unused tokens are refunded. Rendering is a separate small charge.' }) },
53
+ run: async (c, a) => { const { engine } = engineOf(a.style); return c.call('aistudio-anim-create', clean({ engine, tier: a.tier || 'premium', prompt: a.prompt, title: a.title, options: animOptions(a) })) },
54
+ wait: (r) => ({ kind: 'anim', id: r.project_id }) }),
55
+ op({ name: 'animation_story', group: 'animation', action: 'story', kind: 'spend', summary: 'Sam writes or improves an animation story',
56
+ description: 'Sam writes (mode=write) or improves (mode=improve) the story/prompt for an animation style or Inkmation. Charged by actual usage.',
57
+ input: { style: z.enum([...STYLE_IDS, 'inkmation']), text: z.string().max(6000), mode: z.enum(['write', 'improve']).optional(), lang: z.enum(['tr', 'en', 'de', 'fr', 'es', 'it']).optional(), seconds: z.number().int().min(10).max(300).optional() },
58
+ quote: async (c, a) => { const { engine } = engineOf(a.style); const r = await c.call('aistudio-anim-quote', { engine: engine === 'inkmation' ? 'foliomation' : engine, prompt: a.text || 'story', options: clean({ lang: a.lang, maxSeconds: a.seconds }) }).catch(() => ({}))
59
+ return quote(c, [{ item: 'assistant (estimate)', tokens: Number(r.assist || 0) }], { balance: n(r.balance), approximate: true }) },
60
+ run: (c, a) => { const { engine, variant } = engineOf(a.style); return c.call('aistudio-anim-story-assist', { engine, mode: a.mode || 'write', text: a.text || '', options: clean({ lang: a.lang, maxSeconds: a.seconds, variant }) }) } }),
61
+ op({ name: 'animation_list', group: 'animation', action: 'list', kind: 'read', summary: 'Your animation projects',
62
+ description: 'Animation Studio projects (newest first).', input: { style: z.string().optional() },
63
+ run: (c, a) => c.call('aistudio-anim-list', clean({ engine: a.style ? engineOf(a.style).engine : undefined })) }),
64
+ op({ name: 'animation_get', group: 'animation', action: 'get', kind: 'read', summary: 'An animation project with its renders',
65
+ description: 'Status, progress, scenes and renders of an animation project.', input: { project_id: ID('Project id') }, positional: 'project_id',
66
+ run: async (c, a) => { const r = await c.call('aistudio-anim-get', { project_id: Number(a.project_id) }); if (r?.spec) r.spec = { scenes: (r.spec.scenes || []).length }; return r } }),
67
+ op({ name: 'animation_render', group: 'animation', action: 'render', kind: 'spend', summary: 'Render an animation to MP4 (HD/4K), WebP or GIF',
68
+ description: 'Renders a ready animation project to a video file. Priced by render time (small); settled after rendering.',
69
+ input: renderInput, positional: 'project_id',
70
+ quote: async (c, a) => { const r = await c.call('aistudio-anim-render-quote', renderBody(a)); return quote(c, [{ item: `render ${r.format} ${r.quality || ''} ${r.duration || ''}s`, tokens: r.tokens }], { balance: n(r.balance) }) },
71
+ run: async (c, a) => { const r = await c.call('aistudio-anim-render', renderBody(a)); return { render_id: String(r.render_id), status: r.status, tokens_charged: n(r.tokens_charged), wait_id: `${a.project_id}:${r.render_id}` } },
72
+ wait: (r) => ({ kind: 'anim_render', id: r.wait_id }) }),
73
+ op({ name: 'animation_share', group: 'animation', action: 'share', kind: 'write', summary: 'Make an animation public (share link) or private',
74
+ description: 'Creates or removes a public share link for a ready animation project.', input: { project_id: ID('Project id'), public: z.boolean().optional().describe('default true') }, positional: 'project_id',
75
+ run: async (c, a) => { const r = await c.call('aistudio-anim-share', { project_id: Number(a.project_id), public: a.public !== false }); return { ...r, url: r.share_token ? `https://yesilcamstudios.com/app/p/${r.share_token}` : null } } }),
76
+
77
+ // ── Custom
78
+ op({ name: 'custom_animation_create', group: 'animation', action: 'custom', kind: 'spend', summary: 'Animation › Custom: animate your own image along a camera path',
79
+ description: 'Turns an image into a 5, 10 or 15 second video. Give a motion prompt and/or camera moves (each segment at least 3 s; segments must add up to the duration) and up to 3 objects to move. Tier draft (720p) or standard (1080p). Priced per second.',
80
+ input: { image_asset_id: ID('Image asset id').optional(), image_path: z.string().optional().describe('Local image (uploaded first)'), prompt: z.string().max(1500).optional(),
81
+ tier: z.enum(['draft', 'standard']).optional(), aspect: ASPECT3.optional(), duration: z.union([z.literal(5), z.literal(10), z.literal(15)]).optional(),
82
+ camera: z.array(z.object({ axis: z.enum(['pan', 'tilt', 'zoom', 'roll', 'horizontal', 'vertical']), value: z.number().min(-10).max(10), seconds: z.number().int().min(3) })).max(3).optional().describe('Guided camera segments'),
83
+ objects: z.array(z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number(), label: z.string().max(80) })).max(3).optional().describe('Objects to move (0..1 coordinates from start to end)'), negative_prompt: NEG },
84
+ prepare: (c, a) => {
85
+ const duration = a.duration || 5
86
+ if (a.camera?.length && a.camera.reduce((s, x) => s + x.seconds, 0) !== duration) throw new Error(`camera segment seconds must add up to ${duration}`)
87
+ if (!a.prompt && !a.camera?.length && !a.objects?.length) throw new Error('give a prompt, camera segments or objects')
88
+ if (!a.image_asset_id && !a.image_path) throw new Error('give image_asset_id or image_path')
89
+ return { duration, tier: a.tier || 'standard' }
90
+ },
91
+ quote: async (c, a, p) => { const r = await c.call('aistudio-motion-quote', { tier: p.tier, duration: p.duration }); return quote(c, [{ item: `${p.tier} ${p.duration}s`, tokens: r.price?.total }], { balance: n(r.balance) }) },
92
+ run: async (c, a, ctx, p) => {
93
+ const src = a.image_asset_id ? Number(a.image_asset_id) : Number((await uploadAsset(c, a.image_path, { kind: 'image' })).asset_id)
94
+ const camera = a.camera?.length ? { mode: 'guided', style: 'pan', segments: a.camera.map((s) => ({ axis: s.axis, value: s.value, seconds: s.seconds })) } : undefined
95
+ return c.call('aistudio-motion-create', clean({ source_asset_id: src, prompt: a.prompt, tier: p.tier, aspect: a.aspect, duration: p.duration, camera, objects: a.objects, negative_prompt: a.negative_prompt }))
96
+ },
97
+ wait: (r) => ({ kind: 'motion', id: r.job_id }) }),
98
+ op({ name: 'custom_animation_list', group: 'animation', action: 'custom-list', kind: 'read', summary: 'Your Custom animations',
99
+ description: 'Animation › Custom jobs with status and result video.', input: { limit: z.number().int().min(1).max(48).optional() },
100
+ run: (c, a) => c.call('aistudio-motion-list', { limit: a.limit || 24 }) }),
101
+
102
+ // ── Inkmation
103
+ op({ name: 'inkmation_create', group: 'inkmation', action: 'create', kind: 'spend', summary: 'Inkmation: brush-drawn explainer film from a story',
104
+ description: 'Inkmation draws your story with an animated brush, with narration and music in Turkish and/or English. Pick a platform (sets aspect and max length) and a drawing style. Tiers premium / professional. Then render with yesilcam_inkmation_render.',
105
+ positional: 'story',
106
+ input: { story: z.string().min(40).max(12000), title: z.string().max(200).optional(), tier: z.enum(['premium', 'professional']).optional(),
107
+ platform: z.enum(['youtube', 'youtube-shorts', 'instagram-reels', 'tiktok', 'story', 'instagram-post', 'instagram-portrait', 'facebook', 'x', 'linkedin', 'pinterest']).optional(),
108
+ languages: z.array(z.enum(['tr', 'en'])).min(1).max(2).optional(), seconds: z.number().int().min(15).max(300).optional(),
109
+ drawing: z.enum(['normal', 'anime', 'chinese', 'japanese', '3d', 'ink', 'watercolor', 'pastel', 'bw-ink', 'pencil', 'clean-bw']).optional(), tone: z.string().max(80).optional(),
110
+ music: z.string().max(80).optional().describe('auto or a music description'), voice_tr: z.string().optional(), voice_en: z.string().optional(), negative_prompt: NEG },
111
+ prepare: (c, a) => ({ options: clean({ languages: a.languages, platform: a.platform, maxSeconds: a.seconds, style: a.drawing, tone: a.tone, musicStyle: a.music, voices: clean({ tr: a.voice_tr, en: a.voice_en }), autoAudio: true, negative_prompt: a.negative_prompt }) }),
112
+ quote: async (c, a, p) => { const r = await c.call('aistudio-inkmation-quote', { story: a.story, options: p.options, cast: [] }); const t = r.tiers?.[a.tier || 'professional'] || {}
113
+ return quote(c, [{ item: 'script', tokens: t.script_tokens }, { item: 'voice and music', tokens: t.audio_tokens }], { balance: n(r.balance) }) },
114
+ run: (c, a, ctx, p) => c.call('aistudio-inkmation-create', clean({ title: a.title, story: a.story, tier: a.tier || 'professional', options: p.options, cast: [] })),
115
+ wait: (r) => ({ kind: 'inkmation', id: r.project_id }) }),
116
+ op({ name: 'inkmation_list', group: 'inkmation', action: 'list', kind: 'read', summary: 'Your Inkmation projects', description: 'Inkmation projects.', input: {},
117
+ run: (c) => c.call('aistudio-inkmation-list') }),
118
+ op({ name: 'inkmation_get', group: 'inkmation', action: 'get', kind: 'read', summary: 'An Inkmation project', description: 'Status, script, audio state and renders of an Inkmation project.',
119
+ input: { project_id: ID('Project id') }, positional: 'project_id', run: (c, a) => c.call('aistudio-inkmation-get', { project_id: Number(a.project_id) }) }),
120
+ op({ name: 'inkmation_render', group: 'inkmation', action: 'render', kind: 'spend', summary: 'Render an Inkmation film',
121
+ description: 'Renders a ready Inkmation project (MP4 HD/4K, WebP or GIF; optional language and drawing style switch at render time).',
122
+ input: { ...renderInput, project_id: ID('Inkmation project id'), lang: z.enum(['tr', 'en']).optional(), drawing: z.string().optional() }, positional: 'project_id',
123
+ quote: async (c, a) => { const r = await c.call('aistudio-inkmation-render-quote', clean({ ...renderBody(a), lang: a.lang, style: a.drawing })); return quote(c, [{ item: `render ${r.format} ${r.duration || ''}s`, tokens: r.tokens }], { balance: n(r.balance) }) },
124
+ run: async (c, a) => { const r = await c.call('aistudio-inkmation-render', clean({ ...renderBody(a), lang: a.lang, style: a.drawing })); return { render_id: String(r.render_id), status: r.status, tokens_charged: n(r.tokens_charged), wait_id: `${a.project_id}:${r.render_id}` } },
125
+ wait: (r) => ({ kind: 'inkmation_render', id: r.wait_id }) }),
126
+ ]
@@ -0,0 +1,58 @@
1
+ // Cinema Studio (video düzenleyici): proje listesi, dışa aktarma, seslendirme, konuşan sunucu, altyazı.
2
+ import { z, op, ID, n, clean, opCost, quote } from './_util.js'
3
+ import { uploadAsset } from '../files.js'
4
+
5
+ const J = (v) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || '{}') } catch { return {} } }
6
+ /** Zaman çizelgesinin sonu (sn, hız dahil) — sunucunun ücretlendirdiği süre. */
7
+ export function timelineSeconds(data) {
8
+ let end = 0
9
+ for (const t of J(data).tracks || []) for (const it of t.items || []) {
10
+ const start = Number(it.start || 0)
11
+ let len = 0
12
+ if (it.dur != null) len = Number(it.dur)
13
+ else if (it.out != null) len = (Number(it.out) - Number(it.in || 0)) / (Number(it.speed) || 1)
14
+ else if (it.duration != null) len = Number(it.duration) / (Number(it.speed) || 1)
15
+ end = Math.max(end, start + Math.max(0, len))
16
+ }
17
+ return Math.ceil(end)
18
+ }
19
+
20
+ export default [
21
+ op({ name: 'cinema_projects', group: 'cinema', action: 'list', kind: 'read', summary: 'Cinema Studio projects',
22
+ description: 'Cinema Studio (video editor) projects with duration, aspect and last export link.', input: {},
23
+ run: (c) => c.call('aistudio-cinema-project-list') }),
24
+ op({ name: 'cinema_render', group: 'cinema', action: 'render', kind: 'spend', summary: 'Export a Cinema Studio project to a video file',
25
+ description: 'Exports a Cinema Studio timeline to MP4/MOV/WebM at 720p, 1080p or 4K (2160p). Priced per second of timeline at the chosen resolution.',
26
+ input: { project_id: ID('Cinema project id'), resolution: z.enum(['720p', '1080p', '2160p']).optional(), format: z.enum(['mp4', 'mov', 'webm']).optional(), fps: z.number().int().min(1).max(120).optional(),
27
+ preset: z.enum(['youtube', 'shorts', 'ig_square', 'ig_portrait', 'linkedin']).optional() },
28
+ positional: 'project_id',
29
+ prepare: async (c, a) => { const p = await c.call('aistudio-cinema-project-get', { project_id: a.project_id }); const s = timelineSeconds(p.data); if (!s) throw new Error('Empty timeline'); return { seconds: s, res: a.resolution || '1080p' } },
30
+ quote: async (c, a, p) => { const x = await opCost(c, `cinema_render_${p.res}`); return quote(c, [{ item: `cinema_render_${p.res} x ${p.seconds}s`, tokens: x.tokens * p.seconds }], { balance: x.balance }) },
31
+ run: (c, a, ctx, p) => c.call('aistudio-cinema-render', clean({ project_id: a.project_id, format: a.format || 'mp4', resolution: p.res, fps: a.fps || 30, preset: a.preset })),
32
+ wait: (r) => ({ kind: 'job', id: r.job_id }) }),
33
+ op({ name: 'cinema_voices', group: 'cinema', action: 'voices', kind: 'read', summary: 'Narration voices of Cinema Studio (house voices + your cloned voices)',
34
+ description: 'House voices and your own cloned voices for Cinema Studio narration and the talking presenter.', input: {},
35
+ run: (c) => c.call('aistudio-cinema-voices') }),
36
+ op({ name: 'cinema_narrate', group: 'cinema', action: 'narrate', kind: 'spend', summary: 'Text to narration audio (saved to the library)',
37
+ description: 'Turns text (up to 5,000 characters) into a narration audio file with word timings, in a house voice or your cloned voice. Priced per character (minimum 100 tokens).',
38
+ positional: 'text',
39
+ input: { text: z.string().min(1).max(5000), voice: z.string().describe('House voice key (warm_female, warm_male, energetic_female, energetic_male, calm_narrator, deep_trailer) or your v:<id>'), stability: z.union([z.literal(0), z.literal(0.5), z.literal(1)]).optional() },
40
+ quote: async (c, a) => { const r = await c.call('aistudio-cinema-ai-rates'); return quote(c, [{ item: `narration ${a.text.length} chars`, tokens: Math.max(Number(r.min_tokens || 100), Math.ceil(Number(r.per_char) * a.text.length)) }], { balance: n(r.balance) }) },
41
+ run: (c, a) => c.call('aistudio-cinema-narrate', clean({ text: a.text, voice: a.voice, stability: a.stability })) }),
42
+ op({ name: 'cinema_presenter', group: 'cinema', action: 'presenter', kind: 'spend', summary: 'Talking presenter: a photo of a person speaks your text or audio',
43
+ description: 'Makes a talking presenter video from a portrait image and an audio file (2-60 s) or text + voice. Tier standard or pro. Priced per second (plus narration when text is given).',
44
+ input: { image_asset_id: ID('Portrait image asset id').optional(), image_path: z.string().optional(), audio_asset_id: ID('Audio asset id').optional(), text: z.string().max(1000).optional(), voice: z.string().optional(), tier: z.enum(['standard', 'pro']).optional(), prompt: z.string().max(300).optional() },
45
+ prepare: (c, a) => { if (!a.image_asset_id && !a.image_path) throw new Error('give image_asset_id or image_path'); if (!a.audio_asset_id && !(a.text && a.voice)) throw new Error('give audio_asset_id, or text + voice'); return { tier: a.tier || 'standard' } },
46
+ quote: async (c, a, p) => { const r = await c.call('aistudio-cinema-ai-rates'); const sec = a.text ? Math.min(60, Math.max(2, Math.ceil(a.text.length / 14))) : 60
47
+ return quote(c, [{ item: `presenter ${p.tier} ~${sec}s`, tokens: Number(r.per_presenter_sec?.[p.tier] || 0) * sec }, a.text && { item: 'narration', tokens: Math.ceil(Number(r.per_char) * a.text.length) }], { balance: n(r.balance), approximate: true, notes: a.text ? 'Seconds estimated from text length.' : 'Upper bound (60 s); charged for the actual audio length.' }) },
48
+ run: async (c, a, ctx, p) => { const img = a.image_asset_id ? Number(a.image_asset_id) : Number((await uploadAsset(c, a.image_path, { kind: 'image' })).asset_id)
49
+ return c.call('aistudio-cinema-presenter', clean({ image_asset_id: img, audio_asset_id: n(a.audio_asset_id), text: a.audio_asset_id ? undefined : a.text, voice: a.audio_asset_id ? undefined : a.voice, tier: p.tier, prompt: a.prompt })) },
50
+ wait: (r) => ({ kind: 'cinema_ai', id: r.job_id }) }),
51
+ op({ name: 'cinema_subtitles', group: 'cinema', action: 'subtitles', kind: 'spend', summary: 'Automatic subtitles for a Cinema Studio timeline',
52
+ description: 'Transcribes the speech on a Cinema Studio timeline and adds subtitles. Priced per second of speech.',
53
+ input: { project_id: ID('Cinema project id'), language: z.string().max(3).optional().describe('2-3 letter code or auto') }, positional: 'project_id',
54
+ prepare: async (c, a) => { const p = await c.call('aistudio-cinema-project-get', { project_id: a.project_id }); return { seconds: timelineSeconds(p.data) } },
55
+ quote: async (c, a, p) => { const r = await c.call('aistudio-cinema-ai-rates'); return quote(c, [{ item: `subtitles ~${p.seconds}s`, tokens: Number(r.stt_base || 0) + Math.ceil(Number(r.per_stt_sec || 0) * p.seconds) }], { balance: n(r.balance), approximate: true }) },
56
+ run: (c, a) => c.call('aistudio-cinema-subtitles', { project_id: a.project_id, scope: 'timeline', language: a.language || 'auto' }),
57
+ wait: (r) => ({ kind: 'cinema_ai', id: r.job_id }) }),
58
+ ]