mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(hyperframes): land HTML-in-Canvas across web + skills (#866)
* feat(hyperframes): land HTML-in-Canvas across web + skills Ships HTML-in-Canvas as a first-class HyperFrames video path: - 7 new video prompt templates (liquid glass, iPhone+MacBook, portal, shatter, magnetic, liquid background, text-cursor reveal). - skills/hyperframes/references/html-in-canvas.md, surfaced via SKILL.md description+triggers and the system-prompt pre-flight references list. - ChatPane starter prompts now branch by project kind and video model, so the hyperframes-html surface shows HTML-in-canvas-shaped prompts instead of the generic prototype trio. - NewProjectPanel propagates a picked template's model+aspect onto the project, and defaults videoModel to hyperframes-html when the hyperframes skill resolves for the video tab. Polish bundled in the same branch: - DesignFilesPanel empty state becomes a centered pill with a "New sketch" CTA; designFiles.empty copy simplified across 19 locales. - Topbar project title + meta render on one baseline row separated by a middot. - scripts/seed-test-projects.ts hardens daemon URL discovery against pnpm engine warnings on stdout. * fix(new-project): preserve explicit video model choice across tab revisits Latch a videoModelTouched guard once the user picks a model via the dropdown or via a template that declares one, so the hyperframes-html auto-default no longer silently overwrites the override when the Video tab is re-entered. Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code) * fix(i18n): register hyperframes html-in-canvas templates, category, and tags Adds the seven new prompt-template ids, the "VFX / HTML-in-Canvas" category, and the new tag set to the de/ru/fr i18n bundles so the e2e localized-content coverage test passes. Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code) * fix(daemon): inject html-in-canvas preflight for hyperframes runs The contracts-side derivePreflight() learned about references/html-in-canvas.md when this PR landed, but the daemon copy at apps/daemon/src/prompts/system.ts kept the older five-ref allowlist. server.ts:4138 wires composeSystemPrompt from the daemon copy into live chat runs, so the main HyperFrames flow this PR is meant to improve still wasn't auto-injecting the preflight directive in production. Mirror the html-in-canvas case into the daemon composer and lock it behind a daemon-side test so the two copies cannot drift again on this reference. The broader live-artifact preflight gap (artifact- schema / connector-policy / refresh-contract) is pre-existing drift and is intentionally out of scope here. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): restyle designs empty state as centered card on grid backdrop Swap the horizontal pill for a stacked card and add a faint grid backdrop so the empty designs surface reads as an intentional canvas rather than a gap. Title now wraps instead of truncating; container is taller. * fix(new-project): pin skillId to hyperframes when videoModel is hyperframes-html When the Video tab resolves its skill it used to fall back to `list[0]?.id` if no skill declared `default_for: video`. That list is built from an unsorted `readdir()` in apps/daemon/src/skills.ts, so a freshly mounted project could land on `video-shortform` even when the user had explicitly chosen the HyperFrames-HTML model (or one of the new `hyperframes-html-in-canvas-*` templates). The agent then ran without the hyperframes SKILL body or its `references/html-in-canvas.md` preflight — the exact regression PR #866 was meant to land. `skillIdForTab` now pins to `hyperframes` whenever the current video model is `hyperframes-html`, regardless of discovery order. Added a unit test that mounts both `video-shortform` and `hyperframes` (with hyperframes last, simulating the bad readdir order) and asserts the create payload routes through `hyperframes`. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
31e57fd773
commit
e11e86d468
35 changed files with 718 additions and 85 deletions
|
|
@ -600,6 +600,14 @@ function derivePreflight(skillBody: string): string {
|
|||
if (/references\/themes\.md/.test(skillBody)) refs.push('`references/themes.md`');
|
||||
if (/references\/components\.md/.test(skillBody)) refs.push('`references/components.md`');
|
||||
if (/references\/checklist\.md/.test(skillBody)) refs.push('`references/checklist.md`');
|
||||
// The hyperframes skill ships an html-in-canvas reference next to the
|
||||
// VFX catalog blocks. The chat handler at server.ts:4138 routes through
|
||||
// this composer (not the contracts copy), so the case must live here
|
||||
// too — otherwise live agent runs miss the preflight directive even
|
||||
// when the skill body explicitly lists the file.
|
||||
if (/references\/html-in-canvas\.md|html-in-canvas\.md/.test(skillBody)) {
|
||||
refs.push('`references/html-in-canvas.md`');
|
||||
}
|
||||
if (refs.length === 0) return '';
|
||||
return ` **Pre-flight (do this before any other tool):** Read ${refs.join(', ')} via the path written in the skill-root preamble. The seed template defines the class system you'll paste into; the layouts file is the only acceptable source of section/screen/slide skeletons; the checklist is your P0/P1/P2 gate before emitting \`<artifact>\`. Skipping this step is the #1 reason output regresses to generic AI-slop.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,19 @@ const liveArtifactSkillBody = [
|
|||
liveArtifactSkillMarkdown.replace(/^---[\s\S]*?---\n\n/, '').trim(),
|
||||
].join('\n');
|
||||
|
||||
const hyperframesRoot = path.join(repoRoot, 'skills/hyperframes');
|
||||
const hyperframesSkillPath = path.join(repoRoot, 'skills/hyperframes/SKILL.md');
|
||||
const hyperframesSkillMarkdown = readFileSync(hyperframesSkillPath, 'utf8');
|
||||
const hyperframesSkillBody = [
|
||||
`> **Skill root (absolute):** \`${hyperframesRoot}\``,
|
||||
'>',
|
||||
'> This skill ships side files alongside `SKILL.md`. Resolve references',
|
||||
'> like `references/html-in-canvas.md` against the skill root above.',
|
||||
'',
|
||||
'',
|
||||
hyperframesSkillMarkdown.replace(/^---[\s\S]*?---\n\n/, '').trim(),
|
||||
].join('\n');
|
||||
|
||||
describe('composeSystemPrompt', () => {
|
||||
it('injects live-artifact skill guidance and metadata intent', () => {
|
||||
const prompt = composeSystemPrompt({
|
||||
|
|
@ -55,6 +68,27 @@ describe('composeSystemPrompt', () => {
|
|||
expect(prompt).toContain('The first output should be a live artifact/dashboard/report');
|
||||
});
|
||||
|
||||
// The daemon composer (this file) is what apps/daemon/src/server.ts wires
|
||||
// into live chat runs. The contracts copy at packages/contracts/src/prompts
|
||||
// /system.ts exists for non-daemon contexts and was updated in the
|
||||
// hyperframes PR; without this test the two copies drift silently and the
|
||||
// main HyperFrames flow misses its preflight directive in production.
|
||||
it('injects the html-in-canvas preflight for the hyperframes skill', () => {
|
||||
const prompt = composeSystemPrompt({
|
||||
skillName: 'hyperframes',
|
||||
skillMode: 'video',
|
||||
skillBody: hyperframesSkillBody,
|
||||
metadata: {
|
||||
kind: 'video',
|
||||
videoModel: 'hyperframes-html',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(prompt).toContain('## Active skill — hyperframes');
|
||||
expect(prompt).toContain('**Pre-flight (do this before any other tool):**');
|
||||
expect(prompt).toContain('`references/html-in-canvas.md`');
|
||||
});
|
||||
|
||||
describe('artifact handoff no-emit clauses (#1143)', () => {
|
||||
it('drops the absolute "non-negotiable" framing in favor of conditional language', () => {
|
||||
const prompt = composeSystemPrompt({});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,24 @@ type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) =>
|
|||
// Each prompt is intentionally dense — it should showcase ambitious
|
||||
// layout, typographic, and information-design moves rather than a
|
||||
// generic landing page.
|
||||
const EXAMPLE_PROMPT_KEYS: Array<{
|
||||
//
|
||||
// Starter sets are picked per project kind (and per video model) so a
|
||||
// fresh seedance video, a hyperframes html-in-canvas video, an image
|
||||
// project and an audio project each see relevant prompts instead of the
|
||||
// generic prototype trio. The default (prototype/deck/template/other/
|
||||
// live-artifact) set stays i18n-translated via existing chat.example*
|
||||
// keys so the user-facing copy keeps its localizations. The new media
|
||||
// sets are inline English literals — they are technical agent prompts
|
||||
// that work well across locales without translation, and going through
|
||||
// i18n for each of them would balloon every Dict entry by 12+ keys.
|
||||
type StarterPrompt = {
|
||||
icon: string;
|
||||
title: string;
|
||||
tag: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
const DEFAULT_STARTER_KEYS: Array<{
|
||||
icon: string;
|
||||
titleKey: keyof Dict;
|
||||
tagKey: keyof Dict;
|
||||
|
|
@ -47,6 +64,135 @@ const EXAMPLE_PROMPT_KEYS: Array<{
|
|||
},
|
||||
];
|
||||
|
||||
const IMAGE_STARTERS: StarterPrompt[] = [
|
||||
{
|
||||
icon: '◯',
|
||||
title: 'Editorial portrait',
|
||||
tag: 'Portrait',
|
||||
prompt:
|
||||
'A close-up editorial portrait of a young creative director in their late 20s, soft natural light through tall studio windows, warm neutral palette (cream, taupe, soft black), shot at 85mm f/1.8 with shallow depth of field, sharp gaze straight to camera, subtle film grain, no makeup look.',
|
||||
},
|
||||
{
|
||||
icon: '▭',
|
||||
title: 'Product hero',
|
||||
tag: 'E-commerce',
|
||||
prompt:
|
||||
'A premium product hero shot of a single matte ceramic coffee mug on a warm cream paper backdrop. Hard rim light from the upper-left, gentle elongated shadow stretching to the lower-right, faint steam rising from the cup. Square crop, centered composition, room above for headline copy, no props or hands in frame.',
|
||||
},
|
||||
{
|
||||
icon: '◐',
|
||||
title: 'Flat illustration',
|
||||
tag: 'Illustration',
|
||||
prompt:
|
||||
'A flat vector illustration of a cozy reading nook by a rainy window — geometric shapes, restrained 5-color palette (cream, terracotta, deep teal, burnt sienna, soft black), thin 1.5px line accents, no gradients, no textures, soft drop shadows only on the foreground armchair.',
|
||||
},
|
||||
];
|
||||
|
||||
// Pure-video / cinematic-shot starters for seedance, sora, kling, veo,
|
||||
// grok-imagine and similar text-to-video models. Each prompt is one
|
||||
// shot, restrained motion, and a clear visual concept the model can
|
||||
// nail in 5-10 seconds.
|
||||
const VIDEO_SEEDANCE_STARTERS: StarterPrompt[] = [
|
||||
{
|
||||
icon: '◉',
|
||||
title: 'Product reveal',
|
||||
tag: 'Cinematic',
|
||||
prompt:
|
||||
'A 5-second product reveal: a minimal high-end skincare bottle on a clean cream stone surface, soft side light from camera-left, slow camera push-in, subtle depth-of-field shift from the cap to the label, restrained motion, no text overlays, no people in frame.',
|
||||
},
|
||||
{
|
||||
icon: '▣',
|
||||
title: 'Lantern close-up',
|
||||
tag: 'Mood',
|
||||
prompt:
|
||||
'A 6-second cinematic close-up of a young woman holding a glowing paper lantern in a misty pine forest at golden hour. Shallow depth of field on her eyes, gentle dolly-in, ambient particles drifting through the warm shaft of light, no dialogue, ambient forest sound only.',
|
||||
},
|
||||
{
|
||||
icon: '⌘',
|
||||
title: 'Neon street drift',
|
||||
tag: 'Action',
|
||||
prompt:
|
||||
'A 5-second street-racing tracking shot at night in a neon-lit cyberpunk Hong Kong alley. Low-angle camera following a matte-black sports car drifting around a tight corner, motion blur on the wheels, lens flares from oncoming neon signs, rain-slick asphalt reflecting the lights, no on-screen text.',
|
||||
},
|
||||
];
|
||||
|
||||
// HyperFrames HTML-in-canvas starters — these target the
|
||||
// hyperframes-html video model where the renderer captures live DOM
|
||||
// into a WebGL texture and runs shader effects on top. References:
|
||||
// https://www.remotion.dev/docs/html-in-canvas (concept), the seven
|
||||
// vfx-* catalog blocks shipped via `npx hyperframes add vfx-*`, and
|
||||
// skills/hyperframes/references/html-in-canvas.md.
|
||||
const VIDEO_HYPERFRAMES_STARTERS: StarterPrompt[] = [
|
||||
{
|
||||
icon: '◉',
|
||||
title: 'Magnifying glass reveal',
|
||||
tag: 'HTML-in-canvas',
|
||||
prompt:
|
||||
'Make a 5-second composition with a single line of bold display text on a clean canvas. Animate a round magnifying glass that travels left to right across the line, with subtle glass refraction warping the letters underneath as it passes. Use HyperFrames html-in-canvas — capture the text DOM and run the lens shader on top via a vfx-liquid-glass-style pass. Pure CSS for the text; the glass is a WebGL layer.',
|
||||
},
|
||||
{
|
||||
icon: '▦',
|
||||
title: 'CRT terminal scene',
|
||||
tag: 'Vintage VFX',
|
||||
prompt:
|
||||
"Make a CRT-screen composition: dark canvas, monospace terminal text typing `npx hyperframes init my-video`, then `claude` invoked with the prompt 'Add a CRT effect using HTML-in-canvas'. Apply a subtle convex-curvature shader, scanlines, slight chromatic aberration, and a soft phosphor glow on top of the live DOM via html-in-canvas. The terminal text stays as real CSS so it's pixel-sharp before the shader pass.",
|
||||
},
|
||||
{
|
||||
icon: '◈',
|
||||
title: 'Glitch breakdown',
|
||||
tag: 'Glitch',
|
||||
prompt:
|
||||
'Build a 6-second composition that displays a hero headline and a one-line subhead on a dark canvas, then breaks into a hard digital glitch — RGB channel split, horizontal displacement bands, brief frame-stutter, and a final clean reset. Capture the live DOM via html-in-canvas and run the glitch pass on top, so the type is real CSS underneath the shader.',
|
||||
},
|
||||
];
|
||||
|
||||
// Speech-focused audio starters — the New Project audio panel only
|
||||
// surfaces the `speech` kind today (see MediaProjectOptions), so we
|
||||
// match that. If/when the music + sfx tabs come back, broaden this set.
|
||||
const AUDIO_STARTERS: StarterPrompt[] = [
|
||||
{
|
||||
icon: '♪',
|
||||
title: 'Brand voiceover',
|
||||
tag: 'Speech',
|
||||
prompt:
|
||||
"A 30-second warm-toned narrative voiceover for a product launch video — confident but conversational, mid-tempo, with a beat of pause after the brand name. Script: 'Three years in the making. One simple promise. Meet [product name] — the way work was supposed to feel.' English, neutral North American accent.",
|
||||
},
|
||||
{
|
||||
icon: '♫',
|
||||
title: 'Onboarding narration',
|
||||
tag: 'Speech',
|
||||
prompt:
|
||||
"A 20-second friendly onboarding narration for a mobile app's first-launch screen. Reassuring, smiling tone, slow enough to feel attentive without sounding scripted. Script: 'Welcome to Loop. Let's set up your space — three quick questions and you're in. You can change any of this later.'",
|
||||
},
|
||||
{
|
||||
icon: '♬',
|
||||
title: 'Story passage read',
|
||||
tag: 'Speech',
|
||||
prompt:
|
||||
"A 45-second cinematic read of an opening passage. Low, measured delivery with breath between sentences, slightly intimate close-mic'd quality. Script: 'The city sleeps in pieces. A neon sign flickers above the ramen counter. Across the avenue, a window glows — the only one still on this side of midnight.'",
|
||||
},
|
||||
];
|
||||
|
||||
function pickStarters(
|
||||
metadata: ProjectMetadata | undefined,
|
||||
t: TranslateFn,
|
||||
): StarterPrompt[] {
|
||||
const kind = metadata?.kind;
|
||||
if (kind === 'image') return IMAGE_STARTERS;
|
||||
if (kind === 'video') {
|
||||
return metadata?.videoModel === 'hyperframes-html'
|
||||
? VIDEO_HYPERFRAMES_STARTERS
|
||||
: VIDEO_SEEDANCE_STARTERS;
|
||||
}
|
||||
if (kind === 'audio') return AUDIO_STARTERS;
|
||||
return DEFAULT_STARTER_KEYS.map((entry) => ({
|
||||
icon: entry.icon,
|
||||
title: t(entry.titleKey),
|
||||
tag: t(entry.tagKey),
|
||||
prompt: t(entry.promptKey),
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
|
|
@ -489,36 +635,31 @@ export function ChatPane({
|
|||
</span>
|
||||
</div>
|
||||
<div className="chat-examples" role="list">
|
||||
{EXAMPLE_PROMPT_KEYS.map((ex, i) => {
|
||||
const title = t(ex.titleKey);
|
||||
const tag = t(ex.tagKey);
|
||||
const prompt = t(ex.promptKey);
|
||||
return (
|
||||
<button
|
||||
key={ex.titleKey}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="chat-example"
|
||||
style={{ animationDelay: `${i * 70}ms` }}
|
||||
onClick={() => composerRef.current?.setDraft(prompt)}
|
||||
title={t('chat.fillInputTitle')}
|
||||
>
|
||||
<span className="chat-example-icon" aria-hidden>
|
||||
{ex.icon}
|
||||
{pickStarters(projectMetadata, t).map((ex, i) => (
|
||||
<button
|
||||
key={`${ex.title}-${i}`}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="chat-example"
|
||||
style={{ animationDelay: `${i * 70}ms` }}
|
||||
onClick={() => composerRef.current?.setDraft(ex.prompt)}
|
||||
title={t('chat.fillInputTitle')}
|
||||
>
|
||||
<span className="chat-example-icon" aria-hidden>
|
||||
{ex.icon}
|
||||
</span>
|
||||
<span className="chat-example-body">
|
||||
<span className="chat-example-head">
|
||||
<span className="chat-example-title">{ex.title}</span>
|
||||
<span className="chat-example-tag">{ex.tag}</span>
|
||||
</span>
|
||||
<span className="chat-example-body">
|
||||
<span className="chat-example-head">
|
||||
<span className="chat-example-title">{title}</span>
|
||||
<span className="chat-example-tag">{tag}</span>
|
||||
</span>
|
||||
<span className="chat-example-prompt">{prompt}</span>
|
||||
</span>
|
||||
<span className="chat-example-cta" aria-hidden>
|
||||
↵
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="chat-example-prompt">{ex.prompt}</span>
|
||||
</span>
|
||||
<span className="chat-example-cta" aria-hidden>
|
||||
↵
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -619,7 +619,23 @@ export function DesignFilesPanel({
|
|||
</div>
|
||||
) : null}
|
||||
{files.length === 0 && liveArtifacts.length === 0 ? (
|
||||
<div className="df-empty">{t('designFiles.empty')}</div>
|
||||
<div className="df-empty" data-testid="design-files-empty">
|
||||
<div className="df-empty-pill">
|
||||
<span className="df-empty-title">
|
||||
{t('designFiles.empty')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="df-empty-cta"
|
||||
data-testid="design-files-empty-new-sketch"
|
||||
onClick={onNewSketch}
|
||||
title={t('designFiles.newSketch')}
|
||||
>
|
||||
<Icon name="pencil" size={13} />
|
||||
<span>{t('designFiles.newSketch')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{files.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export function NewProjectPanel({
|
|||
const [imageAspect, setImageAspect] = useState<MediaAspect>('1:1');
|
||||
const [imageStyle, setImageStyle] = useState('');
|
||||
const [videoModel, setVideoModel] = useState(DEFAULT_VIDEO_MODEL);
|
||||
const [videoModelTouched, setVideoModelTouched] = useState(false);
|
||||
const [videoAspect, setVideoAspect] = useState<MediaAspect>('16:9');
|
||||
const [videoLength, setVideoLength] = useState(5);
|
||||
const [audioKind, setAudioKind] = useState<AudioKind>('speech');
|
||||
|
|
@ -312,12 +313,76 @@ export function NewProjectPanel({
|
|||
}
|
||||
if (tab === 'image' || tab === 'video' || tab === 'audio') {
|
||||
const list = skills.filter((s) => s.mode === tab || s.surface === tab);
|
||||
// The HyperFrames-HTML render path lives in the `hyperframes` skill.
|
||||
// When the user has chosen `hyperframes-html` (via dropdown or template),
|
||||
// pin the project to that skill explicitly — otherwise this branch falls
|
||||
// back to `list[0]`, which is unsorted (apps/daemon/src/skills.ts builds
|
||||
// the list from `readdir()`), so the project could route through
|
||||
// `video-shortform` while metadata still says `videoModel: 'hyperframes-html'`.
|
||||
if (tab === 'video' && videoModel === 'hyperframes-html') {
|
||||
const hyper = list.find((s) => s.id === 'hyperframes');
|
||||
if (hyper) return hyper.id;
|
||||
}
|
||||
return list.find((s) => s.defaultFor.includes(tab))?.id
|
||||
?? list[0]?.id
|
||||
?? null;
|
||||
}
|
||||
return null;
|
||||
}, [tab, skills]);
|
||||
}, [tab, skills, videoModel]);
|
||||
|
||||
// When the user picks a curated prompt template, propagate the template's
|
||||
// declared `model` and `aspect` onto the actual project state. Without
|
||||
// this the user picks (e.g.) a HyperFrames template but `videoModel`
|
||||
// stays on the default seedance — the agent then dispatches the wrong
|
||||
// model and the render path mismatches the prompt.
|
||||
function handleImagePromptTemplate(pick: PromptTemplatePick | null) {
|
||||
setImagePromptTemplate(pick);
|
||||
const m = pick?.summary.model;
|
||||
if (m && IMAGE_MODELS.some((x) => x.id === m)) setImageModel(m);
|
||||
const a = pick?.summary.aspect;
|
||||
if (a && (MEDIA_ASPECTS as readonly string[]).includes(a)) {
|
||||
setImageAspect(a as MediaAspect);
|
||||
}
|
||||
}
|
||||
function handleVideoPromptTemplate(pick: PromptTemplatePick | null) {
|
||||
setVideoPromptTemplate(pick);
|
||||
const m = pick?.summary.model;
|
||||
if (m && VIDEO_MODELS.some((x) => x.id === m)) {
|
||||
setVideoModel(m);
|
||||
setVideoModelTouched(true);
|
||||
}
|
||||
const a = pick?.summary.aspect;
|
||||
if (a && (MEDIA_ASPECTS as readonly string[]).includes(a)) {
|
||||
setVideoAspect(a as MediaAspect);
|
||||
}
|
||||
}
|
||||
function handleVideoModel(id: string) {
|
||||
setVideoModel(id);
|
||||
setVideoModelTouched(true);
|
||||
}
|
||||
|
||||
// The HyperFrames skill renders HTML compositions through a local
|
||||
// `npx hyperframes render` path, which dispatches under the
|
||||
// `hyperframes-html` model — not seedance/veo/sora. When the resolved
|
||||
// skill for the video tab is hyperframes, default `videoModel` so the
|
||||
// model dropdown matches the actual render path. Once the user has
|
||||
// explicitly chosen a model (via the dropdown or by picking a template
|
||||
// that declares a model), `videoModelTouched` latches and this effect
|
||||
// becomes a no-op for the rest of the panel session — re-entering the
|
||||
// Video tab no longer silently rewrites their override back to
|
||||
// hyperframes-html.
|
||||
useEffect(() => {
|
||||
if (tab !== 'video') return;
|
||||
if (skillIdForTab !== 'hyperframes') return;
|
||||
if (videoModelTouched) return;
|
||||
if (videoPromptTemplate) return;
|
||||
if (!VIDEO_MODELS.some((m) => m.id === 'hyperframes-html')) return;
|
||||
setVideoModel('hyperframes-html');
|
||||
// Intentionally leaving videoPromptTemplate / videoModel out of deps
|
||||
// so this only fires when the user toggles the tab or the skill
|
||||
// resolution shifts — not whenever the user changes the dropdown.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, skillIdForTab, videoModelTouched]);
|
||||
|
||||
const canCreate =
|
||||
!loading && (tab !== 'template' || templateId != null);
|
||||
|
|
@ -548,7 +613,7 @@ export function NewProjectPanel({
|
|||
surface="image"
|
||||
templates={promptTemplates}
|
||||
value={imagePromptTemplate}
|
||||
onChange={setImagePromptTemplate}
|
||||
onChange={handleImagePromptTemplate}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -557,7 +622,7 @@ export function NewProjectPanel({
|
|||
surface="video"
|
||||
templates={promptTemplates}
|
||||
value={videoPromptTemplate}
|
||||
onChange={setVideoPromptTemplate}
|
||||
onChange={handleVideoPromptTemplate}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -618,7 +683,7 @@ export function NewProjectPanel({
|
|||
videoAspect={videoAspect}
|
||||
videoLength={videoLength}
|
||||
mediaProviders={mediaProviders}
|
||||
onVideoModel={setVideoModel}
|
||||
onVideoModel={handleVideoModel}
|
||||
onVideoAspect={setVideoAspect}
|
||||
onVideoLength={setVideoLength}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -487,9 +487,19 @@ export const FR_PROMPT_TEMPLATE_CATEGORIES: Record<string, string> = {
|
|||
'Short Form': 'Short form',
|
||||
Travel: 'Voyage',
|
||||
'Live Artifact': 'Live artifact',
|
||||
'VFX / HTML-in-Canvas': 'VFX / HTML-in-Canvas',
|
||||
};
|
||||
|
||||
export const FR_PROMPT_TEMPLATE_IDS_WITH_EN_FALLBACK = ['notion-team-dashboard-live-artifact'] as const;
|
||||
export const FR_PROMPT_TEMPLATE_IDS_WITH_EN_FALLBACK = [
|
||||
'notion-team-dashboard-live-artifact',
|
||||
'hyperframes-html-in-canvas-iphone-device',
|
||||
'hyperframes-html-in-canvas-liquid-background',
|
||||
'hyperframes-html-in-canvas-liquid-glass',
|
||||
'hyperframes-html-in-canvas-magnetic',
|
||||
'hyperframes-html-in-canvas-portal-reveal',
|
||||
'hyperframes-html-in-canvas-shatter',
|
||||
'hyperframes-html-in-canvas-text-cursor',
|
||||
] as const;
|
||||
|
||||
export const FR_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
|
||||
'3d': '3D',
|
||||
|
|
@ -590,6 +600,25 @@ export const FR_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
|
|||
'website-to-video': 'website-to-video',
|
||||
wuxia: 'wuxia',
|
||||
zhaoyun: 'Zhaoyun',
|
||||
dashboard: 'dashboard',
|
||||
data: 'data',
|
||||
destruction: 'destruction',
|
||||
displacement: 'displacement',
|
||||
hero: 'hero',
|
||||
'html-in-canvas': 'HTML-in-Canvas',
|
||||
iphone: 'iPhone',
|
||||
keynote: 'keynote',
|
||||
liquid: 'liquide',
|
||||
'liquid-glass': 'liquid glass',
|
||||
macbook: 'MacBook',
|
||||
magnetic: 'magnétique',
|
||||
particles: 'particules',
|
||||
portal: 'portail',
|
||||
'product-demo': 'démo produit',
|
||||
shader: 'shader',
|
||||
shatter: 'shatter',
|
||||
text: 'texte',
|
||||
webgl: 'WebGL',
|
||||
};
|
||||
|
||||
export const FR_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary, 'summary' | 'title'>>> = {
|
||||
|
|
|
|||
|
|
@ -487,9 +487,19 @@ export const RU_PROMPT_TEMPLATE_CATEGORIES: Record<string, string> = {
|
|||
'Short Form': 'Короткий формат',
|
||||
Travel: 'Путешествия',
|
||||
'Live Artifact': 'Live-артефакт',
|
||||
'VFX / HTML-in-Canvas': 'VFX / HTML-in-Canvas',
|
||||
};
|
||||
|
||||
export const RU_PROMPT_TEMPLATE_IDS_WITH_EN_FALLBACK = ['notion-team-dashboard-live-artifact'] as const;
|
||||
export const RU_PROMPT_TEMPLATE_IDS_WITH_EN_FALLBACK = [
|
||||
'notion-team-dashboard-live-artifact',
|
||||
'hyperframes-html-in-canvas-iphone-device',
|
||||
'hyperframes-html-in-canvas-liquid-background',
|
||||
'hyperframes-html-in-canvas-liquid-glass',
|
||||
'hyperframes-html-in-canvas-magnetic',
|
||||
'hyperframes-html-in-canvas-portal-reveal',
|
||||
'hyperframes-html-in-canvas-shatter',
|
||||
'hyperframes-html-in-canvas-text-cursor',
|
||||
] as const;
|
||||
|
||||
export const RU_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
|
||||
'3d': '3D',
|
||||
|
|
@ -590,6 +600,25 @@ export const RU_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
|
|||
'website-to-video': 'Сайт-в-видео',
|
||||
wuxia: 'Уся',
|
||||
zhaoyun: 'Чжао Юнь',
|
||||
dashboard: 'Дашборд',
|
||||
data: 'Данные',
|
||||
destruction: 'Разрушение',
|
||||
displacement: 'Смещение',
|
||||
hero: 'Главный герой',
|
||||
'html-in-canvas': 'HTML-в-Canvas',
|
||||
iphone: 'iPhone',
|
||||
keynote: 'Keynote',
|
||||
liquid: 'Жидкость',
|
||||
'liquid-glass': 'Liquid Glass',
|
||||
macbook: 'MacBook',
|
||||
magnetic: 'Магнитный',
|
||||
particles: 'Частицы',
|
||||
portal: 'Портал',
|
||||
'product-demo': 'Демо продукта',
|
||||
shader: 'Шейдер',
|
||||
shatter: 'Раскалывание',
|
||||
text: 'Текст',
|
||||
webgl: 'WebGL',
|
||||
};
|
||||
|
||||
export const RU_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary, 'summary' | 'title'>>> = {
|
||||
|
|
|
|||
|
|
@ -536,9 +536,19 @@ const DE_PROMPT_TEMPLATE_CATEGORIES: Record<string, string> = {
|
|||
'Short Form': 'Short Form',
|
||||
Travel: 'Reise',
|
||||
'Live Artifact': 'Live-Artefakt',
|
||||
'VFX / HTML-in-Canvas': 'VFX / HTML-in-Canvas',
|
||||
};
|
||||
|
||||
const DE_PROMPT_TEMPLATE_IDS_WITH_EN_FALLBACK = ['notion-team-dashboard-live-artifact'] as const;
|
||||
const DE_PROMPT_TEMPLATE_IDS_WITH_EN_FALLBACK = [
|
||||
'notion-team-dashboard-live-artifact',
|
||||
'hyperframes-html-in-canvas-iphone-device',
|
||||
'hyperframes-html-in-canvas-liquid-background',
|
||||
'hyperframes-html-in-canvas-liquid-glass',
|
||||
'hyperframes-html-in-canvas-magnetic',
|
||||
'hyperframes-html-in-canvas-portal-reveal',
|
||||
'hyperframes-html-in-canvas-shatter',
|
||||
'hyperframes-html-in-canvas-text-cursor',
|
||||
] as const;
|
||||
|
||||
const DE_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
|
||||
'3d': '3D',
|
||||
|
|
@ -639,6 +649,25 @@ const DE_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
|
|||
'website-to-video': 'Website-zu-Video',
|
||||
wuxia: 'Wuxia',
|
||||
zhaoyun: 'Zhaoyun',
|
||||
dashboard: 'Dashboard',
|
||||
data: 'Daten',
|
||||
destruction: 'Zerstörung',
|
||||
displacement: 'Displacement',
|
||||
hero: 'Hero',
|
||||
'html-in-canvas': 'HTML-in-Canvas',
|
||||
iphone: 'iPhone',
|
||||
keynote: 'Keynote',
|
||||
liquid: 'Liquid',
|
||||
'liquid-glass': 'Liquid Glass',
|
||||
macbook: 'MacBook',
|
||||
magnetic: 'Magnetic',
|
||||
particles: 'Partikel',
|
||||
portal: 'Portal',
|
||||
'product-demo': 'Produkt-Demo',
|
||||
shader: 'Shader',
|
||||
shatter: 'Shatter',
|
||||
text: 'Text',
|
||||
webgl: 'WebGL',
|
||||
};
|
||||
|
||||
const DE_PROMPT_TEMPLATE_COPY: Record<string, LocalizedPromptTemplateCopy> = {
|
||||
|
|
|
|||
|
|
@ -629,8 +629,7 @@ export const ar: Dict = {
|
|||
'designFiles.upload': 'رفع ملفات',
|
||||
'designFiles.pasteText': 'لصق كملف نصي',
|
||||
'designFiles.newSketch': 'رسم جديد',
|
||||
'designFiles.empty':
|
||||
'لا يوجد شيء هنا بعد. اسحب الملفات أدناه، أو أنشئ رسماً / الصق نصاً.',
|
||||
'designFiles.empty': 'ستظهر الإبداعات هنا',
|
||||
'designFiles.refresh': 'تحديث',
|
||||
'designFiles.delete': 'حذف',
|
||||
'designFiles.deleteSelected': 'حذف {n}',
|
||||
|
|
|
|||
|
|
@ -517,8 +517,7 @@ export const de: Dict = {
|
|||
'designFiles.upload': 'Dateien hochladen',
|
||||
'designFiles.pasteText': 'Als Textdatei einfügen',
|
||||
'designFiles.newSketch': 'Neuer Sketch',
|
||||
'designFiles.empty':
|
||||
'Noch nichts hier. Legen Sie unten Dateien ab oder erstellen Sie einen Sketch / fügen Sie Text ein.',
|
||||
'designFiles.empty': 'Kreationen erscheinen hier',
|
||||
'designFiles.refresh': 'Aktualisieren',
|
||||
'designFiles.delete': 'Löschen',
|
||||
'designFiles.searchPlaceholder': 'Dateien suchen…',
|
||||
|
|
|
|||
|
|
@ -696,8 +696,7 @@ export const en: Dict = {
|
|||
'designFiles.upload': 'Upload files',
|
||||
'designFiles.pasteText': 'Paste as text file',
|
||||
'designFiles.newSketch': 'New sketch',
|
||||
'designFiles.empty':
|
||||
'Nothing here yet. Drop files below, or create a sketch / paste text.',
|
||||
'designFiles.empty': 'Creations will appear here',
|
||||
'designFiles.refresh': 'Refresh',
|
||||
'designFiles.delete': 'Delete',
|
||||
'designFiles.searchPlaceholder': 'Search files…',
|
||||
|
|
|
|||
|
|
@ -518,8 +518,7 @@ export const esES: Dict = {
|
|||
'designFiles.upload': 'Subir archivos',
|
||||
'designFiles.pasteText': 'Pegar como archivo de texto',
|
||||
'designFiles.newSketch': 'Nuevo boceto',
|
||||
'designFiles.empty':
|
||||
'Aquí no hay nada todavía. Suelta archivos abajo, o crea un boceto / pega texto.',
|
||||
'designFiles.empty': 'Las creaciones aparecerán aquí',
|
||||
'designFiles.refresh': 'Actualizar',
|
||||
'designFiles.delete': 'Eliminar',
|
||||
'designFiles.searchPlaceholder': 'Buscar archivos…',
|
||||
|
|
|
|||
|
|
@ -642,8 +642,7 @@ export const fa: Dict = {
|
|||
'designFiles.upload': 'آپلود فایلها',
|
||||
'designFiles.pasteText': 'چسباندن به عنوان فایل متنی',
|
||||
'designFiles.newSketch': 'طرح جدید',
|
||||
'designFiles.empty':
|
||||
'هنوز هیچ چیزی اینجا نیست. فایلها را رها کنید، یا یک طرح ایجاد کنید / متن بچسبانید.',
|
||||
'designFiles.empty': 'آفرینشها اینجا نمایش داده میشوند',
|
||||
'designFiles.refresh': 'بازنشانی',
|
||||
'designFiles.delete': 'حذف',
|
||||
'designFiles.deleteSelected': 'حذف {n}',
|
||||
|
|
|
|||
|
|
@ -629,8 +629,7 @@ export const fr: Dict = {
|
|||
'designFiles.upload': 'Téléverser des fichiers',
|
||||
'designFiles.pasteText': 'Coller comme fichier texte',
|
||||
'designFiles.newSketch': 'Nouveau croquis',
|
||||
'designFiles.empty':
|
||||
'Rien ici pour l\'instant. Déposez des fichiers ci-dessous, ou créez un croquis / collez du texte.',
|
||||
'designFiles.empty': 'Les créations apparaîtront ici',
|
||||
'designFiles.refresh': 'Actualiser',
|
||||
'designFiles.delete': 'Supprimer',
|
||||
'designFiles.searchPlaceholder': 'Rechercher des fichiers…',
|
||||
|
|
|
|||
|
|
@ -629,8 +629,7 @@ export const hu: Dict = {
|
|||
'designFiles.upload': 'Fájlok feltöltése',
|
||||
'designFiles.pasteText': 'Beillesztés szövegfájlként',
|
||||
'designFiles.newSketch': 'Új vázlat',
|
||||
'designFiles.empty':
|
||||
'Még nincs itt semmi. Húzz be fájlokat, vagy hozz létre vázlatot / illessz be szöveget.',
|
||||
'designFiles.empty': 'A kreációk itt jelennek meg',
|
||||
'designFiles.refresh': 'Frissítés',
|
||||
'designFiles.delete': 'Törlés',
|
||||
'designFiles.searchPlaceholder': 'Fájlok keresése…',
|
||||
|
|
|
|||
|
|
@ -516,8 +516,7 @@ export const ja: Dict = {
|
|||
'designFiles.upload': 'ファイルをアップロード',
|
||||
'designFiles.pasteText': 'テキストファイルとして貼り付け',
|
||||
'designFiles.newSketch': '新しいスケッチ',
|
||||
'designFiles.empty':
|
||||
'まだ何もありません。以下にファイルをドロップするか、スケッチを作成するかテキストを貼り付けてください。',
|
||||
'designFiles.empty': 'ここに作品が表示されます',
|
||||
'designFiles.refresh': '更新',
|
||||
'designFiles.delete': '削除',
|
||||
'designFiles.searchPlaceholder': 'ファイルを検索…',
|
||||
|
|
|
|||
|
|
@ -629,8 +629,7 @@ export const ko: Dict = {
|
|||
'designFiles.upload': '파일 업로드',
|
||||
'designFiles.pasteText': '텍스트 파일로 붙여넣기',
|
||||
'designFiles.newSketch': '새 스케치',
|
||||
'designFiles.empty':
|
||||
'아직 파일이 없습니다. 여기에 파일을 끌어다 놓거나, 스케치를 생성하거나, 텍스트를 붙여넣으세요.',
|
||||
'designFiles.empty': '여기에 작업물이 표시됩니다',
|
||||
'designFiles.refresh': '새로고침',
|
||||
'designFiles.delete': '삭제',
|
||||
'designFiles.searchPlaceholder': '파일 검색…',
|
||||
|
|
|
|||
|
|
@ -629,8 +629,7 @@ export const pl: Dict = {
|
|||
'designFiles.upload': 'Prześlij pliki',
|
||||
'designFiles.pasteText': 'Wklej jako plik tekstowy',
|
||||
'designFiles.newSketch': 'Nowy szkic',
|
||||
'designFiles.empty':
|
||||
'Nic tu jeszcze nie ma. Przeciągnij pliki poniżej lub utwórz szkic / wklej tekst.',
|
||||
'designFiles.empty': 'Twoje kreacje pojawią się tutaj',
|
||||
'designFiles.refresh': 'Odśwież',
|
||||
'designFiles.delete': 'Usuń',
|
||||
'designFiles.searchPlaceholder': 'Szukaj plików…',
|
||||
|
|
|
|||
|
|
@ -641,8 +641,7 @@ export const ptBR: Dict = {
|
|||
'designFiles.upload': 'Enviar arquivos',
|
||||
'designFiles.pasteText': 'Colar como arquivo de texto',
|
||||
'designFiles.newSketch': 'Novo esboço',
|
||||
'designFiles.empty':
|
||||
'Nada aqui ainda. Arraste arquivos abaixo, crie um esboço ou cole texto.',
|
||||
'designFiles.empty': 'As criações aparecerão aqui',
|
||||
'designFiles.refresh': 'Atualizar',
|
||||
'designFiles.delete': 'Excluir',
|
||||
'designFiles.searchPlaceholder': 'Buscar arquivos…',
|
||||
|
|
|
|||
|
|
@ -641,8 +641,7 @@ export const ru: Dict = {
|
|||
'designFiles.upload': 'Загрузить файлы',
|
||||
'designFiles.pasteText': 'Вставить как текстовый файл',
|
||||
'designFiles.newSketch': 'Новый эскиз',
|
||||
'designFiles.empty':
|
||||
'Здесь пока ничего нет. Перетащите файлы ниже или создайте эскиз / вставьте текст.',
|
||||
'designFiles.empty': 'Здесь появятся ваши работы',
|
||||
'designFiles.refresh': 'Обновить',
|
||||
'designFiles.delete': 'Удалить',
|
||||
'designFiles.searchPlaceholder': 'Поиск файлов…',
|
||||
|
|
|
|||
|
|
@ -620,8 +620,7 @@ export const tr: Dict = {
|
|||
'designFiles.upload': 'Dosyaları yükle',
|
||||
'designFiles.pasteText': 'Metin dosyası olarak yapıştır',
|
||||
'designFiles.newSketch': 'Yeni taslak',
|
||||
'designFiles.empty':
|
||||
'Burada henüz bir şey yok. Dosyaları aşağı sürükleyin, veya bir taslak oluşturun / metin yapıştırın.',
|
||||
'designFiles.empty': 'Eserler burada görünecek',
|
||||
'designFiles.refresh': 'Yenile',
|
||||
'designFiles.delete': 'Sil',
|
||||
'designFiles.searchPlaceholder': 'Dosyaları ara…',
|
||||
|
|
|
|||
|
|
@ -642,8 +642,7 @@ export const uk: Dict = {
|
|||
'designFiles.upload': 'Завантажити файли',
|
||||
'designFiles.pasteText': 'Вставити як текстовий файл',
|
||||
'designFiles.newSketch': 'Новий ескіз',
|
||||
'designFiles.empty':
|
||||
'Тут нічого немає. Перенесіть файли нижче, або створіть ескіз / вставте текст.',
|
||||
'designFiles.empty': 'Тут з\'являться ваші роботи',
|
||||
'designFiles.refresh': 'Оновити',
|
||||
'designFiles.delete': 'Видалити',
|
||||
'designFiles.searchPlaceholder': 'Пошук файлів…',
|
||||
|
|
|
|||
|
|
@ -687,7 +687,7 @@ export const zhCN: Dict = {
|
|||
'designFiles.upload': '上传文件',
|
||||
'designFiles.pasteText': '粘贴为文本文件',
|
||||
'designFiles.newSketch': '新建草图',
|
||||
'designFiles.empty': '这里还没有文件。可以拖拽下方区域,或新建草图、粘贴文本。',
|
||||
'designFiles.empty': '生成的设计会出现在这里',
|
||||
'designFiles.refresh': '刷新',
|
||||
'designFiles.delete': '删除',
|
||||
'designFiles.searchPlaceholder': '搜索文件…',
|
||||
|
|
|
|||
|
|
@ -680,7 +680,7 @@ export const zhTW: Dict = {
|
|||
'designFiles.upload': '上傳圖片',
|
||||
'designFiles.pasteText': '貼上為文字檔案',
|
||||
'designFiles.newSketch': '新建草圖',
|
||||
'designFiles.empty': '這裡還沒有檔案。可以拖曳下方區域,或新建草圖、貼上文字。',
|
||||
'designFiles.empty': '生成的設計會出現在這裡',
|
||||
'designFiles.refresh': '重新整理',
|
||||
'designFiles.delete': '刪除',
|
||||
'designFiles.searchPlaceholder': '搜尋檔案…',
|
||||
|
|
|
|||
|
|
@ -501,29 +501,40 @@ code {
|
|||
}
|
||||
.app-project-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
max-height: 32px;
|
||||
max-height: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-project-title .title {
|
||||
color: var(--text-strong);
|
||||
font-size: 13.5px;
|
||||
line-height: 16px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.app-project-title .meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 15px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.app-project-title .meta::before {
|
||||
content: '·';
|
||||
margin: 0 8px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
|
|
@ -7254,6 +7265,8 @@ button.connector-action.is-loading {
|
|||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.df-group-toggle {
|
||||
margin: 0 20px 8px;
|
||||
|
|
@ -7667,10 +7680,67 @@ button.connector-action.is-loading {
|
|||
cursor: pointer;
|
||||
}
|
||||
.df-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
min-height: 320px;
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
linear-gradient(to right, var(--border-soft) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--border-soft) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position: -1px -1px;
|
||||
}
|
||||
.df-empty-pill {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 28px 36px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(15, 15, 15, 0.02),
|
||||
0 8px 24px -12px rgba(15, 15, 15, 0.08);
|
||||
max-width: min(420px, 92%);
|
||||
text-align: center;
|
||||
}
|
||||
.df-empty-title {
|
||||
font-size: 16px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.df-empty-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.df-empty-cta:hover {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.df-empty-cta:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.df-drop {
|
||||
|
|
|
|||
|
|
@ -446,4 +446,76 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('pins skillId to hyperframes when the video model is hyperframes-html, regardless of skill discovery order', () => {
|
||||
// Reproduces PR #866 mrcfps's reported regression: when daemon `readdir()`
|
||||
// returns video skills in an order that puts `video-shortform` ahead of
|
||||
// `hyperframes`, the previous `list[0]?.id` fallback would route the
|
||||
// HyperFrames-HTML model through `video-shortform`, dropping the
|
||||
// hyperframes SKILL body and the html-in-canvas preflight. The fix forces
|
||||
// the create-time skillId to `hyperframes` whenever `hyperframes-html` is
|
||||
// the chosen model.
|
||||
const onCreate = vi.fn();
|
||||
const videoSkills: SkillSummary[] = [
|
||||
{
|
||||
id: 'video-shortform',
|
||||
name: 'Video shortform',
|
||||
description: 'Shortform video skill',
|
||||
mode: 'video',
|
||||
surface: 'video',
|
||||
previewType: 'video',
|
||||
designSystemRequired: false,
|
||||
defaultFor: [],
|
||||
triggers: [],
|
||||
upstream: null,
|
||||
hasBody: true,
|
||||
examplePrompt: '',
|
||||
aggregatesExamples: false,
|
||||
},
|
||||
{
|
||||
id: 'hyperframes',
|
||||
name: 'HyperFrames',
|
||||
description: 'HTML-in-canvas video',
|
||||
mode: 'video',
|
||||
surface: 'video',
|
||||
previewType: 'video',
|
||||
designSystemRequired: false,
|
||||
defaultFor: [],
|
||||
triggers: [],
|
||||
upstream: null,
|
||||
hasBody: true,
|
||||
examplePrompt: '',
|
||||
aggregatesExamples: false,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={videoSkills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Video' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /hyperframes-html/i }));
|
||||
fireEvent.change(screen.getByTestId('new-project-name'), {
|
||||
target: { value: 'HyperFrames routing' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'HyperFrames routing',
|
||||
skillId: 'hyperframes',
|
||||
metadata: expect.objectContaining({
|
||||
kind: 'video',
|
||||
videoModel: 'hyperframes-html',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -358,6 +358,9 @@ function derivePreflight(skillBody: string): string {
|
|||
if (/references\/refresh-contract\.md|refresh-contract\.md/.test(skillBody)) {
|
||||
refs.push('`references/refresh-contract.md`');
|
||||
}
|
||||
if (/references\/html-in-canvas\.md|html-in-canvas\.md/.test(skillBody)) {
|
||||
refs.push('`references/html-in-canvas.md`');
|
||||
}
|
||||
if (refs.length === 0) return '';
|
||||
return ` **Pre-flight (do this before any other tool):** Read ${refs.join(', ')} via the path written in the skill-root preamble. If the skill asks for daemon wrapper commands, use the runtime tool environment documented below; it provides the daemon URL and whether a run-scoped tool token is available without exposing token internals. The seed template defines the class system you'll paste into; the layouts file is the only acceptable source of section/screen/slide skeletons; the checklist and live-artifact references are your validation gate before emitting \`<artifact>\` or registering a live artifact. Skipping this step is the #1 reason output regresses to generic AI-slop.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-iphone-device",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: 3D iPhone + MacBook Product Demo",
|
||||
"summary": "A 15-second product demo where a real GLTF iPhone 15 Pro Max and MacBook Pro float on a clean stage with the actual app UI rendering live on their screens via drawElementImage. Morphing glass lens flare + 360° turntable. Built on the vfx-iphone-device catalog block.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "3d", "iphone", "macbook", "product-demo"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build a 15-second HyperFrames composition (1920×1080, 30fps) titled \"device-product-demo\". Pull the catalog block first: `npx hyperframes add vfx-iphone-device`. The block ships the iPhone and MacBook GLTFs plus screen-content placeholders.\n\nVisual identity: ultra-clean studio canvas with a vertical gradient #f7f8fb → #eaecf2, single primary brand color the user supplies (default #4f46e5), muted ink #0f172a for type. Display face: \"Söhne Halbfett\" 96px for the headline; body \"Inter Tight\" 28px (for video sizing); mono \"JetBrains Mono\" 20px on UI bits. Subtle floor-shadow blob beneath each device.\n\nReplace the block's two screen placeholders with the user's actual product surfaces:\n• `mobile-screen` — a `<canvas layoutsubtree width=\"1170\" height=\"2532\">` containing the real iOS app UI as DOM (status bar, large title, primary list, bottom tab bar). Use real semantic HTML and CSS so the captured texture is pixel-perfect.\n• `desktop-screen` — a `<canvas layoutsubtree width=\"2880\" height=\"1800\">` containing the macOS app UI (sidebar, main pane, command palette overlay).\n\nTimeline (paused: true, registered window.__timelines.main, data-duration=15):\n\n0.0–4.5s ENTRY — both devices fly in: iPhone from camera-left (x:-600, rotateY:35°→0°, scale 0.85→1.0, ease expo.out 1.1s), MacBook from camera-right with a 0.25s stagger (x:+800, rotateY:-28°→0°, ease expo.out 1.2s). Headline \"Your work. Anywhere.\" types in via `gsap.from(\".headline\", { y: 60, opacity: 0, duration: 0.7, ease: 'power3.out' }, 1.0)`. Subhead at 1.6s.\n\n4.5–10.5s TURNTABLE — both devices rotate slowly +18° on Y in lock-step (ease sine.inOut). Re-blit `drawElementImage` every frame so the captured screen content stays sharp under the moving normal map. Add the morphing glass-lens flare overlay (block-supplied) sweeping camera-left to camera-right between 6.0s–9.5s. At 7.5s, animate one in-app interaction inside `mobile-screen` (a list item slides in, then a checkmark scales 0→1) — the texture re-capture picks this up automatically.\n\n10.5–15.0s OUTRO — devices ease back to neutral (rotateY 0°, x 0). Headline morphs to a single CTA line \"download today\" with marker-sweep highlight using `references/css-patterns.md`. Final 0.6s holds the hero frame — no opacity-to-0 exits.\n\nFeature detection: gracefully fall back to flat 2D screenshots on browsers without `drawElementImage`. The renderer enables the flag automatically; Studio preview without it must not be black.\n\nNon-negotiables: GLTF assets stay at the block's default paths (`models/iphone.glb`, `models/macbook.glb`); only swap the screen DOM content, not the model URIs. Deterministic — no Math.random / Date.now, no `repeat: -1`. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 10 --at 1,3,5,7.5,10,13` before render. Output: `device-product-demo.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-iphone-device.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-iphone-device.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-iphone-device"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-liquid-background",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: Liquid Background Hero",
|
||||
"summary": "A 12-second hero with HTML content floating above an organic liquid surface — vertex-displaced subdivided plane, real-time wave dynamics, captured DOM rides on top crisp and readable. Built on the vfx-liquid-background catalog block.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "liquid", "displacement", "hero"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build a 12-second HyperFrames composition (1920×1080, 30fps) titled \"liquid-background-hero\". Pull the catalog block first: `npx hyperframes add vfx-liquid-background`.\n\nVisual identity: oceanic canvas — base #0a1f2c flowing into accent #1cd6c4 and secondary #4a7dff for the liquid surface, off-white type #f6fbff for the floating HTML. Display face: \"PP Right Grotesk\" Compact Bold 156px; body \"PP Right Grotesk\" Regular 26px; numerals tabular. The liquid is the painting; the type is the subtitle.\n\nLayer A — `liquid` (background): the vfx-liquid-background WebGL canvas with the block's default subdivided plane, vertex displacement amplitude 0.18, wave speed 0.42, two superposed wave functions (period 1.6s and 4.2s) for organic motion. Color gradient flows base → accent → secondary across the surface, deterministic phase from `seed = mulberry32(2046)`.\n\nLayer B — `hero-content`: a `<canvas layoutsubtree width=\"1920\" height=\"1080\">` containing centered DOM — a single hero headline (\"every drop, accounted for\"), a one-line subhead (\"realtime water analytics across 412 sensors\"), a small numeric ticker (\"412\" tabular-nums) that increments slightly over the composition. Captured every frame and composited above the liquid layer with mix-blend-mode normal at z-index 2.\n\nTimeline (paused: true, window.__timelines.main, data-duration=12):\n\n0.0–2.0s ENTRY — liquid plane starts flat (displacement amplitude 0 → 0.18 over 1.8s ease expo.out), color flow ramps from monochrome → full gradient over 1.6s. Hero content fades in at 0.6s (`gsap.from('.headline', { y: 50, opacity: 0, duration: 0.8, ease: 'power3.out' })`, subhead at 1.0s with y:30, ticker at 1.4s scale 0.9 → 1.0).\n\n2.0–9.5s FLOAT — full motion. Liquid waves continue indefinitely-but-finitely (calculate exact repeat count from data-duration / cycle so the timeline never uses `repeat: -1`). Ticker counts 412 → 437 over 7.5s ease none, tabular-nums on every digit. At 5.0s, a gentle highlight sweep crosses the hero headline (use `references/css-patterns.md` marker sweep). At 7.0s, a soft caustic flicker on the liquid layer (deterministic seeded brightness pulse).\n\n9.5–12.0s SETTLE — wave amplitude tapers 0.18 → 0.10, color flow slows. Hero content holds. Final 0.6s holds the hero frame; no opacity-to-0 exits except optionally a 0.3s liquid-only opacity fade if the user is chaining it before another scene.\n\nFeature detection: if `drawElementImage` is unavailable, layer the hero DOM directly on a static gradient backdrop (#0a1f2c → #1cd6c4 radial-gradient) without the liquid shader. Never show a black canvas.\n\nNon-negotiables: deterministic seeds, no `repeat: -1` (use Math.ceil-based finite repeats), no async timeline construction, min font 60px on headline, 24px on subhead, tabular-nums on the ticker. The liquid timeline and the hero-content timeline both register on `window.__timelines.main` — do not split into sub-compositions unless the user asks for it. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 10 --at 0.5,2,4,6,8,11` before render. Output: `liquid-background-hero.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-background.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-background.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-liquid-background"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-liquid-glass",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: Liquid Glass Landing Reveal",
|
||||
"summary": "A 20-second voronoi liquid-glass reveal of a real product landing page — DOM is captured live via drawElementImage, shattered into refracting glass cells, then settles into a clean hero shot. Built on the vfx-liquid-glass catalog block.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "liquid-glass", "webgl", "product-promo"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build a 20-second HyperFrames composition (1920×1080, 30fps) titled \"liquid-glass-landing-reveal\". Pull the catalog block first: `npx hyperframes add vfx-liquid-glass` (or `npx hyperframes add html-in-canvas` to grab the full set).\n\nVisual identity: deep-space canvas #0a0d14, single warm accent #ff8a4c for refraction tint, secondary cool #6cf3c0 for chromatic edge highlights, off-white text #f5f7fa. Display face: \"Migra\" 140px for the headline; body \"Söhne\" 24px; mono \"JetBrains Mono\" 18px. Tabular-nums on any numerals.\n\nTwo nested compositions:\n\n1. `landing-source` — a `<canvas layoutsubtree width=\"1920\" height=\"1080\">` containing the actual product landing-page DOM: a tall hero (\"Ship faster. Stay calm.\"), a 3-column feature grid with rounded cards using `backdrop-filter: blur(24px)`, a soft radial accent glow behind the headline, and a CTA pill (\"start free\"). Real CSS, real web fonts — this gets captured live by the WebGL pass.\n2. `theater` — the WebGL canvas that runs the vfx-liquid-glass shader on the captured texture.\n\nTimeline (single root composition, paused: true, registered as window.__timelines.main, data-duration=20):\n\n0.0–6.0s ENTRY — voronoi cells start fully fractured at scale 1.6, opacity 0.2, with strong chromatic aberration (0.04 offset). Easing: expo.out. Cells coalesce inward, refraction strength tapers from 1.0 → 0.18, parallax reveals the underlying landing-page shot.\n\n6.0–13.5s SETTLE — full landing page resolves crisp behind a thin glass sheen. Add a slow lateral parallax (drift the texture x by 24px over 7s, ease sine.inOut). Tween a subtle highlight sweep across the hero using the `shimmer-sweep` component at 9.0s.\n\n13.5–20.0s OUTRO — gentle re-fracture: refraction strength 0.18 → 0.42, voronoi scale 1.0 → 1.08, slight rotation 0 → -2deg, opacity hold. Final 0.6s fades the entire theater canvas to black via the root container only — never animate the source DOM out.\n\nFeature detection (mandatory): inside the theater script tag, before instantiating Three.js, check `('layoutSubtree' in document.createElement('canvas')) && typeof CanvasRenderingContext2D.prototype.drawElementImage === 'function'`. If false, hide the theater canvas and let the source DOM display directly — the rendered MP4 always has the flag enabled, but Studio preview without it must not show black.\n\nNon-negotiables: deterministic motion (seeded mulberry32 if any noise needed), no Math.random / Date.now, no `repeat: -1`, no async timeline construction. All entrances via `gsap.from()` against the hero CSS layout. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 12 --at 2,4,6,9,13,17` before render. Output: `liquid-glass-landing-reveal.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-glass.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-liquid-glass.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-liquid-glass"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-magnetic",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: Magnetic Field Visualisation",
|
||||
"summary": "A 15-second magnetic-field particle visualisation reacting to a live DOM heatmap or chart — particles trace field lines that bend around the captured HTML, ideal for ML/data products. Built on the vfx-magnetic catalog block.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "magnetic", "particles", "data"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build a 15-second HyperFrames composition (1920×1080, 30fps) titled \"magnetic-field-data\". Pull the catalog block first: `npx hyperframes add vfx-magnetic`.\n\nVisual identity: graphite canvas #0a0c11, single primary particle color #ff6a00 with a cool secondary #38e6ff for opposing-pole particles, off-white text #eef2f7. Display face: \"PP Neue Machina\" 124px Plain Ultrabold; body \"PP Mondwest\" Regular 24px; mono \"Berkeley Mono\" 18px on labels. Tabular-nums on every numeral.\n\nLayer A — `field-source`: a `<canvas layoutsubtree width=\"1920\" height=\"1080\">` containing real DOM that drives the magnetic field — a centered chart card (760×520px) with a live SVG line chart (12 data points, animated path), two KPI labels above (\"signal strength\" 0.84 and \"coverage\" 92%), and a small mono caption beneath (\"polling 412 nodes · realtime\"). The chart card acts as the magnetic source — particles repel from its bounds, attract toward dipole anchors at the chart's two endpoints.\n\nLayer B — `theater`: WebGL canvas running the vfx-magnetic shader. Particle count 1800, deterministic spawn seeded mulberry32(91347), spawn distribution rim-biased so the field lines read clearly. Two opposing dipoles at the chart's first and last data points sample the captured DOM bounds every frame so the field re-flows when the chart line animates.\n\nTimeline (paused: true, window.__timelines.main, data-duration=15):\n\n0.0–2.4s ENTRY — particles spawn invisible, opacity ramps 0 → 0.85 over 2.0s ease expo.out. Field strength 0 → 1.0 ease power3.out 1.6s. Chart card fades in at 0.4s (`gsap.from('.chart-card', { y: 60, opacity: 0, scale: 0.96, duration: 0.9, ease: 'power3.out' })`). KPI labels stagger in at 0.9s and 1.2s (200ms apart, y:30 → 0).\n\n2.4–10.0s FLOW — particles trace field lines. Chart line animates over 6.0s (`gsap.from('.chart-line', { strokeDashoffset: pathLength, duration: 6.0, ease: 'sine.inOut' })`); the captured-DOM resampling means particles bend around the moving chart line in real time. KPI numerals tick: signal strength 0.84 → 0.91, coverage 92% → 96%, both ease none. At 6.0s, brief field-strength pulse from 1.0 → 1.4 → 1.0 over 0.5s; particles speed up correspondingly.\n\n10.0–13.5s STATEMENT — headline appears (\"see the field, not the dots\") with marker-sweep highlight from `references/css-patterns.md` at 10.4s. Particles dim slightly (opacity 0.85 → 0.55) so the headline reads cleanly above them.\n\n13.5–15.0s SETTLE — field strength tapers 1.0 → 0.6, particle speed halves, ambient drift only. Final 0.6s holds the hero frame. No opacity-to-0 exits.\n\nFeature detection: if `drawElementImage` is unavailable, render the chart card DOM directly on a flat #0a0c11 background without the particle field. Studio preview must never go black.\n\nNon-negotiables: deterministic seeds, finite repeats only, no Math.random / Date.now, no `<br>` in headline (let it wrap with `max-width`). Particle position update runs inside the same window.__timelines.main timeline ticker — don't spin off requestAnimationFrame outside the framework's clock. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 12 --at 1,2.5,4,6,8,11,13.5` before render. Output: `magnetic-field-data.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-magnetic.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-magnetic.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-magnetic"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-portal-reveal",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: Portal Reveal Dashboard",
|
||||
"summary": "A 10-second dimensional portal opens onto a live data dashboard — DOM captured in real time, volumetric light spill, portal-edge particles. Built on the vfx-portal catalog block. Designed for keynote-style data hero shots.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "portal", "dashboard", "keynote"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build a 10-second HyperFrames composition (1920×1080, 30fps) titled \"portal-reveal-dashboard\". Pull the catalog block first: `npx hyperframes add vfx-portal`.\n\nVisual identity: jet canvas #050609, single electric accent #7df9ff for portal rim and volumetric light, secondary magenta #ff4ecd as chromatic edge tint, off-white text #f5f7fa. Display face: \"PP Editorial New\" 132px italic; body \"GT America\" 24px; mono \"GT America Mono\" 18px tabular-nums on every digit.\n\nTwo nested canvases:\n• `dashboard-source` — a `<canvas layoutsubtree width=\"1920\" height=\"1080\">` containing a real-looking analytics dashboard DOM: top KPI strip (4 stats, large numerals), a primary line chart (animated SVG path, 12 data points), a secondary bar chart, a live counter that increments 1247 → 1392 over the composition. Use actual CSS Grid for layout, `backdrop-filter` on the KPI cards, and tabular-nums on every numeral.\n• `theater` — WebGL canvas running the vfx-portal shader on that captured texture.\n\nTimeline (paused: true, window.__timelines.main, data-duration=10):\n\n0.0–2.4s OPEN — portal radius 0 → 1.0 (ease expo.out 1.4s, slight overshoot 1.05 → 1.0). Volumetric light cone intensity 0 → 1.6 → 1.0. Edge particles spawn count ramps 0 → 240 over 1.6s with deterministic seeded mulberry32(1337). Dashboard texture is captured every frame from 0.6s onward; before that the portal is opaque rim.\n\n2.4–7.6s INSPECT — portal stable at 1.0. Camera pushes in gently (z 0 → -120, ease sine.inOut 5.2s). Inside the source DOM, drive the live counter and chart path animations on the same window.__timelines.main timeline so they sync with the camera push. Add a subtle headline outside the portal (\"the numbers, in real time\") fading in at 3.2s and a single mono kicker (\"+11.6% week over week\") with the css-patterns marker sweep at 5.0s.\n\n7.6–10.0s SETTLE — portal radius 1.0 → 1.04 (gentle breathe), light cone tapers to 0.7 intensity, edge particles settle. Final 0.4s holds the hero. No opacity-to-0 exits — the portal IS the frame.\n\nFeature detection: if `drawElementImage` is unavailable, render the dashboard DOM directly without the portal, with a single text overlay \"open in HyperFrames render for the full effect\". Studio preview must never go black.\n\nNon-negotiables: deterministic seeds, no `repeat: -1`, no async timeline construction, no `<br>` in content text — let it wrap. Min font 60px on the headline, 36px on KPI numerals, tabular-nums everywhere. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 12 --at 1,2,3,5,7,9` before render. Output: `portal-reveal-dashboard.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-portal.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-portal.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-portal"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-shatter",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: Glass Shatter Outro",
|
||||
"summary": "A 12-second HTML shatter outro — a real product page or pricing card holds for a beat, then explodes into refracting glass fragments with depth blur and chromatic dispersion. Built on the vfx-shatter catalog block. Pairs as an end-card after a longer composition.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "shatter", "outro", "destruction"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build a 12-second HyperFrames composition (1920×1080, 30fps) titled \"glass-shatter-outro\". Pull the catalog block first: `npx hyperframes add vfx-shatter`.\n\nVisual identity: deep ink canvas #0c0e14, single warm accent #ff5b3a for fragment edges, secondary cool #5cd4ff for chromatic dispersion, off-white type #f5f5f3. Display face: \"PP Telegraf\" Bold 140px on the hero card; body \"PP Telegraf\" 24px. Tabular-nums on the price.\n\nThe composition shatters one piece of UI the user supplies — default: a single pricing card centered on canvas, 760×920px, glass-morphism (rgba(255,255,255,0.06) bg, `backdrop-filter: blur(28px)`, 1px rgba border, 28px radius), with: tier name (\"Pro\"), price (\"$24/mo\" tabular-nums), 4 feature bullets, primary CTA (\"choose Pro\").\n\nSource layer: a `<canvas layoutsubtree width=\"1920\" height=\"1080\">` containing the centered pricing-card DOM on top of a soft #0c0e14 → #14161e radial-gradient backdrop.\n\nTimeline (paused: true, window.__timelines.main, data-duration=12):\n\n0.0–3.5s HOLD — card fully composed, idle state. Subtle ambient drift (translateY ±4px ease sine.inOut, finite period 3.0s, 1 repeat). Capture the source canvas at every frame so any micro-motion in the DOM (e.g., a soft accent shimmer behind the price) makes it through.\n\n3.5–4.0s WIND-UP — card scales 1.0 → 1.04 (ease power2.in 0.5s), chromatic offset on the captured texture grows from 0 → 0.012, faint vibration on the fragment field (precomputed Voronoi seed mulberry32(8743)).\n\n4.0–6.6s SHATTER — fragment spawn at t=4.0s. Each fragment gets a deterministic vector based on its centroid (outward + slight upward bias). Translate over 2.6s ease expo.out to final positions in a 2400×1400px field, rotate 0 → ±240° per fragment (signed by centroid sign), scale 1.0 → 0.92, refraction strength 0 → 0.6 → 0.3. Chromatic dispersion peaks at 4.4s (offset 0.034) then decays. Add depth blur on fragments behind the focal plane (z < -100).\n\n6.6–10.0s DRIFT — fragments slow-tumble in negative space, deterministic per-fragment angular velocity. Camera does a slow dolly z 0 → -80 ease sine.inOut. Reveal the brand wordmark behind the fragment field at 7.4s (`gsap.from('.wordmark', { y: 40, opacity: 0, duration: 0.8, ease: 'power3.out' })`) — wordmark uses 96px display size.\n\n10.0–12.0s SETTLE — fragments tumble out of frame; wordmark and a single CTA line (\"keep building\") stay centered. Final 0.6s of grain-overlay hold. No opacity-to-0 exits on the wordmark — only the fragment field exits.\n\nFeature detection: if `drawElementImage` is unavailable, hold the source DOM card without the shatter, fade to wordmark via a CSS crossfade. Never show a black canvas.\n\nNon-negotiables: deterministic Voronoi seeding, no Math.random / Date.now, all fragment animations finite-repeat, entrance animations on the wordmark, no exit animations except on the fragment field itself. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 14 --at 1,3,4,4.5,5.5,7.5,9.5,11.5` before render. Output: `glass-shatter-outro.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-shatter.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-shatter.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-shatter"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"id": "hyperframes-html-in-canvas-text-cursor",
|
||||
"surface": "video",
|
||||
"title": "HyperFrames HTML-in-Canvas: Cinematic Text Cursor Reveal",
|
||||
"summary": "An 8-second dramatic text reveal with cursor glow, chromatic shadow rays, and directional lighting on a black stage. Real DOM typography under live shader post-processing. Built on the vfx-text-cursor catalog block.",
|
||||
"category": "VFX / HTML-in-Canvas",
|
||||
"tags": ["hyperframes", "html-in-canvas", "text", "cinematic", "shader"],
|
||||
"model": "hyperframes-html",
|
||||
"aspect": "16:9",
|
||||
"prompt": "Build an 8-second HyperFrames composition (1920×1080, 30fps) titled \"cinematic-text-cursor\". Pull the catalog block first: `npx hyperframes add vfx-text-cursor`.\n\nVisual identity: pure black stage #000000, off-white type #f7f7f5, single chromatic shadow tint #ff4d3a (warm) bleeding into #4d8dff (cool) for spectral edges. Display face: \"PP Neue Montreal\" 220px Medium for the hero word, fallback \"NB International Pro\". Tracking -0.02em on the hero. No body type — the whole composition is one line.\n\nThe full composition is one HTML-in-Canvas reveal of a single short line the user supplies (default: \"think bigger\"). Source DOM is a `<canvas layoutsubtree width=\"1920\" height=\"1080\">` with one centered `<h1>` element. The shader does the cinema.\n\nTimeline (paused: true, window.__timelines.main, data-duration=8):\n\n0.0–0.8s DARK — fully black, only a single faint cursor scan-line at 50% canvas height moving left to right at 800px/s, intensity 0.3.\n\n0.8–2.4s REVEAL — cursor reaches the headline x-position. The hero word reveals letter by letter via clip-path (linear left-to-right wipe over 1.4s, ease power3.out). Each letter, the moment it appears, lights up the chromatic shadow rays radiating outward 18° spread, length 320px, intensity peaks at 0.85, decays over 0.5s. Directional key light from camera-upper-left sweeps the type with a soft glow.\n\n2.4–5.0s HOLD — type stays lit, cursor parks just to the right of the final letter and pulses (opacity 0.9 ↔ 0.5, period 0.8s, ease sine.inOut, finite repeat — calculate `Math.ceil(2.6 / 0.8) - 1` repeats). Chromatic shadow stabilises at 0.18 intensity. A subtle film-grain overlay (use `grain-overlay` component at 6%) holds across the scene.\n\n5.0–8.0s OUTRO — cursor accelerates rightward off-canvas (x position +1200 over 1.0s ease expo.in). Chromatic rays intensify briefly to 1.0, then everything dims to black via the root container's opacity over 1.6s. The hero word stays in CSS position — only the root container fades. Final 0.4s pure black hold.\n\nFeature detection: if `drawElementImage` is unavailable, render the source DOM directly as a normal CSS-only kinetic-type composition with class-based reveal — the text itself is the fallback. Never show a black canvas with no text.\n\nNon-negotiables: one font, one expressive line, deterministic motion. Min hero size 200px. No `repeat: -1` (the cursor pulse uses a finite count). No animation conflict — the cursor glow shader and the GSAP letter reveal animate different properties on different elements. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 8 --at 0.5,1.6,2.2,4.0,5.5,7.0` before render. Output: `cinematic-text-cursor.mp4`.",
|
||||
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-text-cursor.png",
|
||||
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-text-cursor.mp4",
|
||||
"source": {
|
||||
"repo": "heygen-com/hyperframes",
|
||||
"license": "Apache-2.0",
|
||||
"author": "HeyGen",
|
||||
"url": "https://hyperframes.heygen.com/catalog/blocks/vfx-text-cursor"
|
||||
}
|
||||
}
|
||||
|
|
@ -207,7 +207,11 @@ async function discoverDaemonUrlFromToolsDev(): Promise<string | null> {
|
|||
return await new Promise<string | null>((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn('pnpm', ['exec', 'tools-dev', 'status', '--json'], {
|
||||
// `--silent` suppresses pnpm's own warnings (notably "Unsupported
|
||||
// engine" when the local node version doesn't match the repo's
|
||||
// engines.node). Without it, those warnings land on stdout under a
|
||||
// nested pnpm context and break the JSON parse below.
|
||||
child = spawn('pnpm', ['--silent', 'exec', 'tools-dev', 'status', '--json'], {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
|
@ -222,20 +226,34 @@ async function discoverDaemonUrlFromToolsDev(): Promise<string | null> {
|
|||
child.stderr?.resume();
|
||||
child.on('error', () => resolve(null));
|
||||
child.on('exit', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
apps?: { daemon?: { url?: string | null } };
|
||||
url?: string | null;
|
||||
};
|
||||
const url = parsed?.apps?.daemon?.url ?? parsed?.url ?? null;
|
||||
resolve(typeof url === 'string' && url.length > 0 ? url : null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
const url = extractDaemonUrlFromStatusOutput(stdout);
|
||||
resolve(url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Robust against any leading non-JSON noise pnpm or a wrapper might still
|
||||
// print on stdout (engine warnings, recursive run banners, deprecation
|
||||
// notices). Find the first `{` and try to parse the JSON object that
|
||||
// starts there; if that fails, walk forward to the next `{`. This keeps
|
||||
// discovery working even if a future pnpm version regresses around
|
||||
// `--silent`.
|
||||
function extractDaemonUrlFromStatusOutput(stdout: string): string | null {
|
||||
for (let i = stdout.indexOf('{'); i !== -1; i = stdout.indexOf('{', i + 1)) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.slice(i)) as {
|
||||
apps?: { daemon?: { url?: string | null } };
|
||||
url?: string | null;
|
||||
};
|
||||
const url = parsed?.apps?.daemon?.url ?? parsed?.url ?? null;
|
||||
if (typeof url === 'string' && url.length > 0) return url;
|
||||
} catch {
|
||||
// try the next `{`
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveDaemonUrl(args: Args): Promise<string> {
|
||||
if (args.daemonUrl) return args.daemonUrl;
|
||||
if (process.env.OD_DAEMON_URL) return process.env.OD_DAEMON_URL;
|
||||
|
|
|
|||
Loading…
Reference in a new issue