Goal
This fork (FrameScript.vue) currently lives as a hard fork of upstream FrameScript. The intent of this issue is to redesign FrameScript around a tiny framework-agnostic kernel and a plugin substrate that everything else (including the React renderer) is built on. Vue support is then one plugin among many — same status as the React renderer, the media primitives, the animation system, and the studio chrome.
This is a single design-doc tracker. Once the design is locked we file individual PRs per phase against ubugeeei/FrameScript upstream and close this with a link to the merged series.
What the kernel actually owns
Cut the kernel down to four things:
@frame-script/core
├─ ProjectSettings // { name, width, height, fps }
├─ FrameStore // current frame; subscribe / set / get
├─ TimelineStore // clip registry + visibility
├─ AudioPlanStore // audio-segment registry
└─ PluginHost // plugin registration + lifecycle + bridge namespace
That's it. No <Project>. No <Clip>. No <Video>. No React. No Vue. No useAnimation. Those are all plugins.
The kernel is framework-agnostic, ~600 lines of TS, no DOM dependencies (only DOM access lives in the bridge for the render harness).
Plugin contract
interface FrameScriptPlugin<Options = void> {
readonly id: string // "@frame-script/react", "@frame-script/vue", ...
readonly version: string
// Plugins this plugin needs at host setup time.
readonly requires?: readonly string[]
// Called once when the plugin is registered with the host.
setup(host: PluginHost, options?: Options): void | (() => void)
}
interface PluginHost {
// Core stores (framework-agnostic).
readonly frame: FrameStore
readonly timeline: TimelineStore
readonly audioPlan: AudioPlanStore
readonly settings: ProjectSettings
// Bridge namespace, scoped per plugin. No more flat window.__frameScript.
// Renders out as window.__frameScript = { core: {...}, animation: {...}, vue: {...} }.
registerBridge<T>(namespace: string, api: T): () => void
// Inter-plugin ready contract: any plugin can publish a signal, any plugin
// (notably the render harness) can wait on them.
registerReady(name: string, contract: ReadyContract): () => void
awaitAllReady(): Promise<void>
// Lifecycle the host emits to plugins that opt in.
on(event: "mount" | "unmount" | "frame", cb: (payload: any) => void): () => void
}
interface ReadyContract {
isReady(): boolean
whenReady(): Promise<void>
// optional, used by the render harness when stepping frame-by-frame
whenReadyAt?(frame: number): Promise<void>
}
Nothing in this contract mentions Vue, React, video, images, animation, or the studio. Those are plugin concerns.
Standard plugins (decomposition of today's monolith)
| Plugin |
Owns |
Bridge namespace |
Ready contracts |
@frame-script/react |
Renderer. Exports <Project>, <TimeLine>, <Clip>, <FillFrame> as React components that read core stores and call timeline.registerClip / audioPlan.register. |
react |
— |
@frame-script/vue |
Renderer. Same primitive surface as Vue components / composables. Wires Vue Suspense / async setup into vue.whenSuspenseReady. |
vue |
vue |
@frame-script/media |
<Video>, <Audio>, <Image>, <Sound>. Contributes audio segments via audioPlan.register. Fetches metadata via the backend client. |
media |
images, mediaMetadata |
@frame-script/animation |
useAnimation for React and the Vue equivalent. Frame-driven derivations. |
animation |
animations |
@frame-script/audio-waveform |
Waveform rendering for sound clips. |
audioWaveform |
audioWaveform |
@frame-script/psd |
<PsdCharacter> and friends, currently src/lib/character/*. |
psd |
psd, psdAtFrame |
@frame-script/webgl |
<Three> wrapper, src/lib/webgl/*. |
webgl |
webgl, webglAtFrame |
@frame-script/draw-text |
src/lib/animation/effect/draw-text.tsx + MathJax / opentype subtree. |
drawText |
drawText |
@frame-script/studio-ui |
Full editor chrome: timeline scrubber, clip panel, render dialogs. Loaded only in dev mode, never in render. |
studio |
— |
@frame-script/backend-client |
Centralised backend URL / token resolver. Used by media and others. No primitives. |
backend |
— |
Counting roughly: ten plugins replace the current monolith. The current src/lib/ directory decomposes into these without renames. The dependency graph is shallow — media and animation depend on core; vue and react depend on core; studio-ui depends on react. No deep tree.
What the project file becomes
defineProject(config) is the new entry point. Users assemble exactly the plugins they need, Vite-style:
// project/project.ts (renderer-agnostic header)
import { defineProject } from "@frame-script/core"
import vue from "@frame-script/vue"
import media from "@frame-script/media"
import animation from "@frame-script/animation"
import drawText from "@frame-script/draw-text"
import ProjectComponent from "./project.vue"
export default defineProject({
settings: { name: "demo", width: 1920, height: 1080, fps: 60 },
plugins: [
vue(),
media(),
animation(),
drawText(),
],
root: ProjectComponent,
})
Render mode is the same minus @frame-script/studio-ui. The Rust render binary doesn't care — it still drives via window.__frameScript.core.setFrame() and waits on the namespaced ready contracts.
For existing React users we ship a convenience preset:
import { defineProject, presetReact } from "@frame-script/core"
import Project from "./project.tsx"
export default defineProject({
settings: { ... },
plugins: presetReact(), // = [react(), media(), animation(), drawText(), audioWaveform(), studioUi()]
root: Project,
})
Same shape, same API for everyone. Vue users swap presetReact() for presetVue() (or compose manually). Future Svelte / Solid / Lit support is just another renderer plugin and the kernel never learns about it.
Bridge namespacing concretely
Before:
window.__frameScript = {
setFrame, getFrame,
waitAnimationsReady, getAnimationsPending,
waitImagesReady, getImagesPending,
waitAudioWaveformsReady, getAudioWaveformsPending,
waitPsdReady, waitPsdFrame, getPsdPending,
waitWebGLReady, waitWebGLFrame, getWebGLPending,
waitDrawTextReady, getDrawTextPending,
waitMediaMetadataReady, getMediaMetadataPending,
waitCanvasFrame,
}
After:
window.__frameScript = {
core: { setFrame, getFrame },
animation: { whenReady, pending },
media: { whenImagesReady, whenMetadataReady, pending },
audioWaveform: { whenReady, pending },
psd: { whenReady, whenReadyAt, pending },
webgl: { whenReady, whenReadyAt, pending },
drawText: { whenReady, pending },
canvas: { whenFrame },
vue: { whenSuspenseReady }, // present only if @frame-script/vue is installed
}
The render binary calls awaitAllReady() which iterates every registered namespace's whenReady — no hardcoded list. New plugins (Svelte, future media types) just appear with their own namespace and the harness picks them up automatically.
Current fork delta — what already exists on this branch
For reviewers sizing the migration cost. Diff against main: 65 files, +4974 / −1087.
A. Pure additions (no behaviour change upstream-side)
| File |
Lines |
Migrates into |
src/lib/vue.tsx |
1467 |
@frame-script/vue |
src/lib/audio-plan.tsx |
186 |
@frame-script/core (AudioPlanStore) |
src/lib/backend.ts |
47 |
@frame-script/backend-client |
src/lib/frame-script-bridge.ts |
56 |
@frame-script/core (PluginHost.registerBridge) |
src/lib/media-metadata.ts |
51 |
@frame-script/media |
src/lib/video/canvas-frame-registry.ts |
26 |
@frame-script/media (canvas namespace) |
project/project.vue |
165 |
Template for @frame-script/vue |
docs/docs/vue-sfc.md (+ i18n) |
79 |
Docs for the Vue plugin |
B. Upstream-leaking refactors (already required, regardless of Vue)
| File |
What changed |
Status |
src/lib/project.tsx |
<Project> wraps in <TimelineStoreProvider> + <AudioPlanProvider> |
Becomes the React plugin's mount logic. |
src/lib/timeline.tsx |
Replaced singletons (globalClips, globalHidden) with store + context |
Stores move into core; React adapter stays in the React plugin. |
src/lib/clip.tsx |
Clip end-clamp removed; Serial inclusive-span arithmetic |
Lands upstream regardless of plugin work. |
src/lib/frame.tsx |
Exported createFrameStore; bridge registration via registerFrameScriptApi |
Store moves into core; React adapter stays in the React plugin. |
src/lib/audio.ts, sound/sound.tsx, video/video.tsx, audio-waveform.ts, image.tsx, webgl/index.ts, video/video-render.tsx |
Migrated to backendFetch + buildBackendUrl; sync XHR removed |
Lands as part of the media / backend-client plugins. |
vite.studio.config.ts, vite.render.config.ts |
Added @vitejs/plugin-vue |
Vue plugin will ship its own @frame-script/vue/vite. |
C. Out of scope here
backend/ security hardening, render IPC validation, CI, prod-readiness work (#74–#79 and surrounding PRs). Independent of the plugin substrate.
Phased plan
Phase 0 — Carve out the kernel and the plugin host
Outcome: upstream still ships exactly the same studio for React users (because presetReact() is the default), but underneath the kernel knows nothing about React anymore.
Phase 1 — Extract the standard plugins
Each row in the plugin table above becomes its own PR. Each PR is a move + rewire, not a feature change.
Outcome: src/lib/* is empty in upstream. Everything lives in packages/<plugin>/ consumed by the kernel.
Phase 2 — Ship @frame-script/vue as one more plugin
Outcome: Vue users pnpm add @frame-script/vue and use presetVue(). Zero upstream code knows the word "Vue".
Phase 3 — Open the door
Why this shape (not just "Vue addon hooks")
A "Vue addon" plan fixes the symptom (vue.tsx needs upstream-leaking refactors) but not the cause — upstream knows about each subsystem by name. As soon as upstream wants to ship Svelte support, somebody wants to maintain a Solid renderer, or the studio chrome gets redesigned, we are back here filing another design issue. The kernel + plugin model pays the refactor cost once and stops paying it.
It also lets the fork stop being a fork: @frame-script/vue is just a sibling of @frame-script/react in the same repo (or in a separate one), with the kernel as the only shared contract.
Open design questions
- Plugin loading: synchronous registration via
defineProject({ plugins: [...] }), or async with await host.load(...)? Async lets us code-split renderer + studio-ui; sync is simpler. Recommend sync registration with lazy module imports inside each plugin.
- Cross-renderer projects: do we want to support a project that mixes Vue components and React components in the same timeline? Technically possible (both renderers reading the same
TimelineStore), UX is weird. Recommend: forbid in v1, leave the door open.
- Plugin options shape:
vue({ ssr: true }) vs vue.with({ ssr: true }) vs vueOptions({ ssr: true }). Recommend factory functions returning a plugin instance, like Vite (vue({ ... })).
- Bridge namespace ownership: first-come-first-served, or reserved namespace list maintained in core? Recommend a reserved list for the standard plugins (
core, animation, media, psd, webgl, drawText, canvas, audioWaveform, vue, react, studio). Anything else any plugin can register.
- Back-compat shim duration: how long do we keep the flat
window.__frameScript.setFrame etc. mirror? Recommend one minor release with @deprecated, removed in the next major.
Asks
- Sanity-check the kernel surface (the four stores +
PluginHost) — anything missing or extra?
- Confirm the plugin table and namespace assignments.
- Confirm
defineProject + presetReact / presetVue as the project file entry point.
- Pick a position on each of the five open design questions.
- Sign off on K.1 + K.2 being the first upstream PR (kernel +
PluginHost, no plugins migrated yet, no behaviour change visible to users beyond the namespaced bridge).
Goal
This fork (
FrameScript.vue) currently lives as a hard fork of upstreamFrameScript. The intent of this issue is to redesign FrameScript around a tiny framework-agnostic kernel and a plugin substrate that everything else (including the React renderer) is built on. Vue support is then one plugin among many — same status as the React renderer, the media primitives, the animation system, and the studio chrome.This is a single design-doc tracker. Once the design is locked we file individual PRs per phase against
ubugeeei/FrameScriptupstream and close this with a link to the merged series.What the kernel actually owns
Cut the kernel down to four things:
That's it. No
<Project>. No<Clip>. No<Video>. No React. No Vue. NouseAnimation. Those are all plugins.The kernel is framework-agnostic, ~600 lines of TS, no DOM dependencies (only DOM access lives in the bridge for the render harness).
Plugin contract
Nothing in this contract mentions Vue, React, video, images, animation, or the studio. Those are plugin concerns.
Standard plugins (decomposition of today's monolith)
@frame-script/react<Project>,<TimeLine>,<Clip>,<FillFrame>as React components that read core stores and calltimeline.registerClip/audioPlan.register.react@frame-script/vuevue.whenSuspenseReady.vuevue@frame-script/media<Video>,<Audio>,<Image>,<Sound>. Contributes audio segments viaaudioPlan.register. Fetches metadata via the backend client.mediaimages,mediaMetadata@frame-script/animationuseAnimationfor React and the Vue equivalent. Frame-driven derivations.animationanimations@frame-script/audio-waveformaudioWaveformaudioWaveform@frame-script/psd<PsdCharacter>and friends, currentlysrc/lib/character/*.psdpsd,psdAtFrame@frame-script/webgl<Three>wrapper,src/lib/webgl/*.webglwebgl,webglAtFrame@frame-script/draw-textsrc/lib/animation/effect/draw-text.tsx+ MathJax / opentype subtree.drawTextdrawText@frame-script/studio-uistudio@frame-script/backend-clientmediaand others. No primitives.backendCounting roughly: ten plugins replace the current monolith. The current
src/lib/directory decomposes into these without renames. The dependency graph is shallow —mediaandanimationdepend oncore;vueandreactdepend oncore;studio-uidepends onreact. No deep tree.What the project file becomes
defineProject(config)is the new entry point. Users assemble exactly the plugins they need, Vite-style:Render mode is the same minus
@frame-script/studio-ui. The Rust render binary doesn't care — it still drives viawindow.__frameScript.core.setFrame()and waits on the namespaced ready contracts.For existing React users we ship a convenience preset:
Same shape, same API for everyone. Vue users swap
presetReact()forpresetVue()(or compose manually). Future Svelte / Solid / Lit support is just another renderer plugin and the kernel never learns about it.Bridge namespacing concretely
Before:
After:
The render binary calls
awaitAllReady()which iterates every registered namespace'swhenReady— no hardcoded list. New plugins (Svelte, future media types) just appear with their own namespace and the harness picks them up automatically.Current fork delta — what already exists on this branch
For reviewers sizing the migration cost. Diff against
main: 65 files, +4974 / −1087.A. Pure additions (no behaviour change upstream-side)
src/lib/vue.tsx@frame-script/vuesrc/lib/audio-plan.tsx@frame-script/core(AudioPlanStore)src/lib/backend.ts@frame-script/backend-clientsrc/lib/frame-script-bridge.ts@frame-script/core(PluginHost.registerBridge)src/lib/media-metadata.ts@frame-script/mediasrc/lib/video/canvas-frame-registry.ts@frame-script/media(canvas namespace)project/project.vue@frame-script/vuedocs/docs/vue-sfc.md(+ i18n)B. Upstream-leaking refactors (already required, regardless of Vue)
src/lib/project.tsx<Project>wraps in<TimelineStoreProvider>+<AudioPlanProvider>src/lib/timeline.tsxglobalClips,globalHidden) with store + contextsrc/lib/clip.tsxClipend-clamp removed;Serialinclusive-span arithmeticsrc/lib/frame.tsxcreateFrameStore; bridge registration viaregisterFrameScriptApisrc/lib/audio.ts,sound/sound.tsx,video/video.tsx,audio-waveform.ts,image.tsx,webgl/index.ts,video/video-render.tsxbackendFetch+buildBackendUrl; sync XHR removedvite.studio.config.ts,vite.render.config.ts@vitejs/plugin-vue@frame-script/vue/vite.C. Out of scope here
backend/security hardening, render IPC validation, CI, prod-readiness work (#74–#79 and surrounding PRs). Independent of the plugin substrate.Phased plan
Phase 0 — Carve out the kernel and the plugin host
ProjectSettings,FrameStore,TimelineStore,AudioPlanStoreinto@frame-script/core. No React, no Vue, no DOM beyondwindow.__frameScript.PluginHostwithregisterBridge,registerReady,awaitAllReady, lifecycle events.defineProject(config)and thepresetReact()/presetVue()presets.window.__frameScriptwith the namespaced shape. Keep a temporary back-compat shim that mirrors the legacy keys for one release.Outcome: upstream still ships exactly the same studio for React users (because
presetReact()is the default), but underneath the kernel knows nothing about React anymore.Phase 1 — Extract the standard plugins
Each row in the plugin table above becomes its own PR. Each PR is a move + rewire, not a feature change.
@frame-script/react(the existing JSX primitives + hooks).@frame-script/media(Video / Audio / Image / Sound + metadata tracker).@frame-script/animation.@frame-script/audio-waveform.@frame-script/psd,@frame-script/webgl,@frame-script/draw-text.@frame-script/studio-ui(React-only for now).@frame-script/backend-client.Outcome:
src/lib/*is empty in upstream. Everything lives inpackages/<plugin>/consumed by the kernel.Phase 2 — Ship
@frame-script/vueas one more pluginsrc/lib/vue.tsxinto the plugin package. It depends only on@frame-script/coreand (where it needs to talk to media / animation / etc.) the plugin contracts of those plugins.<Video>/<Audio>/<Image>/<Sound>as Vue components).vue.whenSuspenseReady.@frame-script/vue/vitefor the Vite plugin (@vitejs/plugin-vue+ auto-configuration).Outcome: Vue users
pnpm add @frame-script/vueand usepresetVue(). Zero upstream code knows the word "Vue".Phase 3 — Open the door
Why this shape (not just "Vue addon hooks")
A "Vue addon" plan fixes the symptom (
vue.tsxneeds upstream-leaking refactors) but not the cause — upstream knows about each subsystem by name. As soon as upstream wants to ship Svelte support, somebody wants to maintain a Solid renderer, or the studio chrome gets redesigned, we are back here filing another design issue. The kernel + plugin model pays the refactor cost once and stops paying it.It also lets the fork stop being a fork:
@frame-script/vueis just a sibling of@frame-script/reactin the same repo (or in a separate one), with the kernel as the only shared contract.Open design questions
defineProject({ plugins: [...] }), or async withawait host.load(...)? Async lets us code-split renderer + studio-ui; sync is simpler. Recommend sync registration with lazy module imports inside each plugin.TimelineStore), UX is weird. Recommend: forbid in v1, leave the door open.vue({ ssr: true })vsvue.with({ ssr: true })vsvueOptions({ ssr: true }). Recommend factory functions returning a plugin instance, like Vite (vue({ ... })).core,animation,media,psd,webgl,drawText,canvas,audioWaveform,vue,react,studio). Anything else any plugin can register.window.__frameScript.setFrameetc. mirror? Recommend one minor release with@deprecated, removed in the next major.Asks
PluginHost) — anything missing or extra?defineProject+presetReact/presetVueas the project file entry point.PluginHost, no plugins migrated yet, no behaviour change visible to users beyond the namespaced bridge).