onToggleEdit(editing ? null : key)}
/>
onRemove(group.path, decl.id)}
/>
@@ -104,6 +106,7 @@ export function VariablesOtherCompositions({
domEditSaveTimestampRef: MutableRefObject;
}) {
const [selfRefresh, setSelfRefresh] = useState(0);
+ const { t } = useTranslation();
const groups = useProjectCompositionVariables(
fileTree,
excludePath,
diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx
index fce436e633..e935973d85 100644
--- a/packages/studio/src/components/panels/VariablesPanel.tsx
+++ b/packages/studio/src/components/panels/VariablesPanel.tsx
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
+import { useTranslation } from "react-i18next";
import type {
Composition,
CompositionVariable,
@@ -89,6 +90,7 @@ function VariableRow({
onSaveEdit: (decl: CompositionVariable) => void;
onRemove: () => void;
}) {
+ const { t } = useTranslation();
return (
@@ -99,7 +101,7 @@ function VariableRow({
{unused && (
unused
@@ -108,13 +110,13 @@ function VariableRow({
{overridden && isScalar(value) && (
onSetDefault(value)}
/>
)}
-
-
+
+
{decl.description &&
{decl.description}
}
@@ -139,18 +141,19 @@ function UndeclaredReads({
usage: VariableUsageReport | null;
onDeclare: (id: string) => void;
}) {
+ const { t } = useTranslation();
if (!usage || usage.undeclaredReads.length === 0) return null;
return (
- Read by scripts, not declared
+ {t("variablesPanel.readByScripts")}
{usage.undeclaredReads.map((id) => (
{id}
onDeclare(id)}
/>
@@ -167,11 +170,12 @@ function PreviewModeHeader({
overrideCount: number;
onReset: () => void;
}) {
+ const { t } = useTranslation();
const hasOverrides = overrideCount > 0;
return (
-
Variables
+
{t("variablesPanel.title")}
void;
}) {
+ const { t } = useTranslation();
const json = JSON.stringify(effectiveValues);
const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`;
return (
@@ -220,13 +225,13 @@ function HandoffFooter({
onCopy(command, "Render command")}
/>
onCopy(json, "Values JSON")}
/>
@@ -251,6 +256,7 @@ export const VariablesPanel = memo(function VariablesPanel({
domEditSaveTimestampRef,
recordEdit,
}: VariablesPanelProps) {
+ const { t } = useTranslation();
const { activeCompPath, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx
index 71c142ad4c..d10ebe86a4 100644
--- a/packages/studio/src/components/renders/RenderQueue.tsx
+++ b/packages/studio/src/components/renders/RenderQueue.tsx
@@ -1,4 +1,5 @@
import { memo, useState, useRef, useEffect, useId } from "react";
+import { useTranslation } from "react-i18next";
import { RenderQueueItem } from "./RenderQueueItem";
import { Button } from "../ui/Button";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
@@ -251,6 +252,7 @@ function FormatExportButton({
compositionDimensions?: CompositionDimensions | null;
lastRenderDurationMs?: number;
}) {
+ const { t } = useTranslation();
const persisted = getPersistedRenderSettings();
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
@@ -317,9 +319,9 @@ function FormatExportButton({
disabled={isRendering}
className={selectCls}
>
- 24 fps
- 30 fps
- 60 fps
+ {t("renderQueue.fps24")}
+ {t("renderQueue.fps30")}
+ {t("renderQueue.fps60")}
{showQuality && (
@@ -357,7 +359,7 @@ function FormatExportButton({
}}
className="w-full text-[11px] font-semibold"
>
- {isRendering ? "Rendering…" : "Export"}
+ {isRendering ? t("renderQueue.rendering") : t("renderQueue.export")}
{lastRenderDurationMs !== undefined && !isRendering && (
@@ -382,6 +384,7 @@ export const RenderQueue = memo(function RenderQueue({
onDismissActionError,
compositionDimensions,
}: RenderQueueProps) {
+ const { t } = useTranslation();
const listRef = useRef(null);
// Auto-scroll to bottom when new jobs are added.
@@ -464,7 +467,9 @@ export const RenderQueue = memo(function RenderQueue({
strokeLinejoin="round"
/>
- No renders yet
+
+ {t("renderQueue.noRenders")}
+
) : (
diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx
index 4b0209e559..bb3b4e6ae5 100644
--- a/packages/studio/src/components/renders/RenderQueueItem.tsx
+++ b/packages/studio/src/components/renders/RenderQueueItem.tsx
@@ -1,4 +1,5 @@
import { memo, useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
import { Button } from "../ui/Button";
import type { RenderJob } from "./useRenderQueue";
@@ -31,6 +32,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
onDelete,
onCancel,
}: RenderQueueItemProps) {
+ const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
@@ -138,7 +140,9 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{isRendering && (
- {job.stage || "Rendering"}
+
+ {job.stage || t("renderQueue.renderStage")}
+
{job.progress}%
void;
onAddAtPlayhead?: (path: string) => void;
}) {
+ const { t } = useTranslation();
return (
- Add at playhead
+ {t("assetsTab.addAtPlayhead")}
)}
- Copy path
+ {t("assetsTab.copyPath")}
{onRename && (
- Rename
+ {t("assetsTab.rename")}
)}
{onDelete && (
@@ -73,10 +76,43 @@ export function ContextMenu({
}}
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
>
- Delete
+ {t("assetsTab.delete")}
)}
);
}
+
+export function DeleteConfirm({
+ name,
+ onConfirm,
+ onCancel,
+}: {
+ name: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("assetsTab.deleteConfirm", { name })}
+
+
+
+ {t("assetsTab.delete")}
+
+
+ {t("assetsTab.cancel")}
+
+
+
+ );
+}
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx
index e1344c88e6..14f4722424 100644
--- a/packages/studio/src/components/sidebar/AssetsTab.tsx
+++ b/packages/studio/src/components/sidebar/AssetsTab.tsx
@@ -1,5 +1,6 @@
// fallow-ignore-file code-duplication
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
+import { useTranslation } from "react-i18next";
import { MEDIA_EXT, FONT_EXT } from "../../utils/mediaTypes";
import { copyTextToClipboard } from "../../utils/clipboard";
import { usePlayerStore } from "../../player/store/playerStore";
@@ -98,6 +99,7 @@ export const AssetsTab = memo(function AssetsTab({
onRename,
onAddAssetToTimeline,
}: AssetsTabProps) {
+ const { t } = useTranslation();
const fileInputRef = useRef
(null);
const [dragOver, setDragOver] = useState(false);
const [copiedPath, setCopiedPath] = useState(null);
@@ -237,7 +239,7 @@ export const AssetsTab = memo(function AssetsTab({
: "text-panel-text-3 hover:text-panel-text-1"
}`}
>
- {m === "local" ? "This project" : "All projects"}
+ {m === "local" ? t("assetsTab.thisProject") : t("assetsTab.allProjects")}
))}
@@ -259,7 +261,7 @@ export const AssetsTab = memo(function AssetsTab({
>
- Import media
+ {t("assetsTab.importMedia")}
-
Drop media files here
+
+ {t("assetsTab.dropMediaHere")}
+
) : (
visibleCategories.map((cat) => (
diff --git a/packages/studio/src/components/sidebar/BlocksTab.tsx b/packages/studio/src/components/sidebar/BlocksTab.tsx
index 6b4c27eec8..613628c35d 100644
--- a/packages/studio/src/components/sidebar/BlocksTab.tsx
+++ b/packages/studio/src/components/sidebar/BlocksTab.tsx
@@ -1,5 +1,6 @@
// fallow-ignore-file code-duplication
import { memo, useState, useCallback, useRef, useEffect } from "react";
+import { useTranslation, Trans } from "react-i18next";
import { createPortal } from "react-dom";
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
import {
@@ -8,6 +9,10 @@ import {
type BlockCategory,
} from "../../utils/blockCategories";
import { usePlayerStore } from "../../player";
+
+function blockCategoryLabelKey(id: BlockCategory): `blocksTab.category.${BlockCategory}` {
+ return `blocksTab.category.${id}`;
+}
import { formatTime } from "../../player/lib/time";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
@@ -24,6 +29,7 @@ interface BlocksTabProps {
// fallow-ignore-next-line complexity
export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
+ const { t } = useTranslation();
const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
useBlockCatalog();
const [promptModal, setPromptModal] = useState<{ title: string; prompt: string } | null>(null);
@@ -31,7 +37,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
if (loading) {
return (
- Loading blocks…
+ {t("blocksTab.loading")}
);
}
@@ -67,7 +73,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
- placeholder="Search by name, category, or tag…"
+ placeholder={t("blocksTab.searchPlaceholder")}
className="w-full bg-neutral-900 border border-neutral-800 rounded-md pl-7 pr-2 py-1.5 text-[11px] text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-700 transition-colors"
/>
@@ -76,11 +82,15 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
{/* Category pills */}
-
setCategory(null)} />
+ setCategory(null)}
+ />
{BLOCK_CATEGORIES.map((cat) => (
setCategory(category === cat.id ? null : cat.id)}
@@ -93,14 +103,16 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
{category === "vfx" && (
- VFX blocks use WebGL via HTML-in-Canvas. Enable{" "}
- chrome://flags/#html-in-canvas for
- preview.
+
+ VFX blocks use WebGL via HTML-in-Canvas. Enable{" "}
+ chrome://flags/#html-in-canvas for
+ preview.
+
)}
{filteredBlocks.length === 0 ? (
- No blocks match your search
+ {t("blocksTab.noMatch")}
) : (
void;
onPreview?: (preview: BlockPreviewInfo | null) => void;
}) {
+ const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [adding, setAdding] = useState(false);
const hoverTimer = useRef
| null>(null);
@@ -428,7 +441,7 @@ function BlockCard({
- {adding ? "Added!" : "Add"}
+ {adding ? t("blocksTab.added") : t("blocksTab.add")}
)}
- Ask agent
+ {t("blocksTab.askAgent")}
@@ -473,7 +486,7 @@ function BlockCard({
{needsWebGL && (
- WebGL
+ {t("blocksTab.webgl")}
)}
{duration != null && (
@@ -491,9 +504,7 @@ function BlockCard({
-
- {BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
-
+ {t(blockCategoryLabelKey(category))}
@@ -509,6 +520,7 @@ function PromptPreviewModal({
prompt: string;
onClose: () => void;
}) {
+ const { t } = useTranslation();
const [value, setValue] = useState(prompt);
const [copied, setCopied] = useState(false);
const textareaRef = useRef
(null);
@@ -534,7 +546,7 @@ function PromptPreviewModal({
>
-
Ask agent
+
{t("blocksTab.modalTitle")}
{title}
-
- Edit the prompt below, then copy and paste into your AI agent
-
+
{t("blocksTab.modalDescription")}
diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx
index a38696798a..52bb193258 100644
--- a/packages/studio/src/components/sidebar/CompositionsTab.tsx
+++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
interface CompositionsTabProps {
projectId: string;
@@ -122,6 +123,7 @@ function CompCard({
isRendering?: boolean;
lintInfo?: { count: number; messages: string[] };
}) {
+ const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
const iframeRef = useRef(null);
@@ -233,8 +235,12 @@ function CompCard({
{onRender && (
{
e.stopPropagation();
@@ -275,10 +281,13 @@ export const CompositionsTab = memo(function CompositionsTab({
isRendering,
lintFindingsByFile,
}: CompositionsTabProps) {
+ const { t } = useTranslation();
if (compositions.length === 0) {
return (
-
No compositions found
+
+ {t("compositionsTab.noCompositions")}
+
);
}
diff --git a/packages/studio/src/components/sidebar/GlobalAssetsView.tsx b/packages/studio/src/components/sidebar/GlobalAssetsView.tsx
index 6660739f4c..518894713e 100644
--- a/packages/studio/src/components/sidebar/GlobalAssetsView.tsx
+++ b/packages/studio/src/components/sidebar/GlobalAssetsView.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
// Cross-project asset view — the global media-use cache (~/.media), fetched from
// /api/assets/global. Self-contained (owns its fetch + state) so AssetsTab stays
@@ -40,6 +41,7 @@ export function globalAssetRows(records: GlobalAssetRecord[], query = ""): Globa
}
export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
+ const { t } = useTranslation();
const [records, setRecords] = useState(null);
useEffect(() => {
let cancelled = false;
@@ -59,13 +61,12 @@ export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
const rows = useMemo(() => globalAssetRows(records ?? [], searchQuery), [records, searchQuery]);
if (records === null) {
- return Loading global assets…
;
+ return {t("globalAssets.loading")}
;
}
if (rows.length === 0) {
return (
- No assets in the global cache yet. Resolved media is promoted to ~/.media and
- becomes reusable across projects.
+ {t("globalAssets.empty")} ~/.media {t("globalAssets.emptySuffix")}
);
}
diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx
index acaed6cda6..c7bd62c9a0 100644
--- a/packages/studio/src/components/sidebar/LeftSidebar.tsx
+++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx
@@ -7,6 +7,7 @@ import {
forwardRef,
type ReactNode,
} from "react";
+import { useTranslation } from "react-i18next";
import { CompositionsTab } from "./CompositionsTab";
import { AssetsTab } from "./AssetsTab";
import { trackStudioEvent } from "../../utils/studioTelemetry";
@@ -97,6 +98,7 @@ export const LeftSidebar = memo(
},
ref,
) {
+ const { t } = useTranslation();
const [tab, setTab] = useState(getPersistedTab);
const tabRef = useRef(tab);
tabRef.current = tab;
@@ -131,7 +133,7 @@ export const LeftSidebar = memo(
: "1fr 1fr 1fr",
}}
>
-
+
selectTab("code")}
@@ -141,10 +143,10 @@ export const LeftSidebar = memo(
: "text-neutral-500 hover:text-neutral-200"
}`}
>
- Code
+ {t("leftSidebar.code")}
-
+
selectTab("compositions")}
@@ -154,10 +156,10 @@ export const LeftSidebar = memo(
: "text-neutral-500 hover:text-neutral-200"
}`}
>
- Comps
+ {t("leftSidebar.comps")}
-
+
selectTab("assets")}
@@ -167,11 +169,11 @@ export const LeftSidebar = memo(
: "text-neutral-500 hover:text-neutral-200"
}`}
>
- Assets
+ {t("leftSidebar.assets")}
{STUDIO_BLOCKS_PANEL_ENABLED && (
-
+
selectTab("blocks")}
@@ -181,7 +183,7 @@ export const LeftSidebar = memo(
: "text-neutral-500 hover:text-neutral-200"
}`}
>
- Catalog
+ {t("leftSidebar.catalog")}
)}
@@ -191,8 +193,8 @@ export const LeftSidebar = memo(
type="button"
onClick={onToggleCollapse}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
- title="Hide sidebar"
- aria-label="Hide sidebar"
+ title={t("leftSidebar.hideSidebar")}
+ aria-label={t("leftSidebar.hideSidebar")}
>
{codeChildren ?? (
- Select a file to edit
+ {t("leftSidebar.selectFileToEdit")}
)}
@@ -287,7 +289,7 @@ export const LeftSidebar = memo(
- {linting ? "Linting…" : "Lint"}
+ {linting ? t("leftSidebar.linting") : t("leftSidebar.lint")}
{!linting && lintFindingCount != null && lintFindingCount > 0 && (
{lintFindingCount}
diff --git a/packages/studio/src/components/storyboard/StoryboardDirection.tsx b/packages/studio/src/components/storyboard/StoryboardDirection.tsx
index dfe64e67ff..5fa78a5dbd 100644
--- a/packages/studio/src/components/storyboard/StoryboardDirection.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardDirection.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import type { StoryboardGlobals } from "@hyperframes/core/storyboard";
export interface StoryboardDirectionProps {
@@ -10,18 +11,20 @@ export interface StoryboardDirectionProps {
* This is the storyboard's "north star" pulled from the manifest frontmatter.
*/
export function StoryboardDirection({ globals, frameCount }: StoryboardDirectionProps) {
+ const { t } = useTranslation();
+
const meta = [
- { label: "Arc", value: globals.arc },
- { label: "Audience", value: globals.audience },
- { label: "Voice", value: globals.extra.voice },
- { label: "Format", value: globals.format },
- { label: "Frames", value: String(frameCount) },
+ { label: t("storyboard.metaArc"), value: globals.arc },
+ { label: t("storyboard.metaAudience"), value: globals.audience },
+ { label: t("storyboard.metaVoice"), value: globals.extra.voice },
+ { label: t("storyboard.metaFormat"), value: globals.format },
+ { label: t("storyboard.metaFrames"), value: String(frameCount) },
].filter((item): item is { label: string; value: string } => Boolean(item.value));
return (
- Storyboard
+ {t("storyboard.title")}
{globals.message ? (
@@ -29,7 +32,7 @@ export function StoryboardDirection({ globals, frameCount }: StoryboardDirection
) : (
- Untitled storyboard
+ {t("storyboard.untitled")}
)}
diff --git a/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx b/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx
index 22300fe6b7..8fa737e660 100644
--- a/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import { useViewMode } from "../../contexts/ViewModeContext";
import { Button } from "../ui/Button";
import { FramePoster, posterTime } from "./FramePoster";
-import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
+import { getFrameStatusMeta, FRAME_STATUS_ORDER } from "./frameStatus";
export interface StoryboardFrameFocusProps {
projectId: string;
@@ -40,6 +41,7 @@ export function StoryboardFrameFocus({
onSaved,
onSelectComposition,
}: StoryboardFrameFocusProps) {
+ const { t } = useTranslation();
const { readProjectFile, writeProjectFile } = useFileManagerContext();
const { setViewMode } = useViewMode();
const [draft, setDraft] = useState(frame.voiceover ?? "");
@@ -56,15 +58,15 @@ export function StoryboardFrameFocus({
await writeProjectFile(storyboardPath, edit(source));
onSaved();
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : "failed to save");
+ setError(err instanceof Error ? err.message : t("storyboard.failedToSave"));
} finally {
setBusy(false);
}
},
- [readProjectFile, writeProjectFile, storyboardPath, onSaved, busy],
+ [readProjectFile, writeProjectFile, storyboardPath, onSaved, busy, t],
);
- const title = frame.title ?? `Frame ${frame.index}`;
+ const title = frame.title ?? t("storyboard.frameFallback", { index: frame.index });
const dirty = draft !== (frame.voiceover ?? "");
const canOpenPreview = frame.srcExists && Boolean(frame.src);
@@ -87,7 +89,7 @@ export function StoryboardFrameFocus({
// dirty. An in-flight save does NOT count as safe: if it fails after unmount
// the error lands on an unmounted component and the draft is silently lost,
// so keep confirming until the save actually lands (dirty clears on success).
- const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
+ const confirmLeave = () => !dirty || window.confirm(t("storyboard.discardVoiceoverChanges"));
const handleBack = () => {
if (confirmLeave()) onBack();
};
@@ -122,19 +124,22 @@ export function StoryboardFrameFocus({
onClick={handleBack}
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800"
>
- ← Board
+ {t("storyboard.backToBoard")}
- Frame {frame.number ?? frame.index} — {title}
+ {t("storyboard.frameHeader", {
+ number: frame.number ?? frame.index,
+ title,
+ })}
handleNavigate(-1)}
/>
= frameCount}
onClick={() => handleNavigate(1)}
/>
@@ -154,7 +159,9 @@ export function StoryboardFrameFocus({
/>
) : (
- {frame.status === "outline" ? "Not built yet" : "No preview"}
+ {frame.status === "outline"
+ ? t("storyboard.notBuiltYet")
+ : t("storyboard.noPreview")}
)}
@@ -168,14 +175,19 @@ export function StoryboardFrameFocus({
/>
- {frame.duration && Duration {frame.duration} }
- {frame.transitionIn && Transition {frame.transitionIn} }
+ {frame.duration && {t("storyboard.duration", { value: frame.duration })} }
+ {frame.transitionIn && (
+ {t("storyboard.transition", { value: frame.transitionIn })}
+ )}
- 🎙 Voiceover guide
+ 🎙 {t("storyboard.voiceoverGuide")}{" "}
+
+ {t("storyboard.voiceoverGuideHint")}
+
- {busy ? "Saving…" : "Save"}
+ {busy ? t("storyboard.saving") : t("storyboard.save")}
- A draft guide. SCRIPT.md locks the final narration that drives TTS.
+ {t("storyboard.voiceoverDraftHint")}
{error && {error}
}
@@ -211,14 +223,14 @@ export function StoryboardFrameFocus({
{frame.narrative && (
- Narrative
+ {t("storyboard.narrative")}
{frame.narrative}
)}
- Open in Preview →
+ {t("storyboard.openInPreview")}
@@ -256,29 +268,34 @@ function StatusRow({
busy: boolean;
onSet: (next: FrameStatus) => void;
}) {
+ const { t } = useTranslation();
+
return (
- Status
+ {t("storyboard.statusLabel")}
- {FRAME_STATUS_ORDER.map((option) => (
- onSet(option)}
- className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
- status === option
- ? "bg-neutral-700 text-neutral-100"
- : "text-neutral-400 hover:text-neutral-200"
- }`}
- >
- {FRAME_STATUS_META[option].label}
-
- ))}
+ {FRAME_STATUS_ORDER.map((option) => {
+ const meta = getFrameStatusMeta(option);
+ return (
+ onSet(option)}
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
+ status === option
+ ? "bg-neutral-700 text-neutral-100"
+ : "text-neutral-400 hover:text-neutral-200"
+ }`}
+ >
+ {meta.label}
+
+ );
+ })}
);
diff --git a/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx b/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx
index 62b0173bfb..38e48ebd9c 100644
--- a/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardFrameTile.tsx
@@ -1,6 +1,7 @@
+import { useTranslation } from "react-i18next";
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
import { FramePoster, posterTime } from "./FramePoster";
-import { FRAME_STATUS_META } from "./frameStatus";
+import { getFrameStatusMeta } from "./frameStatus";
export interface StoryboardFrameTileProps {
projectId: string;
@@ -18,20 +19,21 @@ function firstLine(text: string): string {
);
}
-function placeholderMessage(frame: StoryboardFrameView): string {
- if (frame.status === "outline") return "Not built yet";
- if (frame.src && !frame.srcExists) return "Frame file not found";
- return "No preview";
-}
-
/** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
// fallow-ignore-next-line complexity
export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) {
- const meta = FRAME_STATUS_META[frame.status];
+ const { t } = useTranslation();
+ const meta = getFrameStatusMeta(frame.status);
const renderable = frame.srcExists && frame.status !== "outline";
- const title = frame.title ?? `Frame ${frame.index}`;
+ const title = frame.title ?? t("storyboard.frameFallback", { index: frame.index });
const sceneLine = frame.scene ?? firstLine(frame.narrative);
+ const placeholderMessage = () => {
+ if (frame.status === "outline") return t("storyboard.notBuiltYet");
+ if (frame.src && !frame.srcExists) return t("storyboard.frameFileNotFound");
+ return t("storyboard.noPreview");
+ };
+
return (
) : (
-
+
)}
@@ -58,7 +64,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
{title}
{meta.label}
@@ -78,11 +84,19 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
);
}
-function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
+function FrameTilePlaceholder({
+ frame,
+ message,
+ outlineLabel,
+}: {
+ frame: StoryboardFrameView;
+ message: string;
+ outlineLabel: string;
+}) {
return (
- {frame.title ?? "Outline"}
- {placeholderMessage(frame)}
+ {frame.title ?? outlineLabel}
+ {message}
);
}
diff --git a/packages/studio/src/components/storyboard/StoryboardGrid.tsx b/packages/studio/src/components/storyboard/StoryboardGrid.tsx
index 9e87e53a2f..b5033fc32e 100644
--- a/packages/studio/src/components/storyboard/StoryboardGrid.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardGrid.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
import { StoryboardFrameTile } from "./StoryboardFrameTile";
@@ -10,10 +11,12 @@ export interface StoryboardGridProps {
/** The contact sheet: ordered frame tiles in a responsive grid. */
export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGridProps) {
+ const { t } = useTranslation();
+
if (frames.length === 0) {
return (
- This storyboard has no frames yet.
+ {t("storyboard.noFrames")}
);
}
diff --git a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx
index bc1453c4d3..f173973271 100644
--- a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import type { StoryboardResponse } from "../../hooks/useStoryboard";
import { StoryboardDirection } from "./StoryboardDirection";
import { StoryboardGrid } from "./StoryboardGrid";
@@ -30,6 +31,7 @@ export function StoryboardLoaded({
reload,
onSelectComposition,
}: StoryboardLoadedProps) {
+ const { t } = useTranslation();
const [subView, setSubView] = useState("board");
const [sourceDirty, setSourceDirty] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(null);
@@ -41,6 +43,11 @@ export function StoryboardLoaded({
// produces a fresh object and would needlessly re-create this array.
}, [data.path, data.script?.path, data.script?.exists]);
+ const subViews: Array<{ value: SubView; label: string }> = [
+ { value: "board", label: t("storyboard.board") },
+ { value: "source", label: t("storyboard.source") },
+ ];
+
// Leaving the source editor drops its in-memory buffer; confirm when it's dirty.
// fallow-ignore-next-line complexity
const changeSubView = (next: SubView) => {
@@ -48,7 +55,7 @@ export function StoryboardLoaded({
if (
subView === "source" &&
sourceDirty &&
- !window.confirm("Discard unsaved markdown changes?")
+ !window.confirm(t("storyboard.discardMarkdownChanges"))
) {
return;
}
@@ -79,7 +86,7 @@ export function StoryboardLoaded({
return (
-
+
{subView === "board" ? (
@@ -107,20 +114,25 @@ export function StoryboardLoaded({
);
}
-const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
- { value: "board", label: "Board" },
- { value: "source", label: "Source" },
-];
+function SubViewToggle({
+ value,
+ options,
+ onChange,
+}: {
+ value: SubView;
+ options: Array<{ value: SubView; label: string }>;
+ onChange: (next: SubView) => void;
+}) {
+ const { t } = useTranslation();
-function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
// Complete tabs contract: roving tabIndex + arrow-key navigation (the roles
// alone promised keyboard behavior the buttons didn't have).
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
- const currentIndex = SUB_VIEWS.findIndex((v) => v.value === value);
+ const currentIndex = options.findIndex((v) => v.value === value);
const delta = e.key === "ArrowRight" ? 1 : -1;
- const next = SUB_VIEWS[(currentIndex + delta + SUB_VIEWS.length) % SUB_VIEWS.length];
+ const next = options[(currentIndex + delta + options.length) % options.length];
if (next) onChange(next.value);
};
@@ -128,10 +140,10 @@ function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: S
- {SUB_VIEWS.map((option) => (
+ {options.map((option) => (
- Narration script
+ {t("storyboard.narrationScript")}
{script.path}
diff --git a/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx b/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx
index 5e305ed954..cad7bf7453 100644
--- a/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { SourceEditor } from "../editor/SourceEditor";
@@ -17,7 +18,7 @@ export interface StoryboardSourceEditorProps {
onDirtyChange?: (dirty: boolean) => void;
}
-const DISCARD_PROMPT = "Discard unsaved markdown changes?";
+const DISCARD_PROMPT_KEY = "storyboard.discardMarkdownChanges";
interface EditableFile {
content: string;
@@ -31,6 +32,7 @@ interface EditableFile {
/** Load a project file's raw text and track edits + save state via the shared file manager. */
function useEditableFile(path: string, onSaved: () => void): EditableFile {
+ const { t } = useTranslation();
const { readProjectFile, writeProjectFile } = useFileManagerContext();
const [content, setContent] = useState("");
const [saved, setSaved] = useState("");
@@ -50,7 +52,7 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
setSaved(text);
})
.catch((err: unknown) => {
- if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
+ if (!cancelled) setError(err instanceof Error ? err.message : t("storyboard.failedToLoad"));
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -58,7 +60,7 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
return () => {
cancelled = true;
};
- }, [path, readProjectFile]);
+ }, [path, readProjectFile, t]);
const save = useCallback(() => {
if (saving) return; // coalesce a fast double Cmd+S into one PUT
@@ -69,9 +71,11 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
setSaved(content);
onSaved();
})
- .catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
+ .catch((err: unknown) =>
+ setError(err instanceof Error ? err.message : t("storyboard.failedToSave")),
+ )
.finally(() => setSaving(false));
- }, [writeProjectFile, path, content, onSaved, saving]);
+ }, [writeProjectFile, path, content, onSaved, saving, t]);
return { content, setContent, dirty: content !== saved, loading, saving, error, save };
}
@@ -144,6 +148,7 @@ export function StoryboardSourceEditor({
onSaved,
onDirtyChange,
}: StoryboardSourceEditorProps) {
+ const { t } = useTranslation();
const [selected, setSelected] = useState(files[0]?.path ?? "");
// Reconcile against the current file list so a removed/renamed file can't strand the tab.
const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
@@ -166,7 +171,7 @@ export function StoryboardSourceEditor({
// Switching files discards the in-memory buffer; confirm when there are unsaved edits.
const selectFile = (path: string) => {
if (path === activePath) return;
- if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
+ if (file.dirty && !window.confirm(t(DISCARD_PROMPT_KEY))) return;
setSelected(path);
};
@@ -197,7 +202,11 @@ export function StoryboardSourceEditor({
{file.error && {file.error} }
- {file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
+ {file.saving
+ ? t("storyboard.saving")
+ : file.dirty
+ ? t("storyboard.unsavedChanges")
+ : t("storyboard.saved")}
- Save
+ {t("storyboard.save")}
{file.loading ? (
-
Loading {activePath}…
+
+ {t("storyboard.loadingFile", { path: activePath })}
+
) : (
- Status
- {FRAME_STATUS_ORDER.map((status, i) => (
-
- {i > 0 && → }
-
- {FRAME_STATUS_META[status].label}
- — {FRAME_STATUS_META[status].description}
-
- ))}
+
+ {t("storyboard.statusLabel")}
+
+ {FRAME_STATUS_ORDER.map((status, i) => {
+ const meta = getFrameStatusMeta(status);
+ return (
+
+ {i > 0 && → }
+
+ {meta.label}
+ — {meta.description}
+
+ );
+ })}
);
}
diff --git a/packages/studio/src/components/storyboard/StoryboardView.tsx b/packages/studio/src/components/storyboard/StoryboardView.tsx
index 56310c426e..e6e4c1fe6a 100644
--- a/packages/studio/src/components/storyboard/StoryboardView.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardView.tsx
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
import { Copy, Check } from "@phosphor-icons/react";
import { useStoryboard } from "../../hooks/useStoryboard";
import { copyTextToClipboard } from "../../utils/clipboard";
@@ -18,16 +19,20 @@ export interface StoryboardViewProps {
*/
// fallow-ignore-next-line complexity
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
+ const { t } = useTranslation();
const { data, loading, error, reload } = useStoryboard(projectId);
- if (loading) return
{Loading storyboard… } ;
+ if (loading)
+ return
{{t("storyboard.loading")} } ;
if (error) {
return (
- Couldn’t load the storyboard: {error}
+
+ {t("storyboard.loadError")} {error}
+
- Retry
+ {t("storyboard.retry")}
@@ -96,6 +101,7 @@ Add one \`## Frame N\` section per beat. Keep the arc tight.`;
}
function EmptyState({ path }: { path: string }) {
+ const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const prompt = handoffPrompt(path);
@@ -109,23 +115,25 @@ function EmptyState({ path }: { path: string }) {
return (
-
No storyboard yet
+
{t("storyboard.noStoryboard")}
- Add a {path}{" "}
- at the project root to plan this video frame by frame. Hand this prompt to your coding
- agent to scaffold it.
+ {t("storyboard.addPrompt")}{" "}
+ {path}{" "}
+ {t("storyboard.addPromptSuffix")}
- Prompt for your agent
+
+ {t("storyboard.promptForAgent")}
+
: }
>
- {copied ? "Copied" : "Copy prompt"}
+ {copied ? t("common.copied") : t("storyboard.copyPrompt")}
@@ -142,10 +150,12 @@ function EmptyState({ path }: { path: string }) {
/** Faded placeholder of a filled board so landing here isn't a dead end —
* it previews the contact-sheet layout {@link StoryboardGrid} renders. */
function SkeletonPreview() {
+ const { t } = useTranslation();
+
return (
- Preview
+ {t("storyboard.previewLabel")}
{[0, 1, 2].map((i) => (
diff --git a/packages/studio/src/components/storyboard/frameStatus.ts b/packages/studio/src/components/storyboard/frameStatus.ts
index fff35bcc98..2250bf045b 100644
--- a/packages/studio/src/components/storyboard/frameStatus.ts
+++ b/packages/studio/src/components/storyboard/frameStatus.ts
@@ -1,36 +1,40 @@
import type { FrameStatus } from "@hyperframes/core/storyboard";
+import i18n from "../../i18n";
-/**
- * Single source of truth for how each frame lifecycle status is presented —
- * label, tooltip, description, and the chip/dot color classes — so the tile
- * chip and the legend dot can't drift apart.
- */
-export const FRAME_STATUS_META: Record<
- FrameStatus,
- { label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
-> = {
+const FRAME_STATUS_STYLES: Record
= {
outline: {
- label: "Outline",
- tooltip: "Planned in text — no HTML frame built yet.",
- description: "Planned in text. Story and intent exist; the visual isn’t built.",
chipClass: "bg-neutral-800 text-neutral-300",
dotClass: "bg-neutral-500",
},
built: {
- label: "Built",
- tooltip: "Static HTML frame built — not animated yet.",
- description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
chipClass: "bg-sky-500/20 text-sky-300",
dotClass: "bg-sky-400",
},
animated: {
- label: "Animated",
- tooltip: "Keyframed and animated — plays in the final cut.",
- description: "Keyframed into motion. This is the file stitched into the final video.",
chipClass: "bg-emerald-500/20 text-emerald-300",
dotClass: "bg-emerald-400",
},
};
+/**
+ * Single source of truth for how each frame lifecycle status is presented —
+ * label, tooltip, description, and the chip/dot color classes — so the tile
+ * chip and the legend dot can't drift apart.
+ */
+export function getFrameStatusMeta(status: FrameStatus): {
+ label: string;
+ tooltip: string;
+ description: string;
+ chipClass: string;
+ dotClass: string;
+} {
+ return {
+ label: i18n.t(`storyboard.status.${status}.label`),
+ tooltip: i18n.t(`storyboard.status.${status}.tooltip`),
+ description: i18n.t(`storyboard.status.${status}.description`),
+ ...FRAME_STATUS_STYLES[status],
+ };
+}
+
/** The lifecycle order an agent advances each frame through. */
export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];
diff --git a/packages/studio/src/components/ui/SearchInput.tsx b/packages/studio/src/components/ui/SearchInput.tsx
index 3108cfe0a9..754375e21e 100644
--- a/packages/studio/src/components/ui/SearchInput.tsx
+++ b/packages/studio/src/components/ui/SearchInput.tsx
@@ -1,6 +1,7 @@
// fallow-ignore-file unused-file
// (consumers land in the sidebar/panels PR later in this stack)
import { type InputHTMLAttributes } from "react";
+import { useTranslation } from "react-i18next";
interface SearchInputProps extends Omit, "type"> {
/** Accessible name — placeholder alone is not one. */
@@ -11,7 +12,10 @@ interface SearchInputProps extends Omit, "
* Shared search input — one visual system (panel-input tokens) for every
* panel search box, with a required accessible name.
*/
-export function SearchInput({ className = "", ...props }: SearchInputProps) {
+export function SearchInput({ className = "", placeholder, ...props }: SearchInputProps) {
+ const { t } = useTranslation();
+ const resolvedPlaceholder = placeholder ?? t("ui.searchPlaceholder");
+
return (
diff --git a/packages/studio/src/components/ui/VideoFrameThumbnail.tsx b/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
index 5204e6a793..5ab0a217c3 100644
--- a/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
+++ b/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
/**
* Extracts a representative JPEG frame from a video URL using a hidden
@@ -13,6 +14,7 @@ export function VideoFrameThumbnail({
/** Shown instead of an endless shimmer when the video can't be decoded. */
fallbackLabel?: string;
}) {
+ const { t } = useTranslation();
const [frame, setFrame] = useState
(null);
const [failed, setFailed] = useState(false);
@@ -58,7 +60,9 @@ export function VideoFrameThumbnail({
if (failed && !frame) {
return (
- {fallbackLabel ?? "VIDEO"}
+
+ {fallbackLabel ?? t("ui.videoFallback")}
+
);
}
diff --git a/packages/studio/src/hooks/domEditPersistFailure.ts b/packages/studio/src/hooks/domEditPersistFailure.ts
index 1e7bd7a406..e1ea66879b 100644
--- a/packages/studio/src/hooks/domEditPersistFailure.ts
+++ b/packages/studio/src/hooks/domEditPersistFailure.ts
@@ -1,10 +1,11 @@
import type { DomEditSelection } from "../components/editor/domEditing";
+import i18n from "../i18n";
import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import type { PatchOperation } from "../utils/sourcePatcher";
export class DomEditPersistUnresolvableError extends Error {
constructor(targetPath: string) {
- super(`Couldn't find this element in the source file (${targetPath})`);
+ super(i18n.t("hooks.domEdit.elementNotFoundInSource", { targetPath }));
this.name = "DomEditPersistUnresolvableError";
}
}
@@ -21,7 +22,7 @@ export class DomEditPersistUnsafeValueError extends Error {
export class DomEditPersistUnsupportedTextStructureError extends Error {
constructor() {
- super("Couldn't save this text structure change");
+ super(i18n.t("hooks.domEdit.textStructureChangeFailed"));
this.name = "DomEditPersistUnsupportedTextStructureError";
}
}
@@ -50,7 +51,9 @@ function getErrorDetail(error: unknown): string {
}
function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
- return selection.label || selection.selector || selection.id || "this element";
+ return (
+ selection.label || selection.selector || selection.id || i18n.t("hooks.domEdit.thisElement")
+ );
}
export function reportDomEditPersistFailure(
@@ -73,7 +76,10 @@ export function reportDomEditPersistFailure(
return;
}
- showToast(`Couldn't save "${getSelectionLabel(selection)}": ${detail}`, "error");
+ showToast(
+ i18n.t("hooks.domEdit.saveFailed", { label: getSelectionLabel(selection), detail }),
+ "error",
+ );
}
export function warnDomEditPersistNoOp(
diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts
index db30f55ca9..884d9aafa7 100644
--- a/packages/studio/src/hooks/timelineTrackVisibility.ts
+++ b/packages/studio/src/hooks/timelineTrackVisibility.ts
@@ -1,4 +1,5 @@
import { useCallback } from "react";
+import i18n from "../i18n";
import { usePlayerStore, type TimelineElement } from "../player";
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
@@ -267,7 +268,7 @@ export function useTimelineTrackVisibilityEditing({
return useCallback(
async (track: number, hidden: boolean) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
const pid = projectIdRef.current;
@@ -289,7 +290,9 @@ export function useTimelineTrackVisibilityEditing({
} catch (error) {
console.error("[Timeline] Failed to toggle track visibility", error);
const message =
- error instanceof Error ? error.message : "Failed to toggle track visibility";
+ error instanceof Error
+ ? error.message
+ : i18n.t("hooks.timeline.toggleTrackVisibilityFailed");
showToast(message);
}
},
@@ -328,7 +331,7 @@ export function useTimelineElementVisibilityEditing({
return useCallback(
async (elementKey: string, hidden: boolean) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
const pid = projectIdRef.current;
@@ -350,7 +353,9 @@ export function useTimelineElementVisibilityEditing({
} catch (error) {
console.error("[Timeline] Failed to toggle element visibility", error);
const message =
- error instanceof Error ? error.message : "Failed to toggle element visibility";
+ error instanceof Error
+ ? error.message
+ : i18n.t("hooks.timeline.toggleElementVisibilityFailed");
showToast(message);
}
},
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts
index 165bab8e69..c5ff294779 100644
--- a/packages/studio/src/hooks/useAppHotkeys.ts
+++ b/packages/studio/src/hooks/useAppHotkeys.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
+import i18n from "../i18n";
import { usePlayerStore } from "../player";
import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
@@ -70,7 +71,11 @@ function tryApplyBeatHistory(
const fileAt = fileStack[fileStack.length - 1]?.createdAt ?? null;
if (fileAt !== null && (direction === "undo" ? beatAt < fileAt : beatAt > fileAt)) return false;
const label = direction === "undo" ? ps.undoBeatEdits() : ps.redoBeatEdits();
- if (label) showToast(`${direction === "undo" ? "Undid" : "Redid"} ${label}`, "info");
+ if (label)
+ showToast(
+ i18n.t(direction === "undo" ? "hooks.hotkeys.undid" : "hooks.hotkeys.redid", { label }),
+ "info",
+ );
return true;
}
@@ -250,7 +255,7 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
// that isn't in the raw `elements` list, so the s-key can't resolve them.
// Nudge toward the razor tool instead of failing silently.
if (!el && selectedElementId.includes("#")) {
- cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
+ cb.showToast(i18n.t("hooks.hotkeys.useRazorForSubComp"), "info");
return;
}
}
@@ -380,7 +385,11 @@ export function useAppHotkeys({
});
if (!result.ok && result.reason === "content-mismatch") {
showToast(
- `File changed outside Studio. ${direction === "undo" ? "Undo" : "Redo"} history was not applied.`,
+ i18n.t(
+ direction === "undo"
+ ? "hooks.hotkeys.fileChangedOutsideUndo"
+ : "hooks.hotkeys.fileChangedOutsideRedo",
+ ),
"info",
);
return;
@@ -396,7 +405,12 @@ export function useAppHotkeys({
forceReloadSdkSession?.();
}
await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
- showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
+ showToast(
+ i18n.t(direction === "undo" ? "hooks.hotkeys.undid" : "hooks.hotkeys.redid", {
+ label: result.label,
+ }),
+ "info",
+ );
}
},
[
diff --git a/packages/studio/src/hooks/useAskAgentModal.ts b/packages/studio/src/hooks/useAskAgentModal.ts
index f88f3f2613..1b0bb5e474 100644
--- a/packages/studio/src/hooks/useAskAgentModal.ts
+++ b/packages/studio/src/hooks/useAskAgentModal.ts
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useEffect } from "react";
+import i18n from "../i18n";
import { copyTextToClipboard } from "../utils/clipboard";
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
@@ -99,7 +100,7 @@ export function useAskAgentModal({
const copied = await copyTextToClipboard(prompt);
if (!copied) {
- showToast("Could not copy prompt to clipboard.", "error");
+ showToast(i18n.t("hooks.askAgent.copyFailed"), "error");
return;
}
diff --git a/packages/studio/src/hooks/useBlockHandlers.ts b/packages/studio/src/hooks/useBlockHandlers.ts
index 886c219ec4..0d101815f0 100644
--- a/packages/studio/src/hooks/useBlockHandlers.ts
+++ b/packages/studio/src/hooks/useBlockHandlers.ts
@@ -3,6 +3,7 @@
* Extracted from App.tsx to keep file sizes under the 600-line limit.
*/
import { useCallback, useMemo, useRef, useState } from "react";
+import i18n from "../i18n";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { addBlockToProject } from "../utils/blockInstaller";
@@ -89,11 +90,11 @@ export function useBlockHandlers({
const runBlockInstall = useCallback(
async (blockName: string, install: () => Promise): Promise => {
if (installingBlockRef.current) {
- blockCtx.showToast("A block is already installing — one moment…", "info");
+ blockCtx.showToast(i18n.t("hooks.blockHandlers.alreadyInstalling"), "info");
return null;
}
installingBlockRef.current = true;
- blockCtx.showToast(`Adding ${blockName}…`, "info");
+ blockCtx.showToast(i18n.t("hooks.blockHandlers.addingBlock", { blockName }), "info");
try {
return await install();
} finally {
diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts
index 22f1a26644..db6658ecdb 100644
--- a/packages/studio/src/hooks/useClipboard.ts
+++ b/packages/studio/src/hooks/useClipboard.ts
@@ -1,4 +1,5 @@
import { useCallback, useRef } from "react";
+import i18n from "../i18n";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
@@ -97,13 +98,13 @@ export function useClipboard({
}
if (!html) {
- showToast("Unable to copy this element.", "info");
+ showToast(i18n.t("hooks.clipboard.unableToCopy"), "info");
return false;
}
const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath };
clipboardRef.current = payload;
- showToast("Copied clip", "info");
+ showToast(i18n.t("hooks.clipboard.copiedClip"), "info");
return true;
}
@@ -112,7 +113,7 @@ export function useClipboard({
if (domSelection) {
const html = getElementOuterHtml(previewIframeRef, domSelection);
if (!html) {
- showToast("Unable to copy this element.", "info");
+ showToast(i18n.t("hooks.clipboard.unableToCopy"), "info");
return false;
}
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
@@ -124,7 +125,7 @@ export function useClipboard({
originSelectorIndex: domSelection.selectorIndex,
};
clipboardRef.current = payload;
- showToast("Copied element", "info");
+ showToast(i18n.t("hooks.clipboard.copiedElement"), "info");
return true;
}
@@ -134,7 +135,7 @@ export function useClipboard({
const handlePaste = useCallback(async () => {
const payload = clipboardRef.current;
if (!payload) {
- showToast("Nothing to paste.", "info");
+ showToast(i18n.t("hooks.clipboard.nothingToPaste"), "info");
return;
}
const pid = projectIdRef.current;
@@ -181,9 +182,15 @@ export function useClipboard({
});
reloadPreview();
- showToast(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info");
+ showToast(
+ payload.kind === "timeline-clip"
+ ? i18n.t("hooks.clipboard.pastedClip")
+ : i18n.t("hooks.clipboard.pastedElement"),
+ "info",
+ );
} catch (error) {
- const message = error instanceof Error ? error.message : "Failed to paste";
+ const message =
+ error instanceof Error ? error.message : i18n.t("hooks.clipboard.pasteFailed");
showToast(message);
}
}, [
diff --git a/packages/studio/src/hooks/useCompositionContentLoader.ts b/packages/studio/src/hooks/useCompositionContentLoader.ts
index 7d2549b507..1bf4446384 100644
--- a/packages/studio/src/hooks/useCompositionContentLoader.ts
+++ b/packages/studio/src/hooks/useCompositionContentLoader.ts
@@ -1,4 +1,5 @@
import { useCallback } from "react";
+import i18n from "../i18n";
/**
* Loads a composition file's content for the source editor when a composition
@@ -32,7 +33,12 @@ export function useCompositionContentLoader({
setEditingFile({ path: comp, content: data.content });
})
.catch((err) => {
- showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
+ showToast(
+ err instanceof Error
+ ? err.message
+ : i18n.t("hooks.composition.loadFailed", { path: comp }),
+ "error",
+ );
});
},
[projectId, setEditingFile, setActiveCompPath, showToast],
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 3b88442071..af2b6a785b 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -1,4 +1,5 @@
import { useCallback, useRef } from "react";
+import i18n from "../i18n";
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
import { FONT_EXT } from "../utils/mediaTypes";
@@ -49,8 +50,11 @@ async function readErrorResponseBody(
}
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
- if (!body?.error) return "Couldn't save edit";
- return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
+ if (!body?.error) return i18n.t("hooks.domEdit.couldntSaveEdit");
+ return i18n.t("hooks.domEdit.couldntSaveEditWithDetail", {
+ detail: body.error,
+ fieldsSuffix: formatFieldsSuffix(body.fields),
+ });
}
interface RecordEditInput {
@@ -263,7 +267,7 @@ export function useDomEditCommits({
const unsafeFields = findUnsafeDomPatchValues(patchBody);
if (unsafeFields.length > 0) {
const fields = formatUnsafeFieldList(unsafeFields);
- showToast("Couldn't save edit because it contains invalid layout values", "error");
+ showToast(i18n.t("hooks.domEdit.invalidLayoutValues"), "error");
throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
alreadyToasted: true,
});
@@ -347,7 +351,10 @@ export function useDomEditCommits({
// the already-persisted patchedContent instead of throwing, which
// would otherwise revert a change the server already committed.
showToast(
- `Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
+ i18n.t("hooks.domEdit.savedButUpdateFailed", {
+ path: targetPath,
+ detail: getErrorDetail(error),
+ }),
"error",
);
}
@@ -389,7 +396,7 @@ export function useDomEditCommits({
batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
);
if (unsafeFields.length > 0) {
- showToast("Couldn't save edit because it contains invalid layout values", "error");
+ showToast(i18n.t("hooks.domEdit.invalidLayoutValues"), "error");
throw new DomEditPersistUnsafeValueError(
`DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
{ alreadyToasted: true },
@@ -429,7 +436,10 @@ export function useDomEditCommits({
error instanceof DomEditPersistUnsafeValueError) &&
error.alreadyToasted;
if (!alreadyToasted) {
- showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error");
+ showToast(
+ error instanceof Error ? error.message : i18n.t("hooks.domEdit.reorderFailed"),
+ "error",
+ );
}
trackStudioSaveFailure({
source: "dom_edit",
diff --git a/packages/studio/src/hooks/useEditorSave.ts b/packages/studio/src/hooks/useEditorSave.ts
index 9c3ae7763a..be29a8a9b9 100644
--- a/packages/studio/src/hooks/useEditorSave.ts
+++ b/packages/studio/src/hooks/useEditorSave.ts
@@ -1,4 +1,5 @@
import { useCallback, useRef } from "react";
+import i18n from "../i18n";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -69,10 +70,7 @@ export function useEditorSave({
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
- showToast(
- `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
- "error",
- );
+ showToast(i18n.t("hooks.editorSave.failed", { path }), "error");
}
});
});
diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts
index f8ac938d73..31748ec366 100644
--- a/packages/studio/src/hooks/useElementLifecycleOps.ts
+++ b/packages/studio/src/hooks/useElementLifecycleOps.ts
@@ -1,4 +1,5 @@
import { useCallback } from "react";
+import i18n from "../i18n";
import { usePlayerStore } from "../player";
import {
readProjectFileContent,
@@ -62,7 +63,7 @@ export function useElementLifecycleOps({
if (handled) {
clearDomSelection();
usePlayerStore.getState().setSelectedElementId(null);
- showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
+ showToast(i18n.t("hooks.timeline.deletedUseUndo", { label }), "info");
return;
}
}
@@ -109,9 +110,10 @@ export function useElementLifecycleOps({
forceReloadSdkSession?.();
reloadPreview();
onElementDeleted?.(selection);
- showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
+ showToast(i18n.t("hooks.timeline.deletedUseUndo", { label }), "info");
} catch (error) {
- const message = error instanceof Error ? error.message : "Failed to delete element";
+ const message =
+ error instanceof Error ? error.message : i18n.t("hooks.element.deleteFailed");
showToast(message);
}
},
diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts
index 9fbc8cc20f..7756a02f87 100644
--- a/packages/studio/src/hooks/useFileManager.ts
+++ b/packages/studio/src/hooks/useFileManager.ts
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef } from "react";
+import i18n from "../i18n";
import type { EditingFile } from "../utils/studioHelpers";
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
@@ -157,7 +158,10 @@ export function useFileManager({
}
})
.catch((err: unknown) => {
- showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
+ showToast(
+ err instanceof Error ? err.message : i18n.t("hooks.fileManager.loadFailed", { path }),
+ "error",
+ );
});
},
[showToast],
@@ -218,22 +222,26 @@ export function useFileManager({
if (res.ok) {
const data = await res.json();
if (data.skipped?.length) {
- showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
+ showToast(
+ i18n.t("hooks.fileManager.skippedTooLarge", {
+ names: data.skipped.join(", "),
+ }),
+ );
}
if (data.invalid?.length) {
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
- showToast(`Unsupported media skipped: ${names}`);
+ showToast(i18n.t("hooks.fileManager.unsupportedMediaSkipped", { names }));
}
await refreshFileTree();
setRefreshKey((k) => k + 1);
return Array.isArray(data.files) ? data.files : [];
} else if (res.status === 413) {
- showToast("Upload rejected: payload too large");
+ showToast(i18n.t("hooks.fileManager.uploadRejectedTooLarge"));
} else {
- showToast(`Upload failed (${res.status})`);
+ showToast(i18n.t("hooks.fileManager.uploadFailedStatus", { status: res.status }));
}
} catch {
- showToast("Upload failed: network error");
+ showToast(i18n.t("hooks.fileManager.uploadFailedNetwork"));
}
return [];
},
@@ -262,7 +270,7 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Create file failed: ${err.error}`);
- showToast(`Couldn't create ${path}: ${err.error}`, "error");
+ showToast(i18n.t("hooks.fileManager.createFailed", { path, error: err.error }), "error");
}
},
[refreshFileTree, handleFileSelect, showToast],
@@ -285,7 +293,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Create folder failed: ${err.error}`);
- showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
+ showToast(
+ i18n.t("hooks.fileManager.createFolderFailed", { path, error: err.error }),
+ "error",
+ );
}
},
[refreshFileTree, showToast],
@@ -304,7 +315,7 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Delete failed: ${err.error}`);
- showToast(`Couldn't delete ${path}: ${err.error}`, "error");
+ showToast(i18n.t("hooks.fileManager.deleteFailed", { path, error: err.error }), "error");
}
},
[refreshFileTree, showToast],
@@ -328,7 +339,7 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Rename failed: ${err.error}`);
- showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
+ showToast(i18n.t("hooks.fileManager.renameFailed", { oldPath, error: err.error }), "error");
}
},
[refreshFileTree, handleFileSelect, setRefreshKey, showToast],
@@ -350,7 +361,7 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Duplicate failed: ${err.error}`);
- showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
+ showToast(i18n.t("hooks.fileManager.duplicateFailed", { path, error: err.error }), "error");
}
},
[refreshFileTree, handleFileSelect, showToast],
diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts
index 962298e1bd..308e403795 100644
--- a/packages/studio/src/hooks/useFrameCapture.ts
+++ b/packages/studio/src/hooks/useFrameCapture.ts
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, type MouseEvent } from "react";
+import i18n from "../i18n";
import { useMountEffect } from "./useMountEffect";
import { liveTime, usePlayerStore } from "../player";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
@@ -44,7 +45,10 @@ export function useFrameCapture({
await Promise.race([
waitForPendingDomEditSaves(),
new Promise((_, reject) =>
- setTimeout(() => reject(new Error("Save queue timed out")), 5000),
+ setTimeout(
+ () => reject(new Error(i18n.t("hooks.frameCapture.saveQueueTimedOut"))),
+ 5000,
+ ),
),
]);
const href = buildFrameCaptureUrl({
@@ -59,7 +63,7 @@ export function useFrameCapture({
const response = await fetch(href, { cache: "no-store", signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
- let msg = `Capture failed (${response.status})`;
+ let msg = i18n.t("hooks.frameCapture.failedStatus", { status: response.status });
try {
const json = await response.json();
if (json?.error) msg = json.error;
@@ -80,12 +84,15 @@ export function useFrameCapture({
} catch (fetchErr) {
clearTimeout(timeout);
if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") {
- throw new Error("Capture timed out — the server took too long to respond");
+ throw new Error(i18n.t("hooks.frameCapture.timedOut"));
}
throw fetchErr;
}
} catch (err) {
- showToast(err instanceof Error ? err.message : "Capture failed", "error");
+ showToast(
+ err instanceof Error ? err.message : i18n.t("hooks.frameCapture.failed"),
+ "error",
+ );
} finally {
capturingRef.current = false;
setCapturing(false);
diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts
index a0aba6747f..6850e7b41d 100644
--- a/packages/studio/src/hooks/useGestureCommit.ts
+++ b/packages/studio/src/hooks/useGestureCommit.ts
@@ -3,6 +3,7 @@
* Extracted from App.tsx to keep file sizes under the 600-line limit.
*/
import { useState, useCallback, useRef, useEffect } from "react";
+import i18n from "../i18n";
import { useGestureRecording } from "./useGestureRecording";
import { simplifyGestureSamples } from "../utils/rdpSimplify";
import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
@@ -136,7 +137,7 @@ export function useGestureCommit({
const sel = capturedSelectionRef.current;
if (!sel) {
if (frozenSamples.length > 2) {
- showToast("Selection lost during recording", "error");
+ showToast(i18n.t("hooks.gesture.selectionLost"), "error");
}
return;
}
@@ -144,11 +145,11 @@ export function useGestureCommit({
frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 0;
if (frozenSamples.length <= 2) {
- showToast("No gesture detected — move the pointer while recording", "error");
+ showToast(i18n.t("hooks.gesture.noGestureDetected"), "error");
return;
}
if (duration <= 0) {
- showToast("Recording too short — try again", "error");
+ showToast(i18n.t("hooks.gesture.recordingTooShort"), "error");
return;
}
@@ -170,7 +171,7 @@ export function useGestureCommit({
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) {
- showToast("Cannot save — element has no selector", "error");
+ showToast(i18n.t("hooks.gesture.noSelector"), "error");
return;
}
if (liveSession.commitMutation) {
@@ -303,10 +304,10 @@ export function useGestureCommit({
}
}
}
- showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
+ showToast(i18n.t("hooks.gesture.recordedKeyframes", { count: sortedPcts.length }), "info");
} catch (err) {
console.error("[GR:error]", err);
- showToast(`Gesture commit failed: ${err}`, "error");
+ showToast(i18n.t("hooks.gesture.commitFailed", { detail: String(err) }), "error");
} finally {
store.requestSeek(recordingStartTimeRef.current);
gestureRecording.clearSamples();
@@ -323,12 +324,12 @@ export function useGestureCommit({
}
const sel = domEditSessionRef.current.domEditSelection;
if (!sel) {
- showToast("Select an element first", "error");
+ showToast(i18n.t("hooks.gesture.selectElementFirst"), "error");
return;
}
const iframe = previewIframeRef.current;
if (!iframe) {
- showToast("Preview not ready — try again", "error");
+ showToast(i18n.t("hooks.gesture.previewNotReady"), "error");
return;
}
diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts
index a263cac3b6..8300d13339 100644
--- a/packages/studio/src/hooks/useRazorSplit.ts
+++ b/packages/studio/src/hooks/useRazorSplit.ts
@@ -1,4 +1,5 @@
import { useCallback, useRef } from "react";
+import i18n from "../i18n";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
@@ -256,7 +257,7 @@ export function useRazorSplit({
// fallow-ignore-next-line complexity
async (element: TimelineElement, splitTime: number) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
@@ -268,7 +269,7 @@ export function useRazorSplit({
await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
if (!changed) {
- showToast("Failed to split clip — playhead may be outside the clip", "error");
+ showToast(i18n.t("hooks.razor.splitFailedPlayhead"), "error");
return;
}
@@ -288,10 +289,18 @@ export function useRazorSplit({
forceReloadSdkSession?.();
reloadPreview();
trackStudioRazorSplit({ mode: "single", count: 1 });
- showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
+ showToast(
+ i18n.t("hooks.razor.splitAt", {
+ label: getTimelineElementLabel(element),
+ time: splitTime.toFixed(2),
+ }),
+ "info",
+ );
if (skippedSelectors?.length) {
showToast(
- `Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
+ i18n.t("hooks.razor.animationsNotRetargeted", {
+ selectors: skippedSelectors.join(", "),
+ }),
"info",
);
}
@@ -316,7 +325,7 @@ export function useRazorSplit({
const handleRazorSplitAll = useCallback(
async (splitTime: number) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
@@ -353,7 +362,13 @@ export function useRazorSplit({
forceReloadSdkSession?.();
reloadPreview();
trackStudioRazorSplit({ mode: "all", count: splitCount });
- showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
+ showToast(
+ i18n.t("hooks.razor.splitMultiple", {
+ count: splitCount,
+ time: splitTime.toFixed(2),
+ }),
+ "info",
+ );
} catch (error) {
// Best-effort rollback — a failing restore write must not swallow the
// original error's toast, which is what tells the user the split failed.
diff --git a/packages/studio/src/hooks/useStudioSelectionPublisher.ts b/packages/studio/src/hooks/useStudioSelectionPublisher.ts
index c4d2e09f2e..3fbbec7460 100644
--- a/packages/studio/src/hooks/useStudioSelectionPublisher.ts
+++ b/packages/studio/src/hooks/useStudioSelectionPublisher.ts
@@ -25,12 +25,11 @@ function reportSelectionPublishError(error: unknown): void {
console.warn("[Studio] Failed to update agent selection context", error);
}
-function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise {
+function putSelection(projectId: string, selection: unknown): Promise {
return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection }),
- signal,
}).then(() => undefined);
}
@@ -55,9 +54,13 @@ export function useStudioSelectionPublisher({
currentTime: usePlayerStore.getState().currentTime,
})
: null;
- const controller = new AbortController();
- void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
- return () => controller.abort();
+ let cancelled = false;
+ putSelection(projectId, selection).catch((err) => {
+ if (!cancelled) reportSelectionPublishError(err);
+ });
+ return () => {
+ cancelled = true;
+ };
}, [domEditSelection, projectId]);
// Clear server-side agent context when Studio leaves a project. Without this,
@@ -82,9 +85,13 @@ export function useStudioSelectionPublisher({
lastSelectionRefreshKeyRef.current = refreshKey;
pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
if (!projectId || !domEditSelectionRef.current) return;
- const controller = new AbortController();
- void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
- return () => controller.abort();
+ let cancelled = false;
+ putSelection(projectId, null).catch((err) => {
+ if (!cancelled) reportSelectionPublishError(err);
+ });
+ return () => {
+ cancelled = true;
+ };
}, [domEditSelectionRef, projectId, refreshKey]);
// `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
diff --git a/packages/studio/src/hooks/useTimelineAssetDropOps.ts b/packages/studio/src/hooks/useTimelineAssetDropOps.ts
index b60f5ddcd3..9ba1bb1297 100644
--- a/packages/studio/src/hooks/useTimelineAssetDropOps.ts
+++ b/packages/studio/src/hooks/useTimelineAssetDropOps.ts
@@ -3,6 +3,7 @@
// Extracted verbatim from useTimelineEditing.ts to keep it under the studio
// 600-line cap.
import { useCallback, type MutableRefObject, type RefObject } from "react";
+import i18n from "../i18n";
import type { TimelineElement } from "../player";
import {
buildTimelineAssetId,
@@ -56,7 +57,7 @@ export function useTimelineAssetDropOps({
durationOverride?: number,
) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
const pid = projectIdRef.current;
@@ -64,7 +65,7 @@ export function useTimelineAssetDropOps({
const kind = getTimelineAssetKind(assetPath);
if (!kind) {
- showToast("Only image, video, and audio assets can be dropped onto the timeline.");
+ showToast(i18n.t("hooks.timeline.assetDropRestricted"));
return;
}
@@ -143,7 +144,7 @@ export function useTimelineAssetDropOps({
// fallow-ignore-next-line complexity
async (files: File[], placement?: Pick) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
const pid = projectIdRef.current;
diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts
index 1727521dbd..fa93e0f8e1 100644
--- a/packages/studio/src/hooks/useTimelineEditing.ts
+++ b/packages/studio/src/hooks/useTimelineEditing.ts
@@ -1,5 +1,6 @@
// fallow-ignore-file complexity
import { useCallback, useRef } from "react";
+import i18n from "../i18n";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
@@ -69,7 +70,7 @@ export function useTimelineEditing({
coalesceKey?: string,
): Promise => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return Promise.resolve();
}
const pid = projectIdRef.current;
@@ -372,7 +373,7 @@ export function useTimelineEditing({
// fallow-ignore-next-line complexity
async (element: TimelineElement) => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return;
}
const pid = projectIdRef.current;
@@ -444,9 +445,10 @@ export function useTimelineEditing({
usePlayerStore.getState().setSelectedElementId(null);
forceReloadSdkSession?.();
reloadPreview();
- showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
+ showToast(i18n.t("hooks.timeline.deletedUseUndo", { label }), "info");
} catch (error) {
- const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
+ const message =
+ error instanceof Error ? error.message : i18n.t("hooks.timeline.deleteFailed");
showToast(message);
}
},
@@ -483,7 +485,7 @@ export function useTimelineEditing({
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
- showToast("This clip can't be moved or resized from the timeline yet.", "info");
+ showToast(i18n.t("hooks.timeline.clipNotMovable"), "info");
},
[showToast],
);
diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts
index 3dace0f6f6..6dc4654e6a 100644
--- a/packages/studio/src/hooks/useTimelineGroupEditing.ts
+++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts
@@ -1,4 +1,5 @@
import { useCallback, type MutableRefObject, type RefObject } from "react";
+import i18n from "../i18n";
import type { Composition } from "@hyperframes/sdk";
import type { TimelineElement } from "../player";
import { sdkTimingBatchPersist } from "../utils/sdkCutover";
@@ -112,7 +113,7 @@ export function useTimelineGroupEditing({
const enqueueGroupOperation = useCallback(
(label: string, operation: (projectId: string) => Promise): Promise => {
if (isRecordingRef?.current) {
- showToast("Cannot edit timeline while recording", "error");
+ showToast(i18n.t("hooks.timeline.cannotEditWhileRecording"), "error");
return Promise.reject(new Error(`${label}: blocked while recording`));
}
const projectId = projectIdRef.current;
diff --git a/packages/studio/src/i18n/__tests__/i18n.test.ts b/packages/studio/src/i18n/__tests__/i18n.test.ts
new file mode 100644
index 0000000000..45bb60f1bf
--- /dev/null
+++ b/packages/studio/src/i18n/__tests__/i18n.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from "vitest";
+import en from "../locales/en.json";
+import zh from "../locales/zh.json";
+
+type NestedRecord = {
+ [key: string]: string | string[] | NestedRecord;
+};
+
+function flattenKeys(obj: NestedRecord, prefix = ""): string[] {
+ const keys: string[] = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const fullKey = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === "object" && !Array.isArray(value)) {
+ keys.push(...flattenKeys(value as NestedRecord, fullKey));
+ } else {
+ keys.push(fullKey);
+ }
+ }
+ return keys;
+}
+
+describe("i18n translation files", () => {
+ it("en.json should have all string values", () => {
+ const enKeys = flattenKeys(en as NestedRecord);
+ expect(enKeys.length).toBeGreaterThan(0);
+ for (const key of enKeys) {
+ const value = getNestedValue(en as NestedRecord, key);
+ expect(typeof value).toBe("string");
+ expect((value as string).length).toBeGreaterThan(0);
+ }
+ });
+
+ it("zh.json should have the same structure as en.json", () => {
+ const enKeys = new Set(flattenKeys(en as NestedRecord));
+ const zhKeys = new Set(flattenKeys(zh as NestedRecord));
+
+ const missingInZh = [...enKeys].filter((k) => !zhKeys.has(k));
+ const extraInZh = [...zhKeys].filter((k) => !enKeys.has(k));
+
+ expect(missingInZh, `Keys missing in zh.json: ${missingInZh.join(", ")}`).toHaveLength(0);
+ expect(extraInZh, `Extra keys in zh.json not in en.json: ${extraInZh.join(", ")}`).toHaveLength(
+ 0,
+ );
+ });
+
+ it("zh.json should have all string values", () => {
+ const zhKeys = flattenKeys(zh as NestedRecord);
+ expect(zhKeys.length).toBeGreaterThan(0);
+ for (const key of zhKeys) {
+ const value = getNestedValue(zh as NestedRecord, key);
+ expect(typeof value).toBe("string");
+ expect((value as string).length).toBeGreaterThan(0);
+ }
+ });
+
+ it("en.json should have nested structure consistent with zh.json", () => {
+ function getStructure(obj: unknown): unknown {
+ if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
+ const struct: Record = {};
+ for (const [key, value] of Object.entries(obj as NestedRecord)) {
+ struct[key] = getStructure(value);
+ }
+ return struct;
+ }
+ return typeof obj;
+ }
+
+ const enStruct = JSON.stringify(getStructure(en));
+ const zhStruct = JSON.stringify(getStructure(zh));
+ expect(zhStruct).toBe(enStruct);
+ });
+});
+
+function getNestedValue(obj: NestedRecord, path: string): unknown {
+ return path.split(".").reduce((acc, part) => {
+ if (acc && typeof acc === "object") {
+ return (acc as NestedRecord)[part];
+ }
+ return undefined;
+ }, obj);
+}
diff --git a/packages/studio/src/i18n/index.ts b/packages/studio/src/i18n/index.ts
new file mode 100644
index 0000000000..825cfed1bf
--- /dev/null
+++ b/packages/studio/src/i18n/index.ts
@@ -0,0 +1,68 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import en from "./locales/en.json";
+import zh from "./locales/zh.json";
+
+const SUPPORTED_LANGS = ["en", "zh"] as const;
+export type SupportedLocale = (typeof SUPPORTED_LANGS)[number];
+
+function detectLanguage(): SupportedLocale {
+ try {
+ const stored = localStorage.getItem("hf-studio-lang");
+ if (stored === "en" || stored === "zh") return stored;
+ } catch {
+ // localStorage unavailable
+ }
+
+ try {
+ const navLang = (navigator.language || "").toLowerCase();
+ if (navLang.startsWith("zh")) return "zh";
+ } catch {
+ // navigator unavailable
+ }
+
+ return "en";
+}
+
+export function isValidLocale(locale: string): locale is SupportedLocale {
+ return SUPPORTED_LANGS.includes(locale as SupportedLocale);
+}
+
+const detectedLang = detectLanguage();
+
+function isDevMode(): boolean {
+ try {
+ return import.meta.env.DEV === true;
+ } catch {
+ return false;
+ }
+}
+
+// Set initial html lang attribute
+if (typeof document !== "undefined") {
+ document.documentElement.lang = detectedLang === "zh" ? "zh-CN" : "en";
+}
+
+i18n.use(initReactI18next).init({
+ resources: {
+ en: { translation: en },
+ zh: { translation: zh },
+ },
+ lng: detectedLang,
+ fallbackLng: "en",
+ interpolation: {
+ escapeValue: false,
+ },
+ ...(isDevMode()
+ ? {
+ missingKeyHandler: (_lngs: readonly string[], _ns: string, key: string) => {
+ console.warn(`[i18n] Missing translation key: ${key}`);
+ },
+ }
+ : {}),
+ react: {
+ useSuspense: false,
+ },
+});
+
+export default i18n;
diff --git a/packages/studio/src/i18n/locales/en.json b/packages/studio/src/i18n/locales/en.json
new file mode 100644
index 0000000000..e6c2284eae
--- /dev/null
+++ b/packages/studio/src/i18n/locales/en.json
@@ -0,0 +1,1022 @@
+{
+ "header": {
+ "undo": "Undo",
+ "redo": "Redo",
+ "undoWithLabel": "Undo {{label}} ({{shortcut}})",
+ "redoWithLabel": "Redo {{label}} ({{shortcut}})",
+ "captureFrame": "Capture current frame",
+ "capturingFrame": "Capturing frame…",
+ "capturing": "Capturing…",
+ "capture": "Capture",
+ "inspector": "Inspector",
+ "manualEditingDisabled": "Manual editing is temporarily disabled",
+ "studioView": "Studio view",
+ "storyboard": "Storyboard",
+ "preview": "Preview",
+ "renderInProgress": "A render is already in progress",
+ "renderAndExport": "Render and export this composition",
+ "rendering": "Rendering…",
+ "export": "Export"
+ },
+ "splash": {
+ "waitingForServer": "Waiting for preview server… run <1>npm run dev1>",
+ "connecting": "Connecting to project…"
+ },
+ "rightPanel": {
+ "design": "Design",
+ "layers": "Layers",
+ "motion": "Motion",
+ "renders": "Renders",
+ "rendersWithCount": "Renders ({{count}})",
+ "tooltipDesign": "Element styles and properties",
+ "tooltipLayers": "Composition layer stack",
+ "tooltipMotion": "Animation and motion",
+ "tooltipRenders": "Render queue and exports",
+ "tooltipSlideshow": "Slideshow branching editor",
+ "slideshow": "Slideshow"
+ },
+ "leftSidebar": {
+ "showSidebar": "Show sidebar",
+ "hideSidebar": "Hide sidebar",
+ "code": "Code",
+ "comps": "Comps",
+ "assets": "Assets",
+ "catalog": "Catalog",
+ "tooltipCode": "Source code editor",
+ "tooltipComps": "Compositions and sub-compositions",
+ "tooltipAssets": "Videos, images, audio, fonts",
+ "tooltipCatalog": "Browse blocks and components",
+ "selectFileToEdit": "Select a file to edit",
+ "lint": "Lint",
+ "linting": "Linting…"
+ },
+ "compositionsTab": {
+ "noCompositions": "No compositions found",
+ "rendering": "Rendering…",
+ "render": "Render {{name}}"
+ },
+ "assetsTab": {
+ "importMedia": "Import media",
+ "dropMediaHere": "Drop media files here",
+ "addAtPlayhead": "Add at playhead",
+ "copyPath": "Copy path",
+ "rename": "Rename",
+ "delete": "Delete",
+ "cancel": "Cancel",
+ "deleteConfirm": "Delete {{name}}?",
+ "copied": "Copied!",
+ "thisProject": "This project",
+ "allProjects": "All projects"
+ },
+ "globalAssets": {
+ "loading": "Loading global assets…",
+ "empty": "No assets in the global cache yet. Resolved media is promoted to",
+ "emptySuffix": "and becomes reusable across projects."
+ },
+ "slideshowPanel": {
+ "slides": "Slides ({{count}})",
+ "slideInspector": "Slide Inspector",
+ "selectSceneHint": "Select a scene above to inspect",
+ "branches": "Branches ({{count}})",
+ "hotspotTool": "Hotspot Tool",
+ "noScenesFound": "No scenes found"
+ },
+ "blocksTab": {
+ "loading": "Loading blocks…",
+ "searchPlaceholder": "Search by name, category, or tag…",
+ "all": "All",
+ "vfxNotice": "VFX blocks use WebGL via HTML-in-Canvas. Enable <1>chrome://flags/#html-in-canvas1> for preview.",
+ "noMatch": "No blocks match your search",
+ "add": "Add",
+ "added": "Added!",
+ "askAgent": "Ask agent",
+ "copyPrompt": "Copy prompt",
+ "copied": "Copied!",
+ "modalTitle": "Ask agent",
+ "modalDescription": "Edit the prompt below, then copy and paste into your AI agent",
+ "category": {
+ "captions": "Captions",
+ "code-animation": "Code Animations",
+ "vfx": "VFX",
+ "transitions": "Transitions",
+ "effects": "Effects",
+ "text-effects": "Text Effects",
+ "social": "Social",
+ "data": "Data",
+ "scenes": "Scenes"
+ },
+ "webgl": "WebGL",
+ "shortcutHintMac": "⌘+Enter to copy",
+ "shortcutHintOther": "Ctrl+Enter to copy",
+ "addToComposition": "Add to composition at current time",
+ "generatePrompt": "Generate a prompt to paste into your AI agent"
+ },
+ "common": {
+ "close": "Close",
+ "copied": "Copied"
+ },
+ "lintModal": {
+ "resultsTitle": "{{errors}} error, {{warnings}} warning",
+ "resultsTitlePlural": "{{errors}} errors, {{warnings}} warnings",
+ "allChecksPassed": "All checks passed",
+ "subtitle": "HyperFrame Lint Results",
+ "copyToAgent": "Copy to Agent",
+ "copyFailed": "Copy failed — check permissions",
+ "copied": "Copied!",
+ "noIssuesFound": "No errors or warnings found. Your composition looks good!"
+ },
+ "askAgentModal": {
+ "title": "Copy prompt to AI agent",
+ "placeholder": "Describe what you want to change…",
+ "contextIncluded": "Context included in prompt",
+ "shortcutHintMac": "⌘+Enter to copy",
+ "shortcutHintOther": "Ctrl+Enter to copy",
+ "copyPrompt": "Copy prompt"
+ },
+ "feedbackBar": {
+ "thanks": "Thanks for the feedback!",
+ "detailsPlaceholder": "Any details? (enter to send, esc to close)",
+ "send": "send",
+ "prompt": "How's the Studio experience?",
+ "dismissAria": "Dismiss"
+ },
+ "propertyPanel": {
+ "noSelection": "Select an element in the preview.",
+ "multiSelect": "{{count}} elements selected",
+ "multiSelectHint": "Select a single element to edit its properties. Click an element in the preview or use the timeline layer panel.",
+ "inspectorIntro": "The inspector is tuned for element edits with safer geometry controls, color picking, and cleaner grouped layer controls.",
+ "document": "Document",
+ "layout": "Layout",
+ "text": "Text",
+ "timing": "Timing",
+ "start": "Start",
+ "end": "End",
+ "duration": "Duration",
+ "x": "X",
+ "y": "Y",
+ "w": "W",
+ "h": "H",
+ "r": "R",
+ "z": "Z",
+ "scale": "Scale",
+ "rotate": "Rotate",
+ "rotX": "RotX",
+ "rotY": "RotY",
+ "rotZ": "RotZ",
+ "perspective": "Perspective",
+ "zIndex": "Z-index",
+ "transform3d": "3D Transform",
+ "stacking": "Stacking",
+ "flex": "Flex",
+ "radius": "Radius",
+ "stroke": "Stroke",
+ "effects": "Effects",
+ "clip": "Clip",
+ "transparency": "Transparency",
+ "fill": "Fill",
+ "justify": "Justify",
+ "align": "Align",
+ "gap": "Gap",
+ "width": "Width",
+ "style": "Style",
+ "strokeColor": "Stroke color",
+ "shadow": "Shadow",
+ "layerBlur": "Layer blur",
+ "backdrop": "Backdrop",
+ "overflow": "Overflow",
+ "mask": "Mask",
+ "maskInset": "Mask inset",
+ "mode": "Mode",
+ "fillColor": "Fill color",
+ "content": "Content",
+ "textColor": "Text color",
+ "textLayers": "Text layers",
+ "addText": "Add text",
+ "length": "Length",
+ "startsAt": "Starts at",
+ "speed": "Speed",
+ "solid": "Solid",
+ "gradient": "Gradient",
+ "image": "Image",
+ "linear": "Linear",
+ "radial": "Radial",
+ "conic": "Conic",
+ "repeat": "Repeat",
+ "reverse": "Reverse",
+ "angle": "Angle",
+ "startAngle": "Start angle",
+ "shape": "Shape",
+ "size": "Size",
+ "centerX": "Center X",
+ "centerY": "Center Y",
+ "stops": "Stops",
+ "stop": "Stop",
+ "addStop": "Add stop",
+ "pos": "Pos",
+ "copyPrompt": "Copy prompt to AI agent",
+ "promptCopied": "Prompt copied",
+ "fitToChildren": "Fit to children",
+ "position": "Position",
+ "rotation": "Rotation",
+ "opacity": "Opacity",
+ "backgroundColor": "Background",
+ "borderRadius": "Border radius",
+ "borderColor": "Border color",
+ "borderWidth": "Border width",
+ "boxShadow": "Box shadow",
+ "fontFamily": "Font family",
+ "fontWeight": "Font weight",
+ "fontSize": "Font size",
+ "lineHeight": "Line height",
+ "textAlign": "Text align",
+ "letterSpacing": "Letter spacing",
+ "color": "Color",
+ "textShadow": "Text shadow",
+ "clipPath": "Clip path",
+ "filter": "Filter",
+ "backdropFilter": "Backdrop filter",
+ "transform": "Transform",
+ "transformOrigin": "Transform origin",
+ "mixBlendMode": "Mix blend mode",
+ "cursor": "Cursor",
+ "hideElement": "Hide element",
+ "showElement": "Show element",
+ "copyInfo": "Copy element info to clipboard",
+ "clearSelection": "Clear selection",
+ "ungroup": "Ungroup",
+ "colorGrading": "Color grading",
+ "recordGesture": "Record gesture (R) -- move pointer to capture motion",
+ "stopRecording": "Stop gesture recording (R)",
+ "stopRecordingDuration": "Stop recording {{seconds}}s — press R",
+ "resetOrientation": "Reset 3D orientation",
+ "keyframeTransform": "Keyframe the 3D transform (animate it over time)",
+ "keyframeTransformActive": "3D transform is keyframed -- click a field diamond to add keyframes",
+ "copyDescription": "Copy description to clipboard -- paste into agent prompts",
+ "dragToTilt": "Drag to tilt · Shift-drag to roll · Scroll for depth",
+ "rotate3d": "Drag to rotate in 3D; hold Shift to roll; scroll to change depth",
+ "inferredTiming": "Inferred from this element's animation — edit to pin an explicit clip range.",
+ "scrollToAdjust": "Scroll or use Arrow keys to adjust",
+ "whenPlays": "When this effect plays",
+ "lastingTooltip": "How long this effect lasts",
+ "beginsTooltip": "When this effect begins on the timeline",
+ "removeAnimation": "Remove this animation",
+ "addEffectTooltip": "Add another animated property to this effect",
+ "addFromPropTooltip": "Add a from-state property",
+ "addEffect": "+ Effect",
+ "addFromProperty": "+ From property",
+ "remove": "Remove",
+ "from": "From",
+ "to": "To",
+ "none": "none",
+ "customCurve": "Custom curve",
+ "dragToMove": "drag to move",
+ "keyframedNotice": "Keyframed — click a segment below to edit its curve",
+ "copy": "Copy",
+ "copied": "Copied"
+ },
+ "motionPanel": {
+ "noSelection": "Select an element for motion.",
+ "noSelectionHint": "Timeline layers and inspector selections can receive Studio-authored GSAP motion.",
+ "motionTarget": "Motion Target",
+ "gsapMotion": "GSAP Motion",
+ "easeCurve": "Ease Curve",
+ "fadeUp": "Fade Up",
+ "slide": "Slide",
+ "pop": "Pop",
+ "direction": "Direction",
+ "ease": "Ease",
+ "start": "Start",
+ "duration": "Duration",
+ "distance": "Distance",
+ "customEase": "CustomEase",
+ "clearMotion": "Clear motion",
+ "up": "up",
+ "down": "down",
+ "left": "left",
+ "right": "right"
+ },
+ "layersPanel": {
+ "noLayers": "No layers",
+ "noLayersHint": "Load a composition to see its element tree",
+ "layerCount": "{{count}} layer",
+ "layerCountPlural": "{{count}} layers",
+ "singleLayer": "Only one layer at this level",
+ "nestedLayers": "{{count}} nested selectable layers",
+ "singleSelectableLayer": "Single selectable media layer",
+ "singleSelectableLayerAlt": "Single selectable layer"
+ },
+ "gsapSection": {
+ "title": "Animation",
+ "multipleTimelinesWarning": "This file has multiple GSAP timelines. Animation editing is disabled to prevent data loss — consolidate into a single timeline to enable editing.",
+ "unsupportedPatternWarning": "This composition uses a timeline assignment pattern (window.__timelines[…]) that the editor doesn't support. Use a variable declaration (const tl = gsap.timeline()) to enable editing.",
+ "addEffect": "+ Add effect",
+ "cancel": "Cancel",
+ "set": "Set",
+ "animate": "Animate",
+ "animateIn": "Animate In",
+ "fromTo": "From → To",
+ "setInstantly": "Set Instantly",
+ "tooltipSet": "Instantly snap to these values — no transition",
+ "tooltipAnimate": "Smoothly animate the element to these target values",
+ "tooltipAnimateIn": "Element starts at these values and transitions to its normal state",
+ "tooltipFromTo": "Animate from one state to another",
+ "propMoveX": "Move X",
+ "propMoveY": "Move Y",
+ "propWidth": "Width",
+ "propHeight": "Height",
+ "propRotate": "Rotate",
+ "propOpacity": "Opacity",
+ "propScale": "Scale",
+ "propScaleX": "Scale X",
+ "propScaleY": "Scale Y",
+ "propVisibility": "Visibility",
+ "propVisible": "Visible",
+ "propStretchX": "Stretch X",
+ "propFilter": "Filter",
+ "propClipPath": "Clip Path",
+ "propColor": "Color",
+ "propBackground": "Background",
+ "propBorderColor": "Border Color",
+ "propRadius": "Radius",
+ "propFontSize": "Font Size",
+ "propTracking": "Tracking",
+ "propSkewX": "Skew X",
+ "propSkewY": "Skew Y",
+ "easeLinear": "Constant speed",
+ "easePower1Out": "Gentle slowdown",
+ "easePower2Out": "Smooth slowdown",
+ "easePower3Out": "Strong slowdown",
+ "easePower4Out": "Very strong slowdown",
+ "easeBackOut": "Overshoot & settle",
+ "easeElasticOut": "Springy bounce",
+ "easeBounceOut": "Bouncy landing",
+ "easeRoughOut": "Rough stop",
+ "easeSlow": "Slow motion",
+ "easeSteps": "Step by step",
+ "easeCircOut": "Circular ease out",
+ "easeExpoOut": "Exponential ease out",
+ "easeSineOut": "Sine ease out",
+ "easeQuintOut": "Quintic ease out",
+ "easeQuartOut": "Quartic ease out",
+ "easeCubicOut": "Cubic ease out",
+ "easeQuadOut": "Quadratic ease out",
+ "unitPx": "px",
+ "unitDeg": "°",
+ "unitPercent": "%",
+ "unitTimes": "×"
+ },
+ "fileTree": {
+ "files": "Files",
+ "newFile": "New file",
+ "newFolder": "New folder",
+ "rename": "Rename",
+ "duplicate": "Duplicate",
+ "delete": "Delete"
+ },
+ "renderQueue": {
+ "clear": "Clear",
+ "noRenders": "No renders yet",
+ "draft": "Draft",
+ "standard": "Standard",
+ "highQuality": "High Quality",
+ "draftHint": "Fast render, smaller file",
+ "standardHint": "Good quality, balanced file size",
+ "highQualityHint": "Best quality, larger file",
+ "rendering": "Rendering…",
+ "export": "Export",
+ "auto": "Auto",
+ "resolution1080p": "1080p",
+ "resolution4k": "4K",
+ "mp4": "MP4",
+ "mov": "MOV",
+ "webm": "WebM",
+ "mp4Hint": "Best for general use. Smallest file, universal playback.",
+ "movHint": "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.",
+ "webmHint": "Transparent video for web. Smaller than MOV but limited editor support.",
+ "fps24": "24fps",
+ "fps30": "30fps",
+ "fps60": "60fps",
+ "justNow": "just now",
+ "minutesAgo": "{{count}}m ago",
+ "hoursAgo": "{{count}}h ago",
+ "download": "Download",
+ "remove": "Remove",
+ "renderStage": "Rendering",
+ "renderProgress": "Rendering ({{progress}}%)"
+ },
+ "timelineToolbar": {
+ "timeline": "Timeline",
+ "removeKeyframe": "Remove keyframe at playhead",
+ "addKeyframe": "Add keyframe at playhead",
+ "enableKeyframes": "Enable keyframes",
+ "splitClip": "Split clip at playhead (S)",
+ "splitAtPlayhead": "Split at playhead",
+ "noElementToSplit": "Select a clip to split",
+ "movePlayheadInside": "Move the playhead inside the clip to split",
+ "addBeatAtPlayhead": "Add beat at playhead",
+ "beatAlreadyExists": "A beat already exists at the playhead",
+ "addMusicTrackForBeats": "Add a music track with beat analysis to place beats",
+ "selectionTool": "Selection tool (V)",
+ "selectionToolAria": "Selection tool",
+ "razorTool": "Razor tool (B) — Shift+click splits all tracks",
+ "razorToolAria": "Razor tool",
+ "toggleSnappingAria": "Toggle timeline snapping",
+ "autoRecordOn": "Auto-record manual edits as keyframes (click to turn off)",
+ "autoRecordOff": "Manual edits will not be recorded as keyframes (click to turn on)",
+ "autoRecordAria": "Auto-record manual edits as keyframes",
+ "selectElementForKeyframes": "Select an animated element to add keyframes",
+ "removeKeyframeK": "Remove keyframe at playhead (K)",
+ "addKeyframeExtend": "Add keyframe at playhead, extends animation (K)",
+ "addKeyframeK": "Add keyframe at playhead (K)",
+ "addKeyframeShortcut": "Add keyframe (K)",
+ "removeKeyframeAria": "Remove keyframe at playhead",
+ "addKeyframeAria": "Add keyframe at playhead",
+ "timelineZoom": "Timeline zoom",
+ "timelineZoomLevel": "Timeline zoom level",
+ "fitTimeline": "Fit timeline to width",
+ "zoomOut": "Zoom out",
+ "zoomIn": "Zoom in",
+ "fit": "Fit",
+ "hideEditor": "Hide timeline editor",
+ "snapEnabled": "Snap enabled (S)",
+ "snapDisabled": "Snap disabled (S)",
+ "gridVisible": "Grid visible (G)",
+ "gridHidden": "Grid hidden (G)",
+ "gridSpacing": "Grid spacing",
+ "snapToGrid": "Snap to grid"
+ },
+ "playerControls": {
+ "pause": "Pause",
+ "play": "Play",
+ "mutedAbove1x": "Audio muted above 1x speed",
+ "unmuteAudio": "Unmute audio",
+ "muteAudio": "Mute audio",
+ "loopPlayback": "Loop playback",
+ "disableLoop": "Disable loop playback",
+ "enableLoop": "Enable loop playback",
+ "exitFullscreen": "Exit fullscreen (F)",
+ "enterFullscreen": "Enter fullscreen (F)",
+ "exitFullscreenLabel": "Exit fullscreen",
+ "enterFullscreenLabel": "Enter fullscreen",
+ "switchToFrame": "Switch to frame display",
+ "switchToTime": "Switch to time display",
+ "seek": "Seek"
+ },
+ "speedMenu": {
+ "playbackSpeed": "Playback speed"
+ },
+ "shortcutsPanel": {
+ "shortcutsAndTools": "Shortcuts and tools",
+ "jumpToFrame": "Jump to frame",
+ "workArea": "Work area",
+ "frameNumberPlaceholder": "frame number",
+ "go": "Go",
+ "playbackSection": "Playback",
+ "space": "Play/Pause",
+ "playBackward": "Play backward",
+ "stop": "Stop",
+ "playForward": "Play forward",
+ "toggleMute": "Toggle mute",
+ "toggleLoop": "Toggle loop",
+ "stepFrame": "Step 1 frame",
+ "step10Frames": "Step 10 frames",
+ "toggleFullscreen": "Toggle fullscreen",
+ "inPoint": "In-point",
+ "clearInPoint": "Clear in-point",
+ "outPoint": "Out-point",
+ "clearOutPoint": "Clear out-point",
+ "jumpToInPoint": "Jump to in-point",
+ "jumpToOutPoint": "Jump to out-point"
+ },
+ "editModal": {
+ "timeRange": "{{start}} — {{end}}",
+ "elementCount": "{{count}} element",
+ "elementCountPlural": "{{count}} elements",
+ "whatShouldChange": "What should change?",
+ "copyPrompt": "Copy Prompt",
+ "promptCopied": "Prompt Copied!",
+ "copyToAgent": "Copy to Agent",
+ "copied": "Copied!",
+ "shortcutHint": "Cmd+Enter"
+ },
+ "clipContextMenu": {
+ "splitAt": "Split at {{time}}s",
+ "splitHint": "Split (move playhead inside clip)",
+ "shortcut": "S",
+ "delete": "Delete"
+ },
+ "timelineEditorNotice": {
+ "dismiss": "Dismiss timeline editor notice",
+ "title": "Timeline editing is on",
+ "body": "Drag clips to move timing, use Shift + click to edit a full clip range, and watch for resize handles only on clips Studio can patch safely. Toggle the timeline with",
+ "dismissOnce": "Dismiss once and it stays hidden."
+ },
+ "nle": {
+ "timeline": "Timeline",
+ "show": "Show",
+ "showTimelineEditor": "Show timeline editor ({{shortcut}})",
+ "hideTimelineEditor": "Hide timeline editor ({{shortcut}})",
+ "showTimelineEditorAria": "Show timeline editor",
+ "loadingComposition": "Loading composition…",
+ "compositionPreview": "Composition preview",
+ "fit": "Fit",
+ "resetZoom": "{{zoom}}% — Reset",
+ "resetZoomAria": "Reset zoom to fit",
+ "compositionNavigation": "Composition navigation",
+ "backTooltip": "Back (Esc, or double-click empty timeline)",
+ "backToParentAria": "Back to parent composition",
+ "master": "Master",
+ "assetPreview": "Preview: {{name}}",
+ "closePreview": "Close preview",
+ "resizeTimelineAria": "Resize timeline (arrow keys)"
+ },
+ "errorBoundary": {
+ "somethingWentWrong": "Something went wrong",
+ "tryAgain": "Try again"
+ },
+ "storyboard": {
+ "loading": "Loading storyboard…",
+ "loadError": "Couldn't load the storyboard:",
+ "retry": "Retry",
+ "noStoryboard": "No storyboard yet",
+ "copyPrompt": "Copy prompt",
+ "addPrompt": "Add a",
+ "addPromptSuffix": "at the project root to plan this video frame by frame. Hand this prompt to your coding agent to scaffold it.",
+ "promptForAgent": "Prompt for your agent",
+ "noFrames": "This storyboard has no frames yet.",
+ "narrationScript": "Narration script",
+ "statusLabel": "Status",
+ "title": "Storyboard",
+ "untitled": "Untitled storyboard",
+ "metaArc": "Arc",
+ "metaAudience": "Audience",
+ "metaVoice": "Voice",
+ "metaFormat": "Format",
+ "metaFrames": "Frames",
+ "backToBoard": "← Board",
+ "frameHeader": "Frame {{number}} — {{title}}",
+ "prev": "‹ Prev",
+ "next": "Next ›",
+ "notBuiltYet": "Not built yet",
+ "noPreview": "No preview",
+ "frameFileNotFound": "Frame file not found",
+ "duration": "Duration {{value}}",
+ "transition": "Transition {{value}}",
+ "voiceoverGuide": "Voiceover",
+ "voiceoverGuideHint": "guide",
+ "saving": "Saving…",
+ "save": "Save",
+ "voiceoverPlaceholder": "What the narrator says over this frame…",
+ "voiceoverDraftHint": "A draft guide. SCRIPT.md locks the final narration that drives TTS.",
+ "narrative": "Narrative",
+ "openInPreview": "Open in Preview →",
+ "discardVoiceoverChanges": "Discard unsaved voiceover changes?",
+ "failedToSave": "Failed to save",
+ "failedToLoad": "Failed to load file",
+ "frameFallback": "Frame {{index}}",
+ "outlineFallback": "Outline",
+ "statusAria": "Status: {{label}} — {{tooltip}}",
+ "discardMarkdownChanges": "Discard unsaved markdown changes?",
+ "board": "Board",
+ "source": "Source",
+ "viewAriaLabel": "Storyboard view",
+ "unsavedChanges": "Unsaved changes",
+ "saved": "Saved",
+ "loadingFile": "Loading {{path}}…",
+ "previewLabel": "Preview",
+ "status": {
+ "outline": {
+ "label": "Outline",
+ "tooltip": "Planned in text — no HTML frame built yet.",
+ "description": "Planned in text. Story and intent exist; the visual isn't built."
+ },
+ "built": {
+ "label": "Built",
+ "tooltip": "Static HTML frame built — not animated yet.",
+ "description": "The HTML frame exists as a static key moment. Look is locked; motion isn't."
+ },
+ "animated": {
+ "label": "Animated",
+ "tooltip": "Keyframed and animated — plays in the final cut.",
+ "description": "Keyframed into motion. This is the file stitched into the final video."
+ }
+ }
+ },
+ "mediaPreview": {
+ "binaryFile": "Binary file — preview not available"
+ },
+ "captionPropertyPanel": {
+ "selectWords": "Select caption words to edit their style",
+ "wordCount": "{{count}} word",
+ "wordCountPlural": "{{count}} words",
+ "style": "Style",
+ "animation": "Animation",
+ "position": "Position",
+ "transform": "Transform"
+ },
+ "captionAnimationPanel": {
+ "selectGroup": "Select a caption group to edit animations",
+ "entrance": "Entrance",
+ "highlight": "Highlight",
+ "exit": "Exit",
+ "preset": "Preset",
+ "duration": "Duration",
+ "ease": "Ease",
+ "stagger": "Stagger",
+ "intensity": "Intensity",
+ "applyToAll": "Apply to all groups"
+ },
+ "blockParamsPanel": {
+ "parameters": "Parameters"
+ },
+ "timelineEmptyState": {
+ "dropMedia": "Drop media files to import",
+ "dropOrDescribe": "Drop media here or describe your video to start",
+ "describeToStart": "Describe your video to start creating"
+ },
+ "snapToolbar": {
+ "snapEnabled": "Snap enabled (S)",
+ "snapDisabled": "Snap disabled (S)",
+ "gridVisible": "Grid visible (G)",
+ "gridHidden": "Grid hidden (G)",
+ "gridSpacing": "Grid spacing",
+ "snapToGrid": "Snap to grid"
+ },
+ "shaderLoader": {
+ "preparing": "Preparing scene transitions",
+ "samplingOutgoing": "Sampling outgoing scene motion",
+ "samplingIncoming": "Sampling incoming scene motion",
+ "caching": "Caching transition frames",
+ "finalizing": "Finalizing transition preview",
+ "ariaLabel": "Preparing scene transitions",
+ "defaultDetail": "Rendering animated scene samples for shader transitions.",
+ "transition": "transition",
+ "transitionFrame": "transition frame",
+ "cachedDetail": "Loading cached transition frames before playback.",
+ "cachedFrameLabel": "cached transition frames",
+ "finalizingDetail": "Uploading transition textures for smooth playback.",
+ "finalizingFrameLabel": "finalizing transition frames",
+ "renderingFrameLabel": "rendering transition frames"
+ },
+ "player": {
+ "play": "Play",
+ "pause": "Pause",
+ "playbackSpeed": "Playback speed",
+ "mute": "Mute",
+ "unmute": "Unmute",
+ "volume": "Volume",
+ "compositionTitle": "HyperFrames Composition",
+ "editRangeHint": "+ drag/click to edit range"
+ },
+ "timelineLayerPanel": {
+ "noSelectableLayers": "No selectable layers"
+ },
+ "editor": {
+ "arcPath": {
+ "needKeyframes": "Add at least 2 position keyframes to enable arc motion.",
+ "arcMotion": "Arc motion",
+ "disableArcMotion": "Disable arc motion",
+ "enableArcMotion": "Enable arc motion",
+ "autoRotate": "Auto-rotate",
+ "disableAutoRotate": "Disable auto-rotate along path",
+ "enableAutoRotate": "Rotate element to follow path tangent",
+ "curviness": "Curviness",
+ "segment": "Segment {{index}}",
+ "reset": "Reset",
+ "resetControlPoints": "Reset to auto-generated control points"
+ },
+ "borderRadius": {
+ "all": "All",
+ "unlinkCorners": "Unlink corners",
+ "linkCorners": "Link all corners"
+ },
+ "animationCard": {
+ "visibleHide": "Visible — click to hide",
+ "hiddenShow": "Hidden — click to show",
+ "chooseProperty": "Choose property…",
+ "filterBlur": "Blur",
+ "filterBright": "Brighten",
+ "filterGray": "Grayscale",
+ "filterNone": "None",
+ "clipCircle": "Circle",
+ "clipInset": "Inset",
+ "clipNone": "None"
+ },
+ "computedTween": {
+ "computedValue": "Computed value — edit in the Code tab.",
+ "generatedBy": "Generated by {{source}} — not directly editable.",
+ "loopSource": "loop",
+ "unrollToEdit": "Unroll to edit",
+ "unrollTitle": "Rewrite helper/loop into explicit tweens so this keyframe can be edited directly"
+ },
+ "easeCurve": {
+ "speedCurve": "Speed curve",
+ "playing": "Playing…",
+ "preview": "Preview",
+ "start": "Start",
+ "end": "End",
+ "timeArrow": "Time →",
+ "cubicBezierTitle": "Cubic bezier control points"
+ },
+ "keyframeEase": {
+ "perKeyframeEasing": "Per-keyframe easing",
+ "setAll": "Set all…",
+ "custom": "Custom",
+ "applyAllAria": "Apply one easing to all segments",
+ "applyAllTitle": "Apply one easing to every segment (clears per-segment overrides)"
+ },
+ "keyframeNav": {
+ "convertToKeyframes": "Convert {{property}} to keyframes",
+ "removeKeyframe": "Remove {{property}} keyframe",
+ "addKeyframe": "Add {{property}} keyframe"
+ },
+ "colorPicker": {
+ "colorLabel": "Color",
+ "closeAria": "Close color picker",
+ "pickColorAria": "Pick {{label}} color",
+ "hue": "Hue",
+ "alpha": "Alpha",
+ "hex": "Hex",
+ "sbaFormat": "S {{s}}% · B {{b}}% · A {{a}}%"
+ },
+ "colorGrading": {
+ "preset": "Preset",
+ "presetStrength": "Preset strength",
+ "customLut": "Custom LUT",
+ "none": "None",
+ "uploadedLuts": "Uploaded LUTs",
+ "uploadedLut": "Uploaded LUT",
+ "lutStrength": "LUT strength",
+ "importCubeLut": "Import .cube LUT",
+ "uploadedCubeLut": "Uploaded .cube LUT",
+ "adjust": "Adjust",
+ "finishing": "Finishing",
+ "effects": "Effects",
+ "closeSettings": "Close settings",
+ "vignetteSettings": "Vignette settings",
+ "grainSettings": "Grain settings",
+ "settingsButton": "{{label}} settings",
+ "exposure": "Exposure",
+ "contrast": "Contrast",
+ "highlights": "Highlights",
+ "shadows": "Shadows",
+ "whitePoint": "White point",
+ "blackPoint": "Black point",
+ "warmth": "Warmth",
+ "tint": "Tint",
+ "vibrance": "Vibrance",
+ "saturation": "Saturation",
+ "vignette": "Vignette",
+ "midpoint": "Midpoint",
+ "roundness": "Roundness",
+ "feather": "Feather",
+ "grain": "Grain",
+ "grainSize": "Grain size",
+ "roughness": "Roughness",
+ "blur": "Blur",
+ "pixelate": "Pixelate",
+ "resetSlider": "Reset {{label}}",
+ "decreaseSlider": "Decrease {{label}}",
+ "increaseSlider": "Increase {{label}}",
+ "resetColorGrading": "Reset color grading",
+ "chooseCopyScope": "Choose where to copy these color grading settings",
+ "currentFileMedia": "Current file media",
+ "allProjectMedia": "All project media",
+ "copyToScope": "Copy these color grading settings to the selected scope",
+ "applying": "Applying",
+ "apply": "Apply",
+ "holdOriginal": "Hold to view original",
+ "waitingRuntime": "Waiting for runtime",
+ "previewUnavailable": "Preview unavailable",
+ "hdrSource": "{{label}} source",
+ "updatingShader": "Updating shader",
+ "preparing": "Preparing",
+ "appliedCutout": "Applied cutout",
+ "failed": "Failed",
+ "processing": "Processing",
+ "sdrPreview": "SDR preview",
+ "hdrWarning": "These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this is not true HDR color grading yet."
+ },
+ "font": {
+ "fontBadge": "Font",
+ "loadingGoogleFonts": "Loading Google Fonts...",
+ "searchFonts": "Search fonts",
+ "local": "Local",
+ "import": "Import",
+ "importLocalFontsAria": "Import local font files",
+ "noFontsFound": "No fonts found.",
+ "browserNoLocalFonts": "This browser cannot access installed fonts. Import font files instead.",
+ "noLocalFontsReturned": "No browser local fonts returned.",
+ "localFontDenied": "Local font access denied. Import font files instead.",
+ "localFontUnavailable": "Local font access unavailable. Import font files instead.",
+ "noSupportedFontsImported": "No supported font files imported.",
+ "sourceCurrent": "Current",
+ "sourceDocument": "Document",
+ "sourceImported": "Imported",
+ "sourceGoogle": "Google",
+ "sourceLocal": "Local",
+ "sourceSystem": "System"
+ },
+ "media": {
+ "video": "Video",
+ "audio": "Audio",
+ "source": "Source",
+ "cutout": "Cutout",
+ "cutoutDescVideo": "Create transparent WebM video",
+ "cutoutDescImage": "Create transparent PNG image",
+ "removeBg": "Remove background",
+ "working": "Working",
+ "removeBgTitle": "Remove background and save transparent asset",
+ "selectLocalAsset": "Select a project-local image or video asset",
+ "quality": "Quality",
+ "qualityFast": "Fast",
+ "qualityBalanced": "Balanced",
+ "qualityBest": "Best",
+ "bgPlate": "Background plate",
+ "on": "On",
+ "off": "Off",
+ "bgPlateHint": "Optional cutout background duplicate.",
+ "appliedPath": "Applied {{path}}",
+ "volume": "Volume",
+ "playbackRate": "Playback rate",
+ "mediaStart": "Media start",
+ "loop": "Loop",
+ "muted": "Muted",
+ "hasAudioTrack": "Has audio track",
+ "yes": "Yes",
+ "no": "No",
+ "fit": "Fit"
+ },
+ "domEdit": {
+ "rotate": "Rotate",
+ "rotateSelection": "Rotate selection",
+ "moveAnimationPath": "Move animation path"
+ },
+ "cropHandles": {
+ "reposition": "Reposition crop",
+ "crop": "Crop"
+ },
+ "promotable": {
+ "makeVariable": "Make this a variable"
+ }
+ },
+ "language": {
+ "switchLanguage": "Switch language",
+ "en": "English",
+ "zh": "中文"
+ },
+ "ui": {
+ "searchPlaceholder": "Search…",
+ "videoFallback": "VIDEO"
+ },
+ "hooks": {
+ "editorSave": {
+ "failed": "Couldn't save {{path}} — your latest edits are NOT persisted. Check the preview server; editing again retries the save."
+ },
+ "fileManager": {
+ "loadFailed": "Failed to load {{path}}",
+ "skippedTooLarge": "Skipped (too large): {{names}}",
+ "unsupportedMediaSkipped": "Unsupported media skipped: {{names}}",
+ "uploadRejectedTooLarge": "Upload rejected: payload too large",
+ "uploadFailedStatus": "Upload failed ({{status}})",
+ "uploadFailedNetwork": "Upload failed: network error",
+ "createFailed": "Couldn't create {{path}}: {{error}}",
+ "createFolderFailed": "Couldn't create folder {{path}}: {{error}}",
+ "deleteFailed": "Couldn't delete {{path}}: {{error}}",
+ "renameFailed": "Couldn't rename {{oldPath}}: {{error}}",
+ "duplicateFailed": "Couldn't duplicate {{path}}: {{error}}"
+ },
+ "clipboard": {
+ "unableToCopy": "Unable to copy this element.",
+ "copiedClip": "Copied clip",
+ "copiedElement": "Copied element",
+ "nothingToPaste": "Nothing to paste.",
+ "pastedClip": "Pasted clip",
+ "pastedElement": "Pasted element",
+ "pasteFailed": "Failed to paste"
+ },
+ "hotkeys": {
+ "undid": "Undid {{label}}",
+ "redid": "Redid {{label}}",
+ "useRazorForSubComp": "Use the razor tool (B) to split clips inside a sub-composition",
+ "fileChangedOutsideUndo": "File changed outside Studio. Undo history was not applied.",
+ "fileChangedOutsideRedo": "File changed outside Studio. Redo history was not applied."
+ },
+ "askAgent": {
+ "copyFailed": "Could not copy prompt to clipboard."
+ },
+ "frameCapture": {
+ "failed": "Capture failed",
+ "failedStatus": "Capture failed ({{status}})",
+ "saveQueueTimedOut": "Save queue timed out",
+ "timedOut": "Capture timed out — the server took too long to respond"
+ },
+ "domEdit": {
+ "invalidLayoutValues": "Couldn't save edit because it contains invalid layout values",
+ "reorderFailed": "Failed to reorder layers",
+ "couldntSaveEdit": "Couldn't save edit",
+ "couldntSaveEditWithDetail": "Couldn't save edit: {{detail}}{{fieldsSuffix}}",
+ "savedButUpdateFailed": "Saved, but couldn't finish updating {{path}}: {{detail}}",
+ "saveFailed": "Couldn't save \"{{label}}\": {{detail}}",
+ "elementNotFoundInSource": "Couldn't find this element in the source file ({{targetPath}})",
+ "textStructureChangeFailed": "Couldn't save this text structure change",
+ "thisElement": "this element"
+ },
+ "gesture": {
+ "selectionLost": "Selection lost during recording",
+ "noGestureDetected": "No gesture detected — move the pointer while recording",
+ "recordingTooShort": "Recording too short — try again",
+ "noSelector": "Cannot save — element has no selector",
+ "recordedKeyframes": "Recorded {{count}} keyframes",
+ "commitFailed": "Gesture commit failed: {{detail}}",
+ "selectElementFirst": "Select an element first",
+ "previewNotReady": "Preview not ready — try again"
+ },
+ "composition": {
+ "loadFailed": "Failed to load {{path}}"
+ },
+ "timeline": {
+ "cannotEditWhileRecording": "Cannot edit timeline while recording",
+ "deletedUseUndo": "Deleted {{label}}. Use Undo to restore it.",
+ "deleteFailed": "Failed to delete timeline clip",
+ "assetDropRestricted": "Only image, video, and audio assets can be dropped onto the timeline.",
+ "dropFailed": "Failed to drop asset onto timeline",
+ "clipNotMovable": "This clip can't be moved or resized from the timeline yet.",
+ "toggleTrackVisibilityFailed": "Failed to toggle track visibility",
+ "toggleElementVisibilityFailed": "Failed to toggle element visibility"
+ },
+ "blockHandlers": {
+ "alreadyInstalling": "A block is already installing — one moment…",
+ "addingBlock": "Adding {{blockName}}…"
+ },
+ "element": {
+ "deleteFailed": "Failed to delete element"
+ },
+ "razor": {
+ "splitFailedPlayhead": "Failed to split clip — playhead may be outside the clip",
+ "splitAt": "Split {{label}} at {{time}}s",
+ "animationsNotRetargeted": "Some animations use non-ID selectors ({{selectors}}) and were not retargeted",
+ "splitMultiple": "Split {{count}} clips at {{time}}s",
+ "splitFailed": "Failed to split timeline clip",
+ "splitClipsFailed": "Failed to split clips"
+ }
+ },
+ "shell": {
+ "toast": {
+ "dismiss": "Dismiss"
+ },
+ "errorBoundary": {
+ "somethingWentWrong": "Something went wrong",
+ "tryAgain": "Try again",
+ "reloadStudio": "Reload Studio"
+ },
+ "overlays": {
+ "consoleErrorsTitle": "Console errors in preview",
+ "consoleErrorsIntro": "Fix these runtime console errors from the composition preview"
+ },
+ "previewArea": {
+ "captions": "Captions"
+ },
+ "saveQueuePaused": {
+ "retrySaving": "Retry saving"
+ },
+ "mediaPreview": {
+ "loadError": "Couldn't load this file — it may be missing or corrupt",
+ "binaryFile": "Binary file — preview not available"
+ },
+ "globalDragOverlay": {
+ "dropFiles": "Drop files to import into project"
+ },
+ "leftSidebar": {
+ "showSidebar": "Show sidebar",
+ "loadingFile": "Loading {{path}}…",
+ "resizeSidebar": "Resize sidebar"
+ }
+ },
+ "variablesPanel": {
+ "noScriptReads": "No script reads this variable",
+ "setDefault": "Set default",
+ "persistDefault": "Persist this value as the declared default",
+ "edit": "Edit",
+ "editDeclaration": "Edit declaration",
+ "removeDeclaration": "Remove declaration",
+ "readByScripts": "Read by scripts, not declared",
+ "declare": "Declare",
+ "declareAsString": "Declare as a string variable",
+ "title": "Variables",
+ "copyRenderCommand": "Copy render command",
+ "cliCommandHint": "CLI command rendering exactly what the preview shows",
+ "copyValuesJson": "Copy values JSON",
+ "effectiveValuesHint": "Effective values (defaults merged with preview overrides)"
+ },
+ "variablesForm": {
+ "id": "ID",
+ "idPlaceholder": "title",
+ "label": "Label",
+ "labelPlaceholder": "Title",
+ "type": "Type",
+ "default": "Default",
+ "optionsHint": "Options (one per line, value:Label)",
+ "descriptionOptional": "Description (optional)"
+ }
+}
diff --git a/packages/studio/src/i18n/locales/zh.json b/packages/studio/src/i18n/locales/zh.json
new file mode 100644
index 0000000000..054ed04e6c
--- /dev/null
+++ b/packages/studio/src/i18n/locales/zh.json
@@ -0,0 +1,1022 @@
+{
+ "header": {
+ "undo": "撤销",
+ "redo": "重做",
+ "undoWithLabel": "撤销 {{label}} ({{shortcut}})",
+ "redoWithLabel": "重做 {{label}} ({{shortcut}})",
+ "captureFrame": "捕获当前帧",
+ "capturingFrame": "捕获帧中…",
+ "capturing": "捕获中…",
+ "capture": "捕获",
+ "inspector": "检查器",
+ "manualEditingDisabled": "手动编辑暂时不可用",
+ "studioView": "工作区视图",
+ "storyboard": "故事板",
+ "preview": "预览",
+ "renderInProgress": "已有渲染正在进行",
+ "renderAndExport": "渲染并导出此合成",
+ "rendering": "渲染中…",
+ "export": "导出"
+ },
+ "splash": {
+ "waitingForServer": "等待预览服务器… 运行 <1>npm run dev1>",
+ "connecting": "正在连接到项目…"
+ },
+ "rightPanel": {
+ "design": "设计",
+ "layers": "图层",
+ "motion": "动效",
+ "renders": "渲染",
+ "rendersWithCount": "渲染 ({{count}})",
+ "tooltipDesign": "元素样式和属性",
+ "tooltipLayers": "合成图层堆栈",
+ "tooltipMotion": "动画和动效",
+ "tooltipRenders": "渲染队列和导出",
+ "tooltipSlideshow": "幻灯片分支编辑器",
+ "slideshow": "幻灯片"
+ },
+ "leftSidebar": {
+ "showSidebar": "显示侧边栏",
+ "hideSidebar": "隐藏侧边栏",
+ "code": "代码",
+ "comps": "合成",
+ "assets": "资源",
+ "catalog": "目录",
+ "tooltipCode": "源代码编辑器",
+ "tooltipComps": "合成和子合成",
+ "tooltipAssets": "视频、图片、音频、字体",
+ "tooltipCatalog": "浏览 Block 和组件",
+ "selectFileToEdit": "选择一个文件进行编辑",
+ "lint": "检查",
+ "linting": "检查中…"
+ },
+ "compositionsTab": {
+ "noCompositions": "未找到合成",
+ "rendering": "渲染中…",
+ "render": "渲染 {{name}}"
+ },
+ "assetsTab": {
+ "importMedia": "导入媒体",
+ "dropMediaHere": "拖放媒体文件到此",
+ "addAtPlayhead": "添加到播放头",
+ "copyPath": "复制路径",
+ "rename": "重命名",
+ "delete": "删除",
+ "cancel": "取消",
+ "deleteConfirm": "删除 {{name}}?",
+ "copied": "已复制!",
+ "thisProject": "此项目",
+ "allProjects": "所有项目"
+ },
+ "globalAssets": {
+ "loading": "正在加载全局资源…",
+ "empty": "全局缓存中尚无资源。已解析的媒体将提升到",
+ "emptySuffix": ",可在项目间复用。"
+ },
+ "slideshowPanel": {
+ "slides": "幻灯片 ({{count}})",
+ "slideInspector": "幻灯片检查器",
+ "selectSceneHint": "选择上方场景以检查",
+ "branches": "分支 ({{count}})",
+ "hotspotTool": "热点工具",
+ "noScenesFound": "未找到场景"
+ },
+ "blocksTab": {
+ "loading": "加载 Block 中…",
+ "searchPlaceholder": "按名称、分类或标签搜索…",
+ "all": "全部",
+ "vfxNotice": "VFX Block 使用 WebGL via HTML-in-Canvas 实现。启用 <1>chrome://flags/#html-in-canvas1> 以预览。",
+ "noMatch": "没有匹配的 Block",
+ "add": "添加",
+ "added": "已添加!",
+ "askAgent": "询问 AI",
+ "copyPrompt": "复制提示词",
+ "copied": "已复制!",
+ "modalTitle": "询问 AI",
+ "modalDescription": "编辑下面的提示词,然后复制粘贴到您的 AI 工具中",
+ "category": {
+ "captions": "字幕",
+ "code-animation": "代码动画",
+ "vfx": "VFX",
+ "transitions": "过渡",
+ "effects": "特效",
+ "text-effects": "文字特效",
+ "social": "社交媒体",
+ "data": "数据",
+ "scenes": "场景"
+ },
+ "webgl": "WebGL",
+ "shortcutHintMac": "⌘+Enter 复制",
+ "shortcutHintOther": "Ctrl+Enter 复制",
+ "addToComposition": "添加到当前时间的合成中",
+ "generatePrompt": "生成提示词以粘贴到您的 AI 工具"
+ },
+ "common": {
+ "close": "关闭",
+ "copied": "已复制"
+ },
+ "lintModal": {
+ "resultsTitle": "{{errors}} 个错误,{{warnings}} 个警告",
+ "resultsTitlePlural": "{{errors}} 个错误,{{warnings}} 个警告",
+ "allChecksPassed": "全部通过",
+ "subtitle": "HyperFrame Lint 结果",
+ "copyToAgent": "复制到 AI",
+ "copyFailed": "复制失败 — 请检查权限",
+ "copied": "已复制!",
+ "noIssuesFound": "未发现错误或警告,您的合成看起来不错!"
+ },
+ "askAgentModal": {
+ "title": "复制提示词到 AI 工具",
+ "placeholder": "描述您想要更改的内容…",
+ "contextIncluded": "提示词中包含的上下文",
+ "shortcutHintMac": "⌘+Enter 复制",
+ "shortcutHintOther": "Ctrl+Enter 复制",
+ "copyPrompt": "复制提示词"
+ },
+ "feedbackBar": {
+ "thanks": "感谢您的反馈!",
+ "detailsPlaceholder": "详细说明?(Enter 发送,Esc 关闭)",
+ "send": "发送",
+ "prompt": "Studio 使用体验如何?",
+ "dismissAria": "关闭"
+ },
+ "propertyPanel": {
+ "noSelection": "在预览中选择一个元素。",
+ "multiSelect": "已选择 {{count}} 个元素",
+ "multiSelectHint": "选择单个元素以编辑其属性。在预览中点击一个元素或使用时间线图层面板。",
+ "inspectorIntro": "检查器用于元素编辑,提供更安全的几何控制、颜色选择和更清晰的图层分组控制。",
+ "document": "文档",
+ "layout": "布局",
+ "text": "文本",
+ "timing": "时间",
+ "start": "开始",
+ "end": "结束",
+ "duration": "时长",
+ "x": "X",
+ "y": "Y",
+ "w": "宽",
+ "h": "高",
+ "r": "R",
+ "z": "Z",
+ "scale": "缩放",
+ "rotate": "旋转",
+ "rotX": "旋转X",
+ "rotY": "旋转Y",
+ "rotZ": "旋转Z",
+ "perspective": "透视",
+ "zIndex": "Z 层级",
+ "transform3d": "3D 变换",
+ "stacking": "堆叠",
+ "flex": "弹性布局",
+ "radius": "圆角",
+ "stroke": "描边",
+ "effects": "特效",
+ "clip": "裁剪",
+ "transparency": "透明度",
+ "fill": "填充",
+ "justify": "对齐",
+ "align": "对齐方式",
+ "gap": "间距",
+ "width": "宽度",
+ "style": "样式",
+ "strokeColor": "描边颜色",
+ "shadow": "阴影",
+ "layerBlur": "图层模糊",
+ "backdrop": "背景模糊",
+ "overflow": "溢出",
+ "mask": "遮罩",
+ "maskInset": "遮罩内缩",
+ "mode": "模式",
+ "fillColor": "填充颜色",
+ "content": "内容",
+ "textColor": "文字颜色",
+ "textLayers": "文本图层",
+ "addText": "添加文本",
+ "length": "时长",
+ "startsAt": "开始于",
+ "speed": "速度",
+ "solid": "纯色",
+ "gradient": "渐变",
+ "image": "图片",
+ "linear": "线性",
+ "radial": "径向",
+ "conic": "锥形",
+ "repeat": "重复",
+ "reverse": "反转",
+ "angle": "角度",
+ "startAngle": "起始角度",
+ "shape": "形状",
+ "size": "大小",
+ "centerX": "中心 X",
+ "centerY": "中心 Y",
+ "stops": "色标",
+ "stop": "色标",
+ "addStop": "添加色标",
+ "pos": "位置",
+ "copyPrompt": "复制提示词到 AI 工具",
+ "promptCopied": "提示词已复制",
+ "fitToChildren": "适应子元素",
+ "position": "位置",
+ "rotation": "旋转",
+ "opacity": "不透明度",
+ "backgroundColor": "背景",
+ "borderRadius": "圆角",
+ "borderColor": "边框颜色",
+ "borderWidth": "边框宽度",
+ "boxShadow": "阴影",
+ "fontFamily": "字体",
+ "fontWeight": "字重",
+ "fontSize": "字号",
+ "lineHeight": "行高",
+ "textAlign": "对齐",
+ "letterSpacing": "字间距",
+ "color": "颜色",
+ "textShadow": "文字阴影",
+ "clipPath": "裁剪路径",
+ "filter": "滤镜",
+ "backdropFilter": "背景滤镜",
+ "transform": "变换",
+ "transformOrigin": "变换原点",
+ "mixBlendMode": "混合模式",
+ "cursor": "光标",
+ "hideElement": "隐藏元素",
+ "showElement": "显示元素",
+ "copyInfo": "复制元素信息到剪贴板",
+ "clearSelection": "取消选择",
+ "ungroup": "取消组合",
+ "colorGrading": "色彩校正",
+ "recordGesture": "录制手势 (R) -- 移动指针以捕获运动轨迹",
+ "stopRecording": "停止录制手势 (R)",
+ "stopRecordingDuration": "停止录制 {{seconds}}s -- 按 R",
+ "resetOrientation": "重置 3D 朝向",
+ "keyframeTransform": "为 3D 变换添加关键帧(随时间动画)",
+ "keyframeTransformActive": "3D 变换已添加关键帧 — 点击场菱形图标添加关键帧",
+ "copyDescription": "复制描述到剪贴板 — 粘贴到 AI 工具",
+ "dragToTilt": "拖动倾斜 · Shift+拖动滚动 · 滚轮调整深度",
+ "rotate3d": "在 3D 中旋转拖动;按 Shift 滚动;滚轮更改深度",
+ "inferredTiming": "从元素的动画推断而来 — 编辑以固定显式片段范围。",
+ "scrollToAdjust": "滚动或使用方向键调整",
+ "whenPlays": "此效果何时播放",
+ "lastingTooltip": "此效果的持续时长",
+ "beginsTooltip": "此效果在时间线上何时开始",
+ "removeAnimation": "移除此动画",
+ "addEffectTooltip": "为此效果添加另一个动画属性",
+ "addFromPropTooltip": "添加一个起始状态属性",
+ "addEffect": "+ 效果",
+ "addFromProperty": "+ 起始属性",
+ "remove": "移除",
+ "from": "起始",
+ "to": "结束",
+ "none": "无",
+ "customCurve": "自定义曲线",
+ "dragToMove": "拖动移动",
+ "keyframedNotice": "已添加关键帧 — 点击下方线段以编辑其曲线",
+ "copy": "复制",
+ "copied": "已复制"
+ },
+ "motionPanel": {
+ "noSelection": "选择一个元素以添加动效。",
+ "noSelectionHint": "时间线图层和检查器选择可以接收 Studio 创作的 GSAP 动效。",
+ "motionTarget": "动效目标",
+ "gsapMotion": "GSAP 动效",
+ "easeCurve": "缓动曲线",
+ "fadeUp": "淡入升起",
+ "slide": "滑入",
+ "pop": "弹入",
+ "direction": "方向",
+ "ease": "缓动",
+ "start": "开始",
+ "duration": "持续",
+ "distance": "距离",
+ "customEase": "自定义缓动",
+ "clearMotion": "清除动效",
+ "up": "上",
+ "down": "下",
+ "left": "左",
+ "right": "右"
+ },
+ "layersPanel": {
+ "noLayers": "无图层",
+ "noLayersHint": "加载合成以查看元素树",
+ "layerCount": "{{count}} 个图层",
+ "layerCountPlural": "{{count}} 个图层",
+ "singleLayer": "此级别只有一个图层",
+ "nestedLayers": "{{count}} 个可选的嵌套图层",
+ "singleSelectableLayer": "单个可选媒体图层",
+ "singleSelectableLayerAlt": "单个可选图层"
+ },
+ "gsapSection": {
+ "title": "动画",
+ "multipleTimelinesWarning": "此文件包含多个 GSAP 时间线。为防止数据丢失,动画编辑已禁用——请合并为单个时间线以启用编辑。",
+ "unsupportedPatternWarning": "此合成使用了编辑器不支持的时间线赋值模式 (window.__timelines[…])。请使用变量声明 (const tl = gsap.timeline()) 以启用编辑。",
+ "addEffect": "+ 添加效果",
+ "cancel": "取消",
+ "set": "设置",
+ "animate": "动画",
+ "animateIn": "渐入",
+ "fromTo": "从 → 到",
+ "setInstantly": "即时设置",
+ "tooltipSet": "立即跳转到这些值——无过渡",
+ "tooltipAnimate": "平滑地将元素动画到这些目标值",
+ "tooltipAnimateIn": "元素从这些值开始,过渡到正常状态",
+ "tooltipFromTo": "从一个状态动画到另一个状态",
+ "propMoveX": "移动 X",
+ "propMoveY": "移动 Y",
+ "propWidth": "宽度",
+ "propHeight": "高度",
+ "propRotate": "旋转",
+ "propOpacity": "不透明度",
+ "propScale": "缩放",
+ "propScaleX": "缩放 X",
+ "propScaleY": "缩放 Y",
+ "propVisibility": "可见性",
+ "propVisible": "可见",
+ "propStretchX": "拉伸 X",
+ "propFilter": "滤镜",
+ "propClipPath": "裁剪路径",
+ "propColor": "颜色",
+ "propBackground": "背景",
+ "propBorderColor": "边框颜色",
+ "propRadius": "半径",
+ "propFontSize": "字号",
+ "propTracking": "字距",
+ "propSkewX": "倾斜 X",
+ "propSkewY": "倾斜 Y",
+ "easeLinear": "匀速",
+ "easePower1Out": "轻微减速",
+ "easePower2Out": "平滑减速",
+ "easePower3Out": "强烈减速",
+ "easePower4Out": "非常强烈减速",
+ "easeBackOut": "过冲并稳定",
+ "easeElasticOut": "弹性弹跳",
+ "easeBounceOut": "弹跳着陆",
+ "easeRoughOut": "粗糙停止",
+ "easeSlow": "慢动作",
+ "easeSteps": "逐帧",
+ "easeCircOut": "圆形缓出",
+ "easeExpoOut": "指数缓出",
+ "easeSineOut": "正弦缓出",
+ "easeQuintOut": "五次缓出",
+ "easeQuartOut": "四次缓出",
+ "easeCubicOut": "三次缓出",
+ "easeQuadOut": "二次缓出",
+ "unitPx": "px",
+ "unitDeg": "°",
+ "unitPercent": "%",
+ "unitTimes": "×"
+ },
+ "fileTree": {
+ "files": "文件",
+ "newFile": "新建文件",
+ "newFolder": "新建文件夹",
+ "rename": "重命名",
+ "duplicate": "复制",
+ "delete": "删除"
+ },
+ "renderQueue": {
+ "clear": "清除",
+ "noRenders": "暂无渲染",
+ "draft": "草稿",
+ "standard": "标准",
+ "highQuality": "高质量",
+ "draftHint": "快速渲染,文件较小",
+ "standardHint": "良好质量,文件大小适中",
+ "highQualityHint": "最佳质量,文件较大",
+ "rendering": "渲染中…",
+ "export": "导出",
+ "auto": "自动",
+ "resolution1080p": "1080p",
+ "resolution4k": "4K",
+ "mp4": "MP4",
+ "mov": "MOV",
+ "webm": "WebM",
+ "mp4Hint": "适合普通用途。文件最小,通用播放支持。",
+ "movHint": "透明视频。适用于 CapCut、Final Cut Pro、Premiere、DaVinci Resolve、After Effects。文件较大。",
+ "webmHint": "网页透明视频。比 MOV 更小,但编辑器支持有限。",
+ "fps24": "24fps",
+ "fps30": "30fps",
+ "fps60": "60fps",
+ "justNow": "刚刚",
+ "minutesAgo": "{{count}} 分钟前",
+ "hoursAgo": "{{count}} 小时前",
+ "download": "下载",
+ "remove": "移除",
+ "renderStage": "渲染中",
+ "renderProgress": "渲染中 ({{progress}}%)"
+ },
+ "timelineToolbar": {
+ "timeline": "时间线",
+ "removeKeyframe": "删除播放头处的关键帧",
+ "addKeyframe": "在播放头处添加关键帧",
+ "enableKeyframes": "启用关键帧",
+ "splitClip": "在播放头处分割片段 (S)",
+ "splitAtPlayhead": "在播放头处分割",
+ "noElementToSplit": "选择一个片段进行分割",
+ "movePlayheadInside": "将播放头移到片段内以分割",
+ "addBeatAtPlayhead": "在播放头处添加节拍",
+ "beatAlreadyExists": "播放头处已存在节拍",
+ "addMusicTrackForBeats": "添加带节拍分析的音乐轨道以放置节拍",
+ "selectionTool": "选择工具 (V)",
+ "selectionToolAria": "选择工具",
+ "razorTool": "剃刀工具 (B) — Shift+点击分割所有轨道",
+ "razorToolAria": "剃刀工具",
+ "toggleSnappingAria": "切换时间线吸附",
+ "autoRecordOn": "自动将手动编辑记录为关键帧(点击关闭)",
+ "autoRecordOff": "手动编辑不会记录为关键帧(点击开启)",
+ "autoRecordAria": "自动记录手动编辑为关键帧",
+ "selectElementForKeyframes": "选择一个已动画化的元素以添加关键帧",
+ "removeKeyframeK": "删除播放头处的关键帧 (K)",
+ "addKeyframeExtend": "在播放头处添加关键帧,扩展动画 (K)",
+ "addKeyframeK": "在播放头处添加关键帧 (K)",
+ "addKeyframeShortcut": "添加关键帧 (K)",
+ "removeKeyframeAria": "删除播放头处的关键帧",
+ "addKeyframeAria": "在播放头处添加关键帧",
+ "timelineZoom": "时间线缩放",
+ "timelineZoomLevel": "时间线缩放级别",
+ "fitTimeline": "适合时间线宽度",
+ "zoomOut": "缩小",
+ "zoomIn": "放大",
+ "fit": "适配",
+ "hideEditor": "隐藏时间线编辑器",
+ "snapEnabled": "吸附已启用 (S)",
+ "snapDisabled": "吸附已禁用 (S)",
+ "gridVisible": "网格可见 (G)",
+ "gridHidden": "网格隐藏 (G)",
+ "gridSpacing": "网格间距",
+ "snapToGrid": "吸附到网格"
+ },
+ "playerControls": {
+ "pause": "暂停",
+ "play": "播放",
+ "mutedAbove1x": "1x 以上速度时音频静音",
+ "unmuteAudio": "取消静音",
+ "muteAudio": "静音",
+ "loopPlayback": "循环播放",
+ "disableLoop": "禁用循环播放",
+ "enableLoop": "启用循环播放",
+ "exitFullscreen": "退出全屏 (F)",
+ "enterFullscreen": "进入全屏 (F)",
+ "exitFullscreenLabel": "退出全屏",
+ "enterFullscreenLabel": "进入全屏",
+ "switchToFrame": "切换到帧显示",
+ "switchToTime": "切换到时间显示",
+ "seek": "拖动"
+ },
+ "speedMenu": {
+ "playbackSpeed": "播放速度"
+ },
+ "shortcutsPanel": {
+ "shortcutsAndTools": "快捷键和工具",
+ "jumpToFrame": "跳转到帧",
+ "workArea": "工作区",
+ "frameNumberPlaceholder": "帧号",
+ "go": "跳转",
+ "playbackSection": "播放",
+ "space": "播放/暂停",
+ "playBackward": "向后播放",
+ "stop": "停止",
+ "playForward": "向前播放",
+ "toggleMute": "切换静音",
+ "toggleLoop": "切换循环",
+ "stepFrame": "步进 1 帧",
+ "step10Frames": "步进 10 帧",
+ "toggleFullscreen": "切换全屏",
+ "inPoint": "入点",
+ "clearInPoint": "清除入点",
+ "outPoint": "出点",
+ "clearOutPoint": "清除出点",
+ "jumpToInPoint": "跳转到入点",
+ "jumpToOutPoint": "跳转到出点"
+ },
+ "editModal": {
+ "timeRange": "{{start}} — {{end}}",
+ "elementCount": "{{count}} 个元素",
+ "elementCountPlural": "{{count}} 个元素",
+ "whatShouldChange": "想要更改什么?",
+ "copyPrompt": "复制提示词",
+ "promptCopied": "提示词已复制!",
+ "copyToAgent": "复制到 AI",
+ "copied": "已复制!",
+ "shortcutHint": "Cmd+Enter"
+ },
+ "clipContextMenu": {
+ "splitAt": "在 {{time}}s 处分割",
+ "splitHint": "分割(将播放头移入片段内)",
+ "shortcut": "S",
+ "delete": "删除"
+ },
+ "timelineEditorNotice": {
+ "dismiss": "关闭时间线编辑器通知",
+ "title": "时间线编辑已开启",
+ "body": "拖拽片段调整时间,使用 Shift + 点击编辑整个片段范围,注意 Studio 只能安全修补的片段上会出现调整大小手柄。",
+ "dismissOnce": "关闭后保持隐藏。"
+ },
+ "nle": {
+ "timeline": "时间线",
+ "show": "显示",
+ "showTimelineEditor": "显示时间线编辑器 ({{shortcut}})",
+ "hideTimelineEditor": "隐藏时间线编辑器 ({{shortcut}})",
+ "showTimelineEditorAria": "显示时间线编辑器",
+ "loadingComposition": "正在加载合成…",
+ "compositionPreview": "合成预览",
+ "fit": "适配",
+ "resetZoom": "{{zoom}}% — 重置",
+ "resetZoomAria": "重置缩放以适配",
+ "compositionNavigation": "合成导航",
+ "backTooltip": "返回 (Esc,或双击空白时间线)",
+ "backToParentAria": "返回父级合成",
+ "master": "主合成",
+ "assetPreview": "预览:{{name}}",
+ "closePreview": "关闭预览",
+ "resizeTimelineAria": "调整时间线大小(方向键)"
+ },
+ "errorBoundary": {
+ "somethingWentWrong": "发生了一些错误",
+ "tryAgain": "重试"
+ },
+ "storyboard": {
+ "loading": "正在加载故事板…",
+ "loadError": "无法加载故事板:",
+ "retry": "重试",
+ "noStoryboard": "暂无故事板",
+ "copyPrompt": "复制提示词",
+ "addPrompt": "在项目根目录添加",
+ "addPromptSuffix": "文件来逐帧规划此视频。将此提示词交给您的编码 AI 助手来搭建框架。",
+ "promptForAgent": "AI 助手提示词",
+ "noFrames": "此故事板尚无帧。",
+ "narrationScript": "旁白脚本",
+ "statusLabel": "状态",
+ "title": "故事板",
+ "untitled": "未命名故事板",
+ "metaArc": "叙事弧",
+ "metaAudience": "受众",
+ "metaVoice": "旁白",
+ "metaFormat": "格式",
+ "metaFrames": "帧数",
+ "backToBoard": "← 看板",
+ "frameHeader": "帧 {{number}} — {{title}}",
+ "prev": "‹ 上一帧",
+ "next": "下一帧 ›",
+ "notBuiltYet": "尚未构建",
+ "noPreview": "无预览",
+ "frameFileNotFound": "未找到帧文件",
+ "duration": "时长 {{value}}",
+ "transition": "转场 {{value}}",
+ "voiceoverGuide": "旁白",
+ "voiceoverGuideHint": "指南",
+ "saving": "保存中…",
+ "save": "保存",
+ "voiceoverPlaceholder": "此帧上旁白要说的内容…",
+ "voiceoverDraftHint": "草稿指南。SCRIPT.md 锁定驱动 TTS 的最终旁白。",
+ "narrative": "叙事",
+ "openInPreview": "在预览中打开 →",
+ "discardVoiceoverChanges": "放弃未保存的旁白更改?",
+ "failedToSave": "保存失败",
+ "failedToLoad": "加载文件失败",
+ "frameFallback": "帧 {{index}}",
+ "outlineFallback": "大纲",
+ "statusAria": "状态:{{label}} — {{tooltip}}",
+ "discardMarkdownChanges": "放弃未保存的 Markdown 更改?",
+ "board": "看板",
+ "source": "源码",
+ "viewAriaLabel": "故事板视图",
+ "unsavedChanges": "未保存的更改",
+ "saved": "已保存",
+ "loadingFile": "正在加载 {{path}}…",
+ "previewLabel": "预览",
+ "status": {
+ "outline": {
+ "label": "大纲",
+ "tooltip": "仅以文本规划 — 尚未构建 HTML 帧。",
+ "description": "仅以文本规划。故事与意图已存在;画面尚未构建。"
+ },
+ "built": {
+ "label": "已构建",
+ "tooltip": "已构建静态 HTML 帧 — 尚未添加动画。",
+ "description": "HTML 帧已作为静态关键画面存在。外观已锁定;动作尚未添加。"
+ },
+ "animated": {
+ "label": "已动画",
+ "tooltip": "已添加关键帧动画 — 在最终成片中播放。",
+ "description": "已关键帧化为动态画面。这是拼入最终视频的文件。"
+ }
+ }
+ },
+ "mediaPreview": {
+ "binaryFile": "二进制文件 — 预览不可用"
+ },
+ "captionPropertyPanel": {
+ "selectWords": "选择字幕词以编辑其样式",
+ "wordCount": "{{count}} 个词",
+ "wordCountPlural": "{{count}} 个词",
+ "style": "样式",
+ "animation": "动画",
+ "position": "位置",
+ "transform": "变换"
+ },
+ "captionAnimationPanel": {
+ "selectGroup": "选择一个字幕组以编辑动画",
+ "entrance": "入场",
+ "highlight": "高亮",
+ "exit": "退场",
+ "preset": "预设",
+ "duration": "持续",
+ "ease": "缓动",
+ "stagger": "交错",
+ "intensity": "强度",
+ "applyToAll": "应用到所有组"
+ },
+ "blockParamsPanel": {
+ "parameters": "参数"
+ },
+ "timelineEmptyState": {
+ "dropMedia": "拖放媒体文件以导入",
+ "dropOrDescribe": "拖放媒体到此处或描述您的视频以开始",
+ "describeToStart": "描述您的视频以开始创作"
+ },
+ "snapToolbar": {
+ "snapEnabled": "吸附已启用 (S)",
+ "snapDisabled": "吸附已禁用 (S)",
+ "gridVisible": "网格可见 (G)",
+ "gridHidden": "网格隐藏 (G)",
+ "gridSpacing": "网格间距",
+ "snapToGrid": "吸附到网格"
+ },
+ "shaderLoader": {
+ "preparing": "准备场景过渡",
+ "samplingOutgoing": "采样退场场景运动",
+ "samplingIncoming": "采样入场场景运动",
+ "caching": "缓存过渡帧",
+ "finalizing": "最终确定过渡预览",
+ "ariaLabel": "准备场景过渡中",
+ "defaultDetail": "为着色器过渡渲染动画场景样本。",
+ "transition": "过渡",
+ "transitionFrame": "过渡帧",
+ "cachedDetail": "在播放前加载缓存的过渡帧。",
+ "cachedFrameLabel": "缓存的过渡帧",
+ "finalizingDetail": "上传过渡纹理以实现平滑播放。",
+ "finalizingFrameLabel": "最终确定过渡帧",
+ "renderingFrameLabel": "渲染过渡帧"
+ },
+ "player": {
+ "play": "播放",
+ "pause": "暂停",
+ "playbackSpeed": "播放速度",
+ "mute": "静音",
+ "unmute": "取消静音",
+ "volume": "音量",
+ "compositionTitle": "HyperFrames 合成",
+ "editRangeHint": "+ 拖拽/点击以编辑范围"
+ },
+ "timelineLayerPanel": {
+ "noSelectableLayers": "无可选图层"
+ },
+ "editor": {
+ "arcPath": {
+ "needKeyframes": "至少添加 2 个位置关键帧以启用弧线运动。",
+ "arcMotion": "弧线运动",
+ "disableArcMotion": "禁用弧线运动",
+ "enableArcMotion": "启用弧线运动",
+ "autoRotate": "自动旋转",
+ "disableAutoRotate": "禁用沿路径自动旋转",
+ "enableAutoRotate": "旋转元素以跟随路径切线",
+ "curviness": "曲率",
+ "segment": "片段 {{index}}",
+ "reset": "重置",
+ "resetControlPoints": "重置为自动生成的控制点"
+ },
+ "borderRadius": {
+ "all": "全部",
+ "unlinkCorners": "取消链接圆角",
+ "linkCorners": "链接所有圆角"
+ },
+ "animationCard": {
+ "visibleHide": "可见 — 点击隐藏",
+ "hiddenShow": "隐藏 — 点击显示",
+ "chooseProperty": "选择属性…",
+ "filterBlur": "模糊",
+ "filterBright": "增亮",
+ "filterGray": "灰度",
+ "filterNone": "无",
+ "clipCircle": "圆形",
+ "clipInset": "内缩",
+ "clipNone": "无"
+ },
+ "computedTween": {
+ "computedValue": "计算值 — 请在代码标签页中编辑。",
+ "generatedBy": "由 {{source}} 生成 — 无法直接编辑。",
+ "loopSource": "循环",
+ "unrollToEdit": "展开以编辑",
+ "unrollTitle": "将辅助函数/循环重写为显式补间,以便直接编辑此关键帧"
+ },
+ "easeCurve": {
+ "speedCurve": "速度曲线",
+ "playing": "播放中…",
+ "preview": "预览",
+ "start": "起始",
+ "end": "结束",
+ "timeArrow": "时间 →",
+ "cubicBezierTitle": "三次贝塞尔控制点"
+ },
+ "keyframeEase": {
+ "perKeyframeEasing": "逐关键帧缓动",
+ "setAll": "全部设置…",
+ "custom": "自定义",
+ "applyAllAria": "将一种缓动应用到所有片段",
+ "applyAllTitle": "将一种缓动应用到每个片段(清除逐片段覆盖)"
+ },
+ "keyframeNav": {
+ "convertToKeyframes": "将 {{property}} 转换为关键帧",
+ "removeKeyframe": "移除 {{property}} 关键帧",
+ "addKeyframe": "添加 {{property}} 关键帧"
+ },
+ "colorPicker": {
+ "colorLabel": "颜色",
+ "closeAria": "关闭颜色选择器",
+ "pickColorAria": "选择 {{label}} 颜色",
+ "hue": "色相",
+ "alpha": "透明度",
+ "hex": "十六进制",
+ "sbaFormat": "S {{s}}% · B {{b}}% · A {{a}}%"
+ },
+ "colorGrading": {
+ "preset": "预设",
+ "presetStrength": "预设强度",
+ "customLut": "自定义 LUT",
+ "none": "无",
+ "uploadedLuts": "已上传 LUT",
+ "uploadedLut": "已上传 LUT",
+ "lutStrength": "LUT 强度",
+ "importCubeLut": "导入 .cube LUT",
+ "uploadedCubeLut": "已上传 .cube LUT",
+ "adjust": "调整",
+ "finishing": "精修",
+ "effects": "特效",
+ "closeSettings": "关闭设置",
+ "vignetteSettings": "暗角设置",
+ "grainSettings": "颗粒设置",
+ "settingsButton": "{{label}} 设置",
+ "exposure": "曝光",
+ "contrast": "对比度",
+ "highlights": "高光",
+ "shadows": "阴影",
+ "whitePoint": "白点",
+ "blackPoint": "黑点",
+ "warmth": "色温",
+ "tint": "色调",
+ "vibrance": "自然饱和度",
+ "saturation": "饱和度",
+ "vignette": "暗角",
+ "midpoint": "中点",
+ "roundness": "圆度",
+ "feather": "羽化",
+ "grain": "颗粒",
+ "grainSize": "颗粒大小",
+ "roughness": "粗糙度",
+ "blur": "模糊",
+ "pixelate": "像素化",
+ "resetSlider": "重置 {{label}}",
+ "decreaseSlider": "减少 {{label}}",
+ "increaseSlider": "增加 {{label}}",
+ "resetColorGrading": "重置色彩校正",
+ "chooseCopyScope": "选择复制这些色彩校正设置的位置",
+ "currentFileMedia": "当前文件媒体",
+ "allProjectMedia": "所有项目媒体",
+ "copyToScope": "将这些色彩校正设置复制到所选范围",
+ "applying": "应用中",
+ "apply": "应用",
+ "holdOriginal": "按住查看原图",
+ "waitingRuntime": "等待运行时",
+ "previewUnavailable": "预览不可用",
+ "hdrSource": "{{label}} 源",
+ "updatingShader": "更新着色器",
+ "preparing": "准备中",
+ "appliedCutout": "已应用抠图",
+ "failed": "失败",
+ "processing": "处理中",
+ "sdrPreview": "SDR 预览",
+ "hdrWarning": "这些控件使用当前 SDR 着色器预览路径。渲染可能仍标记为 HDR,但尚非真正的 HDR 色彩校正。"
+ },
+ "font": {
+ "fontBadge": "字体",
+ "loadingGoogleFonts": "正在加载 Google 字体...",
+ "searchFonts": "搜索字体",
+ "local": "本地",
+ "import": "导入",
+ "importLocalFontsAria": "导入本地字体文件",
+ "noFontsFound": "未找到字体。",
+ "browserNoLocalFonts": "此浏览器无法访问已安装字体。请改为导入字体文件。",
+ "noLocalFontsReturned": "未返回浏览器本地字体。",
+ "localFontDenied": "本地字体访问被拒绝。请改为导入字体文件。",
+ "localFontUnavailable": "本地字体访问不可用。请改为导入字体文件。",
+ "noSupportedFontsImported": "未导入支持的字体文件。",
+ "sourceCurrent": "当前",
+ "sourceDocument": "文档",
+ "sourceImported": "已导入",
+ "sourceGoogle": "Google",
+ "sourceLocal": "本地",
+ "sourceSystem": "系统"
+ },
+ "media": {
+ "video": "视频",
+ "audio": "音频",
+ "source": "源",
+ "cutout": "抠图",
+ "cutoutDescVideo": "创建透明 WebM 视频",
+ "cutoutDescImage": "创建透明 PNG 图片",
+ "removeBg": "移除背景",
+ "working": "处理中",
+ "removeBgTitle": "移除背景并保存透明资源",
+ "selectLocalAsset": "选择项目本地图片或视频资源",
+ "quality": "质量",
+ "qualityFast": "快速",
+ "qualityBalanced": "均衡",
+ "qualityBest": "最佳",
+ "bgPlate": "背景板",
+ "on": "开",
+ "off": "关",
+ "bgPlateHint": "可选的镂空背景副本。",
+ "appliedPath": "已应用 {{path}}",
+ "volume": "音量",
+ "playbackRate": "播放速率",
+ "mediaStart": "媒体起始",
+ "loop": "循环",
+ "muted": "静音",
+ "hasAudioTrack": "有音轨",
+ "yes": "是",
+ "no": "否",
+ "fit": "适配"
+ },
+ "domEdit": {
+ "rotate": "旋转",
+ "rotateSelection": "旋转选区",
+ "moveAnimationPath": "移动动画路径"
+ },
+ "cropHandles": {
+ "reposition": "重新定位裁剪",
+ "crop": "裁剪"
+ },
+ "promotable": {
+ "makeVariable": "将此设为变量"
+ }
+ },
+ "language": {
+ "switchLanguage": "切换语言",
+ "en": "English",
+ "zh": "中文"
+ },
+ "ui": {
+ "searchPlaceholder": "搜索…",
+ "videoFallback": "视频"
+ },
+ "hooks": {
+ "editorSave": {
+ "failed": "无法保存 {{path}} — 您的最新编辑未持久化。请检查预览服务器;再次编辑将重试保存。"
+ },
+ "fileManager": {
+ "loadFailed": "加载 {{path}} 失败",
+ "skippedTooLarge": "已跳过(过大):{{names}}",
+ "unsupportedMediaSkipped": "不支持的媒体已跳过:{{names}}",
+ "uploadRejectedTooLarge": "上传被拒绝:负载过大",
+ "uploadFailedStatus": "上传失败 ({{status}})",
+ "uploadFailedNetwork": "上传失败:网络错误",
+ "createFailed": "无法创建 {{path}}:{{error}}",
+ "createFolderFailed": "无法创建文件夹 {{path}}:{{error}}",
+ "deleteFailed": "无法删除 {{path}}:{{error}}",
+ "renameFailed": "无法重命名 {{oldPath}}:{{error}}",
+ "duplicateFailed": "无法复制 {{path}}:{{error}}"
+ },
+ "clipboard": {
+ "unableToCopy": "无法复制此元素。",
+ "copiedClip": "已复制片段",
+ "copiedElement": "已复制元素",
+ "nothingToPaste": "没有可粘贴的内容。",
+ "pastedClip": "已粘贴片段",
+ "pastedElement": "已粘贴元素",
+ "pasteFailed": "粘贴失败"
+ },
+ "hotkeys": {
+ "undid": "已撤销 {{label}}",
+ "redid": "已重做 {{label}}",
+ "useRazorForSubComp": "使用剃刀工具 (B) 在子合成内分割片段",
+ "fileChangedOutsideUndo": "文件在 Studio 外被修改。撤销历史未应用。",
+ "fileChangedOutsideRedo": "文件在 Studio 外被修改。重做历史未应用。"
+ },
+ "askAgent": {
+ "copyFailed": "无法将提示词复制到剪贴板。"
+ },
+ "frameCapture": {
+ "failed": "捕获失败",
+ "failedStatus": "捕获失败 ({{status}})",
+ "saveQueueTimedOut": "保存队列超时",
+ "timedOut": "捕获超时 — 服务器响应时间过长"
+ },
+ "domEdit": {
+ "invalidLayoutValues": "无法保存编辑,因为包含无效的布局值",
+ "reorderFailed": "重新排序图层失败",
+ "couldntSaveEdit": "无法保存编辑",
+ "couldntSaveEditWithDetail": "无法保存编辑:{{detail}}{{fieldsSuffix}}",
+ "savedButUpdateFailed": "已保存,但无法完成更新 {{path}}:{{detail}}",
+ "saveFailed": "无法保存 \"{{label}}\":{{detail}}",
+ "elementNotFoundInSource": "在源文件中找不到此元素 ({{targetPath}})",
+ "textStructureChangeFailed": "无法保存此文本结构更改",
+ "thisElement": "此元素"
+ },
+ "gesture": {
+ "selectionLost": "录制期间选择丢失",
+ "noGestureDetected": "未检测到手势 — 录制时请移动指针",
+ "recordingTooShort": "录制时间过短 — 请重试",
+ "noSelector": "无法保存 — 元素没有选择器",
+ "recordedKeyframes": "已录制 {{count}} 个关键帧",
+ "commitFailed": "手势提交失败:{{detail}}",
+ "selectElementFirst": "请先选择一个元素",
+ "previewNotReady": "预览未就绪 — 请重试"
+ },
+ "composition": {
+ "loadFailed": "加载 {{path}} 失败"
+ },
+ "timeline": {
+ "cannotEditWhileRecording": "录制时无法编辑时间线",
+ "deletedUseUndo": "已删除 {{label}}。使用撤销可恢复。",
+ "deleteFailed": "删除时间线片段失败",
+ "assetDropRestricted": "只有图片、视频和音频资源可以拖放到时间线上。",
+ "dropFailed": "将资源拖放到时间线失败",
+ "clipNotMovable": "此片段尚无法从时间线移动或调整大小。",
+ "toggleTrackVisibilityFailed": "切换轨道可见性失败",
+ "toggleElementVisibilityFailed": "切换元素可见性失败"
+ },
+ "blockHandlers": {
+ "alreadyInstalling": "正在安装另一个模块 — 请稍候…",
+ "addingBlock": "正在添加 {{blockName}}…"
+ },
+ "element": {
+ "deleteFailed": "删除元素失败"
+ },
+ "razor": {
+ "splitFailedPlayhead": "分割片段失败 — 播放头可能在片段外",
+ "splitAt": "在 {{time}}s 处分割 {{label}}",
+ "animationsNotRetargeted": "部分动画使用非 ID 选择器 ({{selectors}}) 且未重新定位",
+ "splitMultiple": "在 {{time}}s 处分割 {{count}} 个片段",
+ "splitFailed": "分割时间线片段失败",
+ "splitClipsFailed": "分割片段失败"
+ }
+ },
+ "shell": {
+ "toast": {
+ "dismiss": "关闭"
+ },
+ "errorBoundary": {
+ "somethingWentWrong": "发生了一些错误",
+ "tryAgain": "重试",
+ "reloadStudio": "重新加载 Studio"
+ },
+ "overlays": {
+ "consoleErrorsTitle": "预览中的控制台错误",
+ "consoleErrorsIntro": "修复合成预览中的这些运行时控制台错误"
+ },
+ "previewArea": {
+ "captions": "字幕"
+ },
+ "saveQueuePaused": {
+ "retrySaving": "重试保存"
+ },
+ "mediaPreview": {
+ "loadError": "无法加载此文件 — 可能已丢失或损坏",
+ "binaryFile": "二进制文件 — 预览不可用"
+ },
+ "globalDragOverlay": {
+ "dropFiles": "拖放文件以导入到项目"
+ },
+ "leftSidebar": {
+ "showSidebar": "显示侧边栏",
+ "loadingFile": "正在加载 {{path}}…",
+ "resizeSidebar": "调整侧边栏大小"
+ }
+ },
+ "variablesPanel": {
+ "noScriptReads": "没有脚本读取此变量",
+ "setDefault": "设为默认值",
+ "persistDefault": "将此值持久化为声明的默认值",
+ "edit": "编辑",
+ "editDeclaration": "编辑声明",
+ "removeDeclaration": "移除声明",
+ "readByScripts": "被脚本读取,未声明",
+ "declare": "声明",
+ "declareAsString": "声明为字符串变量",
+ "title": "变量",
+ "copyRenderCommand": "复制渲染命令",
+ "cliCommandHint": "渲染与预览完全一致的 CLI 命令",
+ "copyValuesJson": "复制值 JSON",
+ "effectiveValuesHint": "有效值(默认值与预览覆盖合并)"
+ },
+ "variablesForm": {
+ "id": "标识符",
+ "idPlaceholder": "标题",
+ "label": "标签",
+ "labelPlaceholder": "标题",
+ "type": "类型",
+ "default": "默认值",
+ "optionsHint": "选项(每行一个,格式:值:标签)",
+ "descriptionOptional": "描述(可选)"
+ }
+}
diff --git a/packages/studio/src/i18n/types.d.ts b/packages/studio/src/i18n/types.d.ts
new file mode 100644
index 0000000000..d5ef2f18a1
--- /dev/null
+++ b/packages/studio/src/i18n/types.d.ts
@@ -0,0 +1,9 @@
+import "i18next";
+import type en from "./locales/en.json";
+
+declare module "i18next" {
+ interface CustomTypeOptions {
+ defaultNS: "translation";
+ resources: { translation: typeof en };
+ }
+}
diff --git a/packages/studio/src/i18n/useLanguage.ts b/packages/studio/src/i18n/useLanguage.ts
new file mode 100644
index 0000000000..49a0c1063e
--- /dev/null
+++ b/packages/studio/src/i18n/useLanguage.ts
@@ -0,0 +1,37 @@
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import type { SupportedLocale } from "./index";
+
+const STORAGE_KEY = "hf-studio-lang";
+
+export function useLanguage() {
+ const { i18n } = useTranslation();
+
+ const currentLanguage = i18n.language as SupportedLocale;
+
+ const setLanguage = useCallback(
+ (lang: SupportedLocale) => {
+ i18n.changeLanguage(lang);
+ try {
+ localStorage.setItem(STORAGE_KEY, lang);
+ } catch {
+ // localStorage unavailable
+ }
+ document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
+ },
+ [i18n],
+ );
+
+ const toggleLanguage = useCallback(() => {
+ const next = currentLanguage === "en" ? "zh" : "en";
+ setLanguage(next);
+ }, [currentLanguage, setLanguage]);
+
+ return {
+ currentLanguage,
+ setLanguage,
+ toggleLanguage,
+ isZh: currentLanguage === "zh",
+ isEn: currentLanguage === "en",
+ };
+}
diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx
index 4bf0587430..cb28d557b9 100644
--- a/packages/studio/src/main.tsx
+++ b/packages/studio/src/main.tsx
@@ -1,5 +1,6 @@
-import { StrictMode } from "react";
+import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
+import "./i18n";
import { StudioApp } from "./App";
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -93,8 +94,10 @@ window.addEventListener("unhandledrejection", (event) => {
createRoot(document.getElementById("root")!).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/packages/studio/src/player/components/TimelineShortcutHint.tsx b/packages/studio/src/player/components/TimelineShortcutHint.tsx
index d5de067f9c..892c615b02 100644
--- a/packages/studio/src/player/components/TimelineShortcutHint.tsx
+++ b/packages/studio/src/player/components/TimelineShortcutHint.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import type { TimelineTheme } from "./timelineTheme";
interface TimelineShortcutHintProps {
@@ -5,6 +6,8 @@ interface TimelineShortcutHintProps {
}
export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
+ const { t } = useTranslation();
+
return (
- + drag/click to edit range
+ {t("player.editRangeHint")}
diff --git a/packages/studio/src/telemetry/client.test.ts b/packages/studio/src/telemetry/client.test.ts
index 6a7570400c..ef6e908fad 100644
--- a/packages/studio/src/telemetry/client.test.ts
+++ b/packages/studio/src/telemetry/client.test.ts
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
+import { clearMockStorage, mockLocalStorage } from "./testUtils";
// `shouldTrack()` reads module-level constants evaluated at module load time,
// so changing env after import has no effect. Each test resets module cache.
@@ -29,8 +30,9 @@ describe("studio client shouldTrack", () => {
beforeEach(() => {
setDev(false);
setNoTelemetry(undefined);
- localStorage.clear();
+ clearMockStorage();
vi.unstubAllGlobals();
+ mockLocalStorage(vi);
});
it("returns true when not in dev mode and no opt-outs", async () => {
diff --git a/packages/studio/src/telemetry/distinctId.test.ts b/packages/studio/src/telemetry/distinctId.test.ts
index b420114639..879654339e 100644
--- a/packages/studio/src/telemetry/distinctId.test.ts
+++ b/packages/studio/src/telemetry/distinctId.test.ts
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
-import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
+import { clearMockStorage, mockLocalStorage } from "./testUtils";
import {
resolveStudioDistinctId,
getCliDistinctId,
@@ -15,7 +16,8 @@ function clearCliId(): void {
describe("resolveStudioDistinctId", () => {
beforeEach(() => {
- localStorage.clear();
+ clearMockStorage();
+ mockLocalStorage(vi);
clearCliId();
__resetStudioDistinctIdForTests();
});
diff --git a/packages/studio/src/telemetry/testUtils.ts b/packages/studio/src/telemetry/testUtils.ts
new file mode 100644
index 0000000000..7ec424ce16
--- /dev/null
+++ b/packages/studio/src/telemetry/testUtils.ts
@@ -0,0 +1,34 @@
+import type { vi as ViType } from "vitest";
+
+const STORE = new Map();
+
+class MockStorage {
+ get length() {
+ return STORE.size;
+ }
+ getItem(k: string) {
+ return STORE.get(k) ?? null;
+ }
+ setItem(k: string, v: string) {
+ STORE.set(k, v);
+ }
+ removeItem(k: string) {
+ STORE.delete(k);
+ }
+ clear() {
+ STORE.clear();
+ }
+ key(i: number) {
+ return [...STORE.keys()][i] ?? null;
+ }
+}
+
+export function mockLocalStorage(vi: typeof ViType): void {
+ if (typeof localStorage === "undefined") {
+ vi.stubGlobal("localStorage", new MockStorage());
+ }
+}
+
+export function clearMockStorage(): void {
+ STORE.clear();
+}
diff --git a/packages/studio/src/test-setup.ts b/packages/studio/src/test-setup.ts
index ac3c7debc0..3c6580e4df 100644
--- a/packages/studio/src/test-setup.ts
+++ b/packages/studio/src/test-setup.ts
@@ -1,3 +1,25 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import en from "./i18n/locales/en.json";
+
+try {
+ i18n.use(initReactI18next).init({
+ resources: {
+ en: { translation: en },
+ },
+ lng: "en",
+ fallbackLng: "en",
+ interpolation: { escapeValue: false },
+ returnObjects: true,
+ react: {
+ useSuspense: false,
+ },
+ });
+} catch {
+ // i18n init skipped — tests referencing useTranslation will fall back to
+ // react-i18next's default key prefix and not crash.
+}
+
if (typeof globalThis.CSS === "undefined") {
(globalThis as Record).CSS = {};
}
diff --git a/registry/examples/kinetic-type/compositions/main-graphics.html b/registry/examples/kinetic-type/compositions/main-graphics.html
index b2a7fc7b96..ec4f3b5925 100644
--- a/registry/examples/kinetic-type/compositions/main-graphics.html
+++ b/registry/examples/kinetic-type/compositions/main-graphics.html
@@ -548,7 +548,12 @@
tl.to("#scene-5", { opacity: 1, duration: 0.1 }, 11.7);
tl.fromTo(".wes-text-top", { y: -100 }, { y: 0, duration: 0.6, ease: "back.out(1.2)" }, 12.0);
tl.to(".wes-rule", { width: 400, duration: 0.6 }, 12.3);
- tl.fromTo(".wes-text-bottom", { y: 100 }, { y: 0, duration: 0.6, ease: "back.out(1.2)" }, 12.6);
+ tl.fromTo(
+ ".wes-text-bottom",
+ { y: 100 },
+ { y: 0, duration: 0.6, ease: "back.out(1.2)" },
+ 12.6,
+ );
tl.to("#scene-5", { opacity: 0, duration: 0.5 }, 13.0);
// --- Scene 6: Vignelli Style (13.5s - 14.72s) ---
diff --git a/skills-manifest.json b/skills-manifest.json
index 868882e205..46fb9c7e6e 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -6,28 +6,28 @@
"files": 144
},
"faceless-explainer": {
- "hash": "b57c98179677e7a0",
- "files": 18
+ "hash": "cd3487d54b240947",
+ "files": 19
},
"figma": {
- "hash": "0e6e96f5a76ff824",
- "files": 2
+ "hash": "a3e7d9b22ee27a05",
+ "files": 3
},
"general-video": {
"hash": "1aed9f4f68414a45",
"files": 1
},
"hyperframes": {
- "hash": "fce43b4c3355cde9",
- "files": 1
+ "hash": "e0daf4386d1372f3",
+ "files": 2
},
"hyperframes-animation": {
- "hash": "b982a1af9821e326",
- "files": 99
+ "hash": "73ff2ebc30313de9",
+ "files": 110
},
"hyperframes-cli": {
- "hash": "5e1e197baf9b6653",
- "files": 8
+ "hash": "24becff143647ace",
+ "files": 9
},
"hyperframes-core": {
"hash": "690ebb3ba5420b90",
@@ -42,12 +42,12 @@
"files": 3
},
"hyperframes-registry": {
- "hash": "5f49178cc43e100b",
- "files": 10
+ "hash": "4ae2a91cb440c2f4",
+ "files": 13
},
"media-use": {
- "hash": "7a51f53cf615fe97",
- "files": 124
+ "hash": "4cc7aedef2c8e78c",
+ "files": 127
},
"motion-graphics": {
"hash": "dafcdce07d221aa4",
@@ -58,28 +58,28 @@
"files": 132
},
"pr-to-video": {
- "hash": "688b43a31bbc9360",
- "files": 28
+ "hash": "a71fa15e28b0b2bb",
+ "files": 29
},
"product-launch-video": {
"hash": "b1b41c74a52e28f1",
"files": 21
},
"remotion-to-hyperframes": {
- "hash": "aa599f027db4b994",
- "files": 70
+ "hash": "37a3d50ca72af9b3",
+ "files": 71
},
"slideshow": {
- "hash": "0f0364c54f77cb9b",
- "files": 2
+ "hash": "4f8e35421ab9dd12",
+ "files": 3
},
"talking-head-recut": {
- "hash": "d5c51342625c9952",
- "files": 27
+ "hash": "a1dd2e9ba46b8f40",
+ "files": 29
},
"website-to-video": {
- "hash": "e8143700c50b7469",
- "files": 32
+ "hash": "3ca1d74f9b19fd75",
+ "files": 33
}
}
}
diff --git a/skills/hyperframes-animation/adapters/animejs-zh.md b/skills/hyperframes-animation/adapters/animejs-zh.md
new file mode 100644
index 0000000000..7bbe96df1a
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/animejs-zh.md
@@ -0,0 +1,114 @@
+---
+name: animejs
+description: HyperFrames 的 Anime.js 适配器模式。在 HyperFrames 合成中编写 Anime.js 动画或时间轴、在 window.__hfAnime 上注册动画、使 Anime.js 由 seek 驱动且具备确定性时使用;或将 Anime.js 示例转换为可安全渲染的 HyperFrames HTML。
+---
+
+# HyperFrames 中的 Anime.js
+
+HyperFrames 可通过其 `animejs` 运行时适配器对 Anime.js 实例进行 seek。合成拥有动画对象;HyperFrames 拥有时钟。
+
+## 约定
+
+- 在合成初始化期间同步创建动画或时间轴。
+- 将 `autoplay: false`,以免 Anime.js 使用自己的时钟推进。
+- 将每个返回的动画或时间轴注册到 `window.__hfAnime`。
+- 使用有限的持续时间和循环次数。
+- 避免基于墙钟时间(wall-clock time)、网络状态或未设种随机数而修改 DOM 的回调。
+
+适配器通过 `instance.seek(timeMs)` 对每个已注册实例进行 seek,其中 `timeMs` 是 HyperFrames 的毫秒时间。
+
+## 基本模式
+
+```html
+
+
+```
+
+## 时间轴模式
+
+```html
+
+```
+
+## 模块构建
+
+如果你使用 ES 模块构建,适配器不关心实例是如何创建的。它只需要返回的对象暴露 `seek()`、`pause()`,最好还有 `play()`:
+
+```html
+
+```
+
+## 适用场景
+
+- Anime.js 语法紧凑的小型 SVG 和 DOM 装饰效果。
+- 可改为由 seek 驱动(seek-driven)的导入 Anime.js 示例。
+- 推送到同一注册表的多个独立微动画。
+
+除非用户明确要求 Anime.js,否则复杂场景编排请使用 GSAP。GSAP 仍是 HyperFrames 的主要创作路径。
+
+## 避免
+
+- 让 `autoplay` 保持 Anime.js 默认值。
+- 依赖 `anime.running` 自动发现,而非显式 `window.__hfAnime.push(...)`。
+- 无限循环。根据合成时长计算有限的重复次数。
+- 在定时器、Promise、事件处理器中,或在异步资源加载后构建动画。
+
+## 验证
+
+编辑使用 Anime.js 的合成后:
+
+```bash
+npx hyperframes lint
+npx hyperframes validate
+```
+
+## 致谢与参考
+
+- HyperFrames 适配器源码:`packages/core/src/runtime/adapters/animejs.ts`。
+- Anime.js 关于 `autoplay`、`pause()` 和 `seek()` 的文档:https://animejs.com/documentation/
diff --git a/skills/hyperframes-animation/adapters/css-animations-zh.md b/skills/hyperframes-animation/adapters/css-animations-zh.md
new file mode 100644
index 0000000000..83c19edbc0
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/css-animations-zh.md
@@ -0,0 +1,124 @@
+---
+name: css-animations
+description: HyperFrames 的 CSS 动画适配器模式。适用于编写 CSS 关键帧、基于 animation-delay 的时序、animation-fill-mode、animation-play-state,或 HyperFrames 在预览和渲染期间需要确定性寻址的纯 CSS 动效。
+---
+
+# HyperFrames 的 CSS 动画
+
+HyperFrames 可通过其 `css` 运行时适配器寻址 CSS 关键帧动画。适用于简单的重复图案、背景运动、闪光、发光、遮罩以及非序列化的装饰效果。
+
+对于场景编排,通常使用 GSAP 更清晰。CSS 动画最适合属于单个元素且持续时间固定的动效。
+
+## 约定
+
+- 在运行时初始化完成之前,将动画元素放入 DOM。
+- 为带时序的元素设置 `data-start` 值,使本地动画时间与 clip 对齐。
+- 使用有限的 `animation-duration` 和 `animation-iteration-count`,因为在没有 WAAPI 支持的 CSS 动画环境中,负延迟回退无法表示无界持续时间。
+- 优先使用 `animation-fill-mode: both`,以便寻址后的状态在活动动效前后保持。
+- 避免依赖挂钟的 JavaScript、悬停触发状态,以及依赖用户事件的类切换。
+
+适配器会查找具有计算后 `animation-name` 的元素,在可用时寻址其浏览器的 `Animation` 句柄,否则回退为通过负 `animation-delay` 暂停。
+
+## 基础模式
+
+```html
+
+
+
+```
+
+## 交错模式
+
+使用 CSS 自定义属性,避免重复关键帧:
+
+```html
+
+
+
+
+
+
+
+```
+
+## 适用场景
+
+- 已知重复次数的装饰性循环。
+- 遮罩、发光、闪光、颗粒和微妙视差层。
+- 完整 JS 时间线过于冗杂的简单单元素入场。
+
+## 避免使用
+
+- 无限循环的 CSS 动画,除非你已验证浏览器暴露了可寻址的 WAAPI 支持的 CSS 动画句柄。优先使用覆盖可见时长的有限迭代次数。
+- 在 transform 可用时,避免对 `top`、`left`、`width` 或 `height` 等布局属性做动画。
+- 依赖 hover、focus、scroll 或媒体查询来触发渲染关键动效。
+- 在启动后更改动画类,除非有另一个确定性时间线控制该变更。
+
+## 验证
+
+编辑 CSS 动画合成后:
+
+```bash
+npx hyperframes lint
+npx hyperframes validate
+```
+
+## 致谢与参考
+
+- HyperFrames 适配器源码:`packages/core/src/runtime/adapters/css.ts`。
+- MDN CSS 动画文档:https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation
+- MDN `animation-fill-mode`:https://developer.mozilla.org/en-US/docs/Web/CSS/animation-fill-mode
diff --git a/skills/hyperframes-animation/adapters/gsap-zh.md b/skills/hyperframes-animation/adapters/gsap-zh.md
new file mode 100644
index 0000000000..57cd856580
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/gsap-zh.md
@@ -0,0 +1,240 @@
+---
+name: gsap
+description: HyperFrames 的 GSAP 动画参考。涵盖 gsap.to()、from()、fromTo()、缓动、easing、stagger、defaults、时间轴(gsap.timeline()、位置参数、标签、嵌套、播放控制)以及性能(transforms、will-change、quickTo)。在 HyperFrames 合成中编写 GSAP 动画时使用。
+---
+
+# GSAP
+
+## HyperFrames 约定
+
+HyperFrames 通过其 `gsap` 运行时适配器控制 GSAP。同步创建已暂停的时间轴,在 `window.__timelines` 上注册,键名必须与 `data-composition-id` 完全一致,然后由 HyperFrames 对其进行 seek。
+
+```html
+
+
+```
+
+- 注册表键名必须与合成根元素的 `data-composition-id` 一致。
+- 不要为渲染关键动画调用 `tl.play()`。
+- 不要在异步代码、定时器或事件处理器中构建时间轴。
+- 保持循环有限。HyperFrames 渲染的是有限时长的视频。
+
+## 核心 Tween 方法
+
+- **gsap.to(targets, vars)** — 从当前状态动画到 `vars`。最常用。
+- **gsap.from(targets, vars)** — 从 `vars` 动画到当前状态(入场动画)。
+- **gsap.fromTo(targets, fromVars, toVars)** — 显式指定起点和终点。
+- **gsap.set(targets, vars)** — 立即应用(duration 为 0)。
+
+始终使用 **camelCase** 属性名(例如 `backgroundColor`、`rotationX`)。
+
+## 常用 vars
+
+- **duration** — 秒(默认 0.5)。
+- **delay** — 开始前的延迟秒数。
+- **ease** — `"power1.out"`(默认)、`"power3.inOut"`、`"back.out(1.7)"`、`"elastic.out(1, 0.3)"`、`"none"`。
+- **stagger** — 数字 `0.1` 或对象:`{ amount: 0.3, from: "center" }`、`{ each: 0.1, from: "random" }`。
+- **overwrite** — `false`(默认)、`true` 或 `"auto"`。
+- **repeat** — 有限次数;在 HyperFrames 中永远不要使用 `-1`。根据可见时长计算重复次数。**yoyo** — 与 repeat 配合,交替方向。
+- **onComplete**、**onStart**、**onUpdate** — 回调。
+- **immediateRender** — from()/fromTo() 默认为 `true`。对后续作用于同一属性+元素的 tween 设为 `false`,以避免覆盖。
+
+## Transform 与 CSS
+
+优先使用 GSAP 的 **transform 别名**,而非原始 `transform` 字符串:
+
+| GSAP 属性 | 等价效果 |
+| --------------------------- | ------------------- |
+| `x`, `y`, `z` | translateX/Y/Z (px) |
+| `xPercent`, `yPercent` | translateX/Y in % |
+| `scale`, `scaleX`, `scaleY` | scale |
+| `rotation` | rotate (deg) |
+| `rotationX`, `rotationY` | 3D rotate |
+| `skewX`, `skewY` | skew |
+| `transformOrigin` | transform-origin |
+
+- **autoAlpha** — 优先于 `opacity`。为 0 时同时设置 `visibility: hidden`。
+- **CSS variables** — `"--hue": 180`。
+- **svgOrigin** _(仅 SVG)_ — 全局 SVG 坐标空间原点。不要与 `transformOrigin` 组合使用。
+- **Directional rotation** — `"360_cw"`、`"-170_short"`、`"90_ccw"`。
+- **clearProps** — `"all"` 或逗号分隔列表;完成时移除内联样式。
+- **Relative values** — `"+=20"`、`"-=10"`、`"*=2"`。
+
+## 基于函数的值
+
+```javascript
+gsap.to(".item", {
+ x: (i, target, targets) => i * 50,
+ stagger: 0.1,
+});
+```
+
+## 缓动(Easing)
+
+内置缓动:`power1`–`power4`、`back`、`bounce`、`circ`、`elastic`、`expo`、`sine`。每种都有 `.in`、`.out`、`.inOut`。
+
+## 默认值
+
+```javascript
+gsap.defaults({ duration: 0.6, ease: "power2.out" });
+```
+
+## 控制 Tween
+
+```javascript
+const tween = gsap.to(".box", { x: 100 });
+tween.pause();
+tween.play();
+tween.reverse();
+tween.kill();
+tween.progress(0.5);
+tween.time(0.2);
+```
+
+## gsap.matchMedia()(响应式 + 无障碍)
+
+仅在媒体查询匹配时运行设置;不匹配时自动还原。
+
+```javascript
+let mm = gsap.matchMedia();
+mm.add(
+ {
+ isDesktop: "(min-width: 800px)",
+ reduceMotion: "(prefers-reduced-motion: reduce)",
+ },
+ (context) => {
+ const { isDesktop, reduceMotion } = context.conditions;
+ gsap.to(".box", {
+ rotation: isDesktop ? 360 : 180,
+ duration: reduceMotion ? 0 : 2,
+ });
+ },
+);
+```
+
+---
+
+## 时间轴(Timelines)
+
+### 创建时间轴
+
+```javascript
+const tl = gsap.timeline({ defaults: { duration: 0.5, ease: "power2.out" } });
+tl.to(".a", { x: 100 }).to(".b", { y: 50 }).to(".c", { opacity: 0 });
+```
+
+### 位置参数
+
+第三个参数控制放置位置:
+
+- **Absolute**:`1` — 在 1 秒处
+- **Relative**:`"+=0.5"` — 在前一个结束后;`"-=0.2"` — 在前一个结束前
+- **Label**:`"intro"`、`"intro+=0.3"`
+- **Alignment**:`"<"` — 与上一个同时开始;`">"` — 在上一个结束后;`"<0.2"` — 在上一个开始后 0.2 秒
+
+```javascript
+tl.to(".a", { x: 100 }, 0);
+tl.to(".b", { y: 50 }, "<"); // same start as .a
+tl.to(".c", { opacity: 0 }, "<0.2"); // 0.2s after .b starts
+```
+
+### 标签
+
+```javascript
+tl.addLabel("intro", 0);
+tl.to(".a", { x: 100 }, "intro");
+tl.addLabel("outro", "+=0.5");
+tl.play("outro");
+tl.tweenFromTo("intro", "outro");
+```
+
+### 时间轴选项
+
+- **paused: true** — 创建为暂停状态;调用 `.play()` 开始。
+- **repeat**、**yoyo** — 应用于整个时间轴。
+- **defaults** — 合并到每个子 tween 的 vars。
+
+### 嵌套时间轴
+
+```javascript
+const master = gsap.timeline();
+const child = gsap.timeline();
+child.to(".a", { x: 100 }).to(".b", { y: 50 });
+master.add(child, 0);
+```
+
+### 播放控制
+
+`tl.play()`、`tl.pause()`、`tl.reverse()`、`tl.restart()`、`tl.time(2)`、`tl.progress(0.5)`、`tl.kill()`。
+
+---
+
+## 性能
+
+### 优先使用 Transform 和 Opacity
+
+动画 `x`、`y`、`scale`、`rotation`、`opacity` 可留在合成器层。当 transform 能达到相同效果时,避免动画 `width`、`height`、`top`、`left`。
+
+### will-change
+
+```css
+will-change: transform;
+```
+
+仅用于实际会动画的元素。
+
+### gsap.quickTo() 用于频繁更新
+
+```javascript
+let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }),
+ yTo = gsap.quickTo("#id", "y", { duration: 0.4, ease: "power3" });
+container.addEventListener("mousemove", (e) => {
+ xTo(e.pageX);
+ yTo(e.pageY);
+});
+```
+
+### Stagger 优于多个 Tween
+
+使用 `stagger` 代替带手动 delay 的多个独立 tween。
+
+### 清理
+
+暂停或终止屏幕外动画。
+
+---
+
+## 参考资料(按需加载)
+
+- **[references/effects.md](references/effects.md)** — 即插即用效果:打字机文本、音频可视化器。需要 HyperFrames 现成效果模式时阅读。
+
+## 最佳实践
+
+- 使用 camelCase 属性名;优先使用 transform 别名和 autoAlpha。
+- 优先使用时间轴而非用 delay 链式串联;使用位置参数。
+- 用 `addLabel()` 添加标签,使序列更易读。
+- 将 defaults 传入时间轴构造函数。
+- 需要控制播放时,保存 tween/时间轴的返回值。
+
+## 禁止事项
+
+- 当 transform 足够时,不要动画布局属性(width/height/top/left)。
+- 不要在同一 SVG 元素上同时使用 svgOrigin 和 transformOrigin。
+- 当时间轴可以编排序列时,不要用 delay 链式串联动画。
+- 不要在 DOM 存在之前创建 tween。
+- 不要跳过清理 — 不再需要时务必 kill tween。
+- 不要在 HyperFrames 合成中使用无限 repeat 值。根据可见时长计算有限的 repeat 次数。
+
+## 致谢与参考
+
+- HyperFrames 适配器源码:`packages/core/src/runtime/adapters/gsap.ts`。
+- GSAP 文档:https://gsap.com/docs/v3/
+- GSAP 时间轴暂停与 seek 行为:https://gsap.com/docs/v3/GSAP/Timeline/pause%28%29/
diff --git a/skills/hyperframes-animation/adapters/lottie-zh.md b/skills/hyperframes-animation/adapters/lottie-zh.md
new file mode 100644
index 0000000000..eb319e22e6
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/lottie-zh.md
@@ -0,0 +1,112 @@
+---
+name: lottie
+description: HyperFrames 的 Lottie 与 dotLottie 适配器模式。适用于嵌入 lottie-web JSON 动画、.lottie 文件、@lottiefiles/dotlottie-web 播放器,在 window.__hfLottie 上注册实例,或使 After Effects 导出在 HyperFrames 中具有确定性。
+---
+
+# HyperFrames 中的 Lottie
+
+HyperFrames 可通过其 `lottie` 运行时适配器对 `lottie-web` 和 dotLottie 播放器进行 seek。Lottie 非常适合,因为动画时间轴已编码在资源中;HyperFrames 只需一个可以 seek 的播放器对象。
+
+## 约定
+
+- 从本地项目文件加载资源,通常放在 `assets/` 下。
+- 设置 `autoplay: false`。
+- 除非用户明确需要循环,否则优先使用 `loop: false`。
+- 将每个返回的动画或播放器注册到 `window.__hfLottie`。
+- 用 CSS 保持 Lottie 容器尺寸稳定。
+
+适配器通过 `goToAndStop(timeMs, false)` seek `lottie-web`,并根据播放器类型使用帧或百分比 API seek dotLottie。
+
+## lottie-web 模式
+
+```html
+
+
+
+```
+
+```css
+.lottie-layer {
+ width: 100%;
+ height: 100%;
+}
+```
+
+## dotLottie 模式
+
+```html
+
+
+
+```
+
+```css
+.lottie-canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+```
+
+## 多个动画
+
+将每个播放器推入同一注册表:
+
+```js
+window.__hfLottie = window.__hfLottie || [];
+window.__hfLottie.push(backgroundAnim);
+window.__hfLottie.push(iconAnim);
+window.__hfLottie.push(confettiAnim);
+```
+
+HyperFrames 会将它们全部 seek 到同一合成时间。
+
+## 适用场景
+
+- 已在 lottie-web 中验证可正确渲染的 After Effects 导出。
+- Logo 揭示、图标循环、装饰性点缀和产品 UI 动效。
+- 将 Remotion Lottie 用法转换为纯 HyperFrames HTML。
+
+## 应避免
+
+- 在渲染时依赖远程 `path` URL。
+- 使用 `play()` 开始播放。
+- 假设不支持的 After Effects 效果能在导出后保留。请先在浏览器中测试 JSON 或 `.lottie` 文件。
+- 异步加载播放器,并在 HyperFrames 验证已检查页面之后才注册。
+
+## 验证
+
+编辑 Lottie 合成后:
+
+```bash
+npx hyperframes lint
+npx hyperframes validate
+```
+
+## 致谢与参考
+
+- HyperFrames 适配器源码:`packages/core/src/runtime/adapters/lottie.ts`。
+- lottie-web by Airbnb: https://github.com/airbnb/lottie-web
+- lottie-web `loadAnimation` options: https://github.com/airbnb/lottie-web/wiki/loadAnimation-options
+- dotLottie web player methods by LottieFiles: https://developers.lottiefiles.com/docs/dotlottie-player/dotlottie-web/methods
diff --git a/skills/hyperframes-animation/adapters/three-zh.md b/skills/hyperframes-animation/adapters/three-zh.md
new file mode 100644
index 0000000000..d657fcb715
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/three-zh.md
@@ -0,0 +1,106 @@
+---
+name: three
+description: 适用于 HyperFrames 的 Three.js 与 WebGL 适配器模式。在创建确定性 Three.js 场景、WebGL 画布层、AnimationMixer 时间轴、相机动画、着色器驱动的视觉效果,或响应 HyperFrames hf-seek 事件的画布渲染时使用。
+---
+
+# HyperFrames 中的 Three.js
+
+HyperFrames 通过其 `three` 运行时适配器支持 Three.js。该适配器不拥有你的场景。它会发布 HyperFrames 时间并派发 seek 事件,以便你的合成能够渲染精确帧。
+
+## 约定
+
+- 尽可能同步创建场景、相机、渲染器、材质和资源。
+- 根据 HyperFrames 时间渲染,而非墙钟时间(wall-clock time)。
+- 监听 `hf-seek` 事件并按该时间精确渲染。
+- 在渲染关键的 seek 操作之前加载模型、纹理和 HDRI。不要在 seek 时获取它们。
+- 避免将 `requestAnimationFrame` 或 `renderer.setAnimationLoop` 作为渲染关键运动的唯一依据。
+
+适配器会设置 `window.__hfThreeTime`,并在每次 seek 时派发 `new CustomEvent("hf-seek", { detail: { time } })`。
+
+## 基础模式
+
+```html
+
+
+```
+
+```css
+#three-layer {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+```
+
+## AnimationMixer 模式
+
+对于 GLTF 或已创作的片段动画,直接 seek 混合器:
+
+```js
+function renderAt(time) {
+ mixer.setTime(time);
+ renderer.render(scene, camera);
+}
+```
+
+如果存在多个混合器,使用相同的 `time` seek 所有混合器。
+
+## 适用场景
+
+- 确定性 3D 对象、产品旋转、使用种子数据的粒子,以及着色器板。
+- 由 `time` 推导的相机运动。
+- GLTF 动画片段(当资源为本地且在验证完成前已加载时)。
+
+## 避免
+
+- 使用 `Date.now()`、`performance.now()` 或时钟增量来更新场景状态。
+- 将渲染关键工作留在自由运行的动画循环中。
+- 在渲染时加载远程模型或纹理。
+- 依赖设备像素比的输出。为视频渲染固定渲染器尺寸和像素比。
+- 依赖前一帧历史的后处理通道,除非你能从时间重建状态。
+
+## 验证
+
+编辑 Three.js 合成后:
+
+```bash
+npx hyperframes lint
+npx hyperframes validate
+```
+
+## 致谢与参考
+
+- HyperFrames 适配器源码:`packages/core/src/runtime/adapters/three.ts`。
+- Three.js `WebGLRenderer` 文档:https://threejs.org/docs/pages/WebGLRenderer.html
+- Three.js `AnimationMixer.setTime()` 文档:https://threejs.org/docs/pages/AnimationMixer.html
diff --git a/skills/hyperframes-animation/adapters/typegpu-zh.md b/skills/hyperframes-animation/adapters/typegpu-zh.md
new file mode 100644
index 0000000000..17b1cc7817
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/typegpu-zh.md
@@ -0,0 +1,174 @@
+---
+name: typegpu
+description: HyperFrames 的 TypeGPU 与原生 WebGPU 适配器模式。适用于使用 TypeGPU、原生 WebGPU、WGSL 片段着色器、计算管线、液态玻璃效果、粒子系统,或任何由 navigator.gpu 驱动并响应 HyperFrames hf-seek 事件的 canvas 图层来创作 GPU 渲染合成。
+---
+
+# TypeGPU / WebGPU for HyperFrames
+
+HyperFrames 通过 `typegpu` 运行时适配器支持 TypeGPU 和原生 WebGPU。适配器不拥有你的管线。它发布 HyperFrames 时间并派发 seek 事件,让你的合成能够渲染精确的 GPU 帧。
+
+## 约定
+
+- 异步初始化 WebGPU(`await navigator.gpu.requestAdapter()`),但**同步**注册所有 GSAP tween——在任何 `await` 之前。HyperFrames 播放器在页面加载时会立即读取时间轴。
+- 根据 HyperFrames 时间渲染,而非 `performance.now()`。
+- 监听 `hf-seek` 事件,并在该时间点精确重绘。
+- 对 WebGPU 不可用的环境做好防护——适配器不会替你检查。
+- 视频渲染时,在提交 GPU 工作后调用 `await device.queue.onSubmittedWorkDone()`,确保帧捕获前 canvas 已刷新。
+
+适配器会设置 `window.__hfTypegpuTime`,并在每次 seek 时派发 `new CustomEvent("hf-seek", { detail: { time } })`。
+
+## 基本模式
+
+```html
+
+
+```
+
+## 时间轴注册
+
+驱动文本、字幕或 HTML 元素的 GSAP tween 必须**同步**注册——在任何 `await` 之前:
+
+```js
+const tl = gsap.timeline({ paused: true });
+
+// Caption tweens: synchronous, added before WebGPU init
+gsap.set(".cap", { opacity: 0 });
+tl.to("#cap-1", { opacity: 1, duration: 0.3 }, 1.0);
+tl.to("#cap-1", { opacity: 0, duration: 0.2 }, 3.5);
+
+window.__timelines["my-comp"] = tl;
+
+// GPU-dependent tweens can go inside the async IIFE
+(async () => {
+ // ... WebGPU init ...
+ const proxy = { value: 0 };
+ tl.to(proxy, { value: 1, duration: 2, onUpdate: render }, 0.5);
+})();
+```
+
+## 视频驱动效果(液态玻璃、畸变)
+
+将 `` 用作 GPU 输入纹理:
+
+```js
+const videoEl = document.getElementById("aroll");
+
+// Wait for video metadata before creating the texture
+await new Promise((r) => {
+ if (videoEl.readyState >= 1) r();
+ else videoEl.addEventListener("loadedmetadata", r, { once: true });
+});
+
+// Create texture at the video's NATIVE resolution
+const vw = videoEl.videoWidth,
+ vh = videoEl.videoHeight;
+const bgTex = device.createTexture({
+ size: [vw, vh],
+ format: "rgba8unorm",
+ usage:
+ GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
+});
+
+function render(t) {
+ try {
+ device.queue.copyExternalImageToTexture({ source: videoEl }, { texture: bgTex }, [vw, vh]);
+ } catch (_) {
+ /* frame not decoded yet */
+ }
+ // ... draw ...
+}
+```
+
+**Render-mode 注意:** 无头 Chrome 可能对 video 元素执行 `copyExternalImageToTexture` 失败。对于生产渲染,请通过 FFmpeg 预提取关键帧为 PNG,并作为图像纹理加载。
+
+## 通过降采样通道实现磨砂模糊
+
+单通道高斯核对于玻璃般的磨砂模糊效果太弱。采用两通道方案:
+
+1. **通道 1 — 降采样:** 将全分辨率纹理渲染到小纹理(1/6 分辨率)。降采样时的双线性过滤会自然地对像素取平均。
+2. **通道 2 — 玻璃合成:** 从小纹理采样磨砂内部区域(双线性放大 = 强烈平滑模糊),从全分辨率纹理采样锐利区域和色散折射。
+
+这与 TypeGPU 的 `textureSampleBias` mip 级别方案一致,且无需生成 mipmap。
+
+## 透明与不透明 Canvas
+
+- **`alphaMode: 'opaque'`** — GPU canvas 渲染完整帧(视频 + 效果)。当 GPU 管线处理所有视觉内容时使用。
+- **`alphaMode: 'premultiplied'`** — GPU canvas 在 alpha = 0 处透明,让下方 HTML 元素透出。用于常规 `` 元素之上的叠加层(粒子、路径动画等)。
+
+## WGSL 全屏三角形
+
+全屏效果的标准顶点着色器(无需顶点缓冲区):
+
+```wgsl
+struct Vo { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
+
+@vertex fn vs(@builtin(vertex_index) vi: u32) -> Vo {
+ let ps = array(vec2f(-1., -1.), vec2f(3., -1.), vec2f(-1., 3.));
+ let ts = array(vec2f(0., 1.), vec2f(2., 1.), vec2f(0., -1.));
+ return Vo(vec4f(ps[vi], 0., 1.), ts[vi]);
+}
+```
+
+使用 `pass.draw(3)` 绘制——一个覆盖整个视口的三角形。
+
+## 圆角矩形 SDF(液态玻璃胶囊)
+
+```wgsl
+fn sdf_box(p: vec2f, half_size: vec2f, corner_radius: f32) -> f32 {
+ let d = abs(p) - half_size + vec2f(corner_radius);
+ return length(max(d, vec2f(0.))) + min(max(d.x, d.y), 0.) - corner_radius;
+}
+```
+
+用于定义玻璃效果的内部/环形/外部区域。负值表示在形状内部。
+
+## 确定性渲染
+
+- 不使用 `Math.random()`——使用带种子的伪随机数生成器(PRNG)。
+- 不使用 `requestAnimationFrame` 作为渲染循环——仅在响应 `hf-seek` 时渲染。
+- 不使用 `performance.now()` 作为动画时间——读取 `window.__hfTypegpuTime` 或 `e.detail.time`。
+- GPU 提交后,调用 `await device.queue.onSubmittedWorkDone()` 以支持 render-mode 帧捕获。
diff --git a/skills/hyperframes-animation/adapters/waapi-zh.md b/skills/hyperframes-animation/adapters/waapi-zh.md
new file mode 100644
index 0000000000..6bbae393aa
--- /dev/null
+++ b/skills/hyperframes-animation/adapters/waapi-zh.md
@@ -0,0 +1,94 @@
+---
+name: waapi
+description: HyperFrames 的 Web Animations API 适配器模式。在编写 element.animate() 动效、Animation currentTime 寻帧、document.getAnimations()、KeyframeEffect 时序、fill 模式,或必须在 HyperFrames 中确定性渲染的原生浏览器动画时使用。
+---
+
+# HyperFrames 中的 Web Animations API
+
+HyperFrames 可通过其 `waapi` 运行时适配器对 Web Animations API 动画进行寻帧。当你需要原生浏览器关键帧、由 JavaScript 创建的时间控制,且不想依赖 GSAP 时,WAAPI 非常适用。
+
+## 约定
+
+- 在 composition 初始化期间同步创建动画。
+- 使用 `element.animate(...)`,并设置有限的 `duration` 和 `iterations`。
+- 使用 `fill: "both"`,以便寻帧后的状态得以保持。
+- 创建后暂停动画,或在首次寻帧时让适配器暂停它们。
+- 避免使用回调和 promise 来处理渲染关键状态。
+
+适配器会调用 `document.getAnimations()`,将每个动画的 `currentTime` 设为以毫秒为单位的 HyperFrames 时间,然后暂停该动画。
+
+## 基本模式
+
+```html
+
+
+
+```
+
+## 交错模式
+
+```js
+document.querySelectorAll(".token").forEach((token, index) => {
+ const animation = token.animate(
+ [
+ { transform: "translateY(24px)", opacity: 0 },
+ { transform: "translateY(0)", opacity: 1 },
+ ],
+ {
+ duration: 620,
+ delay: index * 80,
+ easing: "cubic-bezier(0.2, 0, 0, 1)",
+ fill: "both",
+ iterations: 1,
+ },
+ );
+ animation.pause();
+});
+```
+
+## 适用场景
+
+- 轻量级 DOM 动效:CSS 关键帧过于死板,又不需要 GSAP。
+- 从结构化数据生成动画。
+- 可用关键帧、延迟和 offset 表达的简单时间线。
+
+## 避免
+
+- 无限 `iterations`。
+- 依赖 `animation.finished` 来修改渲染关键的 DOM。
+- 使用 `requestAnimationFrame`、定时器或 `performance.now()` 运行独立时钟。
+- 在 transform 和 opacity 已能表达动效时,仍去动画化布局属性。
+- 假设 clip 局部起始时间会自动对齐。WAAPI 适配器按文档级动画时间寻帧;请用 `delay` 建模 clip 偏移,或在由 HyperFrames 时序控制可见性的元素上创建动画。
+
+## 验证
+
+编辑 WAAPI composition 后:
+
+```bash
+npx hyperframes lint
+npx hyperframes validate
+```
+
+## 致谢与参考
+
+- HyperFrames 适配器源码:`packages/core/src/runtime/adapters/waapi.ts`。
+- MDN Web Animations API 指南:https://developer.mozilla.org/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API
+- MDN `Animation.currentTime`:https://developer.mozilla.org/en-US/docs/Web/API/Animation/currentTime
diff --git a/skills/hyperframes-cli/SKILL_zh.md b/skills/hyperframes-cli/SKILL_zh.md
new file mode 100644
index 0000000000..1c50c7fea3
--- /dev/null
+++ b/skills/hyperframes-cli/SKILL_zh.md
@@ -0,0 +1,140 @@
+---
+name: hyperframes-cli
+description: HyperFrames CLI 开发循环 — 使用 `npx hyperframes` 进行脚手架搭建(init)、验证(lint、inspect)、预览、渲染以及环境排障(doctor、browser、info、upgrade)。在运行这些命令或排查 HyperFrames 构建/渲染环境时使用。对于资源预处理命令(`tts`、`transcribe`、`remove-background`),请改用 `hyperframes-media` 技能。
+---
+
+# HyperFrames CLI
+
+所有操作均通过 `npx hyperframes` 执行。需要 Node.js >= 22 和 FFmpeg。
+
+## 工作流
+
+1. **脚手架搭建** — `npx hyperframes init my-video`
+2. **编写** — 编写 HTML 合成(参见 `hyperframes` 技能)
+3. **Lint** — `npx hyperframes lint`
+4. **视觉检查** — `npx hyperframes inspect`
+5. **预览** — `npx hyperframes preview`
+6. **渲染** — `npx hyperframes render`
+
+预览前先执行 lint 和 inspect。`lint` 可发现缺失的 `data-composition-id`、轨道重叠以及未注册的时间线。`inspect` 在无头 Chrome 中打开渲染后的合成,沿时间线逐帧跳转,并报告文本溢出气泡/容器或超出画布的情况。
+
+## 脚手架搭建
+
+```bash
+npx hyperframes init my-video # interactive wizard
+npx hyperframes init my-video --example warm-grain # pick an example
+npx hyperframes init my-video --video clip.mp4 # with video file
+npx hyperframes init my-video --audio track.mp3 # with audio file
+npx hyperframes init my-video --example blank --tailwind # with Tailwind v4 browser runtime
+npx hyperframes init my-video --non-interactive # skip prompts (CI/agents)
+```
+
+模板:`blank`、`warm-grain`、`play-mode`、`swiss-grid`、`vignelli`、`decision-tree`、`kinetic-type`、`product-promo`、`nyt-graph`。
+
+`init` 会创建正确的文件结构、复制媒体、使用 Whisper 转录音频,并安装 AI 编码技能。应使用它,而不是手动创建文件。
+
+使用 `--tailwind` 时,在编辑 class 或主题 token 之前先调用 `tailwind` 技能。脚手架通过浏览器运行时(browser runtime)使用 Tailwind v4.2,而非 Studio 的 Tailwind v3 配置。
+
+## Lint
+
+```bash
+npx hyperframes lint # current directory
+npx hyperframes lint ./my-project # specific project
+npx hyperframes lint --verbose # info-level findings
+npx hyperframes lint --json # machine-readable
+```
+
+对 `index.html` 以及 `compositions/` 下的所有文件执行 lint。报告错误(必须修复)、警告(应当修复)以及信息(配合 `--verbose`)。
+
+## 视觉检查
+
+```bash
+npx hyperframes inspect # inspect rendered layout over the timeline
+npx hyperframes inspect ./my-project # specific project
+npx hyperframes inspect --json # agent-readable findings
+npx hyperframes inspect --samples 15 # denser timeline sweep
+npx hyperframes inspect --at 1.5,4,7.25 # explicit hero-frame timestamps
+```
+
+在 `lint` 和 `validate` 之后使用,尤其适用于带对话气泡、卡片、字幕或紧凑排版的合成。它会报告:
+
+- 文本超出最近的视觉容器或气泡
+- 文本被自身固定宽/高的盒子裁剪
+- 文本超出合成画布
+- 子元素逃出裁剪容器
+
+渲染前应修复错误。警告会展示给 agent 审阅;添加 `--strict` 可在警告时也失败。默认会折叠重复的静态问题,使 JSON 输出对 LLM 上下文窗口保持紧凑。若溢出是入场/退场动画的有意效果,在元素或其祖先上标记 `data-layout-allow-overflow`。若装饰性元素不应被审计,用 `data-layout-ignore` 标记。
+
+`npx hyperframes layout` 仍可作为同一视觉检查流程的兼容别名使用。
+
+## 预览
+
+```bash
+npx hyperframes preview # serve current directory
+npx hyperframes preview --port 4567 # custom port (default 3002)
+```
+
+文件变更时热重载。会自动在浏览器中打开 Studio。
+
+将项目交还给用户时,应使用 Studio 项目 URL,而非源 `index.html` 路径:
+
+```text
+http://localhost:/#project/
+```
+
+使用预览输出中的实际端口和项目目录名。例如,在 `codex-openai-video` 中执行 `npx hyperframes preview --port 3017` 后,应报告 `http://localhost:3017/#project/codex-openai-video`。
+
+将 `index.html` 仅视为源代码上下文。可以将其作为实现文件链接,但不要将其标注为项目或预览界面。
+
+## 渲染
+
+```bash
+npx hyperframes render # standard MP4
+npx hyperframes render --output final.mp4 # named output
+npx hyperframes render --quality draft # fast iteration
+npx hyperframes render --fps 60 --quality high # final delivery
+npx hyperframes render --format webm # transparent WebM
+npx hyperframes render --docker # byte-identical
+```
+
+| 标志 | 选项 | 默认值 | 说明 |
+| -------------------- | --------------------- | -------------------------- | ------------------------------------------------------------------ |
+| `--output` | path | renders/name_timestamp.mp4 | 输出路径 |
+| `--fps` | 24, 30, 60 | 30 | 60fps 会使渲染时间翻倍 |
+| `--quality` | draft, standard, high | standard | 迭代时使用 draft |
+| `--format` | mp4, webm | mp4 | WebM 支持透明通道 |
+| `--workers` | 1-8 or auto | auto | 每个 worker 会启动一个 Chrome |
+| `--docker` | flag | off | 可复现的输出 |
+| `--gpu` | flag | off | GPU 加速编码 |
+| `--strict` | flag | off | 遇到 lint 错误时失败 |
+| `--strict-all` | flag | off | 错误与警告均失败 |
+| `--variables` | JSON object | — | 覆盖 `data-composition-variables` 中声明的变量值 |
+| `--variables-file` | path | — | 含变量值的 JSON 文件(`--variables` 的替代方式) |
+| `--strict-variables` | flag | off | 当 `--variables` 中存在未声明的键或类型不匹配时使渲染失败 |
+
+**质量建议:** 迭代时用 `draft`,审阅用 `standard`,最终交付用 `high`。
+
+**参数化渲染:** 合成在 `` 根节点通过 **`data-composition-variables`** 声明变量 — 这是一个 JSON **声明数组**(每项为 `{id, type, label, default}`),用于定义 schema。内部脚本通过 `window.__hyperframes.getVariables()` 读取解析后的值。CLI 的 **`--variables '{"title":"Q4 Report"}'`** 是按 id 为键的 JSON **对象**,用于在一次渲染中覆盖上述默认声明;未提供的键会沿用默认值,因此同一合成在开发预览与生产环境中可保持一致运行。(子合成宿主也可通过 **`data-variable-values`** 按实例覆盖 — 对象形状相同,作用域限于该子合成的一次挂载。完整模式见 `hyperframes` 技能。)
+
+## 资源预处理
+
+`npx hyperframes tts`、`transcribe` 和 `remove-background` 会生成可放入合成的资源(旁白音频、词级转录、透明视频)。各命令首次运行时会下载各自模型。关于音色选择、Whisper 模型规则(`.en` 会把非英语翻译成英语的坑)、输出格式选择(VP9 带 alpha 的 WebM 与 ProRes),以及 TTS → transcribe → 字幕链路,请调用 `hyperframes-media` 技能。
+
+## 排障
+
+```bash
+npx hyperframes doctor # check environment (Chrome, FFmpeg, Node, memory)
+npx hyperframes browser # manage bundled Chrome
+npx hyperframes info # version and environment details
+npx hyperframes upgrade # check for updates
+```
+
+渲染失败时先运行 `doctor`。常见问题:缺少 FFmpeg、缺少 Chrome、内存不足。
+
+## 其他
+
+```bash
+npx hyperframes compositions # list compositions in project
+npx hyperframes docs # open documentation
+npx hyperframes benchmark . # benchmark render performance
+```
diff --git a/skills/hyperframes-registry/SKILL_zh.md b/skills/hyperframes-registry/SKILL_zh.md
new file mode 100644
index 0000000000..a7cf031231
--- /dev/null
+++ b/skills/hyperframes-registry/SKILL_zh.md
@@ -0,0 +1,104 @@
+---
+name: hyperframes-registry
+description: 将 registry 中的 blocks 和 components 安装并接入 HyperFrames 合成。适用于运行 hyperframes add、安装 block 或 component、将已安装项接入 index.html,或处理 hyperframes.json 的场景。涵盖 add 命令、安装路径、block 子合成接入、component 片段合并,以及 registry 发现。
+---
+
+# HyperFrames Registry
+
+registry 提供可复用的 blocks 和 components,可通过 `hyperframes add ` 安装。
+
+- **Blocks** — 独立的子合成(composition)(拥有各自的尺寸、时长、时间轴)。通过宿主合成中的 `data-composition-src` 引入。
+- **Components** — 效果片段(无独立尺寸)。直接粘贴到宿主合成的 HTML 中。
+
+## 何时使用本技能
+
+- 用户提到 `hyperframes add`、"block"、"component" 或 `hyperframes.json`
+- 会话中出现 `hyperframes add` 的输出(文件路径、剪贴板片段)
+- 需要将已安装项接入现有合成
+- 想要发现 registry 中有哪些可用项
+
+## 快速参考
+
+```bash
+hyperframes add data-chart # install a block
+hyperframes add grain-overlay # install a component
+hyperframes add shimmer-sweep --dir . # target a specific project
+hyperframes add data-chart --json # machine-readable output
+hyperframes add data-chart --no-clipboard # skip clipboard (CI/headless)
+```
+
+安装完成后,CLI 会打印写入的文件,以及可粘贴到宿主合成中的片段。该片段只是起点——接入 blocks 时,你还需要添加 `data-composition-id`(必须与 block 内部合成 ID 一致)、`data-start` 和 `data-track-index` 属性。
+
+注意:`hyperframes add` 仅适用于 blocks 和 components。如需 examples,请改用 `hyperframes init --example `。
+
+## 安装路径
+
+Blocks 默认安装到 `compositions/.html`。
+Components 默认安装到 `compositions/components/.html`。
+
+这些路径可在 `hyperframes.json` 中配置:
+
+```json
+{
+ "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
+ "paths": {
+ "blocks": "compositions",
+ "components": "compositions/components",
+ "assets": "assets"
+ }
+}
+```
+
+完整说明见 [install-locations.md](./references/install-locations.md)。
+
+## 接入 blocks
+
+Blocks 是独立合成——在宿主 `index.html` 中通过 `data-composition-src` 引入:
+
+```html
+
+```
+
+关键属性:
+
+- `data-composition-src` — block HTML 文件路径
+- `data-composition-id` — 必须与 block 内部 ID 一致
+- `data-start` — block 在宿主时间轴上的出现时刻(秒)
+- `data-duration` — block 播放时长
+- `data-width` / `data-height` — block 画布尺寸
+- `data-track-index` — 图层顺序(数值越大越靠前)
+
+完整说明见 [wiring-blocks.md](./references/wiring-blocks.md)。
+
+## 接入 components
+
+Components 是片段——将其 HTML 粘贴到合成的标记中,CSS 粘贴到样式块,JS 粘贴到脚本中(如有):
+
+1. 读取已安装文件(例如 `compositions/components/grain-overlay.html`)
+2. 将 HTML 元素复制到合成的 `` 中
+3. 将 `
+
+
+
+
+```
+
+在根中加载:`
`
+
+## 变量(参数化合成)
+
+以不同内容渲染同一合成 — 标题、主题色、价格、字幕 — 无需编辑源 HTML。
+
+**三步模式:**
+
+1. **声明** — 在合成的 `` 根元素上用 `data-composition-variables` 声明变量。每项需要 `id`、`type`(`string`、`number`、`color`、`boolean`、`enum` 之一)、`label` 和 `default`。enum 类型还需要 `options: [{value, label}, ...]`。
+2. **读取** — 在合成脚本内用 `window.__hyperframes.getVariables()` 读取解析后的值。返回声明默认值 + 每实例覆盖 + CLI 覆盖的合并结果。
+3. **覆盖** — 渲染时用 `npx hyperframes render --variables '{...}'`(顶层)或在宿主元素上用 `data-variable-values='{...}'`(子合成的每实例覆盖)。
+
+```html
+
+
+
+
+
+
+
+
+
+```
+
+```bash
+# Dev preview uses declared defaults
+npx hyperframes preview
+
+# Render with overrides
+npx hyperframes render --variables '{"title":"Q4 Report","theme":"dark"}' --output q4.mp4
+
+# Or from a JSON file
+npx hyperframes render --variables-file ./vars.json
+```
+
+**子合成每实例值:** 通过 `data-composition-src` 加载的子合成内同样适用 `getVariables()`。每个宿主元素传递自己的值:
+
+```html
+
+
+```
+
+运行时将每个宿主的 `data-variable-values` 按实例叠加在子合成声明的默认值之上,因此同一源文件可以嵌入多次并携带不同内容。
+
+**经验法则:**
+
+- 为每个声明的变量提供合理的 `default`。开发预览使用默认值 — 没有它们,合成在提供 `--variables` 之前无法正确渲染。
+- 在脚本顶部读取变量一次(`const { title } = ...`),不要在帧循环或事件处理器内读取 — `getVariables()` 每次调用都会分配新对象。
+- 在 CI 中使用 `--strict-variables` 以快速失败于未声明的键或类型不匹配。
+- 变量类型在渲染时验证。`string`、`number`、`boolean` 和 `color`(十六进制字符串)检查 `typeof`;`enum` 检查值是否在声明的 `options` 中。
+
+## 视频和音频
+
+视频必须为 `muted playsinline`。音频始终是独立的 `` 元素:
+
+```html
+
+
+```
+
+## 时间线约定
+
+- 所有时间线以 `{ paused: true }` 启动 — 播放器控制播放
+- 注册每个时间线:`window.__timelines[""] = tl`
+- 框架自动嵌套子时间线 — 不要手动添加
+- 时长来自 `data-duration`,而非 GSAP 时间线长度
+- 不要创建空补间来设置时长
+
+## 规则(不可协商)
+
+**确定性:** 不使用 `Math.random()`、`Date.now()` 或基于时间的逻辑。如需伪随机值,使用带种子的 PRNG(例如 mulberry32)。
+
+**GSAP:** 仅动画视觉属性(`opacity`、`x`、`y`、`scale`、`rotation`、`color`、`backgroundColor`、`borderRadius`、transform)。不要动画 `visibility`、`display`,或调用 `video.play()`/`audio.play()`。
+
+**动画冲突:** 不要从多个时间线同时对同一元素的同一属性做动画。
+
+**禁止 `repeat: -1`:** 无限重复时间线会破坏捕获引擎。根据合成时长计算精确重复次数:`repeat: Math.ceil(duration / cycleDuration) - 1`。
+
+**同步时间线构建:** 不要在 `async`/`await`、`setTimeout` 或 Promise 内构建时间线。捕获引擎在页面加载后同步读取 `window.__timelines`。字体由编译器嵌入,立即可用 — 无需等待字体加载。
+
+**禁止:**
+
+1. 忘记 `window.__timelines` 注册
+2. 用视频播放音频 — 始终使用静音视频 + 独立 ``
+3. 将视频嵌套在定时 div 内 — 使用非定时包装器
+4. 使用 `data-layer`(应使用 `data-track-index`)或 `data-end`(应使用 `data-duration`)
+5. 动画视频元素尺寸 — 动画包装 div
+6. 调用媒体的 play/pause/seek — 框架拥有播放控制权
+7. 创建没有 `data-composition-id` 的顶层容器
+8. 在任何时间线或补间上使用 `repeat: -1` — 始终使用有限重复
+9. 异步构建时间线(在 `async`、`setTimeout`、`Promise` 内)
+10. 对后续场景的剪辑元素使用 `gsap.set()` — 它们在页面加载时不存在于 DOM 中。改用时间线内的 `tl.set(selector, vars, timePosition)`,时间位置在剪辑的 `data-start` 时刻或之后。
+11. 在内容文本中使用 ` ` — 强制换行不考虑实际渲染字体宽度。自然换行的文本 + ` ` 会产生多余的换行,导致重叠。通过 `max-width` 让文本自然换行。例外:每个词刻意独占一行的短展示标题(例如 130px 的 "THE\nIMMORTAL\nGAME")。
+
+## 场景转场(不可协商)
+
+每个多场景合成必须遵循以下所有规则。违反任何一条即为损坏的合成。
+
+1. **场景之间始终使用转场。** 禁止硬切。没有例外。
+2. **每个场景始终使用入场动画。** 每个元素通过 `gsap.from()` 动画进入。任何元素不得完全成型地出现。如果一个场景有 5 个元素,就需要 5 个入场补间。
+3. **禁止使用退场动画**,最终场景除外。即:在转场触发之前,禁止 `gsap.to()` 将 opacity 动画到 0、y 移出屏幕、scale 到 0,或任何其他「退出」动画。转场就是退场。转场开始时,退场场景的内容必须完全可见。
+4. **仅最终场景:** 最后一个场景可以让元素淡出(例如淡出到黑)。这是唯一允许 `gsap.to(..., { opacity: 0 })` 的场景。
+
+**错误 — 转场前的退场动画:**
+
+```js
+// BANNED — this empties the scene before the transition can use it
+tl.to("#s1-title", { opacity: 0, y: -40, duration: 0.4 }, 6.5);
+tl.to("#s1-subtitle", { opacity: 0, duration: 0.3 }, 6.7);
+// transition fires on empty frame
+```
+
+**正确 — 仅入场,转场处理退场:**
+
+```js
+// Scene 1 entrance animations
+tl.from("#s1-title", { y: 50, opacity: 0, duration: 0.7, ease: "power3.out" }, 0.3);
+tl.from("#s1-subtitle", { y: 30, opacity: 0, duration: 0.5, ease: "power2.out" }, 0.6);
+// NO exit tweens — transition at 7.2s handles the scene change
+// Scene 2 entrance animations
+tl.from("#s2-heading", { x: -40, opacity: 0, duration: 0.6, ease: "expo.out" }, 8.0);
+```
+
+## 动画护栏
+
+- 第一个动画偏移 0.1–0.3 秒(不要从 t=0 开始)
+- 入场补间之间变化缓动 — 每个场景至少使用 3 种不同缓动
+- 同一场景内不要重复入场模式
+- 避免在深色背景上使用全屏线性渐变(H.264 色带 — 使用径向或纯色 + 局部发光)
+- 渲染视频中标题 60px+、正文 20px+、数据标签 16px+
+- 数字列使用 `font-variant-numeric: tabular-nums`
+
+如果不存在 `frame.md` 或 `design.md`,遵循 [house-style.md](./house-style.md) 的美学默认值。
+
+## 字体与资源
+
+- **内置字体:** 在 CSS 中写入你想要的 `font-family` — 编译器自动嵌入支持的字体。
+- **自定义字体:** 如果规范(`frame.md` 或 `design.md`)指定了非内置字体,用户必须在 `fonts/` 目录提供 `.woff2` 文件。如果缺失,在编写 HTML 前警告。文件存在时,添加指向本地文件的 `@font-face` 声明。
+- 为外部媒体添加 `crossorigin="anonymous"`
+- 对于动态文本溢出,使用 `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })`
+- 所有文件位于项目根目录,与 `index.html` 同级;子合成使用 `../`
+
+## 编辑现有合成
+
+- **读取实际文件,不要猜测。** 编辑、扩展或创建配套合成时,阅读现有源码。不要从记忆中重建十六进制代码。不要猜测 GSAP 缓动模式。合成就是规范 — 从中提取精确值。
+- 匹配已读内容中的现有字体、颜色、动画模式
+- 仅修改被要求修改的内容
+- 保留无关剪辑的时间线
+
+## 输出检查清单
+
+**快速(立即运行,阻塞于结果):**
+
+- [ ] `npx hyperframes lint` 和 `npx hyperframes validate` 均通过
+- [ ] 若存在设计规范(`frame.md` 或 `design.md`),已验证设计一致性
+
+**慢速(向用户展示预览时并行运行):**
+
+- [ ] `npx hyperframes inspect` 通过,或每个报告的溢出均已有意标记
+- [ ] 对比度警告已处理(见下方质量检查)
+- [ ] 动画编排已验证(见下方质量检查)
+
+## 质量检查
+
+### 视觉检查
+
+`hyperframes inspect` 在无头 Chrome 中运行合成,在时间线上 seek,并映射带时间戳、选择器、边界框和修复提示的视觉布局问题。在 `lint` 和 `validate` 之后运行:
+
+```bash
+npx hyperframes inspect
+npx hyperframes inspect --json
+```
+
+失败通常意味着文本溢出气泡/卡片、固定尺寸标签裁剪动态文案,或文本移出画布。通过增大容器尺寸或 padding、减小字号或字间距、添加真实 `max-width` 使文本在容器内换行,或对动态文案使用 `window.__hyperframes.fitTextFontSize(...)` 来修复。
+
+密集视频使用 `--samples 15`,特定英雄帧使用 `--at 1.5,4,7.25`。重复的静态问题默认折叠,避免淹没代理上下文。如果溢出是入场/退场动画的有意效果,在元素或祖先上标记 `data-layout-allow-overflow`。如果装饰元素不应被审计,标记 `data-layout-ignore`。
+
+`hyperframes layout` 是同一检查的兼容别名。
+
+### 对比度
+
+`hyperframes validate` 默认运行 WCAG 对比度审计。它 seek 到 5 个时间戳,截图页面,采样每个文本元素背后的背景像素,并计算对比度比率。失败显示为警告:
+
+```
+⚠ WCAG AA contrast warnings (3):
+ · .subtitle "secondary text" — 2.67:1 (need 4.5:1, t=5.3s)
+```
+
+如果出现警告:
+
+- 深色背景:提亮失败颜色直至达到 4.5:1(普通文本)或 3:1(大文本,24px+ 或 19px+ 粗体)
+- 浅色背景:加深颜色
+- 保持在调色板族内 — 不要发明新颜色,调整现有颜色
+- 重新运行 `hyperframes validate` 直至干净
+
+快速迭代时可用 `--no-contrast` 跳过,稍后再检查。
+
+### 设计一致性
+
+如果存在设计规范(`frame.md` 或 `design.md`),编写后验证合成是否遵循它。阅读 HTML 并检查:
+
+1. **颜色** — 合成中每个十六进制值都出现在规范的调色板部分(无论用户如何标注:Colors、Palette、Theme 等)。标记任何自行发明的颜色。
+2. **字体** — 字体族和字重与规范的类型规范匹配。禁止替换。
+3. **圆角** — border-radius 值与声明的圆角风格匹配(如有指定)。
+4. **间距** — padding 和 gap 值在声明的密度范围内(如有指定)。
+5. **深度** — 阴影使用与声明的深度级别匹配(如有指定:flat = 无,subtle = 轻,layered = 发光)。
+6. **避免规则** — 如果规范有列出应避免事项的章节(常见为 "What NOT to Do"、"Don'ts"、"Anti-patterns" 或 "Do's and Don'ts"),验证均未出现。
+
+将违规报告为检查清单。在交付前逐一修复。
+
+如果不存在设计规范(仅 house-style 路径),验证:
+
+1. **调色板一致性** — 所有场景使用相同的 bg、fg 和 accent 颜色。禁止每场景发明颜色。
+2. **禁止懒惰默认值** — 对照 house-style.md 的「Lazy Defaults to Question」列表检查合成。如果出现,必须是针对内容的有意选择,而非默认值。
+
+### 动画地图
+
+编写动画后,运行动画地图验证编排:
+
+```bash
+node skills/hyperframes/scripts/animation-map.mjs \
+ --out /.hyperframes/anim-map
+```
+
+输出单个 `animation-map.json`,包含:
+
+- **每补间摘要**:`"#card1 animates opacity+y over 0.50s. moves 23px up. fades in. ends at (120, 200)"`
+- **ASCII 时间线**:合成时长内所有补间的甘特图
+- **交错检测**:报告实际间隔(`"3 elements stagger at 120ms"`)
+- **死区**:超过 1 秒无动画的时段 — 有意停留还是缺少入场?
+- **元素生命周期**:首次/末次动画时间、最终可见性
+- **场景快照**:5 个关键时间戳的可见元素状态
+- **标记**:`offscreen`、`collision`、`invisible`、`paced-fast`(低于 0.2 秒)、`paced-slow`(超过 2 秒)
+
+阅读 JSON。扫描摘要中的意外项。检查每个标记 — 修复或说明理由。验证时间线显示预期的编排节奏。修复后重新运行。
+
+小改动(修复颜色、调整一个时长)可跳过。新合成和重大动画变更时运行。
+
+---
+
+## 参考资料(按需加载)
+
+- **[references/captions.md](references/captions.md)** — 与音频同步的字幕、副标题、歌词、卡拉 OK。情绪自适应风格检测、逐词样式、文本溢出预防、字幕退场保证、词分组。添加任何与音频时间同步的文本时阅读。
+- **[references/audio-reactive.md](references/audio-reactive.md)** — 音频反应式动画:将频段和振幅映射到 GSAP 属性。视觉应对音乐、语音或声音响应时阅读。
+- **[references/css-patterns.md](references/css-patterns.md)** — CSS+GSAP 标记高亮:highlight、circle、burst、scribble、sketchout。确定性、完全可 seek。为文本添加视觉强调时阅读。
+- **[references/video-composition.md](references/video-composition.md)** — 视频媒介规则:密度、色彩存在感、尺度、画框构图、设计规范作为品牌而非布局。**始终阅读** — 这些覆盖网页直觉。
+- **[references/beat-direction.md](references/beat-direction.md)** — 节拍规划:概念、情绪、编排动词、节奏模板、转场决策、深度层。**多场景合成始终阅读。**
+- **[references/typography.md](references/typography.md)** — 字体:字体配对、OpenType 特性、深色背景调整、字体发现脚本。**始终阅读** — 每个合成都有文本。
+- **[references/motion-principles.md](references/motion-principles.md)** — 动效设计原则、图像动效处理、load-bearing GSAP 规则。**始终阅读** — 每个合成都有动效。
+- **[references/techniques.md](references/techniques.md)** — 13 种基础动画技术及代码模式:SVG 绘制、Canvas 2D、CSS 3D、动感字体、Lottie、视频合成、打字效果、可变字体、MotionPath、速度转场、音频反应式、clip-path 揭示、WebGL 着色器。改编模式 — 不要复制粘贴。(预构建 UI 模板 — 终端 chrome、设备 mockup、情绪板布局 — 见 `registry/blocks/`。)
+- **[references/html-in-canvas-patterns.md](references/html-in-canvas-patterns.md)** — HTML-in-Canvas 模式:通过 `drawElementImage` + `layoutsubtree` 将实时 DOM 作为 GPU 纹理。共享样板 + 约 6 种效果配方(iPhone/MacBook mockup、液态玻璃、磁性、传送门、破碎、文本光标)。每个视频 1–3 个英雄节拍时使用。
+- **[references/narration.md](references/narration.md)** — 节奏、语气、脚本结构、数字发音、开场白模式。合成包含旁白或 TTS 时阅读。
+- **[references/design-picker.md](references/design-picker.md)** — 通过可视化选择器创建 design.md。不存在 `frame.md` 或 `design.md` 且用户想创建一个时阅读。
+- **[visual-styles.md](visual-styles.md)** — 8 个命名视觉风格,含十六进制调色板、GSAP 缓动签名和着色器配对。用户指定风格或生成设计规范时阅读。
+- **[house-style.md](house-style.md)** — 未指定 `frame.md` 或 `design.md` 时的默认动效、尺寸和调色板。
+- **[patterns.md](patterns.md)** — 画中画、标题卡、幻灯片模式。
+- **[data-in-motion.md](data-in-motion.md)** — 数据、统计和信息图模式。
+- **[references/transcript-guide.md](references/transcript-guide.md)** — 字幕侧转录处理:输入格式、强制质量检查、清理 JS、OpenAI/Groq API 回退、「无转录时」流程。(`transcribe` CLI 调用、模型选择规则和 `.en` 陷阱见 `hyperframes-media` 技能。)
+- **[references/dynamic-techniques.md](references/dynamic-techniques.md)** — 动态字幕动画技术(卡拉 OK、clip-path、slam、scatter、elastic、3D)。
+
+- **[references/transitions.md](references/transitions.md)** — 场景转场:交叉淡化、擦除、揭示、着色器转场。能量/情绪选择、CSS vs WebGL 指南。**多场景合成始终阅读** — 无转场的场景感觉像硬切。
+ - [transitions/catalog.md](references/transitions/catalog.md) — 硬性规则、场景模板和按类型路由到实现代码。
+ - 着色器转场在 `@hyperframes/shader-transitions`(`packages/shader-transitions/`)— 阅读包源码,而非技能文件。
+
+GSAP 模式和效果见 `/gsap` 技能。
diff --git a/skills/media-use/SKILL_zh.md b/skills/media-use/SKILL_zh.md
new file mode 100644
index 0000000000..d4fb4b74b2
--- /dev/null
+++ b/skills/media-use/SKILL_zh.md
@@ -0,0 +1,243 @@
+---
+name: hyperframes-media
+description: HyperFrames 合成的素材预处理 — 文本转语音旁白(Kokoro)、音视频转录(Whisper)、以及用于透明叠加层的背景移除(u2net)。适用于从文本生成配音、转录语音以生成字幕、移除视频或图像背景用作透明叠加层、选择 TTS 音色或 Whisper 模型,或串联这些步骤(TTS → 转录 → 字幕)。每条命令在首次运行时会下载各自的模型。
+---
+
+# HyperFrames 素材预处理
+
+三条 CLI 命令,用于为合成生成素材:`tts`(语音)、`transcribe`(时间戳)和 `remove-background`(透明视频)。每条命令在首次运行时会下载模型,并缓存在 `~/.cache/hyperframes/` 下。将输出放入项目后,在合成 HTML 中引用 — 音频/视频元素约定见 `hyperframes` 技能。
+
+## 文本转语音(`tts`)
+
+使用 Kokoro-82M 在本地生成语音音频。无需 API 密钥。
+
+```bash
+npx hyperframes tts "Text here" --voice af_nova --output narration.wav
+npx hyperframes tts script.txt --voice bf_emma --output narration.wav
+npx hyperframes tts --list # all 54 voices
+```
+
+### 音色选择
+
+根据内容匹配合适音色。默认为 `af_heart`。
+
+| 内容类型 | 音色 | 原因 |
+| --------------- | --------------------- | -------------------- |
+| 产品演示 | `af_heart`/`af_nova` | 温暖、专业 |
+| 教程 / 操作指南 | `am_adam`/`bf_emma` | 中性、易于理解 |
+| 营销 / 推广 | `af_sky`/`am_michael` | 有活力或权威感 |
+| 文档 | `bf_emma`/`bm_george` | 清晰的英式英语,正式 |
+| 休闲 / 社交 | `af_heart`/`af_sky` | 亲切、自然 |
+
+### 多语言
+
+音色 ID 的首字母编码语言:`a`=美式英语,`b`=英式英语,`e`=西班牙语,`f`=法语,`h`=印地语,`i`=意大利语,`j`=日语,`p`=巴西葡萄牙语,`z`=普通话。CLI 会根据前缀自动检测音素器(phonemizer)区域设置 — 当音色与文本匹配时,无需 `--lang`。
+
+```bash
+npx hyperframes tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav
+npx hyperframes tts "今日はいい天気ですね" --voice jf_alpha --output ja.wav
+```
+
+仅在需要覆盖自动检测时使用 `--lang`(风格化口音)。有效代码:`en-us`、`en-gb`、`es`、`fr-fr`、`hi`、`it`、`pt-br`、`ja`、`zh`。非英语音素化需要系统级安装 `espeak-ng`(`brew install espeak-ng` / `apt-get install espeak-ng`)。
+
+### 语速
+
+- `0.7-0.8` — 教程、复杂内容、无障碍场景
+- `1.0` — 自然语速(默认)
+- `1.1-1.2` — 片头、转场、轻快内容
+- `1.5+` — 很少适用;请仔细测试
+
+### 长脚本
+
+超过几段文字时,写入 `.txt` 文件并传入路径。超过约 5 分钟语音的输入,建议拆分为多段。
+
+### 要求
+
+Python 3.8+,需安装 `kokoro-onnx` 和 `soundfile`(`pip install kokoro-onnx soundfile`)。模型在首次使用时下载(约 311 MB + 约 27 MB 音色文件,缓存在 `~/.cache/hyperframes/tts/`)。
+
+## 转录(`transcribe`)
+
+生成带有词级时间戳的标准化 `transcript.json`。
+
+```bash
+npx hyperframes transcribe audio.mp3
+npx hyperframes transcribe video.mp4 --model small --language es
+npx hyperframes transcribe subtitles.srt # import existing
+npx hyperframes transcribe subtitles.vtt
+npx hyperframes transcribe openai-response.json
+```
+
+### 语言规则(不可违背)
+
+**除非用户明确说明音频为英语,否则切勿使用 `.en` 模型。** `.en` 模型(`small.en`、`medium.en`)会将非英语音频**翻译**为英语,而非转录。这会静默破坏原始语言。
+
+1. 已知语言且非英语 → `--model small --language `(无 `.en` 后缀)
+2. 已知语言且为英语 → `--model small.en`
+3. 语言未知 → `--model small`(无 `.en`,无 `--language`)— Whisper 自动检测
+
+**默认模型为 `small`,而非 `small.en`。**
+
+### 模型大小
+
+| 模型 | 大小 | 速度 | 适用场景 |
+| ---------- | ------ | ---- | -------------------------- |
+| `tiny` | 75 MB | 最快 | 快速预览、测试流水线 |
+| `base` | 142 MB | 快 | 短片段、清晰音频 |
+| `small` | 466 MB | 中等 | **默认** — 大多数内容 |
+| `medium` | 1.5 GB | 慢 | 重要内容、嘈杂音频、含音乐 |
+| `large-v3` | 3.1 GB | 最慢 | 生产级质量 |
+
+含人声的音乐:至少从 `medium` 起步;制作曲目通常需要手动导入 SRT/VTT。字幕质量检查(每次转录后必须执行)、清理 JS、重试规则,以及 OpenAI/Groq API 导入路径,见 [hyperframes/references/transcript-guide.md](../hyperframes/references/transcript-guide.md)。
+
+### 输出结构
+
+合成消费扁平的词对象数组。`id` 字段(`w0`、`w1`、...)在标准化过程中添加,用于字幕覆盖中的稳定引用;为向后兼容,该字段可选。
+
+```json
+[
+ { "id": "w0", "text": "Hello", "start": 0.0, "end": 0.5 },
+ { "id": "w1", "text": "world.", "start": 0.6, "end": 1.2 }
+]
+```
+
+## 背景移除(`remove-background`)
+
+移除视频或图像的背景,使主体(通常为人 — 虚拟形象、演讲者、出镜人物)作为透明叠加层出现在合成中。
+
+```bash
+npx hyperframes remove-background subject.mp4 -o transparent.webm # default: VP9 alpha WebM
+npx hyperframes remove-background subject.mp4 -o transparent.mov # ProRes 4444 (editing)
+npx hyperframes remove-background portrait.jpg -o cutout.png # single-image cutout
+npx hyperframes remove-background subject.mp4 -o subject.webm \
+ --background-output plate.webm # both layers in one pass
+npx hyperframes remove-background subject.mp4 -o transparent.webm --device cpu
+npx hyperframes remove-background --info # detected providers
+```
+
+使用 `u2net_human_seg`(MIT)。首次运行会将约 168 MB 权重下载到 `~/.cache/hyperframes/background-removal/models/`。
+
+### 图层分离(`--background-output`)
+
+传入 `--background-output`(或 `-b`)可在抠图旁额外输出**第二个**透明视频:源 RGB 相同,alpha 为 `255 − mask` 而非 `mask`。抠图为主体透明背景;底板(plate)为原始环境,主体区域透明。
+
+| 文件 | Alpha 为… | 用途 |
+| -------------------------------- | ------------------------------- | ------------------------------------ |
+| `-o subject.webm` | 蒙版 — 主体不透明,背景透明 | 前景层,置于顶层 |
+| `--background-output plate.webm` | 反转 — 环境不透明,主体区域透明 | 底层;在底板与主体之间放置文字或图形 |
+
+两个输出共享相同的 `--quality` 预设,且在一次推理中完成 — 编码成本约翻倍,分割成本不变。仅适用于视频输入及 `.webm`/`.mov` 输出。
+
+**挖洞式底板,而非修复式干净底板。** `plate.webm` 中主体区域完全透明 — 在其下方合成不透明内容以填充空洞。判断 `--background-output` 是否合适的唯一标准:_是否会有内容透过主体轮廓(原主体所在位置)可见?_
+
+| 用例 | 正确工具 |
+| ----------------------------------------------- | ----------------------------------------------------------------- |
+| 文字/图形位于抠图与底板之间(本命令存在的理由) | **挖洞式**(`--background-output`) |
+| 主体叠加到无关场景 | 仅使用 `subject.webm`;忽略底板 |
+| 单独展示无人房间,且下方无其他内容 | **干净底板** — 需要修复器(LaMa、ProPainter、E2FGVI)。非本命令。 |
+| 用不同主体替换原主体 | **干净底板** — 同上 |
+
+若用户要求「移除人物后的房间」并打算单独展示,**不要**使用 `--background-output`。应告知他们需要修复器(inpainter)。
+
+典型分层合成(挖洞式用法的标准场景):
+
+```html
+
+
+
+
+MAKE IT IN HYPERFRAMES
+
+
+
+
+
+```
+
+功能上等价于下方的文字在主体后方模式,但项目中无需保留原始 `presenter.mp4` — 底板可替代它。适用于仅交付两个透明层、让用户在中间任意插入内容的场景。
+
+### 输出格式
+
+| 格式 | 适用场景 |
+| ---------------------- | ------------------------------------------ |
+| `.webm`(VP9 + alpha) | 默认。合成通过 `` 直接播放。 |
+| `.mov`(ProRes 4444) | 在 DaVinci/Premiere/FCP 中编辑。文件较大。 |
+| `.png` | 单张图像抠图(静态主体,叠加在背景上)。 |
+
+Chrome 原生解码 VP9 alpha,因此 `.webm` 可像其他静音自动播放视频一样接入合成 — `` 轨道约定见 `hyperframes` 技能。
+
+### 质量预设
+
+`--quality fast|balanced|best` 仅控制 VP9 编码器的 CRF — 分割质量固定。
+
+| 预设 | CRF | 适用场景 |
+| ---------- | --- | ----------------------------------- |
+| `fast` | 30 | 迭代调试、较小文件、颜色匹配较宽松 |
+| `balanced` | 18 | 默认。大多数场景视觉无差异 |
+| `best` | 12 | 母版 / 最终交付。文件最大,匹配最紧 |
+
+### 合成模式 — 选择正确方案
+
+抠图 webm 是源 mp4 RGB 的**重编码副本**。该选择会影响背后内容的呈现效果:
+
+| 模式 | 抠图背后的内容 | 结果 |
+| ---------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **抠图叠加不同场景**(最常见) | 静态图像、渐变或无关视频 | 效果很好。抠图的 RGB 是主体的唯一来源 — 无重影、无边缘光晕。这正是 `remove-background` 的设计用途。 |
+| **抠图叠加自身源 mp4**(文字在主体后方) | 生成抠图所用的同一 mp4 | 同一人有两个 RGB 来源。默认 `--quality balanced`(crf 18)时重影几乎不可见;`--quality fast`(crf 30)会出现轻微色偏/边缘光晕。母版请用 `--quality best`(crf 12)。 |
+| **抠图叠加同一主体的不同镜头** | 同一主体的其他素材 | 会像两个人重叠。不要这样做。 |
+
+**文字在主体后方**(标题在演讲者背后):
+
+```html
+
+MAKE IT IN HYPERFRAMES
+
+
+
+```
+
+两条关键规则:
+
+1. **将抠图视频包在非定时的 `` 中**,动画作用于包装器的 opacity,而非视频元素本身。框架会强制活动片段(任何带 `data-start`/`data-duration` 的元素)opacity 为 1,因此直接动画视频 opacity 会被静默覆盖。包装器无 `data-*` 属性,由你的 CSS/GSAP 控制。
+2. **两个视频均使用 `data-start="0"` 和 `data-media-start="0"`**,使框架从 t=0 同步解码。延后挂载抠图(`data-start=3.3`)会引入 seek + 预热,导致比基础 mp4 晚一帧 — 在切换点可见一帧错位。
+
+然后在切换点用 GSAP 翻转包装器 opacity:`tl.set(cutoutWrap, { opacity: 1 }, 3.3)`。
+
+## TTS → 转录 → 字幕
+
+无预录配音时,先生成语音再转录,以获得词级时间戳用于字幕:
+
+```bash
+npx hyperframes tts script.txt --voice af_heart --output narration.wav
+npx hyperframes transcribe narration.wav # → transcript.json
+```
+
+Whisper 从生成的音频中提取精确的词边界,字幕时间轴与朗读一致,无需手动微调。
diff --git a/skills/remotion-to-hyperframes/SKILL_zh.md b/skills/remotion-to-hyperframes/SKILL_zh.md
new file mode 100644
index 0000000000..d098ac4fa7
--- /dev/null
+++ b/skills/remotion-to-hyperframes/SKILL_zh.md
@@ -0,0 +1,120 @@
+---
+name: remotion-to-hyperframes
+description: 将现有的 Remotion(基于 React)视频合成迁移为 HyperFrames HTML 合成。仅在用户明确要求移植、转换、迁移、翻译或重写 Remotion 合成为 HyperFrames 时使用(例如「把我的 Remotion 项目移植到 HyperFrames」)。以下情况不要使用:(a) 编写全新的 HyperFrames 合成(即使在对 Remotion 视频做 A/B 测试);(b) 仅顺带提到 Remotion;(c) 分享的 Remotion 代码仅作参考,而非用于翻译;(d) 用户想要「和 Remotion 一样的视频」但未明确要求迁移源码——应视为从零构建 HyperFrames。如有疑问,默认使用 `hyperframes` 技能。会检测不支持的写法(useState、useEffect 副作用、async calculateMetadata、第三方 React 组件库、`@remotion/lambda`),并建议采用运行时互操作(runtime interop)逃生舱,而非有损翻译。
+---
+
+# Remotion 转 HyperFrames
+
+## 概述
+
+将 Remotion(基于 React)视频合成翻译为 HyperFrames(HTML + GSAP)合成。大多数 Remotion 惯用法在 HyperFrames 中都有直接对应——对约 80% 的典型合成,翻译是机械性的。本技能编码了映射关系,并对不适合 HF 按帧寻址(seek-driven)模型的 20% 有损模式拒绝翻译,转而建议采用 [PR #214](https://github.com/heygen-com/hyperframes/pull/214) 中的运行时互操作模式。
+
+本技能附带**分级测试语料库**(T1–T4,共 4 个 fixture),按测得的 SSIM 阈值对翻译结果评分。未运行评测前不要进行翻译——看起来正确但 SSIM 比已验证基线低 0.05 的翻译,实际上是静默错误的。
+
+## 何时使用
+
+**仅在用户明确要求从 Remotion 迁移时使用本技能。** 示例触发语:
+
+- "port my Remotion project to HyperFrames"
+- "convert this Remotion code to HyperFrames"
+- "migrate from Remotion"
+- "translate this Remotion comp"
+- "rewrite this as HyperFrames HTML"
+
+**以下情况不要使用本技能:**
+
+- (a) 用户正在编写**全新**的 HyperFrames 合成,即使其拥有或正在对类似的 Remotion 视频做 A/B 测试。
+- (b) 用户仅顺带提到 Remotion,未要求迁移。
+- (c) 用户分享的 Remotion 代码仅作参考材料,而非要求翻译。
+- (d) 用户要求「和 Remotion 一样的视频」,但未明确要求迁移源码——应视为从零构建 HyperFrames。
+
+如有疑问,默认使用 `hyperframes` 技能编写原生 HyperFrames 合成。
+
+## 工作流
+
+### 步骤 1:对源码进行 Lint
+
+对 Remotion 源码目录运行 [`scripts/lint_source.py`](scripts/lint_source.py)。Lint 会检测无法干净翻译的模式:
+
+- **阻断项**(拒绝翻译 + 建议互操作):`useState`、`useReducer`、带非空依赖的 `useEffect`/`useLayoutEffect`、async `calculateMetadata`、第三方 React UI 库(MUI、Chakra、Mantine、antd、shadcn、Radix、NextUI)。
+- **警告**(丢弃该结构后翻译):`@remotion/lambda` 配置、`delayRender`、`useCallback`、`useMemo`、自定义 hooks。
+- **信息**(翻译并附注):`staticFile`、`interpolateColors`。
+
+若出现任何阻断项,**停止**。阅读 [`references/escape-hatch.md`](references/escape-hatch.md) 并展示建议信息。警告不会阻止翻译——在步骤 3 中丢弃违规结构,并在 `TRANSLATION_NOTES.md` 中记录缺口。`@remotion/lambda` 配置是典型的警告情形:技能会丢弃 import 与 `renderMediaOnLambda(...)` 调用,但翻译合成的其余部分。
+
+### 步骤 2:规划翻译
+
+阅读 [`references/api-map.md`](references/api-map.md)——所有 Remotion API 及其 HF 等价物或分主题参考的索引。根据源码实际用法,确定需要加载哪些主题参考:
+
+| 源码包含 | 加载参考 |
+| ------------------------------------------------------------------------- | --------------------------------------------- |
+| `Composition`、`defaultProps`、`schema`、`calculateMetadata` | [`parameters.md`](references/parameters.md) |
+| `Sequence`、`Series`、`Loop`、`AbsoluteFill`、`Freeze` | [`sequencing.md`](references/sequencing.md) |
+| `useCurrentFrame`、`interpolate`、`spring`、`Easing`、`interpolateColors` | [`timing.md`](references/timing.md) |
+| `Audio`、`Video`、`Img`、`IFrame`、`staticFile`、`delayRender` | [`media.md`](references/media.md) |
+| `TransitionSeries`、`@remotion/transitions` | [`transitions.md`](references/transitions.md) |
+| `@remotion/lottie` | [`lottie.md`](references/lottie.md) |
+| `@remotion/google-fonts/
`、`Font.loadFont`、`@font-face` | [`fonts.md`](references/fonts.md) |
+
+不要全部加载——只加载该源码实际需要的部分。
+
+### 步骤 3:生成 HF 合成
+
+输出 `index.html`,包含:
+
+- 根元素 ``,携带合成的 `data-composition-id`、`data-start="0"`、`data-duration`(秒)、`data-fps`、`data-width`、`data-height`,以及每个标量 prop 对应的一个 `data-*`。
+- 扁平的场景 div 列表,带 `data-start` / `data-duration` / `data-track-index`。
+- 内联 `
+```
+
+在浏览器运行时合成中避免 v3 配置模式:
+
+```css
+/* Do not use these in Tailwind v4 browser-runtime compositions. */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+```
+
+不要仅为在 v4 浏览器运行时合成中定义颜色、字体、间距或工具类而添加 `tailwind.config.js`。请在 `text/tailwindcss` 样式块中使用 `@theme` 和 `@utility`。
+
+若编译版 v4 构建确实需要现有 JavaScript 配置,请通过 CSS 中的 `@config` 显式加载,并在浏览器中验证。不要假设 v4 会自动检测 v3 配置文件。
+
+## HyperFrames 合成模式
+
+让 Tailwind 负责静态布局与视觉样式,将动画时序交给 GSAP 或其他可寻址(seekable)适配器。
+
+```html
+
+
+
+ Render-ready Tailwind
+
+
+ Utility classes, deterministic frames.
+
+
+
+```
+
+对于重复项,优先使用类列表配合 CSS 自定义属性,而非动态生成类名:
+
+```html
+
+
+
+```
+
+## 动态类名安全
+
+Tailwind 浏览器运行时会扫描当前文档,并为它能看到的类名生成 CSS。不要在寻址时刻才拼装渲染关键类名:
+
+```js
+// Risky: Tailwind may not see every generated class before capture.
+element.className = `bg-${color}-500`;
+```
+
+请在 HTML、data 属性或显式 CSS 中使用完整类名:
+
+```html
+
+```
+
+若无法避免生成类名,请确保完整类名令牌在验证前已出现在 `text/tailwindcss` 块中。
+
+## 视频相关约束
+
+- 使用稳定尺寸:`w-[...]`、`h-[...]`、`aspect-video`、`grid`、`flex`,以及固定内边距来布局视频画面。
+- 动画属性优先使用 transform 和 opacity。
+- 除非可寻址运行时掌控状态,否则不要在渲染关键时序中使用 Tailwind 过渡。
+- 对必须确定性渲染的内容,避免 hover、focus、scroll、viewport 或 pointer 变体。
+- 使用显式边框颜色。Tailwind v4 相对 v3 改变了默认边框行为,因此 `border border-white/20` 比单独的 `border` 更安全。
+- 使用 v4 工具类名:在适用处使用 `shadow-xs`、`rounded-xs`、`outline-hidden`、`shrink-*` 和 `grow-*` 等替代名称。
+- 若输出需要兼容较旧浏览器,使用现代 CSS 工具类时要谨慎。Tailwind v4 面向现代浏览器。
+
+## 验证
+
+编辑启用 Tailwind 的合成后:
+
+```bash
+npx hyperframes lint
+npx hyperframes validate
+npx hyperframes inspect
+```
+
+渲染验证:
+
+```bash
+npx hyperframes render . --workers 1 --quality draft --output tailwind-proof.mp4
+```
+
+验证流程应在第 0 帧不出现样式缺失闪烁。若预览有样式但渲染没有,请检查 `window.__tailwindReady` 是否存在,并在捕获前已 resolve。
+
+## 快速调试清单
+
+1. 确认项目通过 `hyperframes init --tailwind` 脚手架创建。
+2. 确认脚本指向 `@tailwindcss/browser@4.2.4`。
+3. 确认存在 `window.__tailwindReady`。
+4. 将 v3 的 `@tailwind` 指令替换为 v4 浏览器运行时 CSS。
+5. 将 `tailwind.config.js` 中的自定义令牌迁移到 `@theme`。
+6. 将动态拼装的类名替换为完整的静态令牌。
+7. 运行 `npx hyperframes validate` 并渲染一段短证明视频。
+
+## 致谢与参考
+
+- Tailwind CSS 官方 v4 安装、升级与兼容性文档:https://tailwindcss.com/docs
+- Tailwind CSS v4 发布说明:https://tailwindcss.com/blog/tailwindcss-v4
+- 社区 Tailwind 技能曾用于审阅 v4 常见陷阱与技能结构,但本技能将持久约定保留在仓库内,并针对 HyperFrames 定制。
diff --git a/skills/website-to-video/SKILL_zh.md b/skills/website-to-video/SKILL_zh.md
new file mode 100644
index 0000000000..466bd79649
--- /dev/null
+++ b/skills/website-to-video/SKILL_zh.md
@@ -0,0 +1,140 @@
+---
+name: website-to-hyperframes
+description: |
+ 抓取网站并从中创建 HyperFrames 视频。适用场景:(1) 用户提供 URL 并希望生成视频,(2) 有人说「抓取这个网站」「把它做成视频」「用我的网站做宣传片」,(3) 用户想要社交广告、产品导览,或基于现有网站的任何视频,(4) 用户分享链接并要求任何类型的视频内容。即使用户只是粘贴了一个 URL——也应使用本技能。
+---
+
+# 网站转 HyperFrames
+
+抓取网站,然后从中制作专业视频。
+
+用户可能会这样说:
+
+- "Capture https://... and make me a 25-second product launch video"
+- "Turn this website into a 15-second social ad for Instagram"
+- "Create a 30-second product tour from https://..."
+
+工作流程共 7 步。每一步都会产出一份产物,作为下一步的门槛。默认采用协作模式——标有 💬 的门槛会暂停并询问用户。若用户表示自主模式(「你帮我决定」「给我惊喜」),则跳过 💬 用户偏好门槛;详见 step-2-brief.md 中的传播方式。
+
+**自主模式并非「跳过所有门槛」。** 自动模式涵盖用户偏好类问题(TTS 提供商、音色、色彩强调、节拍数量、是否配乐、是否加字幕——由代理代用户决定)。它**不**涵盖质量验证门槛。以下门槛在自动模式下仍不可跳过:
+
+- 资产审计(Step 3)——查看联系表并为每项资产说明 USE/SKIP 理由
+- 逐节拍 HTML 阅读(Step 5)——每个节拍需有结构化证据块
+- DoD 检查清单(Step 6)——包括 animation-map、逐条警告的 WCAG 验证、音频/动效回放
+- 诚实披露部分(Step 6)——最终摘要中必须包含「我未验证的内容」
+
+若你发现自己这样想:「自动模式说要偏向行动,所以我跳过 X」——而 X 是验证门槛而非偏好问题——这种推理是错误的。偏向行动适用于决定**构建什么**,而非决定**是否验证**。
+
+---
+
+## Step 0:抓取并理解品牌
+
+**阅读:** [references/step-0-capture.md](references/step-0-capture.md)
+
+抓取网站,然后阅读提取的数据以理解**品牌与产品**——做什么、面向谁、用什么语气、营造什么氛围。抓取的资产是后续使用的品牌工具包,而非直接拼装视频的积木。
+
+**门槛:** 打印网站摘要——策略优先(产品做什么、面向谁、品牌调性),再列出资产/颜色/字体清单。
+
+---
+
+## Step 1:品牌识别
+
+**阅读:** [references/step-1-design.md](references/step-1-design.md)
+
+编写 DESIGN.md——涵盖视觉识别的品牌速查表:颜色、字体、组件样式、布局原则。使用 `design-styles.json` 获取精确计算值。
+
+**快速选项:** 对于快节奏视频(每节拍一块广告牌),DESIGN.md 可以是 50 行的颜色 + 字体 + 注意事项摘要——不必写成 300 行文档。Step 5 的子代理提示会直接粘贴品牌值,因此 DESIGN.md 的深度仅对复杂合成有意义。
+
+**门槛:** `DESIGN.md` 已存在(任意长度),且至少包含:调色板、字体选择、注意事项(do's/don'ts)。
+
+---
+
+## Step 2:策略与信息传达
+
+**阅读:** [references/step-2-brief.md](references/step-2-brief.md)、[references/capabilities.md](references/capabilities.md)(浏览目录——仅在需要时深入阅读具体章节)
+
+在讨论视觉或资产之前,先与用户对齐**视频必须传达什么**。解析用户提示——他们很可能已经给出了视频类型和风格。只问缺失项:这条视频必须说的**一件事**、叙事弧线、以及受众。
+
+**门槛:** 视频类型、时长、格式,以及——关键——信息与叙事弧线已锁定。没有这些,Step 3 无法写出概念优先的分镜。
+
+---
+
+## Step 3:分镜 + 脚本 💬
+
+**阅读:** [references/step-3-storyboard.md](references/step-3-storyboard.md)
+
+概念优先编写分镜:信息 → 叙事弧线 → 服务于弧线的节拍 → 每节拍技法 → 最后做品牌点缀。再编写与之匹配的旁白脚本。向用户展示两者及逐节拍摘要。迭代直至用户批准。
+
+**门槛:** `STORYBOARD.md` + `SCRIPT.md` 已存在,且用户已批准方案。
+
+---
+
+## Step 4:旁白、时间轴与字幕 💬
+
+**阅读:** [references/step-4-vo.md](references/step-4-vo.md)
+
+若 Step 2 表示无需旁白——询问背景音乐,然后跳到 Step 5。否则:询问用户选用哪种 TTS 提供商(HeyGen TTS、ElevenLabs 或 Kokoro),生成音频、转写,将时间戳映射到节拍。再询问字幕。
+
+**门槛:** 满足 (a) 未要求旁白且分镜含手动节拍时间,或 (b) `narration.wav` + `transcript.json` 已存在且节拍时间已按实际时长更新。
+
+---
+
+## Step 5:构建合成
+
+**阅读:** `hyperframes` 技能(加载它——每条规则都重要)
+**阅读:** [references/step-5-build.md](references/step-5-build.md)
+
+按 Step 3 分镜所选架构与节奏构建 index.html 与 compositions。子代理在每个节拍回报前运行 `hyperframes lint` 和 `hyperframes snapshot`。
+
+**门槛:** 主代理已对照 DESIGN.md 与 STORYBOARD.md 逐行阅读每个 `compositions/beat-N.html`。逐节拍检查清单见 [step-5-build.md](references/step-5-build.md)。
+
+---
+
+## Step 6:验证与交付
+
+**阅读:** [references/step-6-validate.md](references/step-6-validate.md)
+
+执行 lint、validate,按视频长度缩放拍摄快照(公式:`max(beats × 3, ceil(duration_seconds / 2))`),并逐一审查。交付前修复问题。交付 localhost Studio 项目 URL——仅在用户明确要求时才渲染为 MP4。
+
+**交付你引以为豪的作品。** 交接前自问:我会愿意把自己的名字标在这条社交媒体上发布吗?若不会,先修好问题。
+
+**门槛:** `npx hyperframes lint` 与 `npx hyperframes validate` 零错误通过,且最终回复包含当前 Studio 项目 URL。
+
+---
+
+## 快速参考
+
+### 视频类型
+
+各视频类型的典型约束——作为起点,而非公式。节拍数量应来自内容与旁白,而非目标区间。
+
+| 类型 | 典型时长 | 时长驱动因素 | 旁白 |
+| --------------------- | -------- | ------------ | ---------------- |
+| 社交广告(IG/TikTok) | 10–15s | 平台限制 | 可选 |
+| 产品演示 | 30–60s | 脚本长度 | 完整旁白 |
+| 功能发布 | 15–30s | 功能复杂度 | 完整旁白 |
+| 品牌短片 | 20–45s | 音乐轨道 | 可选,以音乐为主 |
+| 发布预告 | 10–20s | 开场冲击力 | 极简 |
+
+节拍数量有意不在此表中——应来自分镜,而非「社交广告 = 3–4 节拍」。复杂产品的社交广告可能需要 5 个节奏精准的节拍。只有一个强视觉论点的品牌短片可能只需 3 个。
+
+### 格式
+
+- **横屏**:1920x1080(默认)
+- **竖屏**:1080x1920(Instagram Stories、TikTok)
+- **方形**:1080x1080(Instagram 信息流)
+
+### 参考文件
+
+| 文件 | 何时阅读 |
+| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| [step-0-capture.md](references/step-0-capture.md) | Step 0 — 抓取、理解品牌与产品,撰写策略优先的网站摘要 |
+| [step-1-design.md](references/step-1-design.md) | Step 1 — 编写 DESIGN.md 品牌速查表(5 节,250–350 行;广告牌式社交广告可走 50 行快速路径) |
+| [step-2-brief.md](references/step-2-brief.md) | Step 2 — 与用户对齐信息、叙事弧线、受众 |
+| [capabilities.md](references/capabilities.md) | Step 2 与 5 — HyperFrames 能力全览(24 节)。简报阶段浏览目录,构建阶段深入具体章节 |
+| [step-3-storyboard.md](references/step-3-storyboard.md) | Step 3 — 分镜 + 脚本(合并)及用户审阅门槛 |
+| [step-4-vo.md](references/step-4-vo.md) | Step 4 — TTS 提供商选择、生成、时间轴 |
+| [step-5-build.md](references/step-5-build.md) | Step 5 — 构建 index.html + compositions |
+| [step-6-validate.md](references/step-6-validate.md) | Step 6 — lint、validate、快照(按视频长度缩放)、预览 |
+| [techniques.md](../hyperframes/references/techniques.md) | Step 3 与 5 — 13 种基础动效技法及代码模式(适配使用,勿照搬) |
+| [html-in-canvas-patterns.md](../hyperframes/references/html-in-canvas-patterns.md) | Step 5 — HTML-in-Canvas 效果的完整代码模式(位于 hyperframes 技能中) |