mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(hyperframes-creative): add frame-preset library Add a library of ready-made visual frame presets (claude, biennale-yellow, blockframe, blue-professional, bold-poster, broadside, capsule, cartesian, cobalt-grid, coral, creative-mode, daisy-days, editorial-forest, …), each with a FRAME.md spec, a frame-showcase.html, and a per-preset caption-skin.html. Registered in the creative design-spec so workflows can remix a preset onto brand tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hyperframes-media): shared TTS/BGM/SFX audio engine Add a shared audio engine under hyperframes-media (scripts/audio.mjs + lib/ tts.mjs, bgm.mjs, sfx.mjs, heygen.mjs) plus a bundled SFX pack and manifest. Workflows resolve this engine by path (../../hyperframes-media/scripts/ audio.mjs) for text-to-speech, background music, and sound effects, so audio is authored once and reused across skills instead of duplicated per workflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(skills): gate render on user review; refresh router, core, general-video - hyperframes-cli: render is now user-gated — preview opens Studio (the timeline editor where the user can hand-edit anything, not just watch); never auto-render once checks pass, pause at preview and render only after approval. - hyperframes (router): tighten the entry SKILL.md description + routing. - hyperframes-core: rewrite SKILL.md and add script-format.md + storyboard-format.md references for the script-driven authoring architecture. - general-video: tidy the fallback-workflow description and routing table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(hyperframes-creative): reformat frame-preset showcase HTML Run the HTML formatter over the frame-showcase.html files (indentation, self-closing void tags, one CSS declaration per line). Formatting only — no content or markup changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hyperframes-media): correct wait-bgm field mapping and guard credential parse Two correctness fixes from review (#1632): - wait-bgm.mjs read audioMeta.bgm_path / audioMeta.bgm_enabled, but audio.mjs writes the path nested as bgm.path and the flag as bgm_pending. The detached generate path (Lyria/MusicGen) therefore always saw an empty path and exited status: disabled, silently dropping the music track even while generation was running. Read audioMeta.bgm?.path and gate on bgm_pending. - heygenCredential() had an unguarded JSON.parse despite documenting that it never throws — a malformed ~/.heygen credentials file crashed the engine at startup instead of degrading to no-credential. Wrap the parse and return null. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(hyperframes): add router tag to entry skill metadata Fold the router metadata tag into the foundation rewrite of the entry SKILL.md. This file is owned by this PR (the full router rewrite); keeping the tag tweak here — instead of a separate edit on the pre-rewrite version in another PR — avoids a guaranteed merge conflict between the two. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate BGM using Google Lyria RealTime API.
|
|
|
|
Usage:
|
|
python lyria-recipe.py --output <path> --duration <seconds> [tuning flags]
|
|
|
|
Requires:
|
|
$GOOGLE_API_KEY or $GEMINI_API_KEY environment variable (treated as aliases).
|
|
pip install google-genai python-dotenv. audio.mjs Step 4b installs these on
|
|
demand when a key is set but google.genai is not importable; if that install
|
|
fails it falls back to local MusicGen rather than leaving the video with no BGM.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
DEFAULT_PROMPT = "Uplifting corporate tech, bright and modern, gentle piano with synth pads"
|
|
SAMPLE_RATE = 48000
|
|
CHANNELS = 2
|
|
SAMPLE_WIDTH = 2 # 16-bit
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description="Generate BGM via Google Lyria RealTime.")
|
|
p.add_argument("--output", required=True, help="Output WAV path.")
|
|
p.add_argument("--duration", type=float, required=True, help="Target duration in seconds.")
|
|
p.add_argument("--prompt", default=DEFAULT_PROMPT, help="Mood / instrumentation prompt.")
|
|
p.add_argument("--negative-prompt", default=None, help="Styles to exclude (optional).")
|
|
p.add_argument("--bpm", type=int, default=110)
|
|
p.add_argument("--brightness", type=float, default=0.8, help="0-1, higher = brighter mood.")
|
|
p.add_argument("--density", type=float, default=0.5, help="0-1, higher = fuller mix.")
|
|
p.add_argument(
|
|
"--scale",
|
|
default="MAJOR",
|
|
help="MAJOR / MINOR / PENTATONIC / etc. — see google.genai.types.Scale. Pass empty string for none.",
|
|
)
|
|
return p.parse_args()
|
|
|
|
|
|
async def generate_bgm(args: argparse.Namespace) -> dict:
|
|
from google import genai
|
|
from google.genai import types
|
|
|
|
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") or ""
|
|
if not api_key:
|
|
raise RuntimeError("Neither GOOGLE_API_KEY nor GEMINI_API_KEY is set.")
|
|
|
|
client = genai.Client(
|
|
api_key=api_key,
|
|
http_options={"api_version": "v1alpha"},
|
|
)
|
|
|
|
out_path = Path(args.output)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
target_bytes = int(args.duration * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
|
|
|
|
cfg: dict = {"bpm": args.bpm, "temperature": 1.0}
|
|
if args.density is not None:
|
|
cfg["density"] = args.density
|
|
if args.brightness is not None:
|
|
cfg["brightness"] = args.brightness
|
|
if args.scale:
|
|
scale_enum = getattr(types.Scale, args.scale, None)
|
|
if scale_enum:
|
|
cfg["scale"] = scale_enum
|
|
|
|
prompts = [types.WeightedPrompt(text=args.prompt, weight=1.0)]
|
|
if args.negative_prompt:
|
|
prompts.append(types.WeightedPrompt(text=args.negative_prompt, weight=-1.0))
|
|
|
|
buf = bytearray()
|
|
timeout = args.duration + 8
|
|
|
|
async with client.aio.live.music.connect(
|
|
model="models/lyria-realtime-exp",
|
|
) as session:
|
|
await session.set_weighted_prompts(prompts=prompts)
|
|
await session.set_music_generation_config(
|
|
config=types.LiveMusicGenerationConfig(**cfg),
|
|
)
|
|
await session.play()
|
|
|
|
async def collect():
|
|
while len(buf) < target_bytes:
|
|
async for msg in session.receive():
|
|
sc = msg.server_content
|
|
if sc and sc.audio_chunks:
|
|
for chunk in sc.audio_chunks:
|
|
buf.extend(chunk.data)
|
|
if len(buf) >= target_bytes:
|
|
return
|
|
await asyncio.sleep(1e-6)
|
|
|
|
try:
|
|
await asyncio.wait_for(collect(), timeout=timeout)
|
|
except TimeoutError:
|
|
print(f"Timeout after {timeout:.0f}s, collected {len(buf)} bytes", file=sys.stderr)
|
|
|
|
audio = bytes(buf[:target_bytes])
|
|
with wave.open(str(out_path), "wb") as wf:
|
|
wf.setnchannels(CHANNELS)
|
|
wf.setsampwidth(SAMPLE_WIDTH)
|
|
wf.setframerate(SAMPLE_RATE)
|
|
wf.writeframes(audio)
|
|
|
|
actual_duration = len(audio) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
|
|
print(f"BGM: {out_path} ({actual_duration:.2f}s)")
|
|
return {"file": str(out_path), "duration_sec": round(actual_duration, 2)}
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
try:
|
|
asyncio.run(generate_bgm(args))
|
|
except RuntimeError as exc:
|
|
print(f"BGM generation failed: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|