@leejungkiin/awkit 1.4.0 → 1.4.3
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/bin/awk.js +458 -7
- package/bin/claude-generators.js +122 -0
- package/core/AGENTS.md +16 -0
- package/core/CLAUDE.md +155 -0
- package/core/GEMINI.md +44 -9
- package/package.json +1 -1
- package/skills/ai-sprite-maker/SKILL.md +81 -0
- package/skills/ai-sprite-maker/scripts/animate_sprite.py +102 -0
- package/skills/ai-sprite-maker/scripts/process_sprites.py +140 -0
- package/skills/code-review/SKILL.md +21 -33
- package/skills/lucylab-tts/SKILL.md +64 -0
- package/skills/lucylab-tts/resources/voices_library.json +908 -0
- package/skills/lucylab-tts/scripts/.env +1 -0
- package/skills/lucylab-tts/scripts/lucylab_tts.py +506 -0
- package/skills/orchestrator/SKILL.md +5 -0
- package/skills/short-maker/SKILL.md +150 -0
- package/skills/short-maker/_backup/storyboard.html +106 -0
- package/skills/short-maker/_backup/video_mixer.py +296 -0
- package/skills/short-maker/outputs/fitbite-promo/background.jpg +0 -0
- package/skills/short-maker/outputs/fitbite-promo/final/promo-final.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/script.md +19 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-01.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-02.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-03.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-04.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-01.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-02.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-03.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-04.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard.html +133 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard.json +38 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/merged_chroma.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/merged_crossfaded.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_00.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_01.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_02.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_03.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/manifest.json +31 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-01.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-02.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-03.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-04.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts_script.txt +11 -0
- package/skills/short-maker/scripts/google-flow-cli/.project-identity +41 -0
- package/skills/short-maker/scripts/google-flow-cli/.trae/rules/project_rules.md +52 -0
- package/skills/short-maker/scripts/google-flow-cli/CODEBASE.md +67 -0
- package/skills/short-maker/scripts/google-flow-cli/GoogleFlowCli.code-workspace +29 -0
- package/skills/short-maker/scripts/google-flow-cli/README.md +168 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/PROJECT.md +12 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/REQUIREMENTS.md +22 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/ROADMAP.md +16 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/TECH-SPEC.md +13 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/__init__.py +3 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/__init__.py +19 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +1921 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +64 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/rpc_ids.py +98 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/__init__.py +15 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/browser_auth.py +692 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/humanizer.py +417 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/proxy_ext.py +120 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/recaptcha.py +482 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/__init__.py +5 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/client.py +414 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/__init__.py +1 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +1075 -0
- package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +36 -0
- package/skills/short-maker/scripts/google-flow-cli/script.txt +22 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/__init__.py +0 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/test_batchexecute.py +113 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/test_client.py +190 -0
- package/skills/short-maker/templates/aida_script.md +40 -0
- package/skills/short-maker/templates/mimic_analyzer.md +29 -0
- package/skills/single-flow-task-execution/SKILL.md +9 -6
- package/skills/skill-creator/SKILL.md +44 -0
- package/skills/spm-build-analysis/SKILL.md +92 -0
- package/skills/spm-build-analysis/references/build-optimization-sources.md +155 -0
- package/skills/spm-build-analysis/references/recommendation-format.md +85 -0
- package/skills/spm-build-analysis/references/spm-analysis-checks.md +105 -0
- package/skills/spm-build-analysis/scripts/check_spm_pins.py +118 -0
- package/skills/symphony-enforcer/SKILL.md +51 -83
- package/skills/symphony-orchestrator/SKILL.md +1 -1
- package/skills/trello-sync/SKILL.md +27 -28
- package/skills/verification-gate/SKILL.md +13 -2
- package/skills/xcode-build-benchmark/SKILL.md +88 -0
- package/skills/xcode-build-benchmark/references/benchmark-artifacts.md +94 -0
- package/skills/xcode-build-benchmark/references/benchmarking-workflow.md +67 -0
- package/skills/xcode-build-benchmark/schemas/build-benchmark.schema.json +230 -0
- package/skills/xcode-build-benchmark/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-fixer/SKILL.md +218 -0
- package/skills/xcode-build-fixer/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-build-fixer/references/fix-patterns.md +290 -0
- package/skills/xcode-build-fixer/references/recommendation-format.md +85 -0
- package/skills/xcode-build-fixer/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-orchestrator/SKILL.md +156 -0
- package/skills/xcode-build-orchestrator/references/benchmark-artifacts.md +94 -0
- package/skills/xcode-build-orchestrator/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-build-orchestrator/references/orchestration-report-template.md +143 -0
- package/skills/xcode-build-orchestrator/references/recommendation-format.md +85 -0
- package/skills/xcode-build-orchestrator/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-orchestrator/scripts/diagnose_compilation.py +273 -0
- package/skills/xcode-build-orchestrator/scripts/generate_optimization_report.py +533 -0
- package/skills/xcode-compilation-analyzer/SKILL.md +89 -0
- package/skills/xcode-compilation-analyzer/references/build-optimization-sources.md +155 -0
- package/skills/xcode-compilation-analyzer/references/code-compilation-checks.md +106 -0
- package/skills/xcode-compilation-analyzer/references/recommendation-format.md +85 -0
- package/skills/xcode-compilation-analyzer/scripts/diagnose_compilation.py +273 -0
- package/skills/xcode-project-analyzer/SKILL.md +76 -0
- package/skills/xcode-project-analyzer/references/build-optimization-sources.md +155 -0
- package/skills/xcode-project-analyzer/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-project-analyzer/references/project-audit-checks.md +101 -0
- package/skills/xcode-project-analyzer/references/recommendation-format.md +85 -0
- package/templates/project-identity/android.json +0 -10
- package/templates/project-identity/backend-nestjs.json +0 -10
- package/templates/project-identity/expo.json +0 -10
- package/templates/project-identity/ios.json +0 -10
- package/templates/project-identity/web-nextjs.json +0 -10
- package/workflows/_uncategorized/ship-to-code.md +85 -0
- package/workflows/context/codebase-sync.md +10 -87
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="vi">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Storyboard Preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #121212; --card-bg: #1E1E1E; --text: #E0E0E0; --accent: #BB86FC; --accent-hover: #9965f4;
|
|
10
|
+
}
|
|
11
|
+
body {
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
13
|
+
background-color: var(--bg); color: var(--text); padding: 20px;
|
|
14
|
+
}
|
|
15
|
+
header { text-align: center; margin-bottom: 30px; }
|
|
16
|
+
h1 { margin: 0; color: var(--accent); }
|
|
17
|
+
.meta { color: #888; font-size: 0.9em; margin-top: 5px; }
|
|
18
|
+
.grid {
|
|
19
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px;
|
|
20
|
+
}
|
|
21
|
+
.card {
|
|
22
|
+
background: var(--card-bg); border-radius: 12px; overflow: hidden;
|
|
23
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.3); border: 1px solid #333;
|
|
24
|
+
}
|
|
25
|
+
.card-img-wrapper { position: relative; padding-top: 56.25%; /* 16:9 Aspect Ratio */ background: #000; }
|
|
26
|
+
.card img {
|
|
27
|
+
position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;
|
|
28
|
+
}
|
|
29
|
+
.scene-number {
|
|
30
|
+
position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.7);
|
|
31
|
+
color: #fff; padding: 4px 8px; border-radius: 6px; font-weight: bold; font-size: 0.8em;
|
|
32
|
+
}
|
|
33
|
+
.card-content { padding: 15px; }
|
|
34
|
+
.duration { color: var(--accent); font-weight: bold; font-size: 0.9em; margin-bottom: 8px; }
|
|
35
|
+
.script { font-size: 1em; line-height: 1.4; margin-bottom: 12px; }
|
|
36
|
+
.prompt { font-size: 0.8em; color: #888; font-style: italic; background: #2a2a2a; padding: 8px; border-radius: 6px; }
|
|
37
|
+
.timeline-bar {
|
|
38
|
+
height: 6px; background: #333; margin-top: 30px; border-radius: 3px; position: relative; overflow: hidden;
|
|
39
|
+
}
|
|
40
|
+
.timeline-progress { height: 100%; background: var(--accent); width: 0%; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
|
|
45
|
+
<header>
|
|
46
|
+
<h1 id="title">Loading Storyboard...</h1>
|
|
47
|
+
<div class="meta" id="meta">Duration: 0s</div>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<div class="timeline-bar">
|
|
51
|
+
<div class="timeline-progress" id="timeline"></div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="grid" id="grid">
|
|
55
|
+
<!-- Cards will be injected here -->
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
async function loadStoryboard() {
|
|
60
|
+
try {
|
|
61
|
+
// Fetch expects storyboard.json in the same directory
|
|
62
|
+
const response = await fetch('storyboard.json');
|
|
63
|
+
const data = await response.json();
|
|
64
|
+
|
|
65
|
+
document.getElementById('title').textContent = data.title || 'App Promo Storyboard';
|
|
66
|
+
document.getElementById('meta').textContent = `Total Duration: ${data.duration || 0}s | Video Target: ${data.target_format || '16:9'}`;
|
|
67
|
+
|
|
68
|
+
const grid = document.getElementById('grid');
|
|
69
|
+
grid.innerHTML = '';
|
|
70
|
+
|
|
71
|
+
let totalTime = data.duration || 1; // avoid /0
|
|
72
|
+
let cumulative = 0;
|
|
73
|
+
|
|
74
|
+
data.scenes.forEach((scene, index) => {
|
|
75
|
+
cumulative += scene.duration;
|
|
76
|
+
|
|
77
|
+
const card = document.createElement('div');
|
|
78
|
+
card.className = 'card';
|
|
79
|
+
card.innerHTML = `
|
|
80
|
+
<div class="card-img-wrapper">
|
|
81
|
+
<img src="${scene.image || 'https://via.placeholder.com/640x360?text=Generating+Image...'}" alt="Scene ${scene.id}">
|
|
82
|
+
<div class="scene-number">Scene ${scene.id}</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="card-content">
|
|
85
|
+
<div class="duration">⏱️ ${scene.duration}s</div>
|
|
86
|
+
<div class="script">🎙️ "${scene.script}"</div>
|
|
87
|
+
<div class="prompt">🎨 Prompt: ${scene.prompt}</div>
|
|
88
|
+
</div>
|
|
89
|
+
`;
|
|
90
|
+
grid.appendChild(card);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Setup dumb timeline animation for fun
|
|
94
|
+
const timeline = document.getElementById('timeline');
|
|
95
|
+
timeline.style.width = '100%';
|
|
96
|
+
timeline.style.transition = `width ${data.duration}s linear`;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
document.getElementById('title').innerHTML = `<span style="color:red">Error Loading storyboard.json</span>`;
|
|
99
|
+
console.error(e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
loadStoryboard();
|
|
104
|
+
</script>
|
|
105
|
+
</body>
|
|
106
|
+
</html>
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import glob
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_duration(filepath: Path) -> float:
|
|
11
|
+
cmd = [
|
|
12
|
+
"ffprobe",
|
|
13
|
+
"-v", "error",
|
|
14
|
+
"-show_entries", "format=duration",
|
|
15
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
16
|
+
str(filepath)
|
|
17
|
+
]
|
|
18
|
+
try:
|
|
19
|
+
output = subprocess.check_output(cmd, text=True).strip()
|
|
20
|
+
return float(output)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
print(f"Error getting duration for {filepath}: {e}")
|
|
23
|
+
return 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_has_audio(filepath: Path) -> bool:
|
|
27
|
+
cmd = [
|
|
28
|
+
"ffprobe", "-v", "error",
|
|
29
|
+
"-select_streams", "a",
|
|
30
|
+
"-show_entries", "stream=codec_type",
|
|
31
|
+
"-of", "csv=p=0",
|
|
32
|
+
str(filepath)
|
|
33
|
+
]
|
|
34
|
+
try:
|
|
35
|
+
output = subprocess.check_output(cmd, text=True).strip()
|
|
36
|
+
return len(output) > 0
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_dir(path: Path) -> Path:
|
|
42
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Supported FFmpeg xfade transitions
|
|
47
|
+
SUPPORTED_TRANSITIONS = [
|
|
48
|
+
'fade', 'slideleft', 'slideright', 'circlecrop',
|
|
49
|
+
'dissolve', 'wipeleft', 'wiperight',
|
|
50
|
+
'smoothleft', 'smoothright', 'smoothup', 'smoothdown',
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_storyboard_transitions(project_dir: Path) -> list:
|
|
55
|
+
"""Read per-scene transitions from storyboard.json if available."""
|
|
56
|
+
sb_path = project_dir / "storyboard.json"
|
|
57
|
+
if not sb_path.exists():
|
|
58
|
+
return []
|
|
59
|
+
try:
|
|
60
|
+
with open(sb_path, 'r') as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
transitions = []
|
|
63
|
+
for scene in data.get('scenes', []):
|
|
64
|
+
t = scene.get('transition', 'fade')
|
|
65
|
+
if t not in SUPPORTED_TRANSITIONS:
|
|
66
|
+
t = 'fade'
|
|
67
|
+
transitions.append(t)
|
|
68
|
+
return transitions
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"Warning: Could not read storyboard.json: {e}")
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
parser = argparse.ArgumentParser(description="Auto-Mixer cho Short Maker bằng FFmpeg")
|
|
76
|
+
parser.add_argument("--project-dir", required=True, help="Thư mục project chứa outputs (ví dụ: outputs/promo-app)")
|
|
77
|
+
parser.add_argument("--fade-duration", type=float, default=1.0, help="Thời gian crossfade giữa các cảnh (giây)")
|
|
78
|
+
parser.add_argument("--bgm-volume", type=float, default=0.1, help="Âm lượng nhạc nền (0.0 đến 1.0)")
|
|
79
|
+
parser.add_argument("--chroma-bg", type=str, help="Đường dẫn đến ảnh/video nền để thay thế phông xanh")
|
|
80
|
+
parser.add_argument("--chroma-color", type=str, default="0x00FF00", help="Mã màu phông xanh (mặc định: 0x00FF00)")
|
|
81
|
+
parser.add_argument("--chroma-sim", type=float, default=0.3, help="Độ tương đồng màu (0.01 - 1.0, mặc định: 0.3)")
|
|
82
|
+
parser.add_argument("--chroma-blend", type=float, default=0.2, help="Độ mượt viền (0.0 - 1.0, mặc định: 0.2)")
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
|
|
85
|
+
project_dir = Path(args.project_dir)
|
|
86
|
+
segments_dir = project_dir / "segments"
|
|
87
|
+
tts_dir = project_dir / "tts"
|
|
88
|
+
audio_dir = project_dir / "audio"
|
|
89
|
+
temp_dir = ensure_dir(project_dir / "temp")
|
|
90
|
+
final_dir = ensure_dir(project_dir / "final")
|
|
91
|
+
|
|
92
|
+
if not segments_dir.exists() or not tts_dir.exists():
|
|
93
|
+
print(f"Error: Thư mục segments hoặc tts không tồn tại trong {project_dir}")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
# Liệt kê các cảnh
|
|
97
|
+
video_files = sorted(glob.glob(str(segments_dir / "*.mp4")))
|
|
98
|
+
if not video_files:
|
|
99
|
+
print("Không tìm thấy video nào trong segments/")
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
scenes = []
|
|
103
|
+
for v_path in video_files:
|
|
104
|
+
v_path = Path(v_path)
|
|
105
|
+
scene_name = v_path.stem # vd: scene-01
|
|
106
|
+
|
|
107
|
+
# Tìm file TTS tương ứng (mp3 hoặc wav)
|
|
108
|
+
tts_path = tts_dir / f"{scene_name}.mp3"
|
|
109
|
+
if not tts_path.exists():
|
|
110
|
+
tts_path = tts_dir / f"{scene_name}.wav"
|
|
111
|
+
|
|
112
|
+
if not tts_path.exists():
|
|
113
|
+
print(f"Thêm cảnh {scene_name} nhưng KHÔNG có file âm thanh TTS.")
|
|
114
|
+
scenes.append({"video": v_path, "audio": None})
|
|
115
|
+
else:
|
|
116
|
+
scenes.append({"video": v_path, "audio": tts_path})
|
|
117
|
+
|
|
118
|
+
print(f"Bắt đầu mix {len(scenes)} cảnh...")
|
|
119
|
+
|
|
120
|
+
# Bước 1: Render từng cảnh riêng lẻ (Loop video để khớp với audio)
|
|
121
|
+
ready_scenes = []
|
|
122
|
+
for idx, scene in enumerate(scenes):
|
|
123
|
+
out_scene = temp_dir / f"ready_{idx:02d}.mp4"
|
|
124
|
+
ready_scenes.append(out_scene)
|
|
125
|
+
|
|
126
|
+
# Tiết kiệm thời gian nếu render rồi
|
|
127
|
+
if out_scene.exists():
|
|
128
|
+
print(f" - [Skip] Cảnh {idx+1} đã được chuẩn bị.")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
print(f" - [Render] Chuẩn bị cảnh {idx+1}...")
|
|
132
|
+
if scene["audio"]:
|
|
133
|
+
# Dùng stream_loop để loop video nếu nó ngắn hơn audio
|
|
134
|
+
cmd = [
|
|
135
|
+
"ffmpeg", "-y",
|
|
136
|
+
"-stream_loop", "-1", "-i", str(scene["video"]),
|
|
137
|
+
"-i", str(scene["audio"]),
|
|
138
|
+
"-c:v", "libx264", "-c:a", "aac",
|
|
139
|
+
"-map", "0:v:0", "-map", "1:a:0",
|
|
140
|
+
"-shortest", "-pix_fmt", "yuv420p",
|
|
141
|
+
str(out_scene)
|
|
142
|
+
]
|
|
143
|
+
else:
|
|
144
|
+
# Ưu tiên Native Voice của Veo 3 nếu không có file TTS ngoài.
|
|
145
|
+
# Nếu video không có audio stream, thêm audio âm câm để mix tránh lỗi acrossfade.
|
|
146
|
+
has_audio = check_has_audio(scene["video"])
|
|
147
|
+
if has_audio:
|
|
148
|
+
cmd = [
|
|
149
|
+
"ffmpeg", "-y",
|
|
150
|
+
"-i", str(scene["video"]),
|
|
151
|
+
"-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p",
|
|
152
|
+
str(out_scene)
|
|
153
|
+
]
|
|
154
|
+
else:
|
|
155
|
+
cmd = [
|
|
156
|
+
"ffmpeg", "-y",
|
|
157
|
+
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
|
|
158
|
+
"-i", str(scene["video"]),
|
|
159
|
+
"-c:v", "libx264", "-c:a", "aac",
|
|
160
|
+
"-map", "1:v:0", "-map", "0:a:0",
|
|
161
|
+
"-shortest", "-pix_fmt", "yuv420p",
|
|
162
|
+
str(out_scene)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
166
|
+
|
|
167
|
+
# Bước 2: Ghép các cảnh bằng XFade (Chuyển cảnh nhẹ nhàng)
|
|
168
|
+
# Load per-scene transitions from storyboard.json
|
|
169
|
+
scene_transitions = load_storyboard_transitions(project_dir)
|
|
170
|
+
print("Bắt đầu Crossfade nối màn...")
|
|
171
|
+
merged_output = temp_dir / "merged_crossfaded.mp4"
|
|
172
|
+
fade_d = args.fade_duration
|
|
173
|
+
|
|
174
|
+
if len(ready_scenes) == 1:
|
|
175
|
+
merged_output = ready_scenes[0]
|
|
176
|
+
else:
|
|
177
|
+
# Tính toán duration để tính offset
|
|
178
|
+
durations = [get_duration(f) for f in ready_scenes]
|
|
179
|
+
|
|
180
|
+
filter_complex = ""
|
|
181
|
+
inputs = []
|
|
182
|
+
|
|
183
|
+
for i, sc in enumerate(ready_scenes):
|
|
184
|
+
inputs.extend(["-i", str(sc)])
|
|
185
|
+
|
|
186
|
+
offsets = []
|
|
187
|
+
current_offset = 0.0
|
|
188
|
+
|
|
189
|
+
for i in range(len(durations) - 1):
|
|
190
|
+
current_offset += durations[i] - fade_d
|
|
191
|
+
offsets.append(current_offset)
|
|
192
|
+
|
|
193
|
+
# Build video filter with per-scene transitions
|
|
194
|
+
v_labels = [f"[{i}:v]" for i in range(len(ready_scenes))]
|
|
195
|
+
for i in range(len(offsets)):
|
|
196
|
+
# Get transition for this join (from scene i to i+1)
|
|
197
|
+
transition_type = 'fade' # default
|
|
198
|
+
if i < len(scene_transitions):
|
|
199
|
+
t = scene_transitions[i]
|
|
200
|
+
if t != 'none':
|
|
201
|
+
transition_type = t
|
|
202
|
+
|
|
203
|
+
last_out = v_labels[0]
|
|
204
|
+
next_in = v_labels[i+1]
|
|
205
|
+
out_label = f"[v{i+1}]"
|
|
206
|
+
|
|
207
|
+
if i < len(scene_transitions) and scene_transitions[i] == 'none':
|
|
208
|
+
# No transition — simple concat (handled by offset = duration)
|
|
209
|
+
filter_complex += f"{last_out}{next_in}xfade=transition=fade:duration=0.01:offset={offsets[i]}{out_label}; "
|
|
210
|
+
else:
|
|
211
|
+
filter_complex += f"{last_out}{next_in}xfade=transition={transition_type}:duration={fade_d}:offset={offsets[i]}{out_label}; "
|
|
212
|
+
v_labels[0] = out_label # Cập nhật pipe hiện tại
|
|
213
|
+
|
|
214
|
+
print(f" Scene {i+1} → {i+2}: transition={transition_type}")
|
|
215
|
+
|
|
216
|
+
# Build audio filter
|
|
217
|
+
a_labels = [f"[{i}:a]" for i in range(len(ready_scenes))]
|
|
218
|
+
for i in range(len(offsets)):
|
|
219
|
+
last_out = a_labels[0]
|
|
220
|
+
next_in = a_labels[i+1]
|
|
221
|
+
out_label = f"[a{i+1}]"
|
|
222
|
+
filter_complex += f"{last_out}{next_in}acrossfade=d={fade_d}{out_label}; "
|
|
223
|
+
a_labels[0] = out_label
|
|
224
|
+
|
|
225
|
+
cmd_merge = ["ffmpeg", "-y"] + inputs + [
|
|
226
|
+
"-filter_complex", filter_complex.strip(" ;"),
|
|
227
|
+
"-map", v_labels[0],
|
|
228
|
+
"-map", a_labels[0],
|
|
229
|
+
"-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p",
|
|
230
|
+
str(merged_output)
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
print(" Đang render crossfade... (có thể mất vài phút)")
|
|
234
|
+
subprocess.run(cmd_merge, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
235
|
+
|
|
236
|
+
# Bước 2.5: Ghép hình nền tách phông xanh (nếu có)
|
|
237
|
+
if args.chroma_bg and os.path.exists(args.chroma_bg):
|
|
238
|
+
print(f"Bắt đầu tách phông xanh và ghép nền: {args.chroma_bg}...")
|
|
239
|
+
merged_chroma = temp_dir / "merged_chroma.mp4"
|
|
240
|
+
bg_ext = Path(args.chroma_bg).suffix.lower()
|
|
241
|
+
is_image = bg_ext in ['.png', '.jpg', '.jpeg']
|
|
242
|
+
|
|
243
|
+
filter_complex = (
|
|
244
|
+
f"[0:v][1:v]scale2ref=w=iw:h=ih[bg][ref];"
|
|
245
|
+
f"[ref]colorkey={args.chroma_color}:{args.chroma_sim}:{args.chroma_blend}[ckout];"
|
|
246
|
+
f"[bg][ckout]overlay=shortest=1"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if is_image:
|
|
250
|
+
cmd_chroma = [
|
|
251
|
+
"ffmpeg", "-y",
|
|
252
|
+
"-loop", "1", "-framerate", "30", "-i", str(args.chroma_bg),
|
|
253
|
+
"-i", str(merged_output),
|
|
254
|
+
"-filter_complex", filter_complex,
|
|
255
|
+
"-c:v", "libx264", "-c:a", "copy", "-pix_fmt", "yuv420p",
|
|
256
|
+
str(merged_chroma)
|
|
257
|
+
]
|
|
258
|
+
else:
|
|
259
|
+
cmd_chroma = [
|
|
260
|
+
"ffmpeg", "-y",
|
|
261
|
+
"-stream_loop", "-1", "-i", str(args.chroma_bg),
|
|
262
|
+
"-i", str(merged_output),
|
|
263
|
+
"-filter_complex", filter_complex,
|
|
264
|
+
"-c:v", "libx264", "-c:a", "copy", "-pix_fmt", "yuv420p",
|
|
265
|
+
str(merged_chroma)
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
subprocess.run(cmd_chroma, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
269
|
+
merged_output = merged_chroma
|
|
270
|
+
|
|
271
|
+
# Bước 3: Lồng nhạc nền
|
|
272
|
+
bgm_file = audio_dir / "bgm.mp3"
|
|
273
|
+
final_output = final_dir / "promo-final.mp4"
|
|
274
|
+
|
|
275
|
+
if bgm_file.exists():
|
|
276
|
+
print(f"Mix nhạc nền (âm lượng {args.bgm_volume})...")
|
|
277
|
+
cmd_bgm = [
|
|
278
|
+
"ffmpeg", "-y",
|
|
279
|
+
"-i", str(merged_output),
|
|
280
|
+
"-stream_loop", "-1", "-i", str(bgm_file),
|
|
281
|
+
"-filter_complex", f"[1:a]volume={args.bgm_volume}[bgm];[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=2[a]",
|
|
282
|
+
"-map", "0:v:0", "-map", "[a]",
|
|
283
|
+
"-c:v", "copy", "-c:a", "aac",
|
|
284
|
+
str(final_output)
|
|
285
|
+
]
|
|
286
|
+
subprocess.run(cmd_bgm, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
287
|
+
else:
|
|
288
|
+
print("Không tìm thấy bgm.mp3, bỏ qua mix nhạc nền.")
|
|
289
|
+
import shutil
|
|
290
|
+
shutil.copy(merged_output, final_output)
|
|
291
|
+
|
|
292
|
+
print(f"\n✅ Hoàn tất! Video lưu tại: {final_output}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
main()
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# FitBite Pro - Promo Script (Green Screen)
|
|
2
|
+
|
|
3
|
+
**Concept:** A modern fitness coach avatar on a green screen, explaining the core value of FitBite Pro. The background will be replaced later in video_mixer.
|
|
4
|
+
|
|
5
|
+
## Scene 1: Hook (Attention)
|
|
6
|
+
- **Visual:** A young enthusiastic female fitness coach wearing sports attire, pointing directly at the camera with an excited but empathetic expression. She is standing on a solid chroma green screen background.
|
|
7
|
+
- **Audio (Vietnamese):** Gặp khó khăn khi theo dõi dinh dưỡng hàng ngày?
|
|
8
|
+
|
|
9
|
+
## Scene 2: Interest
|
|
10
|
+
- **Visual:** The same fitness coach looking at a modern smartphone in her hand, smiling broadly. Solid chroma green screen background.
|
|
11
|
+
- **Audio (Vietnamese):** FitBite Pro với AI thông minh sẽ tự động lên thực đơn chuẩn y khoa cho bạn.
|
|
12
|
+
|
|
13
|
+
## Scene 3: Desire
|
|
14
|
+
- **Visual:** The fitness coach standing in a strong, confident pose with arms crossed, nodding approvingly. Solid chroma green screen background.
|
|
15
|
+
- **Audio (Vietnamese):** Báo cáo chi tiết, giao diện tối ưu hoàn hảo cho cả iPad.
|
|
16
|
+
|
|
17
|
+
## Scene 4: Action (CTA)
|
|
18
|
+
- **Visual:** The fitness coach showing a "Download now" gesture with both hands pointing down. Solid chroma green screen background.
|
|
19
|
+
- **Audio (Vietnamese):** Tải ngay FitBite Pro trên App Store để thay đổi vóc dáng!
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="vi">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Storyboard Preview</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #121212; --card-bg: #1E1E1E; --text: #E0E0E0; --accent: #BB86FC; --accent-hover: #9965f4;
|
|
10
|
+
}
|
|
11
|
+
body {
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
13
|
+
background-color: var(--bg); color: var(--text); padding: 20px;
|
|
14
|
+
}
|
|
15
|
+
header { text-align: center; margin-bottom: 30px; }
|
|
16
|
+
h1 { margin: 0; color: var(--accent); }
|
|
17
|
+
.meta { color: #888; font-size: 0.9em; margin-top: 5px; }
|
|
18
|
+
.grid {
|
|
19
|
+
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px;
|
|
20
|
+
}
|
|
21
|
+
.card {
|
|
22
|
+
background: var(--card-bg); border-radius: 12px; overflow: hidden;
|
|
23
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.3); border: 1px solid #333;
|
|
24
|
+
}
|
|
25
|
+
.card-img-wrapper { position: relative; padding-top: 56.25%; /* 16:9 Aspect Ratio */ background: #000; }
|
|
26
|
+
.card img {
|
|
27
|
+
position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;
|
|
28
|
+
}
|
|
29
|
+
.scene-number {
|
|
30
|
+
position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.7);
|
|
31
|
+
color: #fff; padding: 4px 8px; border-radius: 6px; font-weight: bold; font-size: 0.8em;
|
|
32
|
+
}
|
|
33
|
+
.card-content { padding: 15px; }
|
|
34
|
+
.duration { color: var(--accent); font-weight: bold; font-size: 0.9em; margin-bottom: 8px; }
|
|
35
|
+
.script { font-size: 1em; line-height: 1.4; margin-bottom: 12px; }
|
|
36
|
+
.prompt { font-size: 0.8em; color: #888; font-style: italic; background: #2a2a2a; padding: 8px; border-radius: 6px; }
|
|
37
|
+
.timeline-bar {
|
|
38
|
+
height: 6px; background: #333; margin-top: 30px; border-radius: 3px; position: relative; overflow: hidden;
|
|
39
|
+
}
|
|
40
|
+
.timeline-progress { height: 100%; background: var(--accent); width: 0%; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
|
|
45
|
+
<header>
|
|
46
|
+
<h1 id="title">Loading Storyboard...</h1>
|
|
47
|
+
<div class="meta" id="meta">Duration: 0s</div>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<div class="timeline-bar">
|
|
51
|
+
<div class="timeline-progress" id="timeline"></div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="grid" id="grid">
|
|
55
|
+
<!-- Cards will be injected here -->
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
async function loadStoryboard() {
|
|
60
|
+
const data = {
|
|
61
|
+
"title": "FitBite Pro - Promo Storyboard",
|
|
62
|
+
"duration": 12,
|
|
63
|
+
"target_format": "16:9",
|
|
64
|
+
"scenes": [
|
|
65
|
+
{
|
|
66
|
+
"id": "1",
|
|
67
|
+
"duration": 3,
|
|
68
|
+
"image": "storyboard/scene-01.png",
|
|
69
|
+
"script": "Gặp khó khăn khi theo dõi dinh dưỡng hàng ngày?",
|
|
70
|
+
"prompt": "A young enthusiastic female fitness coach wearing modern sports attire..."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "2",
|
|
74
|
+
"duration": 3,
|
|
75
|
+
"image": "storyboard/scene-02.png",
|
|
76
|
+
"script": "FitBite Pro với AI thông minh sẽ tự động lên thực đơn chuẩn y khoa cho bạn.",
|
|
77
|
+
"prompt": "A young enthusiastic female fitness coach looking at a smartphone..."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "3",
|
|
81
|
+
"duration": 3,
|
|
82
|
+
"image": "storyboard/scene-03.png",
|
|
83
|
+
"script": "Báo cáo chi tiết, giao diện tối ưu hoàn hảo cho cả iPad.",
|
|
84
|
+
"prompt": "A young enthusiastic female fitness coach standing in a strong confident pose..."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "4",
|
|
88
|
+
"duration": 3,
|
|
89
|
+
"image": "storyboard/scene-04.png",
|
|
90
|
+
"script": "Tải ngay FitBite Pro trên App Store để thay đổi vóc dáng!",
|
|
91
|
+
"prompt": "A young enthusiastic female fitness coach showing a download now gesture..."
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
document.getElementById('title').textContent = data.title || 'App Promo Storyboard';
|
|
97
|
+
document.getElementById('meta').textContent = `Total Duration: ${data.duration || 0}s | Video Target: ${data.target_format || '16:9'}`;
|
|
98
|
+
|
|
99
|
+
const grid = document.getElementById('grid');
|
|
100
|
+
grid.innerHTML = '';
|
|
101
|
+
|
|
102
|
+
let totalTime = data.duration || 1; // avoid /0
|
|
103
|
+
let cumulative = 0;
|
|
104
|
+
|
|
105
|
+
data.scenes.forEach((scene, index) => {
|
|
106
|
+
cumulative += scene.duration;
|
|
107
|
+
|
|
108
|
+
const card = document.createElement('div');
|
|
109
|
+
card.className = 'card';
|
|
110
|
+
card.innerHTML = `
|
|
111
|
+
<div class="card-img-wrapper">
|
|
112
|
+
<img src="${scene.image || 'https://via.placeholder.com/640x360?text=Generating+Image...'}" alt="Scene ${scene.id}">
|
|
113
|
+
<div class="scene-number">Scene ${scene.id}</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="card-content">
|
|
116
|
+
<div class="duration">⏱️ ${scene.duration}s</div>
|
|
117
|
+
<div class="script">🎙️ "${scene.script}"</div>
|
|
118
|
+
<div class="prompt">🎨 Prompt: ${scene.prompt}</div>
|
|
119
|
+
</div>
|
|
120
|
+
`;
|
|
121
|
+
grid.appendChild(card);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Setup dumb timeline animation for fun
|
|
125
|
+
const timeline = document.getElementById('timeline');
|
|
126
|
+
timeline.style.width = '100%';
|
|
127
|
+
timeline.style.transition = `width ${data.duration}s linear`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
loadStoryboard();
|
|
131
|
+
</script>
|
|
132
|
+
</body>
|
|
133
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project": "fitbite-promo",
|
|
3
|
+
"background_music": "energetic_corporate_pop",
|
|
4
|
+
"scenes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "scene-01",
|
|
7
|
+
"type": "hook",
|
|
8
|
+
"image_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, pointing directly at the camera with an excited but empathetic expression, standing on a solid chroma green screen background, highly detailed, cinematic lighting, 8k resolution.",
|
|
9
|
+
"video_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, pointing directly at the camera with an excited but empathetic expression, standing on a solid chroma green screen background. She speaks with a British accent: \"Struggling to keep track of your daily nutrition?\". High quality, smooth motion, 4k.",
|
|
10
|
+
"tts_text": "Struggling to keep track of your daily nutrition?",
|
|
11
|
+
"status": "pending_image"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "scene-02",
|
|
15
|
+
"type": "interest",
|
|
16
|
+
"image_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, looking at a modern smartphone in her hand and smiling broadly, standing on a solid chroma green screen background, highly detailed, cinematic lighting, 8k resolution.",
|
|
17
|
+
"video_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, looking at a modern smartphone in her hand and smiling broadly, standing on a solid chroma green screen background. She speaks with a British accent: \"FitWitness, with its smart AI, will automatically create medically approved meal plans for you.\". High quality, smooth motion, 4k.",
|
|
18
|
+
"tts_text": "FitWitness, with its smart AI, will automatically create medically approved meal plans for you.",
|
|
19
|
+
"status": "pending_image"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "scene-03",
|
|
23
|
+
"type": "desire",
|
|
24
|
+
"image_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, standing in a strong confident pose with arms crossed, nodding approvingly, standing on a solid chroma green screen background, highly detailed, cinematic lighting, 8k resolution.",
|
|
25
|
+
"video_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, standing in a strong confident pose with arms crossed, nodding approvingly, standing on a solid chroma green screen background. She speaks with a British accent: \"Detailed reports, and an interface perfectly optimised for iPad.\". High quality, smooth motion, 4k.",
|
|
26
|
+
"tts_text": "Detailed reports, and an interface perfectly optimised for iPad.",
|
|
27
|
+
"status": "pending_image"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "scene-04",
|
|
31
|
+
"type": "action",
|
|
32
|
+
"image_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, showing a download now gesture by pointing down with both index fingers, standing on a solid chroma green screen background, highly detailed, cinematic lighting, 8k resolution.",
|
|
33
|
+
"video_prompt": "A young enthusiastic female fitness coach wearing modern sports attire, showing a download now gesture by pointing down with both index fingers, standing on a solid chroma green screen background. She speaks with a British accent: \"Download FitWitness on the App Store today to transform your body!\". High quality, smooth motion, 4k.",
|
|
34
|
+
"tts_text": "Download FitWitness on the App Store today to transform your body!",
|
|
35
|
+
"status": "pending_image"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"endpoint": "https://api.lucylab.io/json-rpc",
|
|
3
|
+
"speed": 1.0,
|
|
4
|
+
"blockVersion": 0,
|
|
5
|
+
"mode": "script-scenes",
|
|
6
|
+
"voices": [
|
|
7
|
+
{
|
|
8
|
+
"id": "nqak8C85bsAG5mihyunRkj",
|
|
9
|
+
"name": "Chi Chi",
|
|
10
|
+
"slug": "chi-chi"
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"items": [
|
|
14
|
+
{
|
|
15
|
+
"label": "scene-01",
|
|
16
|
+
"text": "Gặp khó khăn khi theo dõi dinh dưỡng hàng ngày?"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"label": "scene-02",
|
|
20
|
+
"text": "FitBite Pro với AI thông minh sẽ tự động lên thực đơn chuẩn y khoa cho bạn."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"label": "scene-03",
|
|
24
|
+
"text": "Báo cáo chi tiết, giao diện tối ưu hoàn hảo cho cả iPad."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"label": "scene-04",
|
|
28
|
+
"text": "Tải ngay FitBite Pro trên App Store để thay đổi vóc dáng!"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|