quickmagic-cli 1.0.0 → 1.1.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/package.json +1 -1
- package/skills/quickmagic-generate/SKILL.md +4 -4
- package/src/commands/generate.js +5 -32
- package/src/commands/tools.js +11 -6
- package/src/media.js +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quickmagic-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Quick Magic CLI — generate AI images/videos, product photoshoots, virtual try-on, hook ad videos, subtitles and more via the Quick Magic REST API, with browser-based OAuth PKCE login (no API keys to manage). Ships with 8 Claude Agent Skills and an MCP server integration.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"quickmagic": "src/index.js",
|
|
@@ -63,10 +63,10 @@ holds credit immediately.
|
|
|
63
63
|
| `qm generate image` | `--prompt <p>` | `--model <m>` (default `qimi_3`), `--quality <q>`, `--aspect-ratio <r>`, `--n <n>` (default 1), `--ref <r...>` (repeatable — URL or local file path), `--wait`, `--out <dir>` |
|
|
64
64
|
| `qm generate video` | `--prompt <p>`, `--model <m>` | `--duration <d>`, `--resolution <r>`, `--aspect-ratio <r>`, `--image <i...>` (repeatable), `--mode reference\|frames`, `--wait`, `--out <dir>` |
|
|
65
65
|
|
|
66
|
-
- `--ref` / `--image` accept a URL (kept as-is) or a local file path
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
- `--ref` / `--image` accept a URL (kept as-is) or a local file path — files are
|
|
67
|
+
UPLOADED automatically to Quick Magic storage (presigned, full original quality)
|
|
68
|
+
and the resulting file_url is used; tiny images ≤64KB are inlined. URLs from
|
|
69
|
+
`qm scrape <url>` / `qm import <url>` also work directly.
|
|
70
70
|
- `--mode frames` caps at 2 images (first/last frame); `--mode reference` allows
|
|
71
71
|
more (see the `IMAGES` column). A wrong count is rejected server-side.
|
|
72
72
|
|
package/src/commands/generate.js
CHANGED
|
@@ -1,39 +1,12 @@
|
|
|
1
|
-
// commands/generate.js — tạo ảnh/video AI. --ref/--image: URL giữ nguyên
|
|
2
|
-
|
|
3
|
-
const path = require('path');
|
|
1
|
+
// commands/generate.js — tạo ảnh/video AI. --ref/--image: URL giữ nguyên; file local →
|
|
2
|
+
// upload presigned (>64KB) hoặc base64 nhỏ — xem src/media.js [260722].
|
|
4
3
|
const api = require('../api');
|
|
5
4
|
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
|
-
}
|
|
5
|
+
const { resolveMediaInputs } = require('../media');
|
|
33
6
|
|
|
34
7
|
// generate image — POST /public/v1/images.
|
|
35
8
|
async function image(options) {
|
|
36
|
-
const ref_image_urls =
|
|
9
|
+
const ref_image_urls = await resolveMediaInputs(options.ref);
|
|
37
10
|
const num_images = parseInt(options.n, 10) || 1;
|
|
38
11
|
const body = { prompt: options.prompt, model: options.model, num_images };
|
|
39
12
|
if (options.quality) body.quality = options.quality;
|
|
@@ -50,7 +23,7 @@ async function image(options) {
|
|
|
50
23
|
|
|
51
24
|
// generate video — POST /public/v1/videos.
|
|
52
25
|
async function video(options) {
|
|
53
|
-
const image_urls =
|
|
26
|
+
const image_urls = await resolveMediaInputs(options.image);
|
|
54
27
|
const body = { prompt: options.prompt, model: options.model };
|
|
55
28
|
if (options.duration) body.duration = options.duration;
|
|
56
29
|
if (options.resolution) body.resolution = options.resolution;
|
package/src/commands/tools.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
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
|
+
// [260722] Mọi param ảnh/media đi qua resolveMediaInput(s): URL giữ nguyên, file local
|
|
3
|
+
// tự upload presigned (server chặn inline base64 >64KB) — user truyền path file thoải mái.
|
|
2
4
|
const { call } = require('../api');
|
|
5
|
+
const { resolveMediaInput, resolveMediaInputs } = require('../media');
|
|
6
|
+
|
|
7
|
+
const rmi = (v) => (v == null ? v : resolveMediaInput(v)); // giữ undefined/null nguyên vẹn
|
|
3
8
|
|
|
4
9
|
const out = (data) => console.log(JSON.stringify(data, null, 2));
|
|
5
10
|
const jobHint = (data) => {
|
|
@@ -20,22 +25,22 @@ const marketingModes = run(async () => out(await call('GET', '/marketing/modes')
|
|
|
20
25
|
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
26
|
|
|
22
27
|
// ── 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 } })));
|
|
28
|
+
const product = run(async (o) => jobHint(await call('POST', '/product/images', { body: { refs: await Promise.all((o.refs || []).map(async (r) => (/^\d+$/.test(r) ? Number(r) : await rmi(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
29
|
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
30
|
|
|
26
31
|
// ── Ả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 } })));
|
|
32
|
+
const edit = run(async (image, o) => jobHint(await call('POST', '/edit', { body: { image: await rmi(image), tool: o.tool, model: o.model, upscale_target: o.upscaleTarget, style: o.style, client_request_id: o.crid } })));
|
|
33
|
+
const tryon = run(async (o) => jobHint(await call('POST', '/tryon', { body: { type: o.type, model: o.model, model_image: await rmi(o.model_image), garment_image: await rmi(o.garment), upper_image: await rmi(o.upper), lower_image: await rmi(o.lower), background_image: await rmi(o.background), prompt: o.prompt, client_request_id: o.crid } })));
|
|
29
34
|
|
|
30
35
|
// ── Video nặng / audio ──
|
|
31
36
|
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
37
|
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
38
|
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 } })));
|
|
39
|
+
const motion = run(async (video_url, o) => jobHint(await call('POST', '/motion', { body: { video_url, image_urls: await resolveMediaInputs(o.image || []), model: o.model, mode: o.mode, prompt: o.prompt, client_request_id: o.crid } })));
|
|
35
40
|
|
|
36
41
|
// ── 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 } })));
|
|
42
|
+
const cutout = run(async (o) => jobHint(await call('POST', '/cutout', { body: { operation: o.operation, ref_image_urls: await resolveMediaInputs(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
43
|
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 } })));
|
|
44
|
+
const hookVideo = run(async (o) => jobHint(await call('POST', '/hook/videos', { body: { preset_id: o.preset, character_url: await rmi(o.character), product_url: await rmi(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
45
|
|
|
41
46
|
module.exports = { scrape, importSocial, assets, analyze, marketingModes, marketingVideo, product, fashion, edit, tryon, stt, subtitle, split, motion, cutout, hookPresets, hookVideo };
|
package/src/media.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// media.js — resolve input media cho mọi lệnh nhận ảnh/video/audio [260722].
|
|
2
|
+
// URL/data URI → giữ nguyên. File local: ≤64KB → data:base64 (server chấp nhận inline nhỏ);
|
|
3
|
+
// >64KB → POST /public/v1/uploads lấy presigned URL → PUT bytes thẳng lên storage → dùng file_url.
|
|
4
|
+
// WHY: server chặn inline base64 >64KB (chống agent chép base64 khổng lồ qua hội thoại) —
|
|
5
|
+
// CLI có mạng thật nên upload trực tiếp luôn nhanh hơn và giữ NGUYÊN chất lượng file gốc.
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const api = require('./api');
|
|
9
|
+
|
|
10
|
+
const MAX_INLINE_BYTES = 64 * 1024;
|
|
11
|
+
const MIME_BY_EXT = {
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.png': 'image/png',
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
'.mp4': 'video/mp4',
|
|
17
|
+
'.mov': 'video/quicktime',
|
|
18
|
+
'.webm': 'video/webm',
|
|
19
|
+
'.mp3': 'audio/mpeg',
|
|
20
|
+
'.wav': 'audio/wav',
|
|
21
|
+
'.m4a': 'audio/mp4',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function putBytes(upload_url, buf, content_type) {
|
|
25
|
+
const res = await fetch(upload_url, {
|
|
26
|
+
method: 'PUT',
|
|
27
|
+
headers: { 'Content-Type': content_type },
|
|
28
|
+
body: buf,
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) throw new Error(`Upload thất bại (HTTP ${res.status}). Thử lại hoặc dùng URL.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 1 input → URL dùng được ngay cho tool (async — có thể upload).
|
|
34
|
+
async function resolveMediaInput(ref) {
|
|
35
|
+
if (/^https?:\/\//i.test(ref) || /^data:/i.test(ref)) return ref;
|
|
36
|
+
const abs_path = path.resolve(ref);
|
|
37
|
+
if (!fs.existsSync(abs_path)) throw new Error(`Không thấy file: ${ref}`);
|
|
38
|
+
const ext = path.extname(abs_path).toLowerCase();
|
|
39
|
+
const content_type = MIME_BY_EXT[ext];
|
|
40
|
+
if (!content_type) throw new Error(`Định dạng không hỗ trợ: ${ext} (${ref}) — hỗ trợ: ${Object.keys(MIME_BY_EXT).join(' ')}`);
|
|
41
|
+
const buf = fs.readFileSync(abs_path);
|
|
42
|
+
|
|
43
|
+
if (buf.length <= MAX_INLINE_BYTES && content_type.startsWith('image/')) {
|
|
44
|
+
return `data:${content_type};base64,${buf.toString('base64')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data = await api.call('POST', '/uploads', {
|
|
48
|
+
body: { content_type, filename: path.basename(abs_path).slice(0, 120) },
|
|
49
|
+
});
|
|
50
|
+
if (!data.upload_url || !data.file_url) throw new Error('Server không cấp được link upload. Thử lại.');
|
|
51
|
+
await putBytes(data.upload_url, buf, content_type);
|
|
52
|
+
console.log(`Đã upload ${path.basename(abs_path)} (${Math.round(buf.length / 1024)}KB) → ${data.file_url}`);
|
|
53
|
+
return data.file_url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function resolveMediaInputs(refs) {
|
|
57
|
+
if (!refs || !refs.length) return [];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const r of refs) out.push(await resolveMediaInput(r));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { resolveMediaInput, resolveMediaInputs };
|