quickmagic-cli 1.0.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/api.js ADDED
@@ -0,0 +1,77 @@
1
+ // api.js — fetch wrapper Bearer + auto-refresh (sớm 60s / retry 1 lần khi 401).
2
+ // H5: lockfile ~/.quickmagic/refresh.lock chống 2 process refresh đồng thời (revoke family).
3
+ const fs = require('fs');
4
+ const config = require('./config');
5
+ const { refresh } = require('./oauth');
6
+
7
+ const EARLY_REFRESH_MS = 60000; // refresh sớm khi access token còn < 60s
8
+ const LOCK_MAX_TRIES = 50; // chờ tối đa ~5s để lấy lock
9
+ const LOCK_WAIT_MS = 100;
10
+
11
+ // H5 — lockfile best-effort (kết hợp grace-window server P1). Tự giải phóng sau khi xong.
12
+ async function withRefreshLock(fn) {
13
+ for (let i = 0; i < LOCK_MAX_TRIES; i += 1) {
14
+ let fd;
15
+ try {
16
+ fd = fs.openSync(config.LOCK_FILE, 'wx'); // 'wx' — lỗi nếu lock đã tồn tại
17
+ } catch (e) {
18
+ if (e.code !== 'EEXIST') throw e;
19
+ await new Promise((r) => setTimeout(r, LOCK_WAIT_MS)); // đang có process khác refresh → chờ
20
+ continue;
21
+ }
22
+ try {
23
+ return await fn();
24
+ } finally {
25
+ fs.closeSync(fd);
26
+ try {
27
+ fs.unlinkSync(config.LOCK_FILE);
28
+ } catch {
29
+ // lock đã bị xoá — bỏ qua
30
+ }
31
+ }
32
+ }
33
+ return fn(); // hết thời gian chờ → cứ refresh (grace-window server đỡ)
34
+ }
35
+
36
+ function doRefresh() {
37
+ return withRefreshLock(refresh);
38
+ }
39
+
40
+ // Gọi REST /public/v1<rest_path>. Trả về trường `data`. Ném lỗi khi HTTP lỗi hoặc success=false.
41
+ async function call(method, rest_path, { body } = {}) {
42
+ let creds = config.load();
43
+ if (!creds || (!creds.access_token && !creds.refresh_token)) {
44
+ throw new Error('Chưa đăng nhập. Chạy: quickmagic auth login');
45
+ }
46
+
47
+ // Refresh sớm khi access token sắp hết hạn (bỏ qua nếu không có expires_at/refresh_token — env CI).
48
+ if (creds.expires_at && creds.refresh_token && creds.expires_at - Date.now() < EARLY_REFRESH_MS) {
49
+ await doRefresh();
50
+ creds = config.load();
51
+ }
52
+
53
+ const doFetch = () =>
54
+ fetch(`${config.restBase(creds.api_url)}${rest_path}`, {
55
+ method,
56
+ headers: {
57
+ Authorization: `Bearer ${creds.access_token}`,
58
+ 'content-type': 'application/json',
59
+ },
60
+ body: body ? JSON.stringify(body) : undefined,
61
+ });
62
+
63
+ let res = await doFetch();
64
+ if (res.status === 401 && creds.refresh_token) {
65
+ await doRefresh(); // 401 → refresh 1 lần rồi retry
66
+ creds = config.load();
67
+ res = await doFetch();
68
+ }
69
+
70
+ const json = await res.json().catch(() => ({}));
71
+ if (!res.ok || json.success === false) {
72
+ throw new Error(json.message || `HTTP ${res.status}`);
73
+ }
74
+ return json.data;
75
+ }
76
+
77
+ module.exports = { call };
@@ -0,0 +1,32 @@
1
+ // commands/auth.js — login / logout / status.
2
+ const oauth = require('../oauth');
3
+ const config = require('../config');
4
+ const api = require('../api');
5
+
6
+ // Đăng nhập qua browser (OAuth PKCE). Cờ --api-url ghi đè URL gốc.
7
+ async function login(options) {
8
+ const api_url = config.apiUrl(options && options.apiUrl);
9
+ await oauth.login(api_url);
10
+ }
11
+
12
+ // Đăng xuất — xoá credentials.json.
13
+ function logout() {
14
+ config.clear();
15
+ console.log('Đã đăng xuất.');
16
+ }
17
+
18
+ // Trạng thái — in email/số dư qua GET /public/v1/credits, hoặc báo chưa đăng nhập (exit 1).
19
+ async function status() {
20
+ const creds = config.load();
21
+ if (!creds || (!creds.access_token && !creds.refresh_token)) {
22
+ console.log('Chưa đăng nhập. Chạy: quickmagic auth login');
23
+ process.exit(1);
24
+ }
25
+ const data = await api.call('GET', '/credits');
26
+ if (data && data.email) console.log(`Email: ${data.email}`);
27
+ console.log(`Số dư: ${(data && data.balance) ?? 0} credit`);
28
+ if (data && data.plan) console.log(`Gói: ${data.plan}`);
29
+ console.log(`API URL: ${creds.api_url || config.DEFAULT_API_URL}`);
30
+ }
31
+
32
+ module.exports = { login, logout, status };
@@ -0,0 +1,19 @@
1
+ // commands/credits.js — in số dư credit qua GET /public/v1/credits.
2
+ const api = require('../api');
3
+
4
+ async function show() {
5
+ const data = await api.call('GET', '/credits');
6
+ console.log(`Số dư (balance): ${data.balance ?? 0}`);
7
+ console.log(`Tạm giữ (held): ${data.held ?? 0}`);
8
+ console.log(`Gói (plan): ${data.plan ?? '—'}`);
9
+ // Benefit Qimi: cửa sổ 10 ngày miễn phí ảnh qimi_3/qimi_2.5 (gói Business/Enterprise).
10
+ if (data.qimi_benefit?.active) {
11
+ const days = data.qimi_benefit.window_ends_at
12
+ ? Math.max(0, Math.ceil((data.qimi_benefit.window_ends_at - Date.now()) / 86400000))
13
+ : 0;
14
+ const mode = data.qimi_benefit.speed_mode ? 'Nhanh — trả credit, chạy song song' : 'Miễn phí — 1 ảnh/lần';
15
+ console.log(`Qimi miễn phí: còn ${days} ngày (chế độ: ${mode})`);
16
+ }
17
+ }
18
+
19
+ module.exports = { show };
@@ -0,0 +1,70 @@
1
+ // commands/generate.js — tạo ảnh/video AI. --ref/--image: URL giữ nguyên, file local → base64 data URI.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const api = require('../api');
5
+ const jobs = require('./jobs');
6
+
7
+ const MAX_LOCAL_BYTES = 8 * 1024 * 1024; // cảnh báo khi file > 8MB (body giới hạn ~10MB)
8
+ const MIME_BY_EXT = {
9
+ '.jpg': 'image/jpeg',
10
+ '.jpeg': 'image/jpeg',
11
+ '.png': 'image/png',
12
+ '.webp': 'image/webp',
13
+ '.gif': 'image/gif',
14
+ };
15
+
16
+ // URL/data URI → giữ nguyên; file local → data:<mime>;base64,...
17
+ function resolveInputImage(ref) {
18
+ if (/^https?:\/\//i.test(ref) || /^data:/i.test(ref)) return ref;
19
+ const abs_path = path.resolve(ref);
20
+ const ext = path.extname(abs_path).toLowerCase();
21
+ const mime = MIME_BY_EXT[ext] || 'image/png';
22
+ const buf = fs.readFileSync(abs_path);
23
+ if (buf.length > MAX_LOCAL_BYTES) {
24
+ console.warn(`Cảnh báo: ${ref} > 8MB — body giới hạn ~10MB, nên dùng URL thay vì file local.`);
25
+ }
26
+ return `data:${mime};base64,${buf.toString('base64')}`;
27
+ }
28
+
29
+ function resolveInputImages(refs) {
30
+ if (!refs || !refs.length) return [];
31
+ return refs.map(resolveInputImage);
32
+ }
33
+
34
+ // generate image — POST /public/v1/images.
35
+ async function image(options) {
36
+ const ref_image_urls = resolveInputImages(options.ref);
37
+ const num_images = parseInt(options.n, 10) || 1;
38
+ const body = { prompt: options.prompt, model: options.model, num_images };
39
+ if (options.quality) body.quality = options.quality;
40
+ if (options.aspectRatio) body.aspect_ratio = options.aspectRatio;
41
+ if (ref_image_urls.length) body.ref_image_urls = ref_image_urls;
42
+
43
+ const data = await api.call('POST', '/images', { body });
44
+ const job_ids = (data.jobs || []).map((j) => j.job_id);
45
+ console.log(`Đã tạo ${job_ids.length} job ảnh: ${job_ids.join(', ')}`);
46
+ console.log(`Credit tạm giữ: ${data.credits_held ?? '?'} · Số dư: ${data.balance ?? '?'}`);
47
+
48
+ if (options.wait) await jobs.waitAll(job_ids, options.out);
49
+ }
50
+
51
+ // generate video — POST /public/v1/videos.
52
+ async function video(options) {
53
+ const image_urls = resolveInputImages(options.image);
54
+ const body = { prompt: options.prompt, model: options.model };
55
+ if (options.duration) body.duration = options.duration;
56
+ if (options.resolution) body.resolution = options.resolution;
57
+ if (options.aspectRatio) body.aspect_ratio = options.aspectRatio;
58
+ if (image_urls.length) body.image_urls = image_urls;
59
+ // --mode reference|frames (R2V 260706): server validate theo model; bỏ trống = default model.
60
+ if (options.mode) body.image_mode = options.mode;
61
+
62
+ const data = await api.call('POST', '/videos', { body });
63
+ const job_id = data.job_id;
64
+ console.log(`Đã tạo job video: ${job_id}`);
65
+ console.log(`Credit tạm giữ: ${data.credits_held ?? '?'} · Số dư: ${data.balance ?? '?'}`);
66
+
67
+ if (options.wait) await jobs.waitAll([job_id], options.out);
68
+ }
69
+
70
+ module.exports = { image, video };
@@ -0,0 +1,69 @@
1
+ // commands/jobs.js — theo dõi job: get / wait / waitAll + tải kết quả.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const api = require('../api');
5
+
6
+ const POLL_INTERVAL_MS = 2500;
7
+ // Trạng thái kết thúc (M6 — union các status server có thể trả sau chuẩn hoá).
8
+ const TERMINAL_STATUSES = new Set(['completed', 'error', 'failed', 'success', 'stalled', 'timeout']);
9
+ const SUCCESS_STATUSES = new Set(['completed', 'success']);
10
+
11
+ function sleep(ms) {
12
+ return new Promise((r) => setTimeout(r, ms));
13
+ }
14
+
15
+ // jobs get <id> — in JSON chi tiết job.
16
+ async function get(id) {
17
+ const data = await api.call('GET', `/jobs/${id}`);
18
+ console.log(JSON.stringify(data, null, 2));
19
+ }
20
+
21
+ // Tải result_url → ghi file, tên lấy từ URL (bỏ query).
22
+ async function download(url, out_dir) {
23
+ const res = await fetch(url);
24
+ if (!res.ok) throw new Error(`tải file thất bại: HTTP ${res.status}`);
25
+ const buf = Buffer.from(await res.arrayBuffer());
26
+ const url_name = path.basename(new URL(url).pathname) || `download-${Date.now()}`;
27
+ fs.mkdirSync(out_dir, { recursive: true });
28
+ const dest = path.join(out_dir, url_name);
29
+ fs.writeFileSync(dest, buf);
30
+ console.log(`Đã tải: ${dest}`);
31
+ return dest;
32
+ }
33
+
34
+ // Poll 1 job mỗi 2.5s tới trạng thái kết thúc; tải kết quả nếu thành công + có --out.
35
+ async function waitOne(id, out_dir) {
36
+ for (;;) {
37
+ const data = await api.call('GET', `/jobs/${id}`);
38
+ const status = data.status;
39
+ const progress = data.progress != null ? ` (${data.progress}%)` : '';
40
+ console.log(`[${id}] ${status}${progress}`);
41
+ if (TERMINAL_STATUSES.has(status)) {
42
+ if (SUCCESS_STATUSES.has(status)) {
43
+ if (data.result_url && out_dir) await download(data.result_url, out_dir);
44
+ } else {
45
+ console.error(`[${id}] job lỗi: ${data.error || status}`);
46
+ }
47
+ return data;
48
+ }
49
+ await sleep(POLL_INTERVAL_MS);
50
+ }
51
+ }
52
+
53
+ // jobs wait <id> [--out dir] — chờ 1 job, exit 1 nếu thất bại.
54
+ async function wait(id, options) {
55
+ const data = await waitOne(id, options && options.out);
56
+ if (!SUCCESS_STATUSES.has(data.status)) process.exit(1);
57
+ }
58
+
59
+ // Chờ nhiều job tuần tự (dùng cho generate --wait); exit 1 nếu có job thất bại.
60
+ async function waitAll(ids, out_dir) {
61
+ let has_error = false;
62
+ for (const id of ids) {
63
+ const data = await waitOne(id, out_dir);
64
+ if (!SUCCESS_STATUSES.has(data.status)) has_error = true;
65
+ }
66
+ if (has_error) process.exit(1);
67
+ }
68
+
69
+ module.exports = { get, wait, waitAll, download };
@@ -0,0 +1,46 @@
1
+ // commands/models.js — liệt kê model ảnh/video dạng bảng key | label | credit (+IMAGES cho video).
2
+ const api = require('../api');
3
+
4
+ // Cột IMAGES (video): "ref 9* · frames 2" — theo image_max_by_mode; * = mode cấm ảnh người thật.
5
+ function imagesCell(m) {
6
+ const by_mode = m.image_max_by_mode || {};
7
+ const banned = m.no_real_person_modes || [];
8
+ const parts = Object.entries(by_mode).map(([mode, max]) => {
9
+ const short = mode === 'reference' ? 'ref' : mode;
10
+ return `${short} ${max}${banned.includes(mode) ? '*' : ''}`;
11
+ });
12
+ return parts.join(' · ') || String(m.image_max ?? '');
13
+ }
14
+
15
+ async function list(options) {
16
+ const type = (options && options.type) || 'image';
17
+ const data = await api.call('GET', `/models?type=${encodeURIComponent(type)}`);
18
+ const models = data.models || [];
19
+ if (!models.length) {
20
+ console.log('Không có model nào.');
21
+ return;
22
+ }
23
+
24
+ const is_video = type === 'video';
25
+ const rows = models.map((m) => ({
26
+ key: String(m.key || ''),
27
+ label: String(m.label || ''),
28
+ credit: String(m.credit ?? m.credit_cost ?? m.credit_per_image ?? ''),
29
+ images: is_video ? imagesCell(m) : '',
30
+ }));
31
+ const key_w = Math.max(3, ...rows.map((r) => r.key.length));
32
+ const label_w = Math.max(5, ...rows.map((r) => r.label.length));
33
+ const credit_w = Math.max(6, ...rows.map((r) => r.credit.length));
34
+
35
+ const header = `${'KEY'.padEnd(key_w)} ${'LABEL'.padEnd(label_w)} ${'CREDIT'.padEnd(credit_w)}${is_video ? ' IMAGES' : ''}`;
36
+ console.log(header);
37
+ console.log('-'.repeat(header.length + (is_video ? 14 : 0)));
38
+ for (const r of rows) {
39
+ console.log(`${r.key.padEnd(key_w)} ${r.label.padEnd(label_w)} ${r.credit.padEnd(credit_w)}${is_video ? ` ${r.images}` : ''}`);
40
+ }
41
+ if (is_video && rows.some((r) => r.images.includes('*'))) {
42
+ console.log('\n* KHÔNG nhận ảnh chứa người thật (gửi vào sẽ lỗi) — dùng gemini-omni / seedance 1.x / wan-2-7 cho ảnh người.');
43
+ }
44
+ }
45
+
46
+ module.exports = { list };
@@ -0,0 +1,41 @@
1
+ // tools.js — lệnh CLI cho 17 tool mở rộng (gọi REST /public/v1 qua api.call). In kết quả JSON gọn.
2
+ const { call } = require('../api');
3
+
4
+ const out = (data) => console.log(JSON.stringify(data, null, 2));
5
+ const jobHint = (data) => {
6
+ out(data);
7
+ const id = data.job_id || (Array.isArray(data.job_ids) && data.job_ids[0]);
8
+ if (id) console.log(`\n→ Theo dõi: quickmagic jobs get ${id}`);
9
+ };
10
+ const run = (fn) => (...a) => fn(...a).catch((e) => { console.error('Lỗi:', e.message); process.exit(1); });
11
+
12
+ // ── Nhập liệu (không tốn credit) ──
13
+ const scrape = run(async (url) => out(await call('POST', '/scrape', { body: { url } })));
14
+ const importSocial = run(async (url, o) => out(await call('POST', '/import', { body: { url, media: o.media || 'image', max: o.max ? Number(o.max) : undefined } })));
15
+ const assets = run(async (kind, o) => out(await call('GET', `/assets?kind=${encodeURIComponent(kind)}${o.limit ? `&limit=${Number(o.limit)}` : ''}`)));
16
+ const analyze = run(async (video_url, o) => out(await call('POST', '/analyze', { body: { video_url, product_id: o.productId ? Number(o.productId) : undefined } })));
17
+
18
+ // ── Marketing ──
19
+ const marketingModes = run(async () => out(await call('GET', '/marketing/modes')));
20
+ const marketingVideo = run(async (o) => jobHint(await call('POST', '/marketing/videos', { body: { mode_id: Number(o.mode), product_id: o.productId ? Number(o.productId) : undefined, avatar_id: o.avatarId ? Number(o.avatarId) : undefined, avatar_kind: o.avatarKind, duration: o.duration ? Number(o.duration) : undefined, aspect_ratio: o.aspectRatio, client_request_id: o.crid } })));
21
+
22
+ // ── Product / Fashion ──
23
+ const product = run(async (o) => jobHint(await call('POST', '/product/images', { body: { refs: (o.refs || []).map((r) => (/^\d+$/.test(r) ? Number(r) : r)), image_types: o.types || ['product'], count_per_type: o.count ? Number(o.count) : undefined, kol_id: o.kolId ? Number(o.kolId) : undefined, instruction: o.instruction, model: o.model, client_request_id: o.crid } })));
24
+ const fashion = run(async (o) => jobHint(await call('POST', '/fashion/images', { body: { mode: o.mode, outfit_ids: (o.outfit || []).map(Number), kol_kind: o.kolKind, kol_id: o.kolId ? Number(o.kolId) : undefined, model: o.model, client_request_id: o.crid } })));
25
+
26
+ // ── Ảnh ──
27
+ const edit = run(async (image, o) => jobHint(await call('POST', '/edit', { body: { image, tool: o.tool, model: o.model, upscale_target: o.upscaleTarget, style: o.style, client_request_id: o.crid } })));
28
+ const tryon = run(async (o) => jobHint(await call('POST', '/tryon', { body: { type: o.type, model: o.model, model_image: o.model_image, garment_image: o.garment, upper_image: o.upper, lower_image: o.lower, background_image: o.background, prompt: o.prompt, client_request_id: o.crid } })));
29
+
30
+ // ── Video nặng / audio ──
31
+ const stt = run(async (audio_url, o) => jobHint(await call('POST', '/stt', { body: { audio_url, language_translate: o.translate, client_request_id: o.crid } })));
32
+ const subtitle = run(async (video_url, o) => jobHint(await call('POST', '/subtitle', { body: { video_url, enable_voice_dubbing: !!o.dub, target_lang: o.translate, client_request_id: o.crid } })));
33
+ const split = run(async (video_url, o) => jobHint(await call('POST', '/split', { body: { video_url, clip_mode: o.mode, title_lang: o.titleLang, translate_lang: o.translate, client_request_id: o.crid } })));
34
+ const motion = run(async (video_url, o) => jobHint(await call('POST', '/motion', { body: { video_url, image_urls: o.image || [], model: o.model, mode: o.mode, prompt: o.prompt, client_request_id: o.crid } })));
35
+
36
+ // ── Cutout / Hook Studio ──
37
+ const cutout = run(async (o) => jobHint(await call('POST', '/cutout', { body: { operation: o.operation, ref_image_urls: o.ref || [], prompt: o.prompt, model: o.model, aspect_ratio: o.aspectRatio, quality: o.quality, num_images: o.n ? Number(o.n) : undefined, transparent_bg: o.transparentBg, client_request_id: o.crid } })));
38
+ const hookPresets = run(async () => out(await call('GET', '/hook/presets')));
39
+ const hookVideo = run(async (o) => jobHint(await call('POST', '/hook/videos', { body: { preset_id: o.preset, character_url: o.character, product_url: o.product, aspect: o.aspect, speech_lang: o.speechLang, custom_cta: o.cta, location_url: o.location, accessory_url: o.accessory, style: o.style, format: o.format, resolution: o.resolution, client_request_id: o.crid } })));
40
+
41
+ module.exports = { scrape, importSocial, assets, analyze, marketingModes, marketingVideo, product, fashion, edit, tryon, stt, subtitle, split, motion, cutout, hookPresets, hookVideo };
package/src/config.js ADDED
@@ -0,0 +1,77 @@
1
+ // config.js — Quản lý credentials ~/.quickmagic/credentials.json (chmod 600, dir 700).
2
+ // H5: ghi atomic temp→rename; env token thắng file (CI headless, chỉ đọc không ghi).
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const QM_DIR = path.join(os.homedir(), '.quickmagic');
8
+ const CRED_FILE = path.join(QM_DIR, 'credentials.json');
9
+ const LOCK_FILE = path.join(QM_DIR, 'refresh.lock');
10
+ // URL GỐC (root) — REST base = + '/public/v1'; discovery OAuth cũng ở root.
11
+ const DEFAULT_API_URL = process.env.QUICKMAGIC_URL || 'https://api.quickmagic.vn';
12
+
13
+ // H5 — CI headless: env token thắng file (bỏ qua browser login). Chỉ đọc, KHÔNG ghi xuống đĩa.
14
+ function getEnvCreds() {
15
+ const access_token = process.env.QUICKMAGIC_TOKEN;
16
+ const refresh_token = process.env.QUICKMAGIC_REFRESH_TOKEN;
17
+ if (!access_token && !refresh_token) return null;
18
+ return {
19
+ api_url: DEFAULT_API_URL,
20
+ access_token: access_token || null,
21
+ refresh_token: refresh_token || null,
22
+ expires_at: 0,
23
+ _from_env: true,
24
+ };
25
+ }
26
+
27
+ // Đọc credentials: ưu tiên env (CI) → file JSON. Trả null nếu chưa có.
28
+ function load() {
29
+ const env_creds = getEnvCreds();
30
+ if (env_creds) return env_creds;
31
+ try {
32
+ return JSON.parse(fs.readFileSync(CRED_FILE, 'utf8'));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // Ghi credentials an toàn: temp→rename atomic, quyền 600 (dir 700). Bỏ qua khi chạy bằng env.
39
+ function save(creds) {
40
+ if (creds && creds._from_env) return; // không ghi đè xuống đĩa khi chạy bằng env token
41
+ fs.mkdirSync(QM_DIR, { recursive: true, mode: 0o700 });
42
+ const tmp_file = `${CRED_FILE}.${process.pid}.tmp`;
43
+ fs.writeFileSync(tmp_file, JSON.stringify(creds, null, 2), { mode: 0o600 });
44
+ fs.renameSync(tmp_file, CRED_FILE); // H5 — atomic, tránh corrupt JSON khi crash giữa write
45
+ fs.chmodSync(CRED_FILE, 0o600);
46
+ }
47
+
48
+ // Xoá credentials (logout). Bỏ qua nếu file không tồn tại.
49
+ function clear() {
50
+ try {
51
+ fs.unlinkSync(CRED_FILE);
52
+ } catch {
53
+ // đã xoá hoặc chưa tồn tại — bỏ qua
54
+ }
55
+ }
56
+
57
+ // Xác định api_url: cờ CLI → file → mặc định.
58
+ function apiUrl(flag) {
59
+ return flag || load()?.api_url || DEFAULT_API_URL;
60
+ }
61
+
62
+ // C1 — REST base = root + /public/v1 (KHÔNG /v1). Discovery OAuth vẫn ở root.
63
+ function restBase(api_url) {
64
+ return `${api_url}/public/v1`;
65
+ }
66
+
67
+ module.exports = {
68
+ load,
69
+ save,
70
+ clear,
71
+ apiUrl,
72
+ restBase,
73
+ CRED_FILE,
74
+ LOCK_FILE,
75
+ QM_DIR,
76
+ DEFAULT_API_URL,
77
+ };
package/src/index.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // index.js — điểm vào CLI quickmagic (commander wiring). Node >=18, CommonJS.
3
+ const { program } = require('commander');
4
+
5
+ const auth = require('./commands/auth');
6
+ const generate = require('./commands/generate');
7
+ const jobs = require('./commands/jobs');
8
+ const models = require('./commands/models');
9
+ const credits = require('./commands/credits');
10
+ const tools = require('./commands/tools');
11
+
12
+ const collect = (v, acc) => { acc.push(v); return acc; };
13
+
14
+ program
15
+ .name('quickmagic')
16
+ .description('Quick Magic CLI — tạo ảnh/video AI qua REST API (OAuth PKCE)')
17
+ .version('1.0.0');
18
+
19
+ // ── auth ─────────────────────────────────────────────────────────────────────
20
+ const auth_cmd = program.command('auth').description('Đăng nhập / đăng xuất / trạng thái');
21
+ auth_cmd
22
+ .command('login')
23
+ .description('Đăng nhập qua trình duyệt (OAuth PKCE)')
24
+ .option('--api-url <url>', 'URL gốc API (mặc định https://api.quickmagic.vn)')
25
+ .action(auth.login);
26
+ auth_cmd.command('logout').description('Đăng xuất, xoá credentials').action(auth.logout);
27
+ auth_cmd.command('status').description('Xem email / số dư').action(auth.status);
28
+
29
+ // ── generate ─────────────────────────────────────────────────────────────────
30
+ const gen_cmd = program.command('generate').description('Tạo ảnh / video AI');
31
+ gen_cmd
32
+ .command('image')
33
+ .description('Tạo ảnh AI')
34
+ .requiredOption('--prompt <p>', 'Mô tả ảnh')
35
+ .option('--model <m>', 'Model', 'qimi_3')
36
+ .option('--quality <q>', 'Chất lượng')
37
+ .option('--aspect-ratio <r>', 'Tỉ lệ khung (vd 1:1, 16:9)')
38
+ .option('--n <n>', 'Số ảnh', '1')
39
+ .option('--ref <r...>', 'Ảnh tham chiếu (URL hoặc file local, lặp nhiều lần)')
40
+ .option('--wait', 'Chờ tới khi job hoàn tất')
41
+ .option('--out <dir>', 'Thư mục lưu kết quả (dùng kèm --wait)')
42
+ .action(generate.image);
43
+ gen_cmd
44
+ .command('video')
45
+ .description('Tạo video AI')
46
+ .requiredOption('--prompt <p>', 'Mô tả video')
47
+ .requiredOption('--model <m>', 'Model')
48
+ .option('--duration <d>', 'Thời lượng (giây)')
49
+ .option('--resolution <r>', 'Độ phân giải (vd 720p, 1080p)')
50
+ .option('--aspect-ratio <r>', 'Tỉ lệ khung')
51
+ .option('--image <i...>', 'Ảnh đầu vào (URL hoặc file local, lặp nhiều lần — max theo model+mode, xem qm models)')
52
+ .option('--mode <m>', 'Chế độ ảnh: reference (nhiều ảnh tham chiếu) | frames (khung đầu/cuối, max 2). Seedance 2.x KHÔNG nhận ảnh người thật ở MỌI chế độ (xem qm models --type video)')
53
+ .option('--wait', 'Chờ tới khi job hoàn tất')
54
+ .option('--out <dir>', 'Thư mục lưu kết quả (dùng kèm --wait)')
55
+ .action(generate.video);
56
+
57
+ // ── jobs ─────────────────────────────────────────────────────────────────────
58
+ const jobs_cmd = program.command('jobs').description('Theo dõi job');
59
+ jobs_cmd.command('get <id>').description('Xem chi tiết job (JSON)').action(jobs.get);
60
+ jobs_cmd
61
+ .command('wait <id>')
62
+ .description('Chờ job tới khi hoàn tất')
63
+ .option('--out <dir>', 'Thư mục lưu kết quả')
64
+ .action((id, options) => jobs.wait(id, options));
65
+
66
+ // ── models ───────────────────────────────────────────────────────────────────
67
+ const models_cmd = program.command('models').description('Model AI');
68
+ models_cmd
69
+ .command('list')
70
+ .description('Liệt kê model')
71
+ .option('--type <t>', 'Loại: image | video', 'image')
72
+ .action(models.list);
73
+
74
+ // ── credits ──────────────────────────────────────────────────────────────────
75
+ program.command('credits').description('Xem số dư credit').action(credits.show);
76
+
77
+ // ── Nhập liệu (không tốn credit) ───────────────────────────────────────────────
78
+ program.command('scrape <url>').description('Quét ảnh + tên sản phẩm từ link trang').action(tools.scrape);
79
+ program.command('import <url>').description('Nhập ảnh/video từ POST TikTok/Instagram')
80
+ .option('--media <m>', 'image | video', 'image').option('--max <n>', 'Số ảnh tối đa').action(tools.importSocial);
81
+ program.command('assets <kind>').description('Liệt kê tài sản: products | outfits | kols')
82
+ .option('--limit <n>', 'Giới hạn').action(tools.assets);
83
+ program.command('analyze <video_url>').description('Phân tích video quảng cáo → kịch bản (15cr)')
84
+ .option('--product-id <id>', 'Sản phẩm trong tủ').action(tools.analyze);
85
+
86
+ // ── Marketing ──────────────────────────────────────────────────────────────────
87
+ const mkt = program.command('marketing').description('Marketing video');
88
+ mkt.command('modes').description('Liệt kê mode + bảng giá theo duration').action(tools.marketingModes);
89
+ mkt.command('video').description('Tạo video marketing (tốn credit theo duration)')
90
+ .requiredOption('--mode <id>', 'mode_id (xem marketing modes)')
91
+ .option('--product-id <id>').option('--avatar-id <id>').option('--avatar-kind <k>')
92
+ .option('--duration <d>', '8/10/12/15').option('--aspect-ratio <r>').option('--crid <id>', 'client_request_id')
93
+ .action(tools.marketingVideo);
94
+
95
+ // ── Product / Fashion ──────────────────────────────────────────────────────────
96
+ program.command('product').description('Tạo ảnh sản phẩm AI')
97
+ .requiredOption('--refs <r...>', 'product_id hoặc URL/base64 (1-3)')
98
+ .option('--types <t...>', 'product|poster|infographic|composite', ['product'])
99
+ .option('--count <n>').option('--kol-id <id>').option('--instruction <s>').option('--model <m>').option('--crid <id>')
100
+ .action(tools.product);
101
+ program.command('fashion').description('Tạo ảnh thời trang AI')
102
+ .requiredOption('--outfit <id...>', 'outfit_ids (xem assets outfits)')
103
+ .option('--mode <m>', 'kol_outfit/outfit_only/...').option('--kol-kind <k>').option('--kol-id <id>').option('--model <m>').option('--crid <id>')
104
+ .action(tools.fashion);
105
+
106
+ // ── Ảnh ────────────────────────────────────────────────────────────────────────
107
+ program.command('edit <image>').description('Sửa ảnh: restore/upscale/beauty/muscle/color_boost')
108
+ .requiredOption('--tool <t>').option('--model <m>', 'qimi_2.5|qimi_3', 'qimi_3')
109
+ .option('--upscale-target <t>', '2k|4k').option('--style <s...>').option('--crid <id>').action(tools.edit);
110
+ program.command('tryon').description('Thử đồ ảo (virtual try-on)')
111
+ .requiredOption('--model-image <img>', 'Ảnh người mẫu (URL/base64)')
112
+ .option('--type <t>', 'full|upper', 'full').option('--model <m>', 'qimi_1.5|qimi_2.5|qimi_3', 'qimi_3')
113
+ .option('--garment <img>').option('--upper <img>').option('--lower <img>').option('--background <img>').option('--prompt <p>').option('--crid <id>')
114
+ .action((o) => tools.tryon({ ...o, model_image: o.modelImage }));
115
+
116
+ // ── Video nặng / audio ─────────────────────────────────────────────────────────
117
+ program.command('stt <audio_url>').description('Chuyển giọng nói → văn bản')
118
+ .option('--translate <lang>', 'Dịch sang ngôn ngữ').option('--crid <id>').action(tools.stt);
119
+ program.command('subtitle <video_url>').description('Thêm phụ đề (tùy chọn lồng tiếng)')
120
+ .option('--dub', 'Bật lồng tiếng').option('--translate <lang>').option('--crid <id>').action(tools.subtitle);
121
+ program.command('split <video_url>').description('Cắt video dài → clip ngắn')
122
+ .option('--mode <m>', 'auto|specific', 'auto').option('--title-lang <l>').option('--translate <lang>').option('--crid <id>').action(tools.split);
123
+ program.command('motion <video_url>').description('Áp chuyển động video vào ảnh (Kling)')
124
+ .requiredOption('--image <url...>', 'Ảnh áp motion (1-20)')
125
+ .option('--model <m>').option('--mode <m>', 'standard|professional').option('--prompt <p>').option('--crid <id>').action(tools.motion);
126
+
127
+ // ── Cutout Studio ────────────────────────────────────────────────────────────
128
+ program.command('cutout').description('Tách nền / tạo ảnh cutout trong suốt (PNG)')
129
+ .option('--operation <o>', 'generate|from_ref|remix_describe', 'from_ref')
130
+ .option('--ref <r...>', 'Ảnh tham chiếu (URL hoặc data:base64, tối đa 5, lặp nhiều lần)')
131
+ .option('--prompt <p>', 'Mô tả ảnh (bắt buộc khi --operation generate)')
132
+ .option('--model <m>', 'Model (xem qm models --type image)', 'nano-banana-2')
133
+ .option('--aspect-ratio <r>').option('--quality <q>')
134
+ .option('--n <n>', 'Số ảnh', '1')
135
+ .option('--no-transparent-bg', 'Giữ nguyên nền gốc (chỉ operation=remix_describe)')
136
+ .option('--crid <id>', 'client_request_id')
137
+ .action(tools.cutout);
138
+
139
+ // ── Hook Studio ──────────────────────────────────────────────────────────────
140
+ const hook_cmd = program.command('hook').description('Hook Studio — video quảng cáo hài từ ảnh nhân vật + sản phẩm');
141
+ hook_cmd.command('presets').description('Liệt kê preset + option hợp lệ + giá').action(tools.hookPresets);
142
+ hook_cmd
143
+ .command('video')
144
+ .description('Tạo video hook (tốn credit theo preset)')
145
+ .requiredOption('--preset <id>', 'preset_id (xem hook presets)')
146
+ .requiredOption('--character <img>', 'Ảnh nhân vật (URL hoặc data:base64)')
147
+ .requiredOption('--product <img>', 'Ảnh sản phẩm (URL hoặc data:base64)')
148
+ .option('--aspect <r>', '9:16|16:9', '9:16')
149
+ .option('--speech-lang <l>', 'Ngôn ngữ thoại (xem hook presets speech_lang_options)', 'vi')
150
+ .option('--cta <s>', 'Call-to-action tuỳ chỉnh')
151
+ .option('--location <img>', 'Ảnh bối cảnh (tuỳ chọn)')
152
+ .option('--accessory <img>', 'Ảnh phụ kiện (tuỳ chọn)')
153
+ .option('--style <s>', 'Phong cách hài (xem hook presets style_options)')
154
+ .option('--format <f>', 'Định dạng (xem hook presets format_options)')
155
+ .option('--resolution <r>', '720p|1080p')
156
+ .option('--crid <id>', 'client_request_id')
157
+ .action(tools.hookVideo);
158
+
159
+ program.parseAsync(process.argv).catch((e) => {
160
+ console.error('Lỗi:', e.message);
161
+ process.exit(1);
162
+ });