@docyrus/animaguru 0.6.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/NOTICE.md +256 -0
- package/README.md +241 -0
- package/dist/animaguru.js +27338 -0
- package/package.json +37 -0
- package/skills/animaguru/SKILL.md +115 -0
- package/skills/animaguru-advertising/SKILL.md +25 -0
- package/skills/animaguru-advertising/references/brand-bumper.md +11 -0
- package/skills/animaguru-advertising/references/comparison.md +11 -0
- package/skills/animaguru-advertising/references/creator-testimonial.md +11 -0
- package/skills/animaguru-advertising/references/editorial-advertorial.md +11 -0
- package/skills/animaguru-advertising/references/game-reward.md +11 -0
- package/skills/animaguru-advertising/references/offer-promotion.md +11 -0
- package/skills/animaguru-advertising/references/product-launch.md +11 -0
- package/skills/animaguru-advertising/references/product-proof.md +11 -0
- package/skills/animaguru-analog-film-tape/SKILL.md +29 -0
- package/skills/animaguru-analog-film-tape/references/recipe.md +23 -0
- package/skills/animaguru-animated-data-visualization/SKILL.md +39 -0
- package/skills/animaguru-animated-data-visualization/references/data-contract.md +16 -0
- package/skills/animaguru-animated-data-visualization/references/variants.md +17 -0
- package/skills/animaguru-art-deco-motion/SKILL.md +29 -0
- package/skills/animaguru-art-deco-motion/references/recipe.md +22 -0
- package/skills/animaguru-ascii-crt/SKILL.md +29 -0
- package/skills/animaguru-ascii-crt/references/recipe.md +22 -0
- package/skills/animaguru-blueprint-drafting/SKILL.md +29 -0
- package/skills/animaguru-blueprint-drafting/references/recipe.md +22 -0
- package/skills/animaguru-cartoon-character-animation/SKILL.md +35 -0
- package/skills/animaguru-cartoon-character-animation/references/variants.md +17 -0
- package/skills/animaguru-cutout-animation/SKILL.md +33 -0
- package/skills/animaguru-cutout-animation/references/production-recipe.md +37 -0
- package/skills/animaguru-cutout-animation/references/variants.md +17 -0
- package/skills/animaguru-cutout-animation/references/visual-style.md +34 -0
- package/skills/animaguru-dark-tech-stage/SKILL.md +29 -0
- package/skills/animaguru-dark-tech-stage/references/recipe.md +23 -0
- package/skills/animaguru-diagram-formula-motion/SKILL.md +36 -0
- package/skills/animaguru-diagram-formula-motion/references/variants.md +17 -0
- package/skills/animaguru-engraved-relief-print/SKILL.md +29 -0
- package/skills/animaguru-engraved-relief-print/references/recipe.md +24 -0
- package/skills/animaguru-flat-vector-motion/SKILL.md +35 -0
- package/skills/animaguru-flat-vector-motion/references/variants.md +17 -0
- package/skills/animaguru-general-video/SKILL.md +20 -0
- package/skills/animaguru-general-video/references/ambient-loop.md +11 -0
- package/skills/animaguru-general-video/references/music-lyrics.md +11 -0
- package/skills/animaguru-general-video/references/short-film.md +11 -0
- package/skills/animaguru-general-video/references/titles-event.md +11 -0
- package/skills/animaguru-general-video/references/visual-essay.md +11 -0
- package/skills/animaguru-generative-light-signal/SKILL.md +29 -0
- package/skills/animaguru-generative-light-signal/references/recipe.md +23 -0
- package/skills/animaguru-glass-mosaic/SKILL.md +29 -0
- package/skills/animaguru-glass-mosaic/references/recipe.md +23 -0
- package/skills/animaguru-halftone-editorial/SKILL.md +29 -0
- package/skills/animaguru-halftone-editorial/references/recipe.md +23 -0
- package/skills/animaguru-holographic-scan/SKILL.md +29 -0
- package/skills/animaguru-holographic-scan/references/recipe.md +22 -0
- package/skills/animaguru-interface-motion/SKILL.md +36 -0
- package/skills/animaguru-interface-motion/references/screen-mapping.md +16 -0
- package/skills/animaguru-interface-motion/references/variants.md +17 -0
- package/skills/animaguru-isometric-worlds/SKILL.md +35 -0
- package/skills/animaguru-isometric-worlds/references/variants.md +17 -0
- package/skills/animaguru-kinetic-typography/SKILL.md +35 -0
- package/skills/animaguru-kinetic-typography/references/variants.md +17 -0
- package/skills/animaguru-miniature-diorama/SKILL.md +29 -0
- package/skills/animaguru-miniature-diorama/references/recipe.md +23 -0
- package/skills/animaguru-painterly-depth-animation/SKILL.md +35 -0
- package/skills/animaguru-painterly-depth-animation/references/variants.md +17 -0
- package/skills/animaguru-paper-popup/SKILL.md +29 -0
- package/skills/animaguru-paper-popup/references/recipe.md +22 -0
- package/skills/animaguru-photo-footage-editorial/SKILL.md +29 -0
- package/skills/animaguru-photo-footage-editorial/references/recipe.md +23 -0
- package/skills/animaguru-pixel-art-animation/SKILL.md +36 -0
- package/skills/animaguru-pixel-art-animation/references/variants.md +17 -0
- package/skills/animaguru-portal-zoom/SKILL.md +29 -0
- package/skills/animaguru-portal-zoom/references/recipe.md +21 -0
- package/skills/animaguru-risograph-print/SKILL.md +29 -0
- package/skills/animaguru-risograph-print/references/recipe.md +23 -0
- package/skills/animaguru-sand-drawing/SKILL.md +29 -0
- package/skills/animaguru-sand-drawing/references/recipe.md +21 -0
- package/skills/animaguru-shadow-theatre/SKILL.md +29 -0
- package/skills/animaguru-shadow-theatre/references/recipe.md +23 -0
- package/skills/animaguru-silkscreen-poster/SKILL.md +29 -0
- package/skills/animaguru-silkscreen-poster/references/recipe.md +22 -0
- package/skills/animaguru-stop-motion-objects/SKILL.md +29 -0
- package/skills/animaguru-stop-motion-objects/references/recipe.md +23 -0
- package/skills/animaguru-studio-product-reveal/SKILL.md +29 -0
- package/skills/animaguru-studio-product-reveal/references/recipe.md +23 -0
- package/skills/animaguru-video/SKILL.md +79 -0
- package/skills/animaguru-whiteboard-line-drawing/SKILL.md +34 -0
- package/skills/animaguru-whiteboard-line-drawing/references/variants.md +17 -0
- package/templates/video/AGENTS.md +34 -0
- package/templates/video/CLAUDE.md +19 -0
- package/templates/video/README.md +101 -0
- package/templates/video/VIDEO-STYLE-NOTICES.txt +54 -0
- package/templates/video/film.config.json +10 -0
- package/templates/video/gitignore +11 -0
- package/templates/video/index.html +14 -0
- package/templates/video/package.json +28 -0
- package/templates/video/public/brand/logo-mark.svg +5 -0
- package/templates/video/scripts/build.mjs +12 -0
- package/templates/video/scripts/export-html.mjs +12 -0
- package/templates/video/scripts/export-mp4.mjs +209 -0
- package/templates/video/scripts/html-export-plugin.mjs +35 -0
- package/templates/video/scripts/html-export.mjs +54 -0
- package/templates/video/scripts/install-example.mjs +32 -0
- package/templates/video/scripts/review-analysis.mjs +29 -0
- package/templates/video/scripts/review-audio.mjs +24 -0
- package/templates/video/scripts/review.mjs +37 -0
- package/templates/video/scripts/starter-film.sha256 +1 -0
- package/templates/video/shared/locales/en/export.json +10 -0
- package/templates/video/shared/locales/en/film.json +42 -0
- package/templates/video/shared/locales/en/player.json +31 -0
- package/templates/video/shared/locales/en/style-studies.json +168 -0
- package/templates/video/shared/locales/en/timeline.json +33 -0
- package/templates/video/src/assets.ts +4 -0
- package/templates/video/src/download.ts +27 -0
- package/templates/video/src/exporter.ts +70 -0
- package/templates/video/src/film-resources.ts +29 -0
- package/templates/video/src/film.ts +132 -0
- package/templates/video/src/main.ts +219 -0
- package/templates/video/src/resource-timeline.ts +193 -0
- package/templates/video/src/review.ts +50 -0
- package/templates/video/src/studies/film.ts +26 -0
- package/templates/video/src/studies/media.ts +46 -0
- package/templates/video/src/studies/model.ts +60 -0
- package/templates/video/src/studies/pixi-renderer.ts +121 -0
- package/templates/video/src/studies/scenes.ts +410 -0
- package/templates/video/src/studies/timing.ts +53 -0
- package/templates/video/src/studies/variants.ts +39 -0
- package/templates/video/src/style.css +111 -0
- package/templates/video/src/timeline-model.ts +55 -0
- package/templates/video/style-source-provenance.json +11 -0
- package/templates/video/tsconfig.json +15 -0
- package/templates/video/vite.config.mjs +9 -0
- package/vendor/pixijs-skills/LICENSE +21 -0
- package/vendor/pixijs-skills/provenance.json +79 -0
- package/vendor/pixijs-skills/skills/pixijs/SKILL.md +66 -0
- package/vendor/pixijs-skills/skills/pixijs/references/index.md +136 -0
- package/vendor/pixijs-skills/skills/pixijs-accessibility/SKILL.md +224 -0
- package/vendor/pixijs-skills/skills/pixijs-application/SKILL.md +293 -0
- package/vendor/pixijs-skills/skills/pixijs-application/references/application-options.md +314 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/SKILL.md +303 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/background.md +137 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/bundles.md +122 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/caching.md +143 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/compressed-textures.md +120 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/fonts.md +205 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/gif.md +171 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/manifests.md +175 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/progress.md +142 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/resolution.md +142 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/spritesheet.md +119 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/svg.md +132 -0
- package/vendor/pixijs-skills/skills/pixijs-assets/references/video.md +158 -0
- package/vendor/pixijs-skills/skills/pixijs-blend-modes/SKILL.md +215 -0
- package/vendor/pixijs-skills/skills/pixijs-color/SKILL.md +299 -0
- package/vendor/pixijs-skills/skills/pixijs-core-concepts/SKILL.md +132 -0
- package/vendor/pixijs-skills/skills/pixijs-core-concepts/references/render-loop.md +216 -0
- package/vendor/pixijs-skills/skills/pixijs-core-concepts/references/renderers.md +287 -0
- package/vendor/pixijs-skills/skills/pixijs-create/SKILL.md +276 -0
- package/vendor/pixijs-skills/skills/pixijs-custom-rendering/SKILL.md +405 -0
- package/vendor/pixijs-skills/skills/pixijs-custom-rendering/references/advanced-gpu.md +244 -0
- package/vendor/pixijs-skills/skills/pixijs-custom-rendering/references/uniform-types.md +94 -0
- package/vendor/pixijs-skills/skills/pixijs-environments/SKILL.md +243 -0
- package/vendor/pixijs-skills/skills/pixijs-events/SKILL.md +387 -0
- package/vendor/pixijs-skills/skills/pixijs-filters/SKILL.md +300 -0
- package/vendor/pixijs-skills/skills/pixijs-html-source/SKILL.md +228 -0
- package/vendor/pixijs-skills/skills/pixijs-math/SKILL.md +489 -0
- package/vendor/pixijs-skills/skills/pixijs-migration-v8/SKILL.md +415 -0
- package/vendor/pixijs-skills/skills/pixijs-performance/SKILL.md +489 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-container/SKILL.md +345 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/SKILL.md +243 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/constructor-options.md +155 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/container-hierarchy.md +232 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/layers.md +145 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/masking.md +129 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/render-groups.md +140 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/scene-management.md +169 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-core-concepts/references/transforms.md +198 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-dom-container/SKILL.md +193 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-gif/SKILL.md +233 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-graphics/SKILL.md +576 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-mesh/SKILL.md +141 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-mesh/references/mesh-perspective.md +189 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-mesh/references/mesh-plane.md +159 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-mesh/references/mesh-rope.md +176 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-mesh/references/mesh-simple.md +144 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-mesh/references/mesh.md +213 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-particle-container/SKILL.md +323 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-sprite/SKILL.md +120 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-sprite/references/animated-sprite.md +262 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-sprite/references/nineslice-sprite.md +202 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-sprite/references/sprite.md +181 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-sprite/references/tiling-sprite.md +216 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-text/SKILL.md +136 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-text/references/bitmap-text.md +232 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-text/references/html-text.md +192 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-text/references/split-bitmap-text.md +187 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-text/references/split-text.md +168 -0
- package/vendor/pixijs-skills/skills/pixijs-scene-text/references/text.md +311 -0
- package/vendor/pixijs-skills/skills/pixijs-ticker/SKILL.md +292 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Application, Container, Graphics, Text } from "pixi.js";
|
|
2
|
+
import config from "../film.config.json";
|
|
3
|
+
import story from "../shared/locales/en/film.json";
|
|
4
|
+
import { filmResources } from "./film-resources";
|
|
5
|
+
|
|
6
|
+
export const chapters = story.chapters;
|
|
7
|
+
const clamp = (value: number) => Math.max(0, Math.min(1, value));
|
|
8
|
+
const ease = (value: number) => 1 - (1 - clamp(value)) ** 3;
|
|
9
|
+
|
|
10
|
+
function text(value: string, size: number, color = "#16232A", weight: "400" | "700" = "700") {
|
|
11
|
+
return new Text({ text: value, style: { fontFamily: "Arial, sans-serif", fontSize: size, fontWeight: weight, fill: color, lineHeight: size * 1.06, letterSpacing: size > 60 ? -3 : 0 } });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Replace these demo scenes with the film. Keep all motion a function of seconds. */
|
|
15
|
+
export class Film {
|
|
16
|
+
readonly stage = new Container();
|
|
17
|
+
readonly resources = filmResources(story, config);
|
|
18
|
+
private readonly scenes: { root: Container; art: Container; headline: Text; detail: Text }[] = [];
|
|
19
|
+
private readonly caption = text("", 30, "#FFFFFF", "400");
|
|
20
|
+
private readonly captionBack = new Graphics();
|
|
21
|
+
captions = true;
|
|
22
|
+
get cues() { return chapters.map((chapter,index)=>({id:`chapter-${index}`,time:chapter.start,label:chapter.label})); }
|
|
23
|
+
get texts() {
|
|
24
|
+
const result: { id:string; text:string; x:number; y:number; width:number; height:number }[]=[];
|
|
25
|
+
let index=0;
|
|
26
|
+
const visit=(node:Container,visible=true,alpha=1)=>{
|
|
27
|
+
const show=visible&&node.visible,opacity=alpha*node.alpha;
|
|
28
|
+
if(node instanceof Text) {
|
|
29
|
+
const id=`text-${index++}`;
|
|
30
|
+
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});}
|
|
31
|
+
}
|
|
32
|
+
for(const child of node.children)visit(child,show,opacity);
|
|
33
|
+
};
|
|
34
|
+
visit(this.stage);return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
constructor(private readonly app: Application) {
|
|
38
|
+
app.stage.addChild(this.stage);
|
|
39
|
+
this.stage.scale.set(config.width / 1920, config.height / 1080);
|
|
40
|
+
this.stage.addChild(new Graphics().rect(0, 0, 1920, 1080).fill("#F4F1E9"));
|
|
41
|
+
// Deterministic paper flecks: never use Math.random() during frame rendering.
|
|
42
|
+
const grain = new Graphics();
|
|
43
|
+
for (let i = 0; i < 850; i++) grain.circle((i * 137.51) % 1920, (i * 71.73) % 1080, 1).fill({ color: "#23302D", alpha: 0.055 });
|
|
44
|
+
this.stage.addChild(grain);
|
|
45
|
+
chapters.forEach((chapter, index) => {
|
|
46
|
+
const root = new Container();
|
|
47
|
+
const art = new Container();
|
|
48
|
+
art.position.set(1440, 545);
|
|
49
|
+
art.addChild(new Graphics().circle(0, 0, 352).fill(chapter.color));
|
|
50
|
+
const orbit = new Graphics().circle(0, 0, 380).stroke({ color: "#24332D", alpha: 0.2, width: 2 });
|
|
51
|
+
orbit.rotation = -0.25;
|
|
52
|
+
art.addChild(orbit);
|
|
53
|
+
const paper = new Container();
|
|
54
|
+
paper.rotation = index === 1 ? 0.08 : -0.08;
|
|
55
|
+
paper.addChild(new Graphics().roundRect(-260, -274, 534, 572, 30).fill({ color: "#152520", alpha: 0.09 }));
|
|
56
|
+
paper.addChild(new Graphics().roundRect(-274, -290, 534, 572, 30).fill("#FFFDFA"));
|
|
57
|
+
const small = text(chapter.cardLabel, 21);
|
|
58
|
+
small.position.set(-230, -244);
|
|
59
|
+
paper.addChild(small);
|
|
60
|
+
const shape = new Graphics();
|
|
61
|
+
if (index === 0) {
|
|
62
|
+
shape.ellipse(0, 160, 152, 20).fill({ color: "#000000", alpha: 0.07 });
|
|
63
|
+
shape.roundRect(-106, -126, 210, 290, 70).fill("#2C6BD0");
|
|
64
|
+
shape.star(0, -4, 8, 104, 52).fill("#F2D68B");
|
|
65
|
+
shape.circle(0, -4, 30).fill("#FDFBF4");
|
|
66
|
+
} else if (index === 1) {
|
|
67
|
+
shape.roundRect(-168, -144, 334, 270, 24).fill("#DCE7BC");
|
|
68
|
+
shape.circle(88, -67, 36).fill("#F3D88D");
|
|
69
|
+
shape.poly([-166, 108, -55, -54, 32, 67, 95, 4, 166, 126, -166, 126]).fill("#2C6BD0");
|
|
70
|
+
shape.star(-148, -137, 4, 62, 17).fill("#263B32");
|
|
71
|
+
} else {
|
|
72
|
+
shape.circle(0, 0, 155).fill("#2C6BD0");
|
|
73
|
+
shape.poly([-34, -65, 75, 0, -34, 65]).fill("#FFFDFA");
|
|
74
|
+
}
|
|
75
|
+
paper.addChild(shape);
|
|
76
|
+
const cardCaption = text(chapter.cardCaption, 25, "#31443E", "400");
|
|
77
|
+
cardCaption.anchor.set(0.5);
|
|
78
|
+
cardCaption.position.set(-7, 216);
|
|
79
|
+
paper.addChild(cardCaption);
|
|
80
|
+
art.addChild(paper);
|
|
81
|
+
const sparkle = new Graphics().star(236, -310, 4, 67, 19).fill("#2C6BD0").star(-305, 230, 4, 34, 10).fill("#314B38");
|
|
82
|
+
art.addChild(sparkle);
|
|
83
|
+
const eyebrow = text(chapter.eyebrow, 23, "#4D6052");
|
|
84
|
+
eyebrow.position.set(120, 250);
|
|
85
|
+
const headline = text(chapter.headline, 112);
|
|
86
|
+
headline.position.set(114, 338);
|
|
87
|
+
const detail = text(chapter.detail, 32, "#59675F", "400");
|
|
88
|
+
detail.style.wordWrap = true;
|
|
89
|
+
detail.style.wordWrapWidth = 800;
|
|
90
|
+
detail.style.lineHeight = 46;
|
|
91
|
+
detail.position.set(120, 630);
|
|
92
|
+
root.addChild(art, eyebrow, headline, detail);
|
|
93
|
+
this.stage.addChild(root);
|
|
94
|
+
this.scenes.push({ root, art, headline, detail });
|
|
95
|
+
});
|
|
96
|
+
const mark = text(story.brand, 22);
|
|
97
|
+
mark.position.set(120, 88);
|
|
98
|
+
const preview = text(story.tagline, 20, "#637166");
|
|
99
|
+
preview.anchor.set(1, 0);
|
|
100
|
+
preview.position.set(1800, 88);
|
|
101
|
+
this.caption.anchor.set(0.5);
|
|
102
|
+
this.caption.position.set(960, 991);
|
|
103
|
+
this.stage.addChild(mark, preview, this.captionBack, this.caption);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Await video seeks/font readiness here before returning when adding media. */
|
|
107
|
+
async render(seconds: number) {
|
|
108
|
+
const time = Math.min(Math.max(seconds, 0), config.duration - 0.00001);
|
|
109
|
+
this.scenes.forEach((scene, index) => {
|
|
110
|
+
const chapter = chapters[index];
|
|
111
|
+
const local = time - chapter.start;
|
|
112
|
+
scene.root.visible = time >= chapter.start && time < chapter.end;
|
|
113
|
+
if (!scene.root.visible) return;
|
|
114
|
+
const enter = index === 0 ? 1 : 0.2 + 0.8 * ease(local / 0.85);
|
|
115
|
+
const leave = index === chapters.length - 1 ? 1 : clamp((chapter.end - time) / 0.3);
|
|
116
|
+
scene.root.alpha = enter * leave;
|
|
117
|
+
scene.headline.x = 114 - 36 * (1 - enter);
|
|
118
|
+
scene.detail.alpha = index === 0 ? 1 : ease((local - 0.25) / 0.8);
|
|
119
|
+
scene.art.scale.set(0.94 + 0.06 * enter);
|
|
120
|
+
scene.art.y = 545 + 50 * (1 - enter) + Math.sin(local * 0.85) * 10;
|
|
121
|
+
scene.art.rotation = Math.sin(local * 0.65) * 0.018;
|
|
122
|
+
this.caption.text = chapter.caption;
|
|
123
|
+
});
|
|
124
|
+
this.caption.visible = this.captions;
|
|
125
|
+
this.captionBack.visible = this.captions;
|
|
126
|
+
if (this.captions) {
|
|
127
|
+
const width = Math.min(1720, this.caption.width + 64);
|
|
128
|
+
this.captionBack.clear().roundRect(960 - width / 2, 956, width, 70, 18).fill({ color: "#172721", alpha: 0.92 });
|
|
129
|
+
}
|
|
130
|
+
this.app.render();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Application } from "pixi.js";
|
|
2
|
+
import config from "../film.config.json";
|
|
3
|
+
import copy from "../shared/locales/en/player.json";
|
|
4
|
+
import { Film, chapters } from "./film";
|
|
5
|
+
import { publicAsset } from "./assets";
|
|
6
|
+
import { downloadHtml, downloadUrl } from "./download";
|
|
7
|
+
import type { ResourceTimeline } from "./resource-timeline";
|
|
8
|
+
import "./style.css";
|
|
9
|
+
|
|
10
|
+
const root = document.querySelector<HTMLDivElement>("#app")!;
|
|
11
|
+
// All variable copy is inserted as text, including agent-authored film titles.
|
|
12
|
+
root.innerHTML = `<header class="masthead"><a class="brand" href="https://animaguru.com" target="_blank" rel="noreferrer"><img src="${publicAsset("brand/logo-mark.svg")}" alt=""/><strong>AnimaGuru</strong><span data-copy="studio"></span></a><span class="template" data-copy="template"></span></header>
|
|
13
|
+
<main>
|
|
14
|
+
<section id="theater"><div id="canvas-host"><p id="loading" role="status" data-copy="loading"></p></div><div class="transport"><button id="play" class="primary" data-copy="play"></button><span id="time">0:00 / 0:24</span><input id="timeline" type="range" min="0" value="0" step="0.01"/><div class="tools"><button id="sound" data-copy="unmute" aria-pressed="false"></button><button id="captions" aria-pressed="true">CC</button><div class="download-control"><button id="download" class="icon-button" aria-haspopup="menu" aria-expanded="false" aria-controls="download-menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg></button><div id="download-menu" role="menu" hidden><button id="save" role="menuitem" tabindex="-1"><span data-copy="saveFrame"></span><small data-copy="pngFormat"></small></button><button id="export-html" role="menuitem" tabindex="-1"><span data-copy="exportHtml"></span><small data-copy="htmlFormat"></small></button></div></div><button id="fullscreen" class="icon-button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5"/></svg></button></div></div></section>
|
|
15
|
+
<p class="player-note"><span class="preview-meta"><span class="specs"></span><span aria-hidden="true">·</span><span data-copy="starter"></span></span><span data-copy="exportHint"></span></p><p id="status" role="status"></p>
|
|
16
|
+
<section id="resource-timeline"></section>
|
|
17
|
+
<details><summary data-copy="transcript"></summary><div id="transcript"></div></details>
|
|
18
|
+
</main><footer><span data-copy="footer"></span><span data-copy="footerTagline"></span></footer>`;
|
|
19
|
+
for (const element of document.querySelectorAll<HTMLElement>("[data-copy]")) element.textContent = copy[element.dataset.copy as keyof typeof copy];
|
|
20
|
+
const element = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
21
|
+
const playButton = element<HTMLButtonElement>("play");
|
|
22
|
+
const soundButton = element<HTMLButtonElement>("sound");
|
|
23
|
+
const captionsButton = element<HTMLButtonElement>("captions");
|
|
24
|
+
const timeline = element<HTMLInputElement>("timeline");
|
|
25
|
+
const theater = element<HTMLElement>("theater");
|
|
26
|
+
const status = element<HTMLElement>("status");
|
|
27
|
+
const params = new URLSearchParams(location.search);
|
|
28
|
+
const embed = import.meta.env.MODE === "embed" || params.has("embed");
|
|
29
|
+
const capture = params.has("capture");
|
|
30
|
+
const exporting = !embed && params.has("export");
|
|
31
|
+
const autoplay = embed && params.get("autoplay") === "1" && !matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
32
|
+
const loop = embed && params.get("loop") === "1";
|
|
33
|
+
if (capture) document.body.classList.add("capture");
|
|
34
|
+
if (embed) document.body.classList.add("embed");
|
|
35
|
+
if (embed && autoplay && params.get("controls") === "0") document.body.classList.add("no-controls");
|
|
36
|
+
document.documentElement.lang = config.language;
|
|
37
|
+
document.title = `${config.title} · AnimaGuru`;
|
|
38
|
+
document.querySelector(".specs")!.textContent = `${config.width} × ${config.height} / ${config.fps} FPS`;
|
|
39
|
+
timeline.max = String(config.duration);
|
|
40
|
+
theater.setAttribute("aria-label", copy.videoPlayer);
|
|
41
|
+
element("canvas-host").style.aspectRatio = `${config.width} / ${config.height}`;
|
|
42
|
+
timeline.setAttribute("aria-label", copy.timeline);
|
|
43
|
+
captionsButton.setAttribute("aria-label", copy.captions);
|
|
44
|
+
for (const [id, label] of [["download", copy.download], ["fullscreen", copy.fullscreen], ["download-menu", copy.download]] as const) {
|
|
45
|
+
element(id).setAttribute("aria-label", label);
|
|
46
|
+
if (id !== "download-menu") element(id).title = label;
|
|
47
|
+
}
|
|
48
|
+
const format = (time: number) => `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
|
|
49
|
+
|
|
50
|
+
async function start() {
|
|
51
|
+
await document.fonts.ready;
|
|
52
|
+
const app = new Application();
|
|
53
|
+
await app.init({ width: config.width, height: config.height, resolution: 1, autoStart: false, antialias: true, background: "#F4F1E9", preference: "webgl", preserveDrawingBuffer: true });
|
|
54
|
+
app.canvas.setAttribute("aria-label", config.title);
|
|
55
|
+
element("canvas-host").append(app.canvas);
|
|
56
|
+
element("loading").remove();
|
|
57
|
+
const film = new Film(app);
|
|
58
|
+
const audio = config.audio ? new Audio(publicAsset(String(config.audio))) : null;
|
|
59
|
+
if (audio) { audio.preload = "auto"; audio.muted = true; }
|
|
60
|
+
let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
|
|
61
|
+
let playing = false;
|
|
62
|
+
let muted = true;
|
|
63
|
+
let last = performance.now();
|
|
64
|
+
let renderQueue = Promise.resolve();
|
|
65
|
+
let playbackRequest = 0;
|
|
66
|
+
let resources: ResourceTimeline | undefined;
|
|
67
|
+
const update = () => {
|
|
68
|
+
playButton.textContent = playing ? copy.pause : time >= config.duration ? copy.replay : copy.play;
|
|
69
|
+
playButton.setAttribute("aria-label", playButton.textContent);
|
|
70
|
+
element("time").textContent = `${format(time)} / ${format(config.duration)}`;
|
|
71
|
+
timeline.value = String(time);
|
|
72
|
+
timeline.setAttribute("aria-valuetext", `${format(time)} / ${format(config.duration)}`);
|
|
73
|
+
soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
|
|
74
|
+
soundButton.disabled = !audio;
|
|
75
|
+
soundButton.setAttribute("aria-pressed", String(!muted));
|
|
76
|
+
captionsButton.setAttribute("aria-pressed", String(film.captions));
|
|
77
|
+
resources?.update({ time, playing, captions: film.captions, muted });
|
|
78
|
+
};
|
|
79
|
+
const render = (seconds: number) => {
|
|
80
|
+
// Seeking and frame capture are serialized so decoded video frames cannot race.
|
|
81
|
+
renderQueue = renderQueue.then(() => film.render(seconds));
|
|
82
|
+
return renderQueue;
|
|
83
|
+
};
|
|
84
|
+
function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
|
|
85
|
+
async function seek(seconds: number) {
|
|
86
|
+
if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
|
|
87
|
+
time = Math.max(0, Math.min(seconds, config.duration)); pause();
|
|
88
|
+
if (audio) audio.currentTime = time;
|
|
89
|
+
await render(time); update();
|
|
90
|
+
}
|
|
91
|
+
async function play() {
|
|
92
|
+
if (exporting) return;
|
|
93
|
+
if (time >= config.duration) await seek(0);
|
|
94
|
+
const request = ++playbackRequest;
|
|
95
|
+
if (audio) {
|
|
96
|
+
audio.currentTime = time;
|
|
97
|
+
try { await audio.play(); } catch { status.textContent = copy.audioFailed; }
|
|
98
|
+
}
|
|
99
|
+
if (request !== playbackRequest) { audio?.pause(); return; }
|
|
100
|
+
last = performance.now(); playing = true; update();
|
|
101
|
+
}
|
|
102
|
+
function toggleSound() {
|
|
103
|
+
if (!audio) return;
|
|
104
|
+
muted = !muted; audio.muted = muted; update();
|
|
105
|
+
}
|
|
106
|
+
function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
|
|
107
|
+
async function fullscreen() {
|
|
108
|
+
try { if (document.fullscreenElement) await document.exitFullscreen(); else await theater.requestFullscreen(); }
|
|
109
|
+
catch { status.textContent = copy.fullscreenFailed; }
|
|
110
|
+
}
|
|
111
|
+
async function saveFrame() {
|
|
112
|
+
pause(); await render(time);
|
|
113
|
+
app.canvas.toBlob((blob) => {
|
|
114
|
+
if (!blob) return;
|
|
115
|
+
const url = URL.createObjectURL(blob);
|
|
116
|
+
downloadUrl(url, `${config.slug}-${Math.round(time * config.fps)}.png`);
|
|
117
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
118
|
+
}, "image/png");
|
|
119
|
+
}
|
|
120
|
+
const downloadButton = element<HTMLButtonElement>("download");
|
|
121
|
+
const downloadMenu = element<HTMLElement>("download-menu");
|
|
122
|
+
const downloadItems = [...downloadMenu.querySelectorAll<HTMLButtonElement>("button")];
|
|
123
|
+
function closeDownloads(restoreFocus = false) {
|
|
124
|
+
downloadMenu.hidden = true;
|
|
125
|
+
downloadButton.setAttribute("aria-expanded", "false");
|
|
126
|
+
if (restoreFocus) downloadButton.focus();
|
|
127
|
+
}
|
|
128
|
+
function openDownloads(last = false) {
|
|
129
|
+
downloadMenu.hidden = false;
|
|
130
|
+
downloadButton.setAttribute("aria-expanded", "true");
|
|
131
|
+
downloadItems[last ? downloadItems.length - 1 : 0].focus();
|
|
132
|
+
}
|
|
133
|
+
downloadButton.addEventListener("click", () => { if (downloadMenu.hidden) openDownloads(); else closeDownloads(true); });
|
|
134
|
+
downloadButton.addEventListener("keydown", (event) => {
|
|
135
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); openDownloads(event.key === "ArrowUp"); }
|
|
136
|
+
});
|
|
137
|
+
downloadMenu.addEventListener("keydown", (event) => {
|
|
138
|
+
if (event.key === "Escape") { event.preventDefault(); closeDownloads(true); }
|
|
139
|
+
if (event.key === "Tab") { event.preventDefault(); closeDownloads(true); }
|
|
140
|
+
const index = downloadItems.indexOf(document.activeElement as HTMLButtonElement);
|
|
141
|
+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? downloadItems.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + downloadItems.length) % downloadItems.length;
|
|
144
|
+
downloadItems[next].focus();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
document.addEventListener("click", (event) => { if (event.target instanceof Node && !element("download").parentElement!.contains(event.target)) closeDownloads(); });
|
|
148
|
+
element("export-html").addEventListener("click", async () => {
|
|
149
|
+
closeDownloads(true);
|
|
150
|
+
downloadButton.disabled = true;
|
|
151
|
+
downloadButton.setAttribute("aria-busy", "true");
|
|
152
|
+
status.textContent = copy.exportPreparing;
|
|
153
|
+
try { await downloadHtml(); status.textContent = copy.exportReady; }
|
|
154
|
+
catch (error) { status.textContent = copy.exportFailed; console.error(error); }
|
|
155
|
+
finally { downloadButton.disabled = false; downloadButton.removeAttribute("aria-busy"); }
|
|
156
|
+
});
|
|
157
|
+
for (const chapter of chapters) {
|
|
158
|
+
const paragraph = document.createElement("p"); paragraph.textContent = `${format(chapter.start)} — ${chapter.caption}`;
|
|
159
|
+
element("transcript").append(paragraph);
|
|
160
|
+
}
|
|
161
|
+
if (!embed && !capture) {
|
|
162
|
+
const { ResourceTimeline } = await import("./resource-timeline");
|
|
163
|
+
resources = new ResourceTimeline(element("resource-timeline"), {
|
|
164
|
+
duration: config.duration, fps: config.fps, tracks: film.resources, seek,
|
|
165
|
+
onError: (error) => { status.textContent = copy.failed; console.error(error); },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
playButton.addEventListener("click", () => { if (playing) pause(); else void play(); });
|
|
169
|
+
soundButton.addEventListener("click", toggleSound);
|
|
170
|
+
captionsButton.addEventListener("click", () => setCaptions(!film.captions));
|
|
171
|
+
element("fullscreen").addEventListener("click", () => { void fullscreen(); });
|
|
172
|
+
element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
|
|
173
|
+
timeline.addEventListener("input", () => { void seek(Number(timeline.value)); });
|
|
174
|
+
document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
|
|
175
|
+
document.addEventListener("keydown", (event) => {
|
|
176
|
+
if (exporting || event.target instanceof HTMLElement && event.target.closest("button,input,a,summary,textarea,select,[contenteditable]")) return;
|
|
177
|
+
if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
|
|
178
|
+
if (event.code === "ArrowRight") { event.preventDefault(); void seek(time + 5); }
|
|
179
|
+
if (event.code === "ArrowLeft") { event.preventDefault(); void seek(time - 5); }
|
|
180
|
+
if (event.key.toLowerCase() === "m") toggleSound();
|
|
181
|
+
if (event.key.toLowerCase() === "c") setCaptions(!film.captions);
|
|
182
|
+
if (event.key.toLowerCase() === "f") void fullscreen();
|
|
183
|
+
});
|
|
184
|
+
async function tick(now: number) {
|
|
185
|
+
try {
|
|
186
|
+
if (playing) {
|
|
187
|
+
time = Math.min(config.duration, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
|
|
188
|
+
await render(time);
|
|
189
|
+
if (time >= config.duration) { if (loop) { await seek(0); await play(); } else pause(); }
|
|
190
|
+
update();
|
|
191
|
+
}
|
|
192
|
+
last = now;
|
|
193
|
+
requestAnimationFrame((next) => { void tick(next); });
|
|
194
|
+
} catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
|
|
195
|
+
}
|
|
196
|
+
await render(time); update();
|
|
197
|
+
const api = {
|
|
198
|
+
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, canvas: app.canvas,
|
|
199
|
+
get state() { return { time, playing, muted, captions: film.captions }; },
|
|
200
|
+
resources: film.resources,
|
|
201
|
+
get texts() { return film.texts; },
|
|
202
|
+
get cues() { return film.cues; },
|
|
203
|
+
seek, play, pause, setCaptions, saveFrame,
|
|
204
|
+
async renderFrame(frame: number, fps = config.fps) { await seek(frame / fps); },
|
|
205
|
+
};
|
|
206
|
+
Object.assign(window, { animaguru: api });
|
|
207
|
+
window.dispatchEvent(new Event("animaguru:ready"));
|
|
208
|
+
if (!embed && params.has("review")) {
|
|
209
|
+
const { reviewFilm } = await import("./review");
|
|
210
|
+
await reviewFilm(api, params.get("review")!);
|
|
211
|
+
} else if (exporting) {
|
|
212
|
+
const { exportMp4 } = await import("./exporter");
|
|
213
|
+
await exportMp4(api, params.get("export")!);
|
|
214
|
+
} else {
|
|
215
|
+
if (autoplay) await play();
|
|
216
|
+
requestAnimationFrame((next) => { void tick(next); });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
void start().catch((error) => { status.textContent = copy.failed; element("loading")?.remove(); console.error(error); });
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import copy from "../shared/locales/en/timeline.json";
|
|
2
|
+
import { clipRange, createSeekQueue, frameTime, rulerTicks, timecode, type ResourceClip, type ResourceTrack } from "./timeline-model";
|
|
3
|
+
|
|
4
|
+
type PlaybackState = { time: number; playing: boolean; captions: boolean; muted: boolean };
|
|
5
|
+
type TimelineOptions = { duration: number; fps: number; tracks: ResourceTrack[]; seek: (seconds: number) => Promise<void>; onError: (error: unknown) => void };
|
|
6
|
+
const message = (value: string, values: Record<string, string | number>) => value.replace(/\{(\w+)\}/g, (_, name: string) => String(values[name] ?? ""));
|
|
7
|
+
const icon = (path: string) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${path}"/></svg>`;
|
|
8
|
+
const icons = {
|
|
9
|
+
graphics: "m12 3 9 9-9 9-9-9Z", image: "M3 3h18v18H3zM3 17l6-6 4 4 3-3 5 5M15 7h.01",
|
|
10
|
+
video: "M3 5h13v14H3zM16 9l5-3v12l-5-3", text: "M4 5h16M12 5v15M8 20h8",
|
|
11
|
+
captions: "M3 5h18v14H3zM10 9H7v6h3m7-6h-3v6h3", audio: "M4 9v6m4-10v14m4-16v18m4-15v12m4-9v6",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A navigable resource view. It changes the playhead, never the film's clip timing. */
|
|
15
|
+
export class ResourceTimeline {
|
|
16
|
+
private readonly viewport: HTMLDivElement;
|
|
17
|
+
private readonly content: HTMLDivElement;
|
|
18
|
+
private readonly ruler: HTMLDivElement;
|
|
19
|
+
private readonly ticks: HTMLDivElement;
|
|
20
|
+
private readonly playhead: HTMLDivElement;
|
|
21
|
+
private readonly handle: HTMLDivElement;
|
|
22
|
+
private readonly clock: HTMLElement;
|
|
23
|
+
private readonly selection: HTMLElement;
|
|
24
|
+
private readonly zoomLabel: HTMLElement;
|
|
25
|
+
private readonly zoomOut: HTMLButtonElement;
|
|
26
|
+
private readonly zoomIn: HTMLButtonElement;
|
|
27
|
+
private readonly rows: { kind: ResourceTrack["kind"]; element: HTMLElement; state: HTMLElement }[] = [];
|
|
28
|
+
private readonly clips: { clip: ResourceClip; button: HTMLButtonElement }[] = [];
|
|
29
|
+
private readonly observer: ResizeObserver;
|
|
30
|
+
private readonly queue: ReturnType<typeof createSeekQueue>;
|
|
31
|
+
private zoom = 1;
|
|
32
|
+
private width = 600;
|
|
33
|
+
private labelWidth = 160;
|
|
34
|
+
private time = 0;
|
|
35
|
+
private dragging = false;
|
|
36
|
+
private selected?: HTMLButtonElement;
|
|
37
|
+
private lastFrame = -1;
|
|
38
|
+
|
|
39
|
+
constructor(private readonly host: HTMLElement, private readonly options: TimelineOptions) {
|
|
40
|
+
host.classList.add("resource-timeline");
|
|
41
|
+
host.innerHTML = `<div class="timeline-toolbar"><div><h2 class="eyebrow"></h2><span class="timeline-count"></span></div><div class="timeline-zoom"><output class="timeline-clock"></output><button class="zoom-out">${icon("M5 12h14")}</button><span class="zoom-level"></span><button class="zoom-in">${icon("M5 12h14M12 5v14")}</button><button class="zoom-fit"></button></div></div><div class="timeline-viewport"><div class="timeline-content"><div class="timeline-ruler-row"><div class="timeline-corner"></div><div class="timeline-ruler" role="slider" tabindex="0" aria-valuemin="0"><div class="timeline-ticks"></div><div class="timeline-handle"></div></div></div><div class="timeline-rows"></div><div class="resource-playhead"></div></div></div><div class="timeline-inspector" role="status"></div>`;
|
|
42
|
+
const find = <T extends HTMLElement>(selector: string) => host.querySelector<T>(selector)!;
|
|
43
|
+
find("h2").textContent = copy.title;
|
|
44
|
+
host.setAttribute("aria-label", copy.title);
|
|
45
|
+
find(".timeline-count").textContent = message(copy.trackCount, { tracks: options.tracks.length, clips: options.tracks.reduce((count, track) => count + track.clips.length, 0) });
|
|
46
|
+
find(".timeline-corner").textContent = copy.resources;
|
|
47
|
+
this.viewport = find<HTMLDivElement>(".timeline-viewport");
|
|
48
|
+
this.content = find<HTMLDivElement>(".timeline-content");
|
|
49
|
+
this.ruler = find<HTMLDivElement>(".timeline-ruler");
|
|
50
|
+
this.ticks = find<HTMLDivElement>(".timeline-ticks");
|
|
51
|
+
this.playhead = find<HTMLDivElement>(".resource-playhead");
|
|
52
|
+
this.handle = find<HTMLDivElement>(".timeline-handle");
|
|
53
|
+
this.clock = find(".timeline-clock");
|
|
54
|
+
this.clock.setAttribute("aria-live", "off");
|
|
55
|
+
this.selection = find(".timeline-inspector");
|
|
56
|
+
this.selection.textContent = copy.hint;
|
|
57
|
+
this.zoomLabel = find(".zoom-level");
|
|
58
|
+
this.zoomOut = find<HTMLButtonElement>(".zoom-out");
|
|
59
|
+
this.zoomIn = find<HTMLButtonElement>(".zoom-in");
|
|
60
|
+
this.ruler.setAttribute("aria-label", copy.playhead);
|
|
61
|
+
this.ruler.setAttribute("aria-valuemax", String(options.duration));
|
|
62
|
+
for (const [button, label] of [[this.zoomOut, copy.zoomOut], [this.zoomIn, copy.zoomIn], [find<HTMLButtonElement>(".zoom-fit"), copy.fitTitle]] as const) {
|
|
63
|
+
button.setAttribute("aria-label", label); button.title = label;
|
|
64
|
+
}
|
|
65
|
+
find(".zoom-fit").textContent = copy.fit;
|
|
66
|
+
this.zoomOut.addEventListener("click", () => this.setZoom(this.zoom / 2));
|
|
67
|
+
this.zoomIn.addEventListener("click", () => this.setZoom(this.zoom * 2));
|
|
68
|
+
find(".zoom-fit").addEventListener("click", () => this.setZoom(1));
|
|
69
|
+
this.queue = createSeekQueue(options.seek, (error) => { this.selection.textContent = copy.seekFailed; options.onError(error); });
|
|
70
|
+
|
|
71
|
+
for (const track of options.tracks) {
|
|
72
|
+
const row = document.createElement("div"); row.className = `resource-row kind-${track.kind}`;
|
|
73
|
+
const label = document.createElement("div"); label.className = "resource-label";
|
|
74
|
+
const badge = document.createElement("span"); badge.className = "resource-icon"; badge.innerHTML = icons[track.kind] ? icon(icons[track.kind]) : "";
|
|
75
|
+
const name = document.createElement("span"); name.className = "resource-name"; name.textContent = track.label; name.title = track.label;
|
|
76
|
+
const state = document.createElement("small"); state.className = "resource-state";
|
|
77
|
+
label.append(badge, name, state);
|
|
78
|
+
const lane = document.createElement("div"); lane.className = "resource-lane";
|
|
79
|
+
lane.addEventListener("click", (event) => { if (event.target === lane) this.seekAt(event.clientX, lane); });
|
|
80
|
+
for (const clip of track.clips) {
|
|
81
|
+
const range = clipRange(clip, options.duration);
|
|
82
|
+
if (!range) continue;
|
|
83
|
+
const button = document.createElement("button"); button.className = "resource-clip";
|
|
84
|
+
button.style.left = `${range.left}%`; button.style.width = `${range.width}%`;
|
|
85
|
+
const text = document.createElement("span"); text.textContent = clip.label.replace(/\s+/g, " "); button.append(text);
|
|
86
|
+
button.title = `${clip.label}\n${timecode(range.start, options.fps)} — ${timecode(range.end, options.fps)}${clip.source ? `\n${clip.source}` : ""}`;
|
|
87
|
+
button.setAttribute("aria-label", message(copy.seekTo, { label: clip.label, start: timecode(range.start, options.fps), end: timecode(range.end, options.fps) }));
|
|
88
|
+
button.addEventListener("click", (event) => {
|
|
89
|
+
this.selected?.classList.remove("selected");
|
|
90
|
+
this.selected?.setAttribute("aria-pressed", "false");
|
|
91
|
+
this.selected = button; button.classList.add("selected"); button.setAttribute("aria-pressed", "true");
|
|
92
|
+
const name = document.createElement("strong"); name.textContent = clip.label.replace(/\s+/g, " ");
|
|
93
|
+
const info = document.createElement("span"); info.textContent = message(copy.selection, { kind: copy[track.kind], start: timecode(range.start, options.fps), end: timecode(range.end, options.fps), duration: Number((range.end - range.start).toFixed(2)) });
|
|
94
|
+
this.selection.replaceChildren(name, info);
|
|
95
|
+
if (clip.source) { const source = document.createElement("span"); source.textContent = message(copy.source, { source: clip.source }); this.selection.append(source); }
|
|
96
|
+
if (event.detail === 0) this.requestSeek(range.start); else this.seekAt(event.clientX, lane);
|
|
97
|
+
});
|
|
98
|
+
lane.append(button); this.clips.push({ clip, button });
|
|
99
|
+
}
|
|
100
|
+
row.append(label, lane); find(".timeline-rows").append(row);
|
|
101
|
+
this.rows.push({ kind: track.kind, element: row, state });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.ruler.addEventListener("pointerdown", (event) => {
|
|
105
|
+
if (event.button !== 0) return;
|
|
106
|
+
event.preventDefault(); this.ruler.focus(); this.dragging = true;
|
|
107
|
+
this.ruler.setPointerCapture(event.pointerId); this.seekAt(event.clientX, this.ruler);
|
|
108
|
+
});
|
|
109
|
+
this.ruler.addEventListener("pointermove", (event) => { if (this.dragging) this.seekAt(event.clientX, this.ruler); });
|
|
110
|
+
const finishDrag = () => { this.dragging = false; };
|
|
111
|
+
this.ruler.addEventListener("pointerup", finishDrag);
|
|
112
|
+
this.ruler.addEventListener("pointercancel", finishDrag);
|
|
113
|
+
this.ruler.addEventListener("lostpointercapture", finishDrag);
|
|
114
|
+
this.ruler.addEventListener("keydown", (event) => {
|
|
115
|
+
const step = event.shiftKey ? 1 : 1 / options.fps;
|
|
116
|
+
const target = event.key === "Home" ? 0 : event.key === "End" ? options.duration : event.key === "ArrowRight" ? this.time + step : event.key === "ArrowLeft" ? this.time - step : undefined;
|
|
117
|
+
if (target !== undefined) { event.preventDefault(); event.stopPropagation(); this.requestSeek(target); }
|
|
118
|
+
});
|
|
119
|
+
this.observer = new ResizeObserver(() => this.measure());
|
|
120
|
+
this.observer.observe(this.viewport);
|
|
121
|
+
this.measure();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private seekAt(clientX: number, lane: HTMLElement) {
|
|
125
|
+
this.requestSeek((clientX - lane.getBoundingClientRect().left) / this.width * this.options.duration);
|
|
126
|
+
}
|
|
127
|
+
private requestSeek(seconds: number) {
|
|
128
|
+
const time = frameTime(seconds, this.options.duration, this.options.fps);
|
|
129
|
+
this.time = time; this.positionPlayhead();
|
|
130
|
+
if (!this.dragging) this.followPlayhead();
|
|
131
|
+
this.queue.request(time);
|
|
132
|
+
}
|
|
133
|
+
private setZoom(zoom: number) {
|
|
134
|
+
this.zoom = Math.max(1, Math.min(8, zoom)); this.measure();
|
|
135
|
+
this.viewport.scrollLeft = Math.max(0, this.time / this.options.duration * this.width - (this.viewport.clientWidth - this.labelWidth) / 2);
|
|
136
|
+
}
|
|
137
|
+
private measure() {
|
|
138
|
+
this.labelWidth = this.viewport.clientWidth < 540 ? 126 : 160;
|
|
139
|
+
this.width = Math.max(260, this.viewport.clientWidth - this.labelWidth) * this.zoom;
|
|
140
|
+
this.host.style.setProperty("--resource-label-width", `${this.labelWidth}px`);
|
|
141
|
+
this.content.style.width = `${this.labelWidth + this.width}px`;
|
|
142
|
+
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
|
143
|
+
this.zoomOut.disabled = this.zoom <= 1; this.zoomIn.disabled = this.zoom >= 8;
|
|
144
|
+
this.ticks.replaceChildren();
|
|
145
|
+
const ticks = rulerTicks(this.options.duration, this.width, this.options.fps);
|
|
146
|
+
for (const tick of ticks) {
|
|
147
|
+
const marker = document.createElement("span"); marker.className = "timeline-tick";
|
|
148
|
+
if (tick === 0) marker.classList.add("first");
|
|
149
|
+
if (tick === this.options.duration) marker.classList.add("last");
|
|
150
|
+
marker.style.left = `${tick / this.options.duration * 100}%`;
|
|
151
|
+
marker.textContent = timecode(tick, this.options.fps).slice(0, tick % 1 === 0 ? 5 : 8);
|
|
152
|
+
this.ticks.append(marker);
|
|
153
|
+
}
|
|
154
|
+
const step = ticks.length > 2 ? ticks[1] : this.options.duration;
|
|
155
|
+
this.host.style.setProperty("--resource-grid-step", `${step / this.options.duration * this.width}px`);
|
|
156
|
+
this.positionPlayhead();
|
|
157
|
+
}
|
|
158
|
+
private positionPlayhead() {
|
|
159
|
+
const left = this.time / this.options.duration * this.width;
|
|
160
|
+
this.playhead.style.left = `${this.labelWidth + left}px`;
|
|
161
|
+
this.handle.style.left = `${left}px`;
|
|
162
|
+
this.clock.textContent = timecode(this.time, this.options.fps);
|
|
163
|
+
this.ruler.setAttribute("aria-valuenow", String(this.time));
|
|
164
|
+
this.ruler.setAttribute("aria-valuetext", this.clock.textContent);
|
|
165
|
+
}
|
|
166
|
+
private followPlayhead() {
|
|
167
|
+
const position = this.labelWidth + this.time / this.options.duration * this.width;
|
|
168
|
+
if (position > this.viewport.scrollLeft + this.viewport.clientWidth - 18 || position < this.viewport.scrollLeft + this.labelWidth) {
|
|
169
|
+
this.viewport.scrollLeft = Math.max(0, position - this.labelWidth - (this.viewport.clientWidth - this.labelWidth) * 0.2);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
update(state: PlaybackState) {
|
|
174
|
+
const previous = this.time;
|
|
175
|
+
if (!this.dragging) this.time = frameTime(state.time, this.options.duration, this.options.fps);
|
|
176
|
+
this.positionPlayhead();
|
|
177
|
+
const frame = Math.round(this.time * this.options.fps);
|
|
178
|
+
if (frame !== this.lastFrame) {
|
|
179
|
+
const at = Math.min(this.time, this.options.duration - 1 / this.options.fps);
|
|
180
|
+
for (const { clip, button } of this.clips) button.classList.toggle("active", at >= clip.start && at < clip.end);
|
|
181
|
+
this.lastFrame = frame;
|
|
182
|
+
}
|
|
183
|
+
for (const row of this.rows) {
|
|
184
|
+
const off = row.kind === "captions" && !state.captions || row.kind === "audio" && state.muted;
|
|
185
|
+
row.element.classList.toggle("resource-off", off);
|
|
186
|
+
row.state.textContent = off ? row.kind === "audio" ? copy.muted : copy.hidden : "";
|
|
187
|
+
}
|
|
188
|
+
if (!this.dragging && this.time !== previous && (state.playing || Math.abs(this.time - previous) > 0.1)) {
|
|
189
|
+
this.followPlayhead();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
destroy() { this.observer.disconnect(); this.queue.cancel(); this.host.replaceChildren(); }
|
|
193
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type TextBounds = {
|
|
2
|
+
id: string;
|
|
3
|
+
text: string;
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
type ReviewApi = {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
duration: number;
|
|
13
|
+
fps: number;
|
|
14
|
+
canvas: HTMLCanvasElement;
|
|
15
|
+
seek: (t: number) => Promise<void>;
|
|
16
|
+
texts: TextBounds[];
|
|
17
|
+
cues: unknown[];
|
|
18
|
+
};
|
|
19
|
+
export async function reviewFilm(api: ReviewApi, token: string) {
|
|
20
|
+
const samples: {
|
|
21
|
+
time: number;
|
|
22
|
+
texts: TextBounds[];
|
|
23
|
+
}[] = [];
|
|
24
|
+
const frames: {
|
|
25
|
+
time: number;
|
|
26
|
+
image: string;
|
|
27
|
+
}[] = [];
|
|
28
|
+
const reference = new Map<number, string>();
|
|
29
|
+
const points = [0, api.duration * .25, api.duration * .5, api.duration * .75, Math.max(0, api.duration - 1 / api.fps)];
|
|
30
|
+
for (let t = 0; t < api.duration; t += .2) {
|
|
31
|
+
await api.seek(t);
|
|
32
|
+
samples.push({ time: t, texts: api.texts.map((b) => ({ ...b })) });
|
|
33
|
+
}
|
|
34
|
+
for (const time of points) {
|
|
35
|
+
await api.seek(time);
|
|
36
|
+
const image = api.canvas.toDataURL("image/jpeg", .92);
|
|
37
|
+
reference.set(time, image);
|
|
38
|
+
frames.push({ time, image });
|
|
39
|
+
}
|
|
40
|
+
const seekMatches = [];
|
|
41
|
+
for (const time of [...points].reverse()) {
|
|
42
|
+
await api.seek(time);
|
|
43
|
+
seekMatches.push({ time, match: reference.get(time) === api.canvas.toDataURL("image/jpeg", .92) });
|
|
44
|
+
}
|
|
45
|
+
const response = await fetch("/__review/report", { method: "POST", headers: { "Content-Type": "application/json", "x-animaguru-review": token }, body: JSON.stringify({ samples, frames, seekMatches, cues: api.cues }) });
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error(await response.text());
|
|
48
|
+
console.log("AnimaGuru review", await response.json());
|
|
49
|
+
await api.seek(0);
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Application } from "pixi.js";
|
|
2
|
+
import copy from "../../shared/locales/en/style-studies.json";
|
|
3
|
+
import type { ResourceTrack } from "../timeline-model";
|
|
4
|
+
import { StudyRenderer } from "./pixi-renderer";
|
|
5
|
+
import { studyFrame, type StudyId } from "./scenes";
|
|
6
|
+
import { STUDY_DURATION } from "./model";
|
|
7
|
+
export function studyChapters(id: StudyId) {
|
|
8
|
+
return [{ start: 0, end: STUDY_DURATION, label: copy.styles[id].name, caption: copy.styles[id].lines[0] }];
|
|
9
|
+
}
|
|
10
|
+
export class StyleStudyFilm {
|
|
11
|
+
private readonly renderer: StudyRenderer;
|
|
12
|
+
captions = true;
|
|
13
|
+
readonly resources: ResourceTrack[];
|
|
14
|
+
constructor(private readonly app: Application, private readonly id: StudyId) {
|
|
15
|
+
this.renderer = new StudyRenderer(app.stage);
|
|
16
|
+
this.renderer.stage.scale.set(app.screen.width / 960, app.screen.height / 540);
|
|
17
|
+
this.resources = [
|
|
18
|
+
{ id: "material", label: copy.resourceMaterial, kind: "graphics", clips: [{ id: "surface", label: copy.styles[id].name, start: 0, end: STUDY_DURATION }] },
|
|
19
|
+
{ id: "artwork", label: copy.resourceArt, kind: "graphics", clips: [{ id: "scene", label: copy.styles[id].name, start: 0, end: STUDY_DURATION }] },
|
|
20
|
+
{ id: "typography", label: copy.resourceText, kind: "text", clips: copy.styles[id].lines.map((label, i) => ({ id: `text-${i}`, label, start: 0, end: STUDY_DURATION })) },
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
get texts() { return this.renderer.texts; }
|
|
24
|
+
get cues() { return studyFrame(this.id, 0).cues; }
|
|
25
|
+
async render(seconds: number) { this.renderer.render(studyFrame(this.id, seconds)); this.app.render(); }
|
|
26
|
+
}
|