Files
Æmotion StudioandClaude Opus 5.5 ee7fd761fe Restore v1's link and node effects on the native pipeline
- Links run on v1's clock and follow the real path: Link.Style decorations,
  Wave and Coil, reroutes as one chain. The nine animations, markers, the
  centred halo shadow and the density presets are v1's; bases are baked into
  the background and only motion is drawn per frame.
- Particle-like glows come from sprites. With more than 12 glowing bases on
  screen, bases are baked without glow while the view or graph moves and
  with it 150 ms after.
- Nodes: the four effects over the body in under and over layers, halo,
  selection frame, particles only, five title styles, fireworks on this
  client's run, and node menus storing flat enhanced_fx_* properties.
- Level A compares every v1 frame, each difference listed with a proof in
  tests/unit/v1-deviations.ts; Level B pins clock, counts and layering; a
  sha256 manifest guards the v1 traces. Perf gains ELN_GPU=1, pan fps and
  budget failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:07:30 -08:00

97 lines
4.1 KiB
TypeScript

/**
* v1's own code run against the fakes: git 1ecca1d:js/link_animations.js and
* js/node_animations.js (old_*.js in the author's checkout are byte-identical).
* Without that history, e.g. in a shallow clone, HAVE_V1 is false and the v1
* tests skip; with CI set, importing this module throws instead.
*
* @module tests/unit/characterize/v1-code
*/
import { execFileSync } from 'child_process';
import { resolve } from 'path';
import { vi } from 'vitest';
import type { ComfyNode } from '@/core/types';
import { app, api, fake, resetFakeApp } from '../../helpers/fake-app';
import type { NodeClock } from './v1-inputs';
const V1_COMMIT = '1ecca1d3f25d000f71edf0ef38db5c72df1157d7';
let gitError = '';
function show(file: string): string | null {
try {
return execFileSync('git', ['-C', resolve(__dirname, '../../..'), 'show', `${V1_COMMIT}:js/${file}`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) {
gitError ||= String((error as { stderr?: string }).stderr || error).trim();
return null;
}
}
const LINKS = show('link_animations.js');
const NODES = show('node_animations.js');
export const HAVE_V1 = LINKS !== null && NODES !== null;
if (!HAVE_V1 && process.env.CI) {
throw new Error(`git show ${V1_COMMIT} failed, so the v1 tests cannot run (a CI checkout needs fetch-depth: 0): ${gitError}`);
}
type Locals = Record<string, any>;
/**
* Evaluate a v1 extension file against the fakes and expose some closure locals of its setup().
* Its registration is taken back out, so fake.extensions only ever lists the v2 extensions.
*/
async function run(source: string, name: string, anchor: string, locals: string): Promise<Locals> {
const registered = fake.extensions.length;
new Function('app', 'api', source
.replace(/^import .*$/gm, '')
.replace(/^export /gm, '')
.replace(anchor, `globalThis.__v1 = { ${locals} };\n${anchor}`))(app, api);
await fake.extensions.splice(registered).find(e => e.name === name)?.setup?.(app);
return (globalThis as unknown as { __v1: Locals }).__v1;
}
/**
* Both v1 extensions set up on an empty settings file, so v1 reads its own registered defaults.
* Stubs requestAnimationFrame and console.log; undo with vi.restoreAllMocks() and vi.unstubAllGlobals().
*/
export async function setupV1(): Promise<{ links: Locals; nodes: Locals }> {
resetFakeApp({});
// v1's node extension wraps drawNodeShape, as native fills the node's body, title bar included, in the shadow its
// drawNode set, then turns the shadow off (FE157 LGraphCanvas.ts L5880-5911).
Object.assign(fake.LGraphCanvas.prototype, {
drawNodeShape(_node: unknown, ctx: CanvasRenderingContext2D, size: [number, number], _fgcolor: string, bgcolor: string) {
ctx.fillStyle = bgcolor;
ctx.beginPath();
ctx.rect(0, -30, size[0], size[1] + 30);
ctx.fill();
ctx.shadowColor = 'transparent';
},
});
vi.stubGlobal('requestAnimationFrame', () => 0);
vi.spyOn(console, 'log').mockImplementation(() => {});
return {
links: await run(LINKS!, 'enhanced.link.animations', '// 🚀 Initialize Animation System', 'State, AnimationState, StaticRenderers'),
nodes: await run(NODES!, 'enhanced.node.animations', '// Enhanced animation loop', 'State, ParticleController, AnimationEffects, TextAnimator'),
};
}
/**
* A v1 node effect the way its drawNodeShape called it (:2054-2101): node.color is the title
* colour, which custom colour mode ignores; particles-only nodes get intensity 0.
*/
export function drawV1Effect(
nodes: Locals,
effect: string,
ctx: CanvasRenderingContext2D,
node: ComfyNode,
clock: NodeClock,
particlesOnly = false,
): void {
nodes.State.totalTime = clock.totalTime;
nodes.ParticleController.phase = clock.particlePhase;
nodes.ParticleController.lastUpdate = performance.now(); // the 16 ms gate holds the phase
nodes.AnimationEffects[effect](ctx, { ...node, color: '#353535', particlesOnly }, clock.phase, particlesOnly ? 0 : 1.0 * 1.5);
}