Files
matrix-screen-controller/核桃派软件源代码/app/static/scene-model.js
T

126 lines
11 KiB
JavaScript

import * as legacy from "./scene-model-v1.js";
import { encodePixels, decodePixels } from "./pixel-png.js";
export const SCENE_VERSION=2, EDIT_SIZE=192, EDIT_MIN=-64, EDIT_MAX=128;
export const {SCENE_WIDTH,SCENE_HEIGHT,RGB_BYTE_LENGTH,RGBA_BYTE_LENGTH,RGB_CHANNELS,RGBA_CHANNELS,SCENE_STORAGE_KEY,LEGACY_STORAGE_KEYS,DEFAULT_TEXT_ELEMENT,createRgbPixels,createTextElement,nextTextElementId,compositeRgbaLayer,compositeRgbaLayers,migrateLegacyScene}=legacy;
const fields=(v,keys)=>v&&Object.keys(v).sort().join()===[...keys].sort().join();
const uid=()=>globalThis.crypto?.randomUUID?.()||`layer-${Date.now()}-${Math.random().toString(36).slice(2)}`;
export function normalizeTextElement(v={},options={}) {const e=legacy.normalizeTextElement(v,options);
return e?{...e,clip:v.clip?[...v.clip]:null}:null;}
export function attachScene(layers) {
const scene={version:2,width:64,height:64,layers};
Object.defineProperty(scene,"elements",{get:()=>scene.layers.flatMap(l=>l.elements||[])});
Object.defineProperty(scene,"pixels",{get:()=>{const rgba=scene.layers.find(l=>l.type==="brush").pixels,rgb=new Uint8ClampedArray(RGB_BYTE_LENGTH);
for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=((y+64)*192+x+64)*4;rgb.set(rgba.subarray(i,i+3),(y*64+x)*3);}
return rgb;}});
return scene;
}
export function nextLayerName(layers){let n=1;
const names=new Set(layers.map(l=>l.name));while(names.has(`图层${n}`))n++;
return `图层${n}`;}
export function createLayer(type,layers=[]) {return {id:uid(),name:nextLayerName(layers),type,x:0,y:0,...(type==="brush"?{background:null,backgroundColor:"#000000",pixels:new Uint8ClampedArray(192*192*4),revision:0}:{elements:[]})};}
export function createScene({pixels=null,elements=[],layers=null}={}) {
if(layers)return attachScene(layers);
const brush=createLayer("brush");brush.id="layer-1";
if(pixels)for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=(y*64+x)*3,j=((y+64)*192+x+64)*4;brush.pixels.set([pixels[i],pixels[i+1],pixels[i+2],255],j);}
const result=[brush];
if(elements.length)result.push({id:"layer-2",name:"图层2",type:"text",x:0,y:0,elements:elements.map(e=>normalizeTextElement(e,{strict:true})).filter(Boolean)});
return attachScene(result);
}
export function blankScene(){return attachScene([createLayer("brush")]);}
export function layerToStorage(layer){const common={id:layer.id,name:layer.name,type:layer.type,x:layer.x,y:layer.y};
if(layer.type==="brush"){if(layer._encodedRevision!==layer.revision||!layer._png){layer._png=encodePixels(layer.pixels);layer._encodedRevision=layer.revision;}
return {...common,background:layer.background,backgroundColor:layer.backgroundColor||layer.background||"#000000",pixelPng:layer._png};}
return {...common,elements:layer.elements.map(({revision,...e})=>({...e,clip:e.clip||null}))};
}
export function sceneToStorageValue(scene){return {version:2,width:64,height:64,layers:scene.layers.map(layerToStorage)};}
export function serializeScene(scene){return JSON.stringify(sceneToStorageValue(scene));}
export function deserializeScene(value){try{
const raw=typeof value==="string"?JSON.parse(value):value;
if(raw?.version===1){
if(!fields(raw,["version","width","height","pixelRgb","elements"])||!Array.isArray(raw.elements))return null;
const old=legacy.deserializeScene(raw);
if(!old||old.elements.length!==raw.elements.length||raw.elements.some(e=>!fields(e,["id","type","text","font","size","x","y","align","color"])))return null;
const upgraded=sceneToStorageValue(createScene(old));
if(raw.elements.length)upgraded.layers[1].elements=raw.elements.map(element=>({...element,clip:null}));
return deserializeScene(upgraded);
}
if(!fields(raw,["version","width","height","layers"])||raw.version!==2||raw.width!==64||raw.height!==64||!Array.isArray(raw.layers))return null;
const ids=new Set(),elements=new Set();
const layers=raw.layers.map(l=>{
if(!fields(l,["id","name","type","x","y",...(l.type==="brush"?["background","pixelPng",...(Object.hasOwn(l,"backgroundColor")?["backgroundColor"]:[])]:["elements"])])||!["brush","text"].includes(l.type)||typeof l.id!=="string"||!l.id.trim()||l.id.length>128||ids.has(l.id)||typeof l.name!=="string"||!l.name.trim()||l.name.length>80||![l.x,l.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=64))throw Error("invalid layer");ids.add(l.id);
if(l.type==="brush"){if(l.background!==null&&!/^#[0-9a-f]{6}$/i.test(l.background))throw Error("invalid background");
if(Object.hasOwn(l,"backgroundColor")&&!/^#[0-9a-f]{6}$/i.test(l.backgroundColor))throw Error("invalid background color");
const {pixelPng,...rest}=l;
return {...rest,backgroundColor:l.backgroundColor||l.background||"#000000",pixels:decodePixels(pixelPng),revision:0,_png:pixelPng,_encodedRevision:0};}
if(!Array.isArray(l.elements))throw Error("invalid text layer");
return {...l,elements:l.elements.map(e=>{
if(!fields(e,["id","type","text","font","size","x","y","align","color","clip"])||elements.has(e.id)||typeof e.id!=="string"||!e.id.trim()||e.id.length>128||e.type!=="text"||typeof e.text!=="string"||e.text.length>512||typeof e.font!=="string"||!e.font.trim()||e.font.length>512||!Number.isInteger(e.size)||e.size<1||e.size>64||![e.x,e.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=128)||!["left","center","right"].includes(e.align)||!/^#[0-9a-f]{6}$/i.test(e.color))throw Error("invalid text");
if(e.clip!==null&&(!Array.isArray(e.clip)||e.clip.length!==4||!e.clip.every(n=>Number.isFinite(n)&&Math.abs(n)<=4096)||e.clip[2]<e.clip[0]||e.clip[3]<e.clip[1]))throw Error("invalid clip");elements.add(e.id);
return normalizeTextElement(e,{strict:true});
})};});
return layers.some(l=>l.type==="brush")?attachScene(layers):null;
}
catch{return null;}}
export function inspectSceneStorageValue(value){if(value==null)return {status:"missing",scene:null};try{const raw=typeof value==="string"?JSON.parse(value):value;
if(Number.isInteger(raw?.version)&&raw.version>2)return {status:"future",scene:null};
const scene=deserializeScene(raw);
return {status:scene?"valid":"damaged",scene};}
catch{return {status:"damaged",scene:null};}}
export function restoreSceneDraft(storage){let raw;try{raw=storage?.getItem(SCENE_STORAGE_KEY);}
catch{return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"浏览器草稿存储不可用;原草稿已保留。"};}
if(raw){const result=inspectSceneStorageValue(raw);
if(result.status!=="valid")return {...result,status:result.status==="future"?"future-version":"damaged-current",scene:blankScene(),canPersist:false,notice:"草稿损坏或来自未来版本;原值已保留。"};
if(JSON.parse(raw).version===1){try{storage.setItem(SCENE_STORAGE_KEY,serializeScene(result.scene));}
catch{return {...result,canPersist:false,status:"write-failed",notice:"无法保存升级草稿;原值已保留。"};}}
return {...result,canPersist:true,status:"current"};}
try {
const oldValue=storage?.getItem(LEGACY_STORAGE_KEYS.sceneV1);
if(oldValue!=null&&!deserializeScene(oldValue))return {scene:blankScene(),canPersist:false,status:"damaged-legacy",notice:"旧场景草稿无法安全迁移;全部原值已保留。"};
} catch {return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"旧草稿读取失败;全部原值已保留。"};}
const proxy=storage?{getItem:k=>storage.getItem(k),removeItem:k=>storage.removeItem(k),setItem:(k,v)=>{
const oldScene=legacy.deserializeScene(v);storage.setItem(k,oldScene?serializeScene(createScene(oldScene)):v);
}}:null;
const old=legacy.restoreSceneDraft(proxy);
const scene=old.status==="created"?blankScene():createScene(old.scene);
if(old.status==="created"&&old.canPersist){
try {storage?.setItem(SCENE_STORAGE_KEY,serializeScene(scene));}
catch {return {...old,scene,canPersist:false,status:"write-failed",notice:"无法保存新草稿。"};}
}
return {...old,scene};
}
export function cloneScene(scene){return deserializeScene(serializeScene(scene));}
export function addTextElement(scene,overrides={},layerId=null){const target=scene.layers.find(l=>l.type==="text"&&(layerId?l.id===layerId:true))||createLayer("text",scene.layers),element=normalizeTextElement({...legacy.createTextElement(scene.elements,overrides),clip:overrides.clip||null});
return attachScene(scene.layers.some(l=>l.id===target.id)?scene.layers.map(l=>l.id===target.id?{...l,elements:[...l.elements,element]}:l):[...scene.layers,{...target,elements:[element]}]);}
export function updateTextElement(scene,id,patch={}){if(!scene.elements.some(e=>e.id===id))return scene;
return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.map(e=>e.id===id?normalizeTextElement({...e,...patch,id:e.id,revision:e.revision+1}):e)}));}
export function deleteTextElement(scene,id){if(!scene.elements.some(e=>e.id===id))return scene;
return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.filter(e=>e.id!==id)}));}
export function duplicateTextElement(scene,id){const l=scene.layers.find(l=>l.elements?.some(e=>e.id===id)),e=l?.elements.find(e=>e.id===id);
return e?addTextElement(scene,{...e,x:e.x+2,y:e.y+2},l.id):scene;}
export function cloneLayer(layer,layers){const copy=layer.type==="brush"?{...layer,pixels:layer.pixels.slice()}:{...layer,elements:layer.elements.map(e=>({...e,id:uid(),clip:e.clip?[...e.clip]:null}))};
return {...copy,id:uid(),name:nextLayerName(layers)};}
export function clipTextPixels(rgba,element){if(!element.clip)return rgba;
const out=rgba.slice(),[a,b,c,d]=element.clip;
for(let y=0;y<192;y++)for(let x=0;x<192;x++){const u=(x-64-element.x)/element.size,v=(y-64-element.y)/element.size;
if(u<a||u>=c||v<b||v>=d)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);}
return out;}
export function translatedOverflow(rgba,dx,dy){let outside=false,inside=false;
for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(rgba[(y*192+x)*4+3]){if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)outside=true;else inside=true;}
return {outside,inside};}
export function clipBrushPixels(rgba,dx,dy){const out=rgba.slice();
for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);
return out;}
export function textClipAt(e,dx,dy){const n=[(-64-dx-e.x)/e.size,(-64-dy-e.y)/e.size,(128-dx-e.x)/e.size,(128-dy-e.y)/e.size];
return e.clip?[Math.max(n[0],e.clip[0]),Math.max(n[1],e.clip[1]),Math.min(n[2],e.clip[2]),Math.min(n[3],e.clip[3])]:n;}
export function composeScene(scene,textPixels=new Map(),size=64,preview=null){const origin=size===192?-64:0,out=new Uint8ClampedArray(size*size*3);
function paint(rgba,dx,dy){for(let y=0;y<size;y++)for(let x=0;x<size;x++){const sx=x+origin+64-dx,sy=y+origin+64-dy;
if(sx<0||sx>=192||sy<0||sy>=192)continue;
const i=(sy*192+sx)*4,j=(y*size+x)*3,a=rgba[i+3]/255;
for(let c=0;c<3;c++)out[j+c]=Math.floor(rgba[i+c]*a+out[j+c]*(1-a)+0.5);}}
for(const l of scene.layers){const dx=preview?.layerId===l.id?preview.x:l.x,dy=preview?.layerId===l.id?preview.y:l.y;
if(l.type==="brush"){if(l.background){const color=l.background.slice(1).match(/../g).map(v=>parseInt(v,16));
for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(x+origin>=-64+dx&&x+origin<128+dx&&y+origin>=-64+dy&&y+origin<128+dy)out.set(color,(y*size+x)*3);}paint(l.pixels,dx,dy);}
else for(const e of l.elements){const p=textPixels.get(e.id);
if(p)paint(p,dx,dy);}}
return out;}