@docyrus/animaguru 0.11.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -3
- package/dist/animaguru.js +1993 -1462
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +21 -74
- package/skills/animaguru/references/avatars.md +22 -0
- package/skills/animaguru/references/cloud.md +19 -0
- package/skills/animaguru/references/sharing.md +33 -0
- package/skills/animaguru/references/sprites.md +29 -0
- package/skills/animaguru-advertising/SKILL.md +2 -2
- package/skills/animaguru-analog-film-tape/SKILL.md +1 -0
- package/skills/animaguru-animated-data-visualization/SKILL.md +1 -0
- package/skills/animaguru-art-deco-motion/SKILL.md +1 -0
- package/skills/animaguru-ascii-crt/SKILL.md +1 -0
- package/skills/animaguru-blueprint-drafting/SKILL.md +1 -0
- package/skills/animaguru-brand-kit/SKILL.md +1 -1
- package/skills/animaguru-cartoon-character-animation/SKILL.md +1 -0
- package/skills/animaguru-cutout-animation/SKILL.md +1 -0
- package/skills/animaguru-dark-tech-stage/SKILL.md +2 -1
- package/skills/animaguru-dark-tech-stage/references/recipe.md +10 -0
- package/skills/animaguru-diagram-formula-motion/SKILL.md +1 -0
- package/skills/animaguru-engraved-relief-print/SKILL.md +1 -0
- package/skills/animaguru-flat-vector-motion/SKILL.md +1 -0
- package/skills/animaguru-generative-light-signal/SKILL.md +1 -0
- package/skills/animaguru-glass-mosaic/SKILL.md +1 -0
- package/skills/animaguru-halftone-editorial/SKILL.md +1 -0
- package/skills/animaguru-holographic-scan/SKILL.md +1 -0
- package/skills/animaguru-interface-motion/SKILL.md +1 -0
- package/skills/animaguru-isometric-worlds/SKILL.md +1 -0
- package/skills/animaguru-kinetic-typography/SKILL.md +1 -0
- package/skills/animaguru-miniature-diorama/SKILL.md +1 -0
- package/skills/animaguru-painterly-depth-animation/SKILL.md +1 -0
- package/skills/animaguru-paper-popup/SKILL.md +1 -0
- package/skills/animaguru-photo-footage-editorial/SKILL.md +1 -0
- package/skills/animaguru-pixel-art-animation/SKILL.md +1 -0
- package/skills/animaguru-portal-zoom/SKILL.md +1 -0
- package/skills/animaguru-risograph-print/SKILL.md +1 -0
- package/skills/animaguru-sand-drawing/SKILL.md +1 -0
- package/skills/animaguru-shadow-theatre/SKILL.md +1 -0
- package/skills/animaguru-silkscreen-poster/SKILL.md +1 -0
- package/skills/animaguru-stop-motion-objects/SKILL.md +1 -0
- package/skills/animaguru-studio-product-reveal/SKILL.md +1 -0
- package/skills/animaguru-video/SKILL.md +33 -5
- package/skills/animaguru-video/references/short-form-ad.md +57 -0
- package/skills/animaguru-whiteboard-line-drawing/SKILL.md +1 -0
- package/templates/video/AGENTS.md +2 -1
- package/templates/video/WORKSPACE-AGENTS.md +6 -2
- package/templates/video/package.json +7 -1
- package/templates/video/scripts/audio-mix.mjs +101 -0
- package/templates/video/scripts/audio-music.mjs +175 -0
- package/templates/video/scripts/audio-tools.mjs +241 -0
- package/templates/video/scripts/audio-vo.mjs +158 -0
- package/templates/video/scripts/dev.mjs +1 -1
- package/templates/video/scripts/export-mp4.mjs +8 -1
- package/templates/video/scripts/frames.mjs +81 -0
- package/templates/video/scripts/headless-browser.mjs +90 -0
- package/templates/video/scripts/review-analysis.mjs +71 -1
- package/templates/video/scripts/review-audio.mjs +2 -1
- package/templates/video/scripts/review.mjs +11 -3
- package/templates/video/scripts/site-inspect.mjs +128 -0
- package/templates/video/scripts/starter-film.sha256 +1 -1
- package/templates/video/scripts/workspace-plugin.mjs +2 -1
- package/templates/video/shared/safe-areas.json +7 -0
- package/templates/video/src/film.ts +97 -125
- package/templates/video/src/kit/components.ts +95 -0
- package/templates/video/src/kit/dark-stage.ts +55 -0
- package/templates/video/src/kit/film-base.ts +108 -0
- package/templates/video/src/kit/fonts.ts +29 -0
- package/templates/video/src/kit/index.ts +11 -0
- package/templates/video/src/kit/layout.ts +40 -0
- package/templates/video/src/kit/motion.ts +37 -0
- package/templates/video/src/kit/shapes.ts +68 -0
- package/templates/video/src/kit/text.ts +34 -0
- package/templates/video/src/kit/textures.ts +42 -0
- package/templates/video/src/kit/timing.ts +48 -0
- package/templates/video/src/player-types.ts +7 -1
- package/templates/video/src/player.ts +3 -5
- package/templates/video/src/review.ts +2 -0
|
@@ -1,4 +1,53 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const safeAreas=JSON.parse(readFileSync(new URL("../shared/safe-areas.json",import.meta.url),"utf8"));
|
|
4
|
+
const short=(text)=>{const line=String(text).replace(/\s+/g," ").trim();return line.length>60?`${line.slice(0,57)}…`:line;};
|
|
5
|
+
const area=(b)=>Math.max(0,b.width)*Math.max(0,b.height);
|
|
6
|
+
const intersection=(a,b)=>Math.max(0,Math.min(a.x+a.width,b.x+b.width)-Math.max(a.x,b.x))*Math.max(0,Math.min(a.y+a.height,b.y+b.height)-Math.max(a.y,b.y));
|
|
7
|
+
// A text box reserves room above the capitals and below the baseline; tightly leaded lines overlap only there.
|
|
8
|
+
const ink=(b)=>{const top=b.fontSize?.2*b.fontSize:.15*b.height,bottom=b.fontSize?.15*b.fontSize:.1*b.height;return {...b,y:b.y+top,height:Math.max(0,b.height-top-bottom)};};
|
|
9
|
+
|
|
10
|
+
/** film.config.json safeArea: a preset name from shared/safe-areas.json (scaled to the film width) or { top, right, bottom, left } insets. */
|
|
11
|
+
export function safeInsets(config) {
|
|
12
|
+
if(!config.safeArea)return null;
|
|
13
|
+
if(typeof config.safeArea==="object")return config.safeArea;
|
|
14
|
+
const preset=safeAreas.presets[config.safeArea];
|
|
15
|
+
if(!preset)throw new Error(`Unknown safeArea "${config.safeArea}" in film.config.json. Use ${Object.keys(safeAreas.presets).join(", ")} or { top, right, bottom, left } insets.`);
|
|
16
|
+
const scale=config.width/safeAreas.referenceWidth;
|
|
17
|
+
return Object.fromEntries(Object.entries(preset).map(([edge,value])=>[edge,value*scale]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Layout warnings from sampled text boxes: overlapping text, text outside the platform safe area,
|
|
22
|
+
* text smaller than config.minTextSize (default 2.2% of the shorter side) and film text under a burned-in caption.
|
|
23
|
+
* Consecutive samples of the same finding are merged into one warning with a time range.
|
|
24
|
+
*/
|
|
25
|
+
export function analyzeLayout(samples,config) {
|
|
26
|
+
const insets=safeInsets(config),minimum=config.minTextSize??Math.round(Math.min(config.width,config.height)*.022);
|
|
27
|
+
const found=new Map();
|
|
28
|
+
const note=(key,time,data)=>{const run=found.get(key);if(run){run.to=time;run.samples++;}else found.set(key,{...data,from:time,to:time,samples:1});};
|
|
29
|
+
for(const sample of samples) {
|
|
30
|
+
const boxes=sample.texts.filter((b)=>b.text.trim()&&area(b)>0&&[b.x,b.y,b.width,b.height].every(Number.isFinite));
|
|
31
|
+
const captions=boxes.filter((b)=>b.role==="caption"),film=boxes.filter((b)=>b.role!=="caption");
|
|
32
|
+
for(const b of boxes) {
|
|
33
|
+
if(b.fontSize!==undefined&&b.fontSize<minimum-.5)note(`small\0${b.text}`,sample.time,{type:"small-text",text:short(b.text),fontSize:b.fontSize,minimum});
|
|
34
|
+
if(insets) {
|
|
35
|
+
const edges=[b.y<insets.top-2&&"top",b.x+b.width>config.width-insets.right+2&&"right",b.y+b.height>config.height-insets.bottom+2&&"bottom",b.x<insets.left-2&&"left"].filter(Boolean);
|
|
36
|
+
if(edges.length)note(`safe\0${b.text}\0${edges}`,sample.time,{type:"safe-area",text:short(b.text),edges});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for(let i=0;i<film.length;i++)for(let j=i+1;j<film.length;j++) {
|
|
40
|
+
const a=ink(film[i]),b=ink(film[j]),shared=intersection(a,b);
|
|
41
|
+
if(shared>.12*Math.min(area(a),area(b))) {const texts=[short(a.text),short(b.text)].sort();note(`overlap\0${texts.join("\0")}`,sample.time,{type:"text-overlap",texts});}
|
|
42
|
+
}
|
|
43
|
+
for(const caption of captions)for(const b of film) {
|
|
44
|
+
if(intersection(caption,b)>.05*Math.min(area(caption),area(b)))note(`caption\0${b.text}`,sample.time,{type:"caption-overlap",text:short(b.text),caption:short(caption.text)});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...found.values()].sort((a,b)=>a.from-b.from);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Numerical checks complement viewing; they do not judge artistic quality or factual truth. */
|
|
2
51
|
export function analyzeReview(report, config) {
|
|
3
52
|
const problems=[],warnings=[],runs=new Map(),active=new Map();
|
|
4
53
|
const finish=(key)=>{const run=active.get(key);if(run){const previous=runs.get(key);if(!previous||run.duration>previous.duration)runs.set(key,run);active.delete(key);}};
|
|
@@ -21,9 +70,30 @@ export function analyzeReview(report, config) {
|
|
|
21
70
|
if(run.duration<need)warnings.push({type:"reading-hold",...run,suggested:need});
|
|
22
71
|
}
|
|
23
72
|
if(!report.samples.some((s)=>s.texts.length))warnings.push({type:"text-not-checked",message:"Expose actual visible text bounds through window.animaguru.texts."});
|
|
73
|
+
warnings.push(...analyzeLayout(report.samples,config));
|
|
24
74
|
if(report.seekMatches.some((s)=>!s.match))problems.push({type:"seek-determinism",samples:report.seekMatches.filter((s)=>!s.match)});
|
|
25
75
|
for(const cue of report.cues??[]) {
|
|
26
76
|
if(cue.audioTime!==undefined&&Math.abs(cue.time-cue.audioTime)>1/config.fps)problems.push({type:"cue-alignment",cue});
|
|
27
77
|
}
|
|
28
78
|
return {pass:problems.length===0,problems,warnings,readingRuns:[...runs.values()]};
|
|
29
79
|
}
|
|
80
|
+
|
|
81
|
+
/** One line per finding, for the terminal, so the report file rarely needs opening. */
|
|
82
|
+
export function reviewLines(analysis,limit=14) {
|
|
83
|
+
const span=(item)=>item.from===undefined?"":item.from===item.to?`${item.from.toFixed(1)}s `:`${item.from.toFixed(1)}–${item.to.toFixed(1)}s `;
|
|
84
|
+
const describe=(item)=>{
|
|
85
|
+
switch(item.type) {
|
|
86
|
+
case "text-bounds": return `${item.time.toFixed(1)}s text outside the frame: "${short(item.text)}"`;
|
|
87
|
+
case "seek-determinism": return `seeking is not deterministic at ${item.samples.map((s)=>`${s.time.toFixed(2)}s`).join(", ")}`;
|
|
88
|
+
case "cue-alignment": return `cue ${item.cue.label??item.cue.id} at ${item.cue.time}s is off its audio time ${item.cue.audioTime}s`;
|
|
89
|
+
case "reading-hold": return `${item.start.toFixed(1)}s reading-hold ${item.duration.toFixed(1)}s (needs ${item.suggested.toFixed(1)}s): "${short(item.text)}"`;
|
|
90
|
+
case "text-overlap": return `${span(item)}text-overlap: "${item.texts[0]}" × "${item.texts[1]}"`;
|
|
91
|
+
case "safe-area": return `${span(item)}safe-area (${item.edges.join(", ")}): "${item.text}"`;
|
|
92
|
+
case "small-text": return `${span(item)}small-text ${item.fontSize}px < ${item.minimum}px: "${item.text}"`;
|
|
93
|
+
case "caption-overlap": return `${span(item)}caption-overlap: "${item.text}" under caption "${item.caption}"`;
|
|
94
|
+
default: return item.message??item.type;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const all=[...analysis.problems.map((item)=>`✗ ${describe(item)}`),...analysis.warnings.map((item)=>`• ${describe(item)}`)];
|
|
98
|
+
return all.length>limit?[...all.slice(0,limit),`… ${all.length-limit} more in report.json`]:all;
|
|
99
|
+
}
|
|
@@ -12,7 +12,8 @@ export async function inspectAudio(root,config) {
|
|
|
12
12
|
const {stdout}=await run(ffprobe.path,["-v","error","-show_streams","-show_format","-of","json",file]);
|
|
13
13
|
const probe=JSON.parse(stdout),stream=probe.streams.find((s)=>s.codec_type==="audio");
|
|
14
14
|
if(!stream)throw new Error("Configured soundtrack has no audio stream.");
|
|
15
|
-
|
|
15
|
+
// Same targets as npm run audio:mix (−14 LUFS, −2 dBTP); the measured input values do not depend on them.
|
|
16
|
+
const {stderr}=await run(ffmpeg.path,["-hide_banner","-nostats","-i",file,"-af","loudnorm=I=-14:TP=-2:LRA=9:print_format=json","-f","null","-"],{maxBuffer:1024*1024});
|
|
16
17
|
const json=stderr.match(/\{\s*"input_i"[\s\S]*?\}/)?.[0];
|
|
17
18
|
if(!json)throw new Error("Audio loudness could not be measured.");
|
|
18
19
|
const levels=JSON.parse(json),duration=Number(probe.format.duration);
|
|
@@ -5,12 +5,14 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { createServer } from "vite";
|
|
6
6
|
import { projectArguments, projectContext } from "./project-context.mjs";
|
|
7
7
|
import { workspacePlugin } from "./workspace-plugin.mjs";
|
|
8
|
-
import { analyzeReview } from "./review-analysis.mjs";
|
|
8
|
+
import { analyzeReview, reviewLines } from "./review-analysis.mjs";
|
|
9
9
|
import { inspectAudio } from "./review-audio.mjs";
|
|
10
|
+
import { captureHeadless, takeHeadlessFlag } from "./headless-browser.mjs";
|
|
10
11
|
|
|
11
12
|
const root=fileURLToPath(new URL("../",import.meta.url));
|
|
12
13
|
const selection=projectArguments(process.argv.slice(2));
|
|
13
|
-
|
|
14
|
+
const headless=takeHeadlessFlag(selection.rest);
|
|
15
|
+
if(selection.rest.length)throw new Error("Usage: npm run review -- [--animation NAME] [--headless]");
|
|
14
16
|
const context=await projectContext(root,selection.animation),config=context.config;
|
|
15
17
|
const token=randomUUID(),port=Number(process.env.ANIMAGURU_REVIEW_PORT??5197);
|
|
16
18
|
const output=path.join(context.projectRoot,"production/review");
|
|
@@ -31,7 +33,9 @@ const server=await createServer({configFile:false,root,base:"./",publicDir:conte
|
|
|
31
33
|
await writeFile(path.join(output,`frame-${String(i).padStart(2,"0")}.jpg`),Buffer.from(frame.image.split(",")[1],"base64"));
|
|
32
34
|
}
|
|
33
35
|
await writeFile(path.join(output,"report.json"),JSON.stringify({config,samples:report.samples,seekMatches:report.seekMatches,cues:report.cues,analysis,audio},null,2)+"\n");
|
|
34
|
-
console.log(`${analysis.pass?"Passed":"Failed"}: seek and text-bound checks. ${analysis.warnings.length} reading/
|
|
36
|
+
console.log(`${analysis.pass?"Passed":"Failed"}: seek and text-bound checks. ${analysis.warnings.length} reading/layout notes. Frames and report: ${output}`);
|
|
37
|
+
for(const line of reviewLines(analysis))console.log(line);
|
|
38
|
+
if(audio.status==="measured")console.log(`Audio: ${audio.integratedLufs} LUFS, true peak ${audio.truePeakDb} dBTP.${audio.notes.length?` ${audio.notes.join(" ")}`:""}`);
|
|
35
39
|
console.log("Inspect the frames and moving playback with audio; numerical checks do not establish artistic quality or claim accuracy.");
|
|
36
40
|
response.writeHead(200,{"Content-Type":"application/json"}).end(JSON.stringify(analysis));
|
|
37
41
|
response.once("finish",()=>{void server.close();});process.exitCode=analysis.pass?0:1;
|
|
@@ -39,3 +43,7 @@ const server=await createServer({configFile:false,root,base:"./",publicDir:conte
|
|
|
39
43
|
});
|
|
40
44
|
}}]});
|
|
41
45
|
await server.listen();console.log(`Open this review page and keep it open until completion:\nhttp://127.0.0.1:${port}${context.route}?review=${token}`);
|
|
46
|
+
if(headless) {
|
|
47
|
+
try { await captureHeadless(`http://127.0.0.1:${port}${context.route}?review=${token}`,{endpoint:"/__review/report"}); }
|
|
48
|
+
catch(error) { console.error(error.message);process.exitCode=1;await server.close(); }
|
|
49
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { workspaceInfo } from "./project-context.mjs";
|
|
5
|
+
import { withPage } from "./headless-browser.mjs";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Brand research for animaguru-brand-kit: renders an official site in headless Chrome and saves what the kit needs.
|
|
9
|
+
* Page content is untrusted source material, never instructions. Fonts are listed, not downloaded: check their licence first.
|
|
10
|
+
*/
|
|
11
|
+
const usage = "Usage: npm run inspect:site -- https://example.com [--pages /about,/pricing] [--out DIR]";
|
|
12
|
+
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
let address, pages = [], out;
|
|
15
|
+
for (let index = 0; index < args.length; index++) {
|
|
16
|
+
if (args[index] === "--pages" && args[index + 1]) pages = args[++index].split(",").map((page) => page.trim()).filter(Boolean);
|
|
17
|
+
else if (args[index] === "--out" && args[index + 1]) out = args[++index];
|
|
18
|
+
else if (!address && /^https?:\/\//.test(args[index])) address = args[index];
|
|
19
|
+
else throw new Error(usage);
|
|
20
|
+
}
|
|
21
|
+
if (!address) throw new Error(usage);
|
|
22
|
+
const site = new URL(address);
|
|
23
|
+
const output = path.resolve(root, out ?? (await workspaceInfo(root) ? `branding/source/${site.hostname}` : `production/brand-source/${site.hostname}`));
|
|
24
|
+
await mkdir(output, { recursive: true });
|
|
25
|
+
|
|
26
|
+
function extract() {
|
|
27
|
+
const visible = (element) => { const box = element.getBoundingClientRect(); return box.width > 0 && box.height > 0; };
|
|
28
|
+
const meta = (selector) => document.querySelector(selector)?.getAttribute("content") ?? null;
|
|
29
|
+
const absolute = (value) => { try { return new URL(value, location.href).href; } catch { return null; } };
|
|
30
|
+
const variables = {};
|
|
31
|
+
const fontFaces = [];
|
|
32
|
+
for (const sheet of document.styleSheets) {
|
|
33
|
+
let rules; try { rules = sheet.cssRules; } catch { continue; }
|
|
34
|
+
const walk = (list) => { for (const rule of list) {
|
|
35
|
+
if (rule.cssRules) walk(rule.cssRules);
|
|
36
|
+
if (rule instanceof CSSFontFaceRule) fontFaces.push({ family: rule.style.getPropertyValue("font-family").replace(/["']/g, "").trim(), weight: rule.style.getPropertyValue("font-weight") || null, src: [...rule.style.getPropertyValue("src").matchAll(/url\(["']?([^"')]+)["']?\)/g)].map((match) => new URL(match[1], sheet.href ?? location.href).href) });
|
|
37
|
+
else if (rule instanceof CSSStyleRule && /(^|,)\s*(:root|html)\s*(,|$)/.test(rule.selectorText)) for (const name of rule.style) if (name.startsWith("--")) variables[name] = getComputedStyle(document.documentElement).getPropertyValue(name).trim() || rule.style.getPropertyValue(name).trim();
|
|
38
|
+
} };
|
|
39
|
+
walk(rules);
|
|
40
|
+
}
|
|
41
|
+
const colorish = /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|oklab\(|color\()/i;
|
|
42
|
+
const usage = new Map();
|
|
43
|
+
const sampled = [...document.querySelectorAll("body, header, nav, main, footer, h1, h2, h3, p, a, button, [class*='btn'], [class*='button']")].filter(visible).slice(0, 400);
|
|
44
|
+
for (const element of sampled) {
|
|
45
|
+
const style = getComputedStyle(element);
|
|
46
|
+
for (const [property, value] of [["text", style.color], ["background", style.backgroundColor]]) {
|
|
47
|
+
if (!value || /rgba\(0, 0, 0, 0\)|transparent/.test(value)) continue;
|
|
48
|
+
const key = `${property} ${value}`; usage.set(key, (usage.get(key) ?? 0) + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const typeOf = (selector) => { const element = [...document.querySelectorAll(selector)].find(visible); if (!element) return null; const style = getComputedStyle(element); return { family: style.fontFamily, weight: style.fontWeight, size: style.fontSize, color: style.color }; };
|
|
52
|
+
const logos = [];
|
|
53
|
+
const logoLike = (element) => /logo|brand/i.test(`${element.id} ${element.className?.baseVal ?? element.className} ${element.getAttribute("alt") ?? ""} ${element.getAttribute("aria-label") ?? ""} ${element.closest("a")?.getAttribute("aria-label") ?? ""}`);
|
|
54
|
+
for (const element of document.querySelectorAll("header img, header svg, nav img, nav svg, footer img, footer svg, img[alt*='logo' i], img[src*='logo' i], svg[class*='logo' i], [class*='logo' i] img, [class*='logo' i] svg, a[href='/'] img, a[href='/'] svg")) {
|
|
55
|
+
if (element.tagName.toLowerCase() === "svg" && element.parentElement?.closest("svg")) continue;
|
|
56
|
+
const box = element.getBoundingClientRect();
|
|
57
|
+
if (box.width < 16 || box.height < 8) continue;
|
|
58
|
+
const place = element.closest("header, nav") ? "header" : element.closest("footer") ? "footer" : "page";
|
|
59
|
+
const score = (logoLike(element) || element.closest("a[href='/'], a[href='./']") ? 2 : 0) + (place === "header" ? 1 : 0);
|
|
60
|
+
if (element.tagName.toLowerCase() === "img") logos.push({ kind: "img", src: absolute(element.currentSrc || element.src), alt: element.alt, width: Math.round(box.width), height: Math.round(box.height), place, score });
|
|
61
|
+
else if (element.outerHTML.length < 200_000) logos.push({ kind: "svg", svg: element.outerHTML, label: element.getAttribute("aria-label"), width: Math.round(box.width), height: Math.round(box.height), place, score });
|
|
62
|
+
}
|
|
63
|
+
const icons = [...document.querySelectorAll("link[rel~='icon'], link[rel='apple-touch-icon'], link[rel='mask-icon']")].map((link) => ({ rel: link.rel, href: absolute(link.getAttribute("href")), sizes: link.getAttribute("sizes") }));
|
|
64
|
+
return {
|
|
65
|
+
url: location.href, title: document.title, lang: document.documentElement.lang || null,
|
|
66
|
+
description: meta("meta[name='description']"), themeColor: meta("meta[name='theme-color']"), ogImage: meta("meta[property='og:image']"), ogSiteName: meta("meta[property='og:site_name']"),
|
|
67
|
+
variables: Object.fromEntries(Object.entries(variables).slice(0, 200)),
|
|
68
|
+
colorVariables: Object.fromEntries(Object.entries(variables).filter(([, value]) => colorish.test(value)).slice(0, 60)),
|
|
69
|
+
colorsInUse: [...usage.entries()].sort((a, b) => b[1] - a[1]).slice(0, 16).map(([key, count]) => ({ use: key.split(" ")[0], color: key.slice(key.indexOf(" ") + 1), count })),
|
|
70
|
+
typography: { body: typeOf("body"), h1: typeOf("h1"), h2: typeOf("h2"), link: typeOf("nav a, header a, a"), button: typeOf("button, [class*='btn']") },
|
|
71
|
+
loadedFonts: [...document.fonts].filter((face) => face.status === "loaded").map((face) => ({ family: face.family.replace(/["']/g, ""), weight: face.weight, style: face.style })),
|
|
72
|
+
fontFaces: fontFaces.slice(0, 40),
|
|
73
|
+
logos: logos.sort((a, b) => b.score - a.score).slice(0, 8), icons,
|
|
74
|
+
headings: [...document.querySelectorAll("h1, h2, h3")].filter(visible).map((h) => `${h.tagName.toLowerCase()}: ${h.innerText.replace(/\s+/g, " ").trim()}`).filter((line) => line.length > 4).slice(0, 40),
|
|
75
|
+
links: [...document.querySelectorAll("header a, nav a, footer a")].filter(visible).map((a) => ({ text: a.innerText.replace(/\s+/g, " ").trim(), href: absolute(a.getAttribute("href")) })).filter((link) => link.text && link.href).slice(0, 60),
|
|
76
|
+
text: document.body.innerText.replace(/\n{3,}/g, "\n\n").slice(0, 12000),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = await withPage(site.href, async (page) => {
|
|
81
|
+
await page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => {});
|
|
82
|
+
await page.screenshot({ path: path.join(output, "hero.png") });
|
|
83
|
+
const home = await page.evaluate(extract);
|
|
84
|
+
const saved = [];
|
|
85
|
+
for (const [index, logo] of home.logos.entries()) {
|
|
86
|
+
const name = `logo-${index + 1}`;
|
|
87
|
+
if (logo.kind === "svg") {
|
|
88
|
+
const svg = logo.svg.includes("xmlns=") ? logo.svg : logo.svg.replace(/^<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
89
|
+
await writeFile(path.join(output, `${name}.svg`), svg); saved.push({ file: `${name}.svg`, ...logo, svg: undefined });
|
|
90
|
+
} else if (logo.src && !logo.src.startsWith("data:")) {
|
|
91
|
+
const response = await page.request.get(logo.src).catch(() => null);
|
|
92
|
+
const type = response?.headers()["content-type"] ?? "";
|
|
93
|
+
if (!response?.ok() || !type.startsWith("image/")) continue;
|
|
94
|
+
const extension = ({ "image/svg+xml": "svg", "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/avif": "avif" })[type.split(";")[0]] ?? "img";
|
|
95
|
+
await writeFile(path.join(output, `${name}.${extension}`), await response.body()); saved.push({ file: `${name}.${extension}`, ...logo });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const more = [];
|
|
99
|
+
for (const page_ of pages) {
|
|
100
|
+
const target = new URL(page_, site);
|
|
101
|
+
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
102
|
+
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {});
|
|
103
|
+
const data = await page.evaluate(extract);
|
|
104
|
+
more.push({ url: data.url, title: data.title, headings: data.headings, text: data.text });
|
|
105
|
+
}
|
|
106
|
+
return { home, saved, more };
|
|
107
|
+
}, { viewport: { width: 1440, height: 900 } });
|
|
108
|
+
|
|
109
|
+
const { home, saved, more } = result;
|
|
110
|
+
const { text, logos, ...summary } = home;
|
|
111
|
+
await writeFile(path.join(output, "site.json"), JSON.stringify({ accessed: new Date().toISOString(), ...summary, logos: saved, pages: more.map(({ text: _text, ...page }) => page) }, null, 2) + "\n");
|
|
112
|
+
await writeFile(path.join(output, "text.md"), [`# ${home.title}`, `Source: ${home.url}`, "", home.text, ...more.flatMap((page) => ["", `# ${page.title}`, `Source: ${page.url}`, "", page.text])].join("\n") + "\n");
|
|
113
|
+
const fontFiles = home.fontFaces.flatMap((face) => face.src.map((src) => `${face.family} ${face.weight ?? ""} ${src}`.replace(/\s+/g, " ")));
|
|
114
|
+
console.log([
|
|
115
|
+
`${home.title} — ${home.url}`,
|
|
116
|
+
home.description ? `Description: ${home.description}` : null,
|
|
117
|
+
`Typography: headings ${home.typography.h1?.family ?? home.typography.h2?.family ?? "?"}; body ${home.typography.body?.family ?? "?"}`,
|
|
118
|
+
`Loaded fonts: ${[...new Set(home.loadedFonts.map((face) => face.family))].join(", ") || "none detected"}`,
|
|
119
|
+
...fontFiles.slice(0, 8).map((line) => ` font file: ${line}`),
|
|
120
|
+
`Color variables: ${Object.entries(home.colorVariables).slice(0, 14).map(([name, value]) => `${name} ${value}`).join(" · ") || "none on :root"}`,
|
|
121
|
+
`Colors in use: ${home.colorsInUse.slice(0, 8).map((item) => `${item.use} ${item.color} ×${item.count}`).join(" · ")}`,
|
|
122
|
+
home.themeColor ? `Theme color: ${home.themeColor}` : null,
|
|
123
|
+
`Logo candidates saved: ${saved.map((logo) => `${logo.file} (${logo.place}, ${logo.width}×${logo.height}${logo.alt ? `, "${logo.alt}"` : ""})`).join("; ") || "none"}`,
|
|
124
|
+
`Icons: ${home.icons.map((icon) => icon.href).join(" ") || "none"}`,
|
|
125
|
+
`Headings: ${home.headings.slice(0, 6).join(" | ")}`,
|
|
126
|
+
`Saved: ${path.join(output, "hero.png")}, site.json, text.md${more.length ? ` (+${more.length} pages)` : ""}`,
|
|
127
|
+
"Open hero.png and each logo file to confirm they belong to the brand; fonts need a licence check before download.",
|
|
128
|
+
].filter(Boolean).join("\n"));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
1b4813e137e12d7784132424c0fa41dfb14cfc0b75acd2df718e04ad3fb086ed
|
|
@@ -40,7 +40,8 @@ void mountPlayer({config,Film,chapters,branding:${JSON.stringify(context.brandin
|
|
|
40
40
|
})().catch((error) => response.writeHead(500).end(error.message)); return;
|
|
41
41
|
}
|
|
42
42
|
if (!match) return next();
|
|
43
|
-
|
|
43
|
+
// Film modules and the JSON they import (including the voiceover cue map) go to Vite; everything else is public media.
|
|
44
|
+
if (/^(?:src|shared)\//.test(match[2]) || ["film.config.json", "animaguru.project.json", "production/audio/timing.json"].includes(match[2])) return next();
|
|
44
45
|
void (async () => {
|
|
45
46
|
const context = await projectContext(root, match[1]);
|
|
46
47
|
if (!match[2] || match[2] === "index.html") {
|
|
@@ -2,145 +2,117 @@ import { Application, Container, Graphics, Sprite, Text } from "pixi.js";
|
|
|
2
2
|
import config from "../film.config.json";
|
|
3
3
|
import story from "../shared/locales/en/film.json";
|
|
4
4
|
import { filmResources } from "./film-resources";
|
|
5
|
+
import { FilmBase, easeOut, clamp01, txt, useBrandFonts, hex } from "./kit";
|
|
5
6
|
import type { FilmContext } from "./player-types";
|
|
6
|
-
import type { ResourceTrack } from "./timeline-model";
|
|
7
7
|
|
|
8
8
|
export const chapters = story.chapters;
|
|
9
|
-
const clamp = (value: number) => Math.max(0, Math.min(1, value));
|
|
10
|
-
const ease = (value: number) => 1 - (1 - clamp(value)) ** 3;
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Replace these demo scenes with the film. Keep all motion a function of seconds.
|
|
12
|
+
* FilmBase (./kit) supplies the player contract, burned-in captions, review text bounds and cues;
|
|
13
|
+
* a longer film puts each scene in its own module under src/scenes/ and calls it from build().
|
|
14
|
+
*/
|
|
15
|
+
export class Film extends FilmBase {
|
|
16
|
+
private readonly identity: typeof story;
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const visit=(node:Container,visible=true,alpha=1)=>{
|
|
29
|
-
const show=visible&&node.visible,opacity=alpha*node.alpha;
|
|
30
|
-
if(node instanceof Text) {
|
|
31
|
-
const id=`text-${index++}`;
|
|
32
|
-
if(show&&opacity>=.95) {const b=node.getBounds();result.push({id,text:node.text,x:b.x,y:b.y,width:b.width,height:b.height});}
|
|
33
|
-
}
|
|
34
|
-
for(const child of node.children)visit(child,show,opacity);
|
|
35
|
-
};
|
|
36
|
-
visit(this.stage);return result;
|
|
18
|
+
constructor(app: Application, context?: FilmContext) {
|
|
19
|
+
super(app, context, {
|
|
20
|
+
config, chapters, design: { width: 1920, height: 1080 },
|
|
21
|
+
captions: chapters.map((chapter) => ({ start: chapter.start, end: chapter.end, text: chapter.caption })),
|
|
22
|
+
captionStyle: { y: 991, size: 30, weight: "400", maxWidth: 1620, background: 0x172721, backgroundAlpha: 0.92, radius: 18 },
|
|
23
|
+
});
|
|
24
|
+
this.identity = { ...story, brand: context?.branding.name ?? story.brand, tagline: context?.branding.tagline ?? story.tagline };
|
|
25
|
+
this.resources = filmResources(this.identity, config);
|
|
26
|
+
if (context?.branding.logo) {
|
|
27
|
+
this.resources = this.resources.filter((track) => track.id !== "branding");
|
|
28
|
+
this.resources.push({ id: "brand-logo", label: this.identity.brand, kind: "image", clips: [{ id: "brand-logo", label: this.identity.brand, source: context.branding.logo, start: 0, end: config.duration }] });
|
|
29
|
+
}
|
|
37
30
|
}
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
32
|
+
protected build() {
|
|
33
|
+
const brand = this.context?.branding;
|
|
34
|
+
useBrandFonts(brand);
|
|
35
|
+
const primary = brand ? hex(brand.colors.primary) : 0x2c6bd0;
|
|
36
|
+
const heading = (value: string, size: number, fill = 0x16232a) => txt(value, size, { family: "heading", weight: "700", fill, lineHeight: size * 1.06 });
|
|
37
|
+
const body = (value: string, size: number, fill: number) => txt(value, size, { fill });
|
|
38
|
+
|
|
39
|
+
this.world.addChild(new Graphics().rect(0, 0, 1920, 1080).fill(brand?.colors.background ?? "#F4F1E9"));
|
|
45
40
|
// Deterministic paper flecks: never use Math.random() during frame rendering.
|
|
46
41
|
const grain = new Graphics();
|
|
47
|
-
for (let i = 0; i < 850; i++) grain.circle((i * 137.51) % 1920, (i * 71.73) % 1080, 1).fill({ color:
|
|
48
|
-
this.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const orbit = new Graphics().circle(0, 0, 380).stroke({ color: "#24332D", alpha: 0.2, width: 2 });
|
|
55
|
-
orbit.rotation = -0.25;
|
|
56
|
-
art.addChild(orbit);
|
|
57
|
-
const paper = new Container();
|
|
58
|
-
paper.rotation = index === 1 ? 0.08 : -0.08;
|
|
59
|
-
paper.addChild(new Graphics().roundRect(-260, -274, 534, 572, 30).fill({ color: "#152520", alpha: 0.09 }));
|
|
60
|
-
paper.addChild(new Graphics().roundRect(-274, -290, 534, 572, 30).fill("#FFFDFA"));
|
|
61
|
-
const small = text(chapter.cardLabel, 21);
|
|
62
|
-
small.position.set(-230, -244);
|
|
63
|
-
paper.addChild(small);
|
|
64
|
-
const shape = new Graphics();
|
|
65
|
-
if (index === 0) {
|
|
66
|
-
shape.ellipse(0, 160, 152, 20).fill({ color: "#000000", alpha: 0.07 });
|
|
67
|
-
shape.roundRect(-106, -126, 210, 290, 70).fill(context?.branding.colors.primary ?? "#2C6BD0");
|
|
68
|
-
shape.star(0, -4, 8, 104, 52).fill("#F2D68B");
|
|
69
|
-
shape.circle(0, -4, 30).fill("#FDFBF4");
|
|
70
|
-
} else if (index === 1) {
|
|
71
|
-
shape.roundRect(-168, -144, 334, 270, 24).fill("#DCE7BC");
|
|
72
|
-
shape.circle(88, -67, 36).fill("#F3D88D");
|
|
73
|
-
shape.poly([-166, 108, -55, -54, 32, 67, 95, 4, 166, 126, -166, 126]).fill(context?.branding.colors.primary ?? "#2C6BD0");
|
|
74
|
-
shape.star(-148, -137, 4, 62, 17).fill("#263B32");
|
|
75
|
-
} else {
|
|
76
|
-
shape.circle(0, 0, 155).fill(context?.branding.colors.primary ?? "#2C6BD0");
|
|
77
|
-
shape.poly([-34, -65, 75, 0, -34, 65]).fill("#FFFDFA");
|
|
78
|
-
}
|
|
79
|
-
paper.addChild(shape);
|
|
80
|
-
const cardCaption = text(chapter.cardCaption, 25, "#31443E", "400");
|
|
81
|
-
cardCaption.anchor.set(0.5);
|
|
82
|
-
cardCaption.position.set(-7, 216);
|
|
83
|
-
paper.addChild(cardCaption);
|
|
84
|
-
art.addChild(paper);
|
|
85
|
-
const sparkle = new Graphics().star(236, -310, 4, 67, 19).fill(context?.branding.colors.primary ?? "#2C6BD0").star(-305, 230, 4, 34, 10).fill("#314B38");
|
|
86
|
-
art.addChild(sparkle);
|
|
87
|
-
const eyebrow = text(chapter.eyebrow, 23, "#4D6052");
|
|
88
|
-
eyebrow.position.set(120, 250);
|
|
89
|
-
const headline = text(chapter.headline, 112);
|
|
90
|
-
headline.position.set(114, 338);
|
|
91
|
-
const detail = text(chapter.detail, 32, "#59675F", "400");
|
|
92
|
-
detail.style.wordWrap = true;
|
|
93
|
-
detail.style.wordWrapWidth = 800;
|
|
94
|
-
detail.style.lineHeight = 46;
|
|
95
|
-
detail.position.set(120, 630);
|
|
96
|
-
root.addChild(art, eyebrow, headline, detail);
|
|
97
|
-
this.stage.addChild(root);
|
|
98
|
-
this.scenes.push({ root, art, headline, detail });
|
|
99
|
-
});
|
|
100
|
-
const mark = context?.logo ? new Sprite(context.logo) : text(identity.brand, 22, context?.branding.colors.text ?? "#16232A");
|
|
101
|
-
if (context?.logo) mark.scale.set(Math.min(170 / mark.width, 54 / mark.height));
|
|
42
|
+
for (let i = 0; i < 850; i++) grain.circle((i * 137.51) % 1920, (i * 71.73) % 1080, 1).fill({ color: 0x23302d, alpha: 0.055 });
|
|
43
|
+
this.world.addChild(grain);
|
|
44
|
+
|
|
45
|
+
chapters.forEach((chapter, index) => this.scene(chapter, index, primary, heading, body));
|
|
46
|
+
|
|
47
|
+
const mark = this.context?.logo ? new Sprite(this.context.logo) : heading(this.identity.brand, 24, brand ? hex(brand.colors.text) : 0x16232a);
|
|
48
|
+
if (this.context?.logo) mark.scale.set(Math.min(170 / mark.width, 54 / mark.height));
|
|
102
49
|
mark.position.set(120, 88);
|
|
103
|
-
const preview =
|
|
50
|
+
const preview = txt(this.identity.tagline, 24, { family: "heading", weight: "700", fill: 0x637166 });
|
|
104
51
|
preview.anchor.set(1, 0);
|
|
105
52
|
preview.position.set(1800, 88);
|
|
106
|
-
this.
|
|
107
|
-
this.caption.position.set(960, 991);
|
|
108
|
-
this.stage.addChild(mark, preview, this.captionBack, this.caption);
|
|
109
|
-
if (context) {
|
|
110
|
-
const visit = (node: Container) => {
|
|
111
|
-
if (node instanceof Text) node.style.fontFamily = node.style.fontWeight === "700" ? context.branding.fonts.heading.family : context.branding.fonts.body.family;
|
|
112
|
-
for (const child of node.children) visit(child);
|
|
113
|
-
};
|
|
114
|
-
visit(this.stage);
|
|
115
|
-
if (context.branding.logo) this.resources = this.resources.filter((track) => track.id !== "branding");
|
|
116
|
-
if (context.branding.logo) this.resources.push({ id: "brand-logo", label: identity.brand, kind: "image", clips: [{ id: "brand-logo", label: identity.brand, source: context.branding.logo, start: 0, end: config.duration }] });
|
|
117
|
-
}
|
|
53
|
+
this.world.addChild(mark, preview);
|
|
118
54
|
}
|
|
119
55
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
56
|
+
/** One chapter: illustrated card on the right, headline and detail on the left. */
|
|
57
|
+
private scene(chapter: (typeof chapters)[number], index: number, primary: number, heading: (value: string, size: number, fill?: number) => Text, body: (value: string, size: number, fill: number) => Text) {
|
|
58
|
+
const root = new Container();
|
|
59
|
+
const art = new Container();
|
|
60
|
+
art.position.set(1440, 545);
|
|
61
|
+
art.addChild(new Graphics().circle(0, 0, 352).fill(chapter.color));
|
|
62
|
+
const orbit = new Graphics().circle(0, 0, 380).stroke({ color: 0x24332d, alpha: 0.2, width: 2 });
|
|
63
|
+
orbit.rotation = -0.25;
|
|
64
|
+
art.addChild(orbit);
|
|
65
|
+
const paper = new Container();
|
|
66
|
+
paper.rotation = index === 1 ? 0.08 : -0.08;
|
|
67
|
+
paper.addChild(new Graphics().roundRect(-260, -274, 534, 572, 30).fill({ color: 0x152520, alpha: 0.09 }));
|
|
68
|
+
paper.addChild(new Graphics().roundRect(-274, -290, 534, 572, 30).fill(0xfffdfa));
|
|
69
|
+
const small = heading(chapter.cardLabel, 24);
|
|
70
|
+
small.position.set(-230, -244);
|
|
71
|
+
paper.addChild(small);
|
|
72
|
+
const shape = new Graphics();
|
|
73
|
+
if (index === 0) {
|
|
74
|
+
shape.ellipse(0, 160, 152, 20).fill({ color: 0x000000, alpha: 0.07 });
|
|
75
|
+
shape.roundRect(-106, -126, 210, 290, 70).fill(primary);
|
|
76
|
+
shape.star(0, -4, 8, 104, 52).fill(0xf2d68b);
|
|
77
|
+
shape.circle(0, -4, 30).fill(0xfdfbf4);
|
|
78
|
+
} else if (index === 1) {
|
|
79
|
+
shape.roundRect(-168, -144, 334, 270, 24).fill(0xdce7bc);
|
|
80
|
+
shape.circle(88, -67, 36).fill(0xf3d88d);
|
|
81
|
+
shape.poly([-166, 108, -55, -54, 32, 67, 95, 4, 166, 126, -166, 126]).fill(primary);
|
|
82
|
+
shape.star(-148, -137, 4, 62, 17).fill(0x263b32);
|
|
83
|
+
} else {
|
|
84
|
+
shape.circle(0, 0, 155).fill(primary);
|
|
85
|
+
shape.poly([-34, -65, 75, 0, -34, 65]).fill(0xfffdfa);
|
|
143
86
|
}
|
|
144
|
-
|
|
87
|
+
paper.addChild(shape);
|
|
88
|
+
const cardCaption = body(chapter.cardCaption, 26, 0x31443e);
|
|
89
|
+
cardCaption.anchor.set(0.5);
|
|
90
|
+
cardCaption.position.set(-7, 216);
|
|
91
|
+
paper.addChild(cardCaption);
|
|
92
|
+
art.addChild(paper);
|
|
93
|
+
art.addChild(new Graphics().star(236, -310, 4, 67, 19).fill(primary).star(-305, 230, 4, 34, 10).fill(0x314b38));
|
|
94
|
+
const eyebrow = heading(chapter.eyebrow, 24, 0x4d6052);
|
|
95
|
+
eyebrow.position.set(120, 250);
|
|
96
|
+
const headline = heading(chapter.headline, 112);
|
|
97
|
+
headline.position.set(114, 338);
|
|
98
|
+
const detail = body(chapter.detail, 32, 0x59675f);
|
|
99
|
+
Object.assign(detail.style, { wordWrap: true, wordWrapWidth: 800, lineHeight: 46 });
|
|
100
|
+
detail.position.set(120, 630);
|
|
101
|
+
root.addChild(art, eyebrow, headline, detail);
|
|
102
|
+
this.world.addChild(root);
|
|
103
|
+
|
|
104
|
+
this.on((t) => {
|
|
105
|
+
const local = t - chapter.start;
|
|
106
|
+
root.visible = t >= chapter.start && t < chapter.end;
|
|
107
|
+
if (!root.visible) return;
|
|
108
|
+
const enter = index === 0 ? 1 : 0.2 + 0.8 * easeOut(local / 0.85);
|
|
109
|
+
const leave = index === chapters.length - 1 ? 1 : clamp01((chapter.end - t) / 0.3);
|
|
110
|
+
root.alpha = enter * leave;
|
|
111
|
+
headline.x = 114 - 36 * (1 - enter);
|
|
112
|
+
detail.alpha = index === 0 ? 1 : easeOut((local - 0.25) / 0.8);
|
|
113
|
+
art.scale.set(0.94 + 0.06 * enter);
|
|
114
|
+
art.y = 545 + 50 * (1 - enter) + Math.sin(local * 0.85) * 10;
|
|
115
|
+
art.rotation = Math.sin(local * 0.65) * 0.018;
|
|
116
|
+
});
|
|
145
117
|
}
|
|
146
118
|
}
|