Selam! Cihazının teknik gücünü bilmek ve uygulamadan yüksek optimizasyon almak için seni küçük bir taramadan geçireceğiz. Merak etme, bilgilerin güvende ve saklı. Diğer edit programları gibi senin telefonunun gücüne göre testten sonra kısıtlamayacağız :) Bu işlem bir dakikadan biraz daha kısa sürer.
0%
HyperFlow
Kareler üretiliyor
Hazırlanıyor…
0%
süre hesaplanıyor…
—
Motor durursa işlem kesilmez; daha az kaynak kullanan yedeğe
geçilir ve kare sayısı korunur.
Tamamlanamadı
Kalite Artırma
Yeniden kuruluyor
Hazırlanıyor…
0%
süre hesaplanıyor…
Video cihazından çıkmıyor. Ekranı açık tutman işlemi hızlandırır.
Tamamlanamadı
Reference Edit
Referans çözümleniyor
Hazırlanıyor…
0%
Video cihazından çıkmıyor; çözümleme tamamen burada yapılıyor.
Kliplerini yerleştir
Bir kutuya dokun, birden fazla seçersen sıradaki kutulara da dağıtılır.
0 / 0
Çözümlenemedi
Easter Egg
Easter Egg
You Are Very Smart👏
');
editorUrl=URL.createObjectURL(new Blob([html],{type:'text/html'}));
return editorUrl;
}
function openProject(id){
const p=projects.find(x=>x.id===id); if(!p) return;
editorOpenId=id; editorReady=false;
touch(p,{now:true});
const sess=readJSON(SESSION,{}); sess.openProjectId=id; writeJSON(SESSION,sess);
el('editorLoadName').textContent=p.name;
el('editorLoad').classList.remove('gone');
const host=el('editorHost');
host.classList.add('mounted');
// One frame between mounting and animating in, so the transition has a start state
// to run from rather than appearing instantly.
requestAnimationFrame(()=>host.classList.add('on'));
const fr=el('editorFrame');
// Rebuilt per open so a project never inherits the previous one's editor state.
fr.src='about:blank';
setTimeout(()=>{ fr.src=makeEditorUrl(); }, 30);
}
function closeEditor(){
const fr=el('editorFrame');
try{ fr.contentWindow.postMessage({type:'hm:requestState'},'*'); }catch(_){}
// Give the frame a moment to answer before it is torn down, so the card the person
// lands back on already shows the work they just did.
setTimeout(()=>{
const host=el('editorHost');
host.classList.remove('on');
setTimeout(()=>{
fr.src='about:blank';
host.classList.remove('mounted'); // back out of layout
editorOpenId=null; editorReady=false;
sizeSheet();
}, 340);
renderAll();
}, 90);
}
function hmMp4ReadBoxes(dv, start, end){
const boxes = [];
let p = start;
while(p+8 <= end){
const size32 = dv.getUint32(p);
const type = String.fromCharCode(dv.getUint8(p+4),dv.getUint8(p+5),dv.getUint8(p+6),dv.getUint8(p+7));
let headerSize = 8, size = size32;
if(size32===1){ const hi=dv.getUint32(p+8), lo=dv.getUint32(p+12); size = hi*4294967296+lo; headerSize=16; }
else if(size32===0){ size = end-p; }
boxes.push({type, start:p, headerSize, bodyStart:p+headerSize, end:p+size});
if(size<=0) break;
p += size;
}
return boxes;
}
function hmMp4FindBox(boxes, type){ return boxes.find(b=>b.type===type); }
function hmMp4ParseVideoTrack(buf){
const dv = new DataView(buf);
const top = hmMp4ReadBoxes(dv, 0, buf.byteLength);
const moov = hmMp4FindBox(top, 'moov');
if(!moov) throw new Error('kaynak video moov kutusu bulunamadı (fragmented mp4 desteklenmiyor)');
const moovChildren = hmMp4ReadBoxes(dv, moov.bodyStart, moov.end);
for(const trak of moovChildren.filter(b=>b.type==='trak')){
const trakChildren = hmMp4ReadBoxes(dv, trak.bodyStart, trak.end);
const mdia = hmMp4FindBox(trakChildren, 'mdia');
if(!mdia) continue;
const mdiaChildren = hmMp4ReadBoxes(dv, mdia.bodyStart, mdia.end);
const hdlr = hmMp4FindBox(mdiaChildren, 'hdlr');
let handlerType = '';
if(hdlr) handlerType = String.fromCharCode(dv.getUint8(hdlr.bodyStart+8),dv.getUint8(hdlr.bodyStart+9),dv.getUint8(hdlr.bodyStart+10),dv.getUint8(hdlr.bodyStart+11));
if(handlerType !== 'vide') continue;
const mdhd = hmMp4FindBox(mdiaChildren, 'mdhd');
let timescale = 1000, mediaDurationUnits = 0;
if(mdhd){
const version = dv.getUint8(mdhd.bodyStart);
let o = mdhd.bodyStart+4;
if(version===1){ o+=8+8; timescale=dv.getUint32(o); o+=4; mediaDurationUnits=Number(dv.getBigUint64(o)); o+=8; }
else { o+=4+4; timescale=dv.getUint32(o); o+=4; mediaDurationUnits=dv.getUint32(o); o+=4; }
}
const minf = hmMp4FindBox(mdiaChildren, 'minf'); if(!minf) continue;
const minfChildren = hmMp4ReadBoxes(dv, minf.bodyStart, minf.end);
const stbl = hmMp4FindBox(minfChildren, 'stbl'); if(!stbl) continue;
const stblChildren = hmMp4ReadBoxes(dv, stbl.bodyStart, stbl.end);
const stsd = hmMp4FindBox(stblChildren, 'stsd');
let codecFourcc='', avcC=null, hvcC=null, vpcC=null, sampleEntryW=0, sampleEntryH=0;
if(stsd){
let o = stsd.bodyStart+4+4; // version+flags, entry_count (only the first entry is used)
const entryBoxes = hmMp4ReadBoxes(dv, o, stsd.end);
if(entryBoxes.length){
const entry = entryBoxes[0];
codecFourcc = entry.type;
let eo = entry.bodyStart;
eo += 6+2; eo += 2+2+12;
sampleEntryW = dv.getUint16(eo); eo+=2;
sampleEntryH = dv.getUint16(eo); eo+=2;
eo += 4+4+4+2+32+2+2;
const children = hmMp4ReadBoxes(dv, eo, entry.end);
const avcCBox = hmMp4FindBox(children, 'avcC');
if(avcCBox) avcC = new Uint8Array(buf.slice(avcCBox.bodyStart, avcCBox.end));
const hvcCBox = hmMp4FindBox(children, 'hvcC'); if(hvcCBox) hvcC = new Uint8Array(buf.slice(hvcCBox.bodyStart, hvcCBox.end));
const vpcCBox = hmMp4FindBox(children, 'vpcC'); if(vpcCBox) vpcC = new Uint8Array(buf.slice(vpcCBox.bodyStart, vpcCBox.end));
}
}
if(!avcC && !hvcC && !vpcC) continue; // not an H.264 track - skip (there's only ever one video track in these clips anyway)
const stts = hmMp4FindBox(stblChildren, 'stts');
const durations = [];
if(stts){ let o=stts.bodyStart+4; const n=dv.getUint32(o); o+=4;
for(let i=0;i Main -> Baseline profile ladder (each still at Level 5.2, which covers 4K120)
// falling back to a conservative Baseline/Level 3.1 config last - hardware tried before
// software at every rung, so a capable phone's own codec chip gets used whenever it can.
ladder = ['avc1.640034','avc1.4d0034','avc1.42e034','avc1.42e01f'];
muxerCodec = 'avc';
}
for(const codec of ladder){
for(const hardwareAcceleration of ['prefer-hardware','no-preference']){
const cfg = {codec, width, height, bitrate, framerate, hardwareAcceleration, latencyMode:'quality'};
try{ const r = await VideoEncoder.isConfigSupported(cfg); if(r.supported) return {config: Object.assign({}, cfg, r.config||{}), muxerCodec}; }
catch(e){}
}
}
return null;
}
async function hmNegotiateVideoDecoderConfig(codec, width, height, description){
for(const hardwareAcceleration of ['prefer-hardware','no-preference']){
const cfg = {codec, codedWidth:width, codedHeight:height, description, hardwareAcceleration};
try{ const r = await VideoDecoder.isConfigSupported(cfg); if(r.supported) return Object.assign({}, cfg, r.config||{}); }
catch(e){}
}
return null;
}
async function hmNativeReverseVideo(srcBuf, sourceOffset, dur, audioToo, onProgress){
// Runs in the HUB's own realm (not the editor iframe): the editor's document is loaded via
// a plain blob: URL with no base, which makes it an opaque, insecure origin - and WebCodecs'
// actual codec classes (VideoDecoder/VideoEncoder/AudioEncoder) refuse to exist at all in an
// insecure context, even though the plain data classes (VideoFrame, AudioData, ...) do. The
// hub itself IS a secure context, so the editor hands the source video's bytes over via
// postMessage (see the hm:nativeReverseRequest/hm:nativeReverseResult bridge below and its
// pair in the editor's own script) and the hub does the actual decode/encode work here,
// handing the finished .mp4's bytes back the same way.
if(typeof VideoDecoder==='undefined' || typeof VideoEncoder==='undefined'){
throw new Error('bu cihaz/tarayıcı WebCodecs desteklemiyor');
}
if(!window.Mp4Muxer){
throw new Error('mp4-muxer kütüphanesi yüklenmemiş');
}
const track = hmMp4ParseVideoTrack(srcBuf);
const codecStr = 'avc1.' + [track.avcC[1],track.avcC[2],track.avcC[3]].map(b=>b.toString(16).padStart(2,'0')).join('');
const decCfg = await hmNegotiateVideoDecoderConfig(codecStr, track.width, track.height, track.avcC);
if(!decCfg) throw new Error('bu cihaz kaynak videonun kodekini çözemiyor');
const startSec = sourceOffset||0, endSec = startSec + (dur||9999);
const allGops = [];
let cur = null;
for(const s of track.samples){ if(s.isKey){ if(cur) allGops.push(cur); cur=[]; } if(!cur) cur=[]; cur.push(s); }
if(cur) allGops.push(cur);
const usedGops = allGops.filter(g=>{
const gStart = g[0].cts/track.timescale, gEnd=(g[g.length-1].cts+g[g.length-1].duration)/track.timescale;
return gEnd > startSec && gStart < endSec;
});
if(!usedGops.length) throw new Error('seçilen aralıkta video örneği yok');
const avgDurUnits = track.samples.reduce((a,s)=>a+s.duration,0)/track.samples.length;
const fps = track.timescale/avgDurUnits;
const frameDurUs = Math.round(1e6/fps);
const negVideo = await hmNegotiateVideoEncoderConfig(track.width, track.height, fps);
if(!negVideo) throw new Error('bu cihaz H.264 video kodlayamıyor');
const encCfg = negVideo.config;
// Audio is decoded (and, if requested, reversed) BEFORE the muxer is created, so the muxer
// can be told the real channel count/sample rate up front - or told there's no audio track
// at all if decoding fails, rather than declaring an audio track and then never feeding it.
let audioPlan = null; // {samples: Float32Array[] per channel, sampleRate, numberOfChannels} or null
if(audioToo !== false){
try{
const actx = new AudioContext();
const audioBuf = await actx.decodeAudioData(srcBuf.slice(0));
try{ actx.close(); }catch(e){}
const sr = audioBuf.sampleRate;
const s0 = Math.max(0, Math.floor(startSec*sr));
const s1 = Math.min(audioBuf.length, Math.ceil(endSec*sr));
if(s1>s0){
const numberOfChannels = audioBuf.numberOfChannels;
const channels = [];
for(let c=0;cmuxer.addVideoChunk(chunk,meta),
error:e=>{ videoErr = e; }
});
videoEncoder.configure(encCfg);
async function decodeGop(gopSamples){
return new Promise((resolve,reject)=>{
const framesOut = [];
const decoder = new VideoDecoder({ output:f=>framesOut.push(f), error:reject });
try{ decoder.configure(decCfg); }catch(e){ reject(e); return; }
for(const s of gopSamples){
const data = new Uint8Array(srcBuf, s.offset, s.size);
decoder.decode(new EncodedVideoChunk({
type: s.isKey?'key':'delta',
timestamp: Math.round(s.cts/track.timescale*1e6),
duration: Math.round(s.duration/track.timescale*1e6),
data
}));
}
decoder.flush().then(()=>{ decoder.close(); resolve(framesOut); }).catch(reject);
});
}
let outIdx = 0;
const totalGopsToDo = usedGops.length;
for(let g=usedGops.length-1; g>=0; g--){
if(videoErr) throw videoErr;
const frames = await decodeGop(usedGops[g]);
frames.sort((a,b)=>a.timestamp-b.timestamp);
for(let i=frames.length-1;i>=0;i--){
const f = frames[i];
const tSec = f.timestamp/1e6;
if(tSec < startSec || tSec >= endSec){ f.close(); continue; }
const nf = new VideoFrame(f, {timestamp: outIdx*frameDurUs});
videoEncoder.encode(nf, {keyFrame: outIdx % 60 === 0});
nf.close();
f.close();
outIdx++;
}
if(onProgress) onProgress(Math.round(((totalGopsToDo-g)/totalGopsToDo)*(audioPlan?70:99)));
}
await videoEncoder.flush();
videoEncoder.close();
if(videoErr) throw videoErr;
if(outIdx===0) throw new Error('seçilen aralıkta kare üretilemedi');
if(audioPlan){
let audioErr = null;
const audioEncoder = new AudioEncoder({
output:(chunk,meta)=>muxer.addAudioChunk(chunk,meta),
error:e=>{ audioErr = e; }
});
audioEncoder.configure({codec:'mp4a.40.2', numberOfChannels:audioPlan.numberOfChannels, sampleRate:audioPlan.sampleRate, bitrate:192000});
const FRAME = 1024;
const total = audioPlan.channels[0].length;
let ts = 0;
for(let p=0;p> 6) & 3, tier = (h[1] >> 5) & 1, prof = h[1] & 31;
let compat = ((h[2]<<24)|(h[3]<<16)|(h[4]<<8)|h[5]) >>> 0, rev = 0;
for(let i=0;i<32;i++){ rev = (rev << 1) | (compat & 1); compat >>>= 1; }
const cons = Array.from(h.slice(6, 12)); while(cons.length && cons[cons.length-1] === 0) cons.pop();
return 'hvc1.' + (['','A','B','C'][space]) + prof + '.' + (rev >>> 0).toString(16).toUpperCase() + '.' + (tier ? 'H' : 'L') + h[12]
+ (cons.length ? '.' + cons.map(b=>b.toString(16).toUpperCase()).join('.') : '');
}
function hmVp9CodecString(v){ const p = v[4], l = v[5], bd = v[6] >> 4; const pad = x=>String(x).padStart(2,'0'); return 'vp09.' + pad(p) + '.' + pad(l || 10) + '.' + pad(bd || 8); }
async function hmBuildPreviewProxy(srcBuf, opts, onProgress){
if(typeof VideoDecoder === 'undefined' || typeof VideoEncoder === 'undefined') throw new Error('WebCodecs yok');
if(!window.Mp4Muxer) throw new Error('mp4-muxer yok');
const track = hmMp4ParseVideoTrack(srcBuf);
let codec, description;
if(track.avcC){ codec = 'avc1.' + [track.avcC[1],track.avcC[2],track.avcC[3]].map(b=>b.toString(16).padStart(2,'0')).join(''); description = track.avcC; }
else if(track.hvcC){ codec = hmHevcCodecString(track.hvcC); description = track.hvcC; }
else if(track.vpcC){ codec = hmVp9CodecString(track.vpcC); }
else throw new Error('desteklenmeyen video kodeki');
const decCfg = await hmNegotiateVideoDecoderConfig(codec, track.width, track.height, description);
if(!decCfg) throw new Error('kaynak kodek çözülemiyor: ' + codec);
const S = track.samples, n = S.length;
const firstCts = S.reduce((m,s)=>Math.min(m, s.cts), Infinity);
const srcFps = track.timescale / (S.reduce((a,s)=>a + s.duration, 0) / n);
const outFps = Math.max(1, Math.min(60, Math.round(srcFps)));
const longEdge = Math.max(track.width, track.height);
const keyEvery = S.filter(s=>s.isKey).length ? (n / S.filter(s=>s.isKey).length) / srcFps : 99; // ortalama anahtar kare aralığı (sn)
const T = Math.max(640, opts.longEdge || 1280);
if(!opts.force && longEdge <= T && srcFps <= 61 && keyEvery <= 1.1 && track.avcC) return { skipped:true, reason:'kaynak zaten uygun' };
const sc = Math.min(1, T / longEdge);
const outW = Math.max(2, Math.round(track.width * sc / 2) * 2), outH = Math.max(2, Math.round(track.height * sc / 2) * 2);
// kodlayıcı: önce H.264 (donanım), olmazsa VP9 / VP8
const bitrate = Math.round(Math.min(16e6, Math.max(3e6, outW * outH * outFps * 0.12)));
let encCfg = null, muxCodec = null;
const ladder = [['avc1.640034','avc'],['avc1.4d0034','avc'],['avc1.42e034','avc'],['avc1.42e01f','avc'],['vp09.00.41.08','vp9'],['vp8','vp9']];
for(const [c, mc] of ladder){
for(const hardwareAcceleration of ['prefer-hardware','no-preference']){
const cfg = { codec:c, width:outW, height:outH, bitrate, framerate:outFps, hardwareAcceleration, latencyMode:'realtime', bitrateMode:'variable' };
if(mc === 'avc') cfg.avc = { format:'avc' };
try{ const r = await VideoEncoder.isConfigSupported(cfg); if(r.supported){ encCfg = Object.assign({}, cfg, r.config || {}); muxCodec = c === 'vp8' ? 'vp8' : mc; break; } }catch(_){}
}
if(encCfg) break;
}
if(!encCfg) throw new Error('bu cihazda uygun kodlayıcı yok');
if(muxCodec === 'vp8') throw new Error('VP8 MP4 içine konamıyor');
// ses: bir kez çözülür, AAC (olmazsa Opus) olarak yeniden paketlenir
let audio = null;
try{
const actx = new (window.AudioContext || window.webkitAudioContext)();
const ab = await actx.decodeAudioData(srcBuf.slice(0)); try{ actx.close(); }catch(_){}
if(ab && ab.length && ab.numberOfChannels){
for(const [ac, mac] of [['mp4a.40.2','aac'],['opus','opus']]){
const cfg = { codec:ac, sampleRate:ab.sampleRate, numberOfChannels:Math.min(2, ab.numberOfChannels), bitrate:160000 };
try{ const r = await AudioEncoder.isConfigSupported(cfg); if(r.supported){ audio = { ab, cfg, mac }; break; } }catch(_){}
}
}
}catch(_){ audio = null; }
const muxer = new Mp4Muxer.Muxer({ target: new Mp4Muxer.ArrayBufferTarget(), fastStart:'in-memory',
video: { codec:muxCodec, width:outW, height:outH, frameRate:outFps },
audio: audio ? { codec:audio.mac, numberOfChannels:audio.cfg.numberOfChannels, sampleRate:audio.cfg.sampleRate } : undefined,
firstTimestampBehavior:'offset' });
let err = null;
const encoder = new VideoEncoder({ output:(chunk, meta)=>muxer.addVideoChunk(chunk, meta), error:e=>{ err = e; } });
encoder.configure(encCfg);
const canvas = (typeof OffscreenCanvas !== 'undefined') ? new OffscreenCanvas(outW, outH) : Object.assign(document.createElement('canvas'), { width:outW, height:outH });
const ctx = canvas.getContext('2d', { alpha:false });
const frameUs = Math.round(1e6 / outFps), KEY_US = 500000;
let nextTs = 0, lastKey = -1e12, outCount = 0, doneIn = 0;
const decoder = new VideoDecoder({ error:e=>{ err = e; }, output:frame=>{
try{
const ts = frame.timestamp - Math.round(firstCts / track.timescale * 1e6);
if(ts + 1 < nextTs){ frame.close(); return; } // kare hızı düşürme (120 → 60)
ctx.drawImage(frame, 0, 0, outW, outH); frame.close();
const vf = new VideoFrame(canvas, { timestamp:Math.max(0, ts), duration:frameUs });
const key = ts - lastKey >= KEY_US - 1000; if(key) lastKey = ts;
encoder.encode(vf, { keyFrame:key }); vf.close();
nextTs = ts + frameUs - 2000; outCount++;
}catch(e){ err = e; try{ frame.close(); }catch(_){} }
}});
decoder.configure(decCfg);
for(let i=0;i 8 || encoder.encodeQueueSize > 8) && !err) await new Promise(r=>setTimeout(r, 2));
if(i % 15 === 0){ onProgress && onProgress(Math.round(i / n * (audio ? 90 : 99))); await new Promise(r=>setTimeout(r, 0)); }
}
await decoder.flush(); decoder.close();
await encoder.flush(); encoder.close();
if(err) throw err;
if(!outCount) throw new Error('kare üretilemedi');
if(audio){
let aerr = null;
const aenc = new AudioEncoder({ output:(c, m)=>muxer.addAudioChunk(c, m), error:e=>{ aerr = e; } });
aenc.configure(audio.cfg);
const ab = audio.ab, ch = audio.cfg.numberOfChannels, total = ab.length, F = 1024;
const chans = []; for(let c=0;csetTimeout(r, 0)); }
}
await aenc.flush(); aenc.close();
if(aerr) console.warn('[proxy] ses kodlanamadı, kopya sessiz kalacak (orijinal ses kullanılacak)', aerr);
}
muxer.finalize();
onProgress && onProgress(100);
return { buffer: muxer.target.buffer, width:outW, height:outH, fps:outFps, codec:encCfg.codec, hasAudio: !!audio, srcW:track.width, srcH:track.height, srcFps:Math.round(srcFps) };
}
/* ---- Native (WebCodecs) export pipeline: the hub side of the stream ----
Mirrors the Reverse bridge above, but streams many frames instead of one input file: the
editor renders each frame exactly as before (settleFrameAt + compositeExportFrame, including
the sub-frame motion-blur accumulation), turns it into an ImageBitmap, and transfers that
bitmap over one at a time; this side wraps each one in a VideoFrame and feeds it straight to
VideoEncoder with no re-decoding step. The editor's own OfflineAudioContext mixdown (see
hmRenderExportAudio) arrives once as a single PCM buffer and is encoded to AAC here. */
const hmExportNativeSessions = new Map(); // reqId -> {muxer, videoEncoder, frameDurUs, encodeErrRef}
async function hmExportNativeStart(d, send){
// d.codec is the codec FAMILY the person picked in Gelişmiş > Kodek ('h264' default,
// or 'h265'/HEVC) - honored for real here rather than silently re-encoding as H.264
// regardless of what was selected, which is what this used to do. If HEVC genuinely
// isn't available on this device, that is reported back as a real failure (with a clear
// reason) instead of quietly substituting a different codec than the one asked for.
const requestedFamily = d.codec === 'h265' ? 'h265' : 'h264';
const neg = await hmNegotiateVideoEncoderConfig(d.width, d.height, d.fps, requestedFamily);
if(!neg){
const err = requestedFamily === 'h265'
? 'bu cihaz H.265/HEVC ile kodlayamıyor'
: 'bu cihaz H.264 kodlayamıyor';
send({type:'hm:exportNativeResult', reqId:d.reqId, ok:false, error:err});
return;
}
const encCfg = neg.config;
const muxer = new Mp4Muxer.Muxer({
target: new Mp4Muxer.ArrayBufferTarget(),
video: {codec:neg.muxerCodec, width:d.width, height:d.height, frameRate:d.fps},
audio: d.audio ? {codec:'aac', numberOfChannels:d.audio.numberOfChannels, sampleRate:d.audio.sampleRate} : undefined,
// Fragmented, not in-memory fast-start - see the matching comment on the Reverse
// pipeline's muxer above for why. It matters far more here: an export can run minutes
// long, and 'in-memory' would hold every sample's metadata for the whole thing until
// finalize() just to build one upfront moov box - fragmented mode never needs that.
fastStart: 'fragmented'
});
const encodeErrRef = {err:null};
const videoEncoder = new VideoEncoder({
output:(chunk,meta)=>muxer.addVideoChunk(chunk,meta),
error:e=>{ encodeErrRef.err = e; }
});
try{ videoEncoder.configure(encCfg); }
catch(e){ send({type:'hm:exportNativeResult', reqId:d.reqId, ok:false, error:(e&&e.message)||String(e)}); return; }
hmExportNativeSessions.set(d.reqId, {muxer, videoEncoder, frameDurUs: Math.round(1e6/d.fps), encodeErrRef});
}
function hmExportNativeFrame(d, send){
const sess = hmExportNativeSessions.get(d.reqId);
if(!sess){ try{ d.bitmap.close(); }catch(_){} send({type:'hm:exportNativeFrameAck', reqId:d.reqId, frameIndex:d.frameIndex}); return; }
try{
const frame = new VideoFrame(d.bitmap, {timestamp: d.frameIndex*sess.frameDurUs});
sess.videoEncoder.encode(frame, {keyFrame: d.frameIndex % 60 === 0});
frame.close();
}catch(e){ sess.encodeErrRef.err = sess.encodeErrRef.err || e; }
try{ d.bitmap.close(); }catch(_){}
send({type:'hm:exportNativeFrameAck', reqId:d.reqId, frameIndex:d.frameIndex});
}
async function hmExportNativeAudio(d){
const sess = hmExportNativeSessions.get(d.reqId);
if(!sess) return;
try{
let audioErr = null;
const audioEncoder = new AudioEncoder({
output:(chunk,meta)=>sess.muxer.addAudioChunk(chunk,meta),
error:e=>{ audioErr = e; }
});
audioEncoder.configure({codec:'mp4a.40.2', numberOfChannels:d.numberOfChannels, sampleRate:d.sampleRate, bitrate:192000});
const FRAME = 1024, total = d.channels[0].length;
let ts = 0;
for(let p=0;p{
const d=e.data; if(!d||typeof d!=='object') return;
if(d.type==='hm:ready'){
editorReady=true;
const p=projects.find(x=>x.id===editorOpenId);
if(p){
try{ el('editorFrame').contentWindow.postMessage({
type:'hm:open',
theme: prefs.theme,
hwBenchmark: hmGetBenchmark(),
project:{ id:p.id,name:p.name,aspect:p.aspect,w:p.w,h:p.h,fps:p.fps,bg:p.bg,
defaultLayerDur:(prefs.defaultLayerDur||0), doc:p.doc||null,
assets:p.assets||null }
},'*'); }catch(_){}
/* A Reference Edit project arrives with a plan instead of a saved document, and the
plan is handed over as a second message right after the open. The editor builds the
timeline with its own APIs and sends back a doc through the normal state channel -
so from this point on the project is an ordinary one and nothing downstream has to
know how it was made.
The plan is consumed here and dropped: the File objects in it cannot be written to
IndexedDB, and once the timeline exists the doc is the real record anyway. */
if(p.refPlan){
const plan = p.refPlan;
delete p.refPlan;
try{ el('editorFrame').contentWindow.postMessage({
type:'hm:build',
plan:{ slots: plan.slots, audioDur: plan.audioDur },
files: plan.files,
audio: plan.audio
},'*'); }catch(err){ toast('Kurgu aktarılamadı'); }
}
}
/* The editor says it is ready the moment its own script finishes, but ready is not the
same as warm: the first frames are still decoding, the overlay plates are still being
fetched, fonts and thumbnails are still landing. Dropping the loading screen at that
exact instant hands over a project that then visibly settles for half a second. Holding
it a beat longer costs nothing anyone notices and means the first thing seen is the
project already sitting still. */
setTimeout(()=>el('editorLoad').classList.add('gone'), 760);
}
/* .hmp packing lives here in the hub, because the packer does - and because the hub holds
the canonical project record the file is made of. The editor asks, the hub packs, the
bytes go back so the editor can present the result on its own export screen like every
other format. A Uint8Array survives postMessage as a structured clone, so nothing is
re-encoded on the way across. */
else if(d.type==='hm:exportHmp'){
(async ()=>{
const send=(m)=>{ try{ el('editorFrame').contentWindow.postMessage(m,'*'); }catch(_){} };
const p=projects.find(x=>x.id===(d.id||editorOpenId));
if(!p){ send({type:'hm:hmpBytes',ok:false}); return; }
try{
const doc={name:p.name,aspect:p.aspect,w:p.w,h:p.h,fps:p.fps,bg:p.bg,
durMs:p.durMs,thumb:p.thumb,layers:p.layers||[],doc:p.doc||null};
/* The media the editor just handed over, written into the file's asset section. This
is what makes an .hmp portable: without it the file describes an edit of footage
that only exists on the machine it was made on. */
const assets=(d.assets||[]).map(a=>({
id:new TextEncoder().encode(a.id), mime:a.mime||'', name:a.name||'',
data:(a.data instanceof Uint8Array)?a.data:new Uint8Array(a.data)
}));
const bytes=await HMP.pack(doc,assets,{deviceBound:false});
send({type:'hm:hmpBytes', ok:true, bytes, name:p.name});
}catch(err){ send({type:'hm:hmpBytes',ok:false}); }
})();
}
/* Native (WebCodecs) reverse: the editor hands over the source clip's raw bytes because its
own document is an insecure blob: origin where VideoDecoder/VideoEncoder don't exist at
all - see hmNativeReverseVideo above. The hub IS a secure context, so it does the actual
decode/GOP-reverse/encode/mux work and hands the finished .mp4's bytes back the same way,
with progress pushed along as it goes so the editor's own progress bar stays live. */
else if(d.type==='hm:nativeReverseRequest'){
(async ()=>{
const send=(m,transfer)=>{ try{ el('editorFrame').contentWindow.postMessage(m,'*',transfer||[]); }catch(_){} };
try{
const srcBuf = d.videoBytes.buffer instanceof ArrayBuffer ? d.videoBytes.buffer : d.videoBytes;
const buffer = await hmNativeReverseVideo(srcBuf, d.sourceOffset, d.dur, d.audioToo, (pct)=>{
send({type:'hm:nativeReverseProgress', reqId:d.reqId, pct});
});
send({type:'hm:nativeReverseResult', reqId:d.reqId, ok:true, buffer}, [buffer]);
}catch(err){
send({type:'hm:nativeReverseResult', reqId:d.reqId, ok:false, error:(err&&err.message)||String(err)});
}
})();
}
else if(d.type==='hm:proxyRequest'){
(async ()=>{
const send=(m,transfer)=>{ try{ el('editorFrame').contentWindow.postMessage(m,'*',transfer||[]); }catch(_){} };
try{
const srcBuf = d.videoBytes.buffer instanceof ArrayBuffer ? d.videoBytes.buffer : d.videoBytes;
const t0 = performance.now();
const res = await hmBuildPreviewProxy(srcBuf, d.opts || {}, (pct)=>send({type:'hm:proxyProgress', reqId:d.reqId, pct}));
if(res.skipped){ send({type:'hm:proxyResult', reqId:d.reqId, ok:true, skipped:true, reason:res.reason}); return; }
const info = Object.assign({}, res, { buffer:undefined, ms:Math.round(performance.now()-t0) });
send({type:'hm:proxyResult', reqId:d.reqId, ok:true, buffer:res.buffer, info}, [res.buffer]);
}catch(err){
send({type:'hm:proxyResult', reqId:d.reqId, ok:false, error:(err&&err.message)||String(err)});
}
})();
}
else if(d.type==='hm:close'){ closeEditor(); }
else if(d.type==='hm:exportNativeStart'){
const send=(m,transfer)=>{ try{ el('editorFrame').contentWindow.postMessage(m,'*',transfer||[]); }catch(_){} };
hmExportNativeStart(d, send);
}
else if(d.type==='hm:exportNativeFrame'){
const send=(m,transfer)=>{ try{ el('editorFrame').contentWindow.postMessage(m,'*',transfer||[]); }catch(_){} };
hmExportNativeFrame(d, send);
}
else if(d.type==='hm:exportNativeAudio'){ hmExportNativeAudio(d); }
else if(d.type==='hm:exportNativeFinish'){
const send=(m,transfer)=>{ try{ el('editorFrame').contentWindow.postMessage(m,'*',transfer||[]); }catch(_){} };
hmExportNativeFinish(d, send);
}
else if(d.type==='hm:exportNativeAbort'){ hmExportNativeAbort(d); }
else if(d.type==='hm:built'){
if(d.result && d.result.ok) toast(d.result.layers+' klip yerleştirildi');
else toast('Kurgu oluşturulamadı');
}
else if(d.type==='hm:media'){
const p=projects.find(x=>x.id===(d.id||editorOpenId));
if(!p||!d.assets||!d.assets.length) return;
if(!Array.isArray(p.assets)) p.assets=[];
const have=new Set(p.assets.map(a=>a.id));
d.assets.forEach(a=>{
if(have.has(a.id)) return;
p.assets.push({id:a.id, mime:a.mime||'', name:a.name||'',
data:(a.data instanceof Uint8Array)?a.data:new Uint8Array(a.data)});
});
touch(p);
}
else if(d.type==='hm:state'){
const p=projects.find(x=>x.id===(d.id||editorOpenId));
if(!p||!d.state) return;
let changed=false;
if(d.state.name && d.state.name!==p.name){ p.name=d.state.name; changed=true; }
if(typeof d.state.durMs==='number' && d.state.durMs!==p.durMs){ p.durMs=d.state.durMs; changed=true; }
if(d.state.thumb && d.state.thumb!==p.thumb){ p.thumb=d.state.thumb; changed=true; }
if(d.state.doc && d.state.doc!==p.doc){ p.doc=d.state.doc; changed=true; }
// Same autosave path as everything else - the editor's work is journalled
// synchronously too, so closing the app mid-edit loses nothing.
if(changed) touch(p);
}
});
function openProjectMenu(id){
const p=projects.find(x=>x.id===id); if(!p)return;
el('sheet').innerHTML=`
${esc(p.name)}
Aç${I.chev}
Yeniden adlandır${I.chev}
Çoğalt${I.chev}
Model olarak dışa aktar.hmp${I.chev}
Sil${I.chev}
`;
el('sheet').classList.add('on'); el('scrim').classList.add('on'); sizeSheet();
bindSheetGrab();
}
/* ============================================================================
NAV + SEARCH
============================================================================ */
let view='home';
// Read by effects that trade working resolution for speed during live preview/scrubbing
// (see drawZoomBlur) - export must never see this as true, so those effects always render
// at full resolution for the file that actually gets saved.
let HM_EXPORTING = false;
function moveGlow(){
const on=document.querySelector('.tab.on'), g=el('navGlow'); if(!on||!g)return;
const nav=el('nav').getBoundingClientRect(), r=on.getBoundingClientRect();
// calc(...-50%), not a bare pixel translate. The glow's resting transform in CSS
// is translate(-50%,-50%) to centre a 230px circle on its own origin; assigning a
// plain translate() here REPLACED that, so the circle's left edge landed on the
// tab instead of its centre and the light sat half a circle off to one side.
g.style.transform=`translate(calc(${r.left-nav.left+r.width/2}px - 50%), -50%)`;
}
function setSearchVisible(v){
const b=el('searchBar');
b.hidden=!v;
if(!v) closeSearch();
}
function openSearch(){
el('searchBar').classList.add('open');
setTimeout(()=>{const i=el('searchInput'); if(i) i.focus();},260);
}
function closeSearch(){
el('searchBar').classList.remove('open');
const i=el('searchInput'); if(i) i.value='';
if(searchQ){ searchQ=''; renderProjects(); }
}
function goto(v){
view=v;
document.querySelectorAll('.tab[data-v]').forEach(t=>t.classList.toggle('on',t.dataset.v===v));
document.querySelectorAll('.view').forEach(s=>s.classList.toggle('on',s.id==='v-'+v));
el('tbTitle').textContent={home:'HyperMagic',social:'Sosyal Medya',projects:'Projeler',profile:'Profil'}[v];
const rb=el('tbRightBtn'); if(rb) rb.style.visibility = v==='profile' ? 'visible' : 'hidden';
// Search belongs to the project list and nowhere else - there is nothing to
// search on the other three tabs, and an inert control is worse than none.
setSearchVisible(v==='projects');
if(viewDirty[v]) renderView(v);
const sc=el('v-'+v); if(sc)sc.scrollTop=0;
moveGlow();
if(v==='social'&&prefs.autoSyncSocial&&FEED_URL) syncSocial(true);
}
/* Only the visible tab is rendered. renderAll used to rebuild the innerHTML of all
four views on every change - including three nobody was looking at - and each
rebuild throws away and re-parses a few hundred nodes. The others are marked stale
and rebuilt the moment they are switched to, so nothing is ever seen out of date. */
const viewDirty={home:true,social:true,projects:true,profile:true};
const RENDERERS={home:renderHome,social:renderSocial,projects:renderProjects,profile:renderProfile};
function renderView(v){
const fn=RENDERERS[v]; if(!fn) return;
fn(); viewDirty[v]=false;
if(v==='home') armStripAutoScroll();
}
function renderAll(){
Object.keys(viewDirty).forEach(k=>viewDirty[k]=true);
renderView(typeof view!=='undefined'?view:'home');
moveGlow();
}
/* ============================================================================
EVENTS
============================================================================ */
el('searchToggle').addEventListener('click',(e)=>{
e.stopPropagation();
const b=el('searchBar');
if(b.classList.contains('open')) closeSearch(); else openSearch();
});
el('searchClose').addEventListener('click',(e)=>{e.stopPropagation();closeSearch();});
el('searchInput').addEventListener('input',(e)=>{searchQ=e.target.value.trim();renderProjects();});
el('hmpPicker').addEventListener('change',(e)=>{
const f=e.target.files&&e.target.files[0]; e.target.value='';
if(f) importHmpFile(f);
});
el('avatarPicker').addEventListener('change',(e)=>{
const f=e.target.files&&e.target.files[0]; e.target.value='';
if(f) setAvatarFromFile(f);
});
el('jsonPicker').addEventListener('change',(e)=>{
const f=e.target.files&&e.target.files[0]; e.target.value='';
if(!f)return;
const rd=new FileReader();
rd.onload=()=>{try{
const d=JSON.parse(rd.result);
const inc=Array.isArray(d)?d:(d.projects||[]);
let added=0;
inc.forEach(p=>{ if(!p||!p.id)return;
const ex=projects.find(x=>x.id===p.id);
if(ex&&(ex.updatedAt||0)>=(p.updatedAt||0))return; // never clobber newer local work
if(ex)Object.assign(ex,p); else projects.push(p);
idbPut(p).catch(()=>{}); added++;});
renderAll(); toast(added?added+' proje geri yüklendi':'Yeni bir şey yoktu');
}catch(_){toast('Dosya okunamadı');}};
rd.readAsText(f);
});
document.addEventListener('click',(e)=>{
const hit=(s)=>e.target.closest(s);
if(hit('.searchbar')) return;
if(hit('.tab[data-v]')){goto(hit('.tab[data-v]').dataset.v);return;}
if(hit('#fab')||hit('[data-new]')){openNewProject();return;}
if(hit('#menuBtn')){openDrawer();return;}
if(hit('#scrim')){closeOverlays();return;}
if(hit('[data-goto]')){goto(hit('[data-goto]').dataset.goto);return;}
const pl=hit('[data-plan]'); if(pl){ pmPlan=pl.dataset.plan; openPremium(); return; }
const pt=hit('[data-ptab]'); if(pt){projTab=pt.dataset.ptab;searchQ='';closeSearch();renderProjects();return;}
if(hit('#videoPreviewCloseBtn')){closeVideoPreview();return;}
if(hit('#videoPreviewOverlay') && !hit('.video-preview-card')){closeVideoPreview();return;}
if(hit('#sortBtn')){prefs.sortBy=prefs.sortBy==='date'?'name':prefs.sortBy==='name'?'size':'date';
savePrefs();renderProjects();return;}
const mn=hit('[data-menu]'); if(mn){e.stopPropagation();openProjectMenu(mn.dataset.menu);return;}
const op=hit('[data-open]'); if(op){openProject(op.dataset.open);return;}
const pa=hit('[data-pa]');
if(pa){
const id=pa.dataset.id,act=pa.dataset.pa; closeOverlays();
setTimeout(()=>{
if(act==='open')openProject(id);
else if(act==='hmp')exportHmp(id);
else if(act==='del'){
const p=projects.find(x=>x.id===id);
hmConfirm('Proje silinsin mi?', p?('“'+p.name+'” kalıcı olarak silinecek.'):'',
{warn:'Bu işlem geri alınamaz.',ok:'Sil',danger:true}).then(ok=>{
if(!ok) return;
const i=projects.findIndex(x=>x.id===id); if(i<0)return;
const n=projects[i].name; projects.splice(i,1);
idbDel(id).catch(()=>{}); journalClear(id); renderAll(); toast(n+' silindi');});}
else if(act==='dup'){const p=projects.find(x=>x.id===id); if(!p)return;
const c=JSON.parse(JSON.stringify(p)); c.id=uid(); c.name=p.name+' kopya';
c.createdAt=c.updatedAt=nowMs(); projects.push(c); touch(c,{now:true}); renderAll(); toast('Çoğaltıldı');}
else if(act==='rename'){const p=projects.find(x=>x.id===id); if(!p)return;
hmPrompt('Yeniden adlandır',p.name,{placeholder:'Proje adı',ok:'Kaydet'})
.then(n=>{ if(n&&String(n).trim()){p.name=String(n).trim();touch(p,{now:true});renderAll();} });}
},260);
return;
}
const ra=hit('[data-ratio]'); if(ra){np.ratio=ra.dataset.ratio;openNewProject();return;}
const rs=hit('[data-res]'); if(rs){np.res=+rs.dataset.res;openNewProject();return;}
const fp=hit('[data-fps]'); if(fp){np.fps=+fp.dataset.fps;openNewProject();return;}
const bg=hit('[data-bg]'); if(bg){np.bg=bg.dataset.bg;openNewProject();return;}
if(hit('#npCreate')){createFromSheet();return;}
const tg=hit('[data-toggle]'); if(tg){const k=tg.dataset.toggle;prefs[k]=!prefs[k];savePrefs();tg.classList.toggle('on',prefs[k]);return;}
const au=hit('[data-audio]'); if(au){prefs.audioResample=au.dataset.audio;savePrefs();openDrawer();return;}
if(hit('#syncBtn')){syncSocial(false);return;}
const ac=hit('[data-act]');
if(ac){
const a=ac.dataset.act;
if(a==='import-hmp'){el('hmpPicker').click();}
else if(a==='tutorial'){toast('Eğitim modu yakında');}
else if(a==='profile-menu'){ openProfileMenu(); }
else if(a==='toggle-storage'){storageOpen=!storageOpen;openProfileMenu();}
else if(a==='clear-cache'){
// Cache holds nothing the person authored, so one confirmation is the honest
// amount of friction. Asking twice for something harmless just trains people
// to tap through the prompt that actually matters.
hmConfirm('Önbellek temizlensin mi?','Projelerin etkilenmez, sadece küçük resimler ve geçici dosyalar silinir.',{ok:'Temizle'})
.then(ok=>{ if(ok) clearCache(); });
}
else if(a==='clear-data'){ clearDataFlow(); }
else if(a==='edit-profile'){ openProfileEditor(); }
else if(a==='credentials'){ openCredentials(); }
else if(a==='pick-avatar'){ el('avatarPicker').click(); }
else if(a==='copy-id'){
const id=userId();
const done=()=>toast('ID kopyalandı');
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(id).then(done).catch(()=>toast(id));
} else {
// Older WebViews have no async clipboard; the throwaway textarea is the only
// path that still works there.
try{
const t=document.createElement('textarea');
t.value=id; t.style.cssText='position:fixed;opacity:0';
document.body.appendChild(t); t.select(); document.execCommand('copy');
document.body.removeChild(t); done();
}catch(_){ toast(id); }
}
}
else if(a==='drop-avatar'){ prefs.avatar=null; savePrefs(); openProfileEditor(); renderProfile(); }
else if(a==='save-credentials'){
const m=el('crMail'), p1=el('crPass'), p2=el('crPass2');
if(p1 && p1.value && p1.value!==(p2?p2.value:'')){ toast('Şifreler eşleşmiyor'); return; }
if(m) prefs.email=m.value.trim();
savePrefs(); closeOverlays(); renderProfile(); toast('Kaydedildi');
}
else if(a==='save-profile'){
const n=el('peName'), m=el('peMail');
if(n && n.value.trim()) prefs.userName=n.value.trim();
if(m) prefs.email=m.value.trim();
savePrefs(); closeOverlays(); renderProfile(); toast('Profil güncellendi');
}
else if(a==='yt-analytics'){ openAchievements(); }
else if(a==='achievement'){
const def=ACHIEVEMENTS.find(x=>x.id===ac.dataset.ach);
if(def && isAchUnlocked(def.id)) hmAlert(def.title,def.desc,{ok:'Tamam'});
else hmAlert('Kilitli','Bu başarım daha açılmadı.',{ok:'Tamam'});
}
else if(a==='support'){ toast('Destek hattı yakında'); }
else if(a==='ref-edit'){ openRefEdit(); }
else if(a==='quality-boost'){ openQualityBoost(); }
else if(a==='hyperflow'){ openHyperFlow(); }
else if(a==='picture-to-3d'){ openPictureTo3D(); }
else if(a==='p3d-pick-image'){ el('p3dPicker').click(); }
else if(a==='p3d-generate'){ p3dStart(); }
else if(a==='p3d-import-models'){ p3dImportModels(); }
else if(a==='buy-premium'){ closeOverlays(); pmPlan='premium'; setTimeout(openPremium,270); }
else if(a==='checkout'){ closeOverlays(); setTimeout(openCheckout,270); }
else if(a==='back-premium'){ closeOverlays(); pmPlan='premium'; setTimeout(openPremium,270); }
else if(a==='close-sheet'){ closeOverlays(); }
else if(a==='premium-codes'){premiumCodeFlow();}
else if(a==='premium-history'){showPremiumHistory();}
else if(a==='tos'){showDoc('Terms of Service','Kullanım koşulları yakında eklenecek.');}
else if(a==='licenses'){showCredits();}
else if(a==='import-json'){el('jsonPicker').click();}
else if(a==='layerdur'){
hmPrompt('Varsayılan katman süresi',prefs.defaultLayerDur,{body:'Yeni eklenen katmanların saniye cinsinden uzunluğu.',numeric:true,ok:'Kaydet'})
.then(n=>{ const v=parseFloat(n); if(v>0){prefs.defaultLayerDur=v;savePrefs();openDrawer();} });}
else if(a==='export-all'){
const blob=new Blob([JSON.stringify({projects,prefs,at:nowMs()},null,2)],{type:'application/json'});
const l=document.createElement('a'); l.href=URL.createObjectURL(blob);
l.download='hypermagic-yedek-'+new Date().toISOString().slice(0,10)+'.json';
l.click(); setTimeout(()=>URL.revokeObjectURL(l.href),1500); toast('Yedek indirildi');}
else if(a==='language'){ toast('Dil seçenekleri yakında'); }
else if(a==='about'){toast('HyperMagic Studio · HMP v'+HMP.FORMAT_VERSION);}
else if(a==='hw-info'){ showHardwareInfo(); }
else if(a==='rerun-hw-test'){
try{ localStorage.removeItem(HM_BENCH_KEY); }catch(e){}
closeOverlays();
maybeRunHardwareBenchmark();
}
return;
}
});
/* Cache is the derived stuff: thumbnails, the social feed, the transient session
record. None of it is authored work, all of it rebuilds itself. */
function clearCache(){
let freed=0;
projects.forEach(p=>{ if(p.thumb){ freed+=p.thumb.length; p.thumb=null; touch(p); } });
freed+=JSON.stringify(prefs.social||[]).length;
prefs.social=[]; savePrefs();
try{ localStorage.removeItem(SESSION); }catch(_){}
renderAll();
toast(freed>1024 ? (freed/1024).toFixed(0)+'KB önbellek temizlendi' : 'Önbellek temizlendi');
}
/* Two stages, and deliberately not two identical prompts. The first one states what
is about to happen in plain terms; only after that does the second ask for the
decision. A single "emin misin?" on an irreversible wipe of everything the person
has ever made is not enough of a pause. */
async function clearDataFlow(){
const n=projects.length;
await hmAlert('Bu işlem verileri siler',
n+' proje, tüm ayarların ve günlük serin kalıcı olarak silinecek.\n\nDevam etmeden önce "Tüm projeleri yedekle" ile bir kopya alabilirsin.',
{ok:'Devam et'});
const sure=await hmConfirm('Emin misin?', n+' proje geri dönüşü olmadan silinecek.',
{warn:'Bu işlem geri alınamaz.', ok:'Evet, sil', danger:true});
if(!sure) return;
projects.forEach(p=>idbDel(p.id).catch(()=>{}));
projects=[];
// The account ID deliberately survives. It identifies the person, not their data -
// premium entitlements and anything issued against it must still resolve after a
// wipe, or clearing projects would silently cost them what they paid for.
const keepId=userId();
try{localStorage.removeItem(JOURNAL);localStorage.removeItem(PREFS);localStorage.removeItem(STREAK);}catch(_){}
prefs=Object.assign({},prefs,{social:[],premiumHistory:[],userId:keepId});
savePrefs();
storageOpen=false;
closeOverlays(); renderAll(); toast('Tüm veriler silindi');
}
/* Profile editor. Deliberately small: a picture, a name, and the address the account
will be tied to when sign-in lands. The email field is stored but inert - it is a
placeholder for that system, and labelling it as such is more honest than making it
look like it already does something. */
function openProfileEditor(){
const initial=(prefs.userName||'E').trim().charAt(0).toUpperCase();
el('sheet').innerHTML=`
Profilimi Düzenle
${prefs.avatar?``:esc(initial)}
${prefs.avatar?``:''}
Hesap girişi eklendiğinde bu adres kullanılacak.
ID${esc(userId())}
`;
el('sheet').classList.add('on'); el('scrim').classList.add('on'); sizeSheet();
bindSheetGrab();
}
/* Downscaled and re-encoded before it is stored. A phone photo is several megabytes
and prefs live in localStorage, which would blow its quota on the first pick and
take every other setting down with it. 256px is well past what a 104px avatar can
show even on a 3x screen. */
function setAvatarFromFile(file){
const rd=new FileReader();
rd.onload=()=>{
const img=new Image();
img.onload=()=>{
const S=256, c=document.createElement('canvas');
c.width=S; c.height=S;
const g=c.getContext('2d');
const side=Math.min(img.width,img.height);
g.drawImage(img,(img.width-side)/2,(img.height-side)/2,side,side,0,0,S,S);
try{
prefs.avatar=c.toDataURL('image/jpeg',0.85);
savePrefs(); openProfileEditor(); renderProfile();
}catch(_){ toast('Fotoğraf kaydedilemedi'); }
};
img.onerror=()=>toast('Görsel okunamadı');
img.src=rd.result;
};
rd.readAsDataURL(file);
}
/* ---------- premium ----------
One price, one screen. The comparison is a single list rather than two columns of
ticks: what someone is actually deciding is "what changes if I pay", and a row that
states the free limit beside the premium value answers that directly, where a grid
of check marks only manages to say "more". */
const PREMIUM_PRICE=85, PREMIUM_CUR='TL';
const PREMIUM_FEATURES=[
{t:'120 fps dışa aktarma', free:'Sınırlı', yes:true},
{t:'Tüm efektler (300+)', free:'Sınırlı', yes:true},
{t:'.hmp şablon dışa aktarma', free:'Sınırlı', yes:true},
{t:'Reklamsız kullanım', free:'Sınırlı', yes:true},
{t:'Öncelikli destek', free:'Sınırlı', yes:true},
];
let pmPlan='premium';
function isPremium(){
return (prefs.premiumHistory||[]).some(x=>x.endAt&&x.endAt>Date.now());
}
function premiumEndsAt(){
const h=(prefs.premiumHistory||[]).filter(x=>x.endAt&&x.endAt>Date.now());
return h.length?Math.max.apply(null,h.map(x=>x.endAt)):0;
}
function openPremium(){
const owned=isPremium();
const onFree=(pmPlan==='free');
const feats=PREMIUM_FEATURES.map((f,i)=>{
const has=onFree?false:true;
return `
${owned
? 'Premium aktif · '+fmtShortDate(premiumEndsAt())+' tarihine kadar'
: '1 ay boyunca tüm özellikler açık. Otomatik yenileme yok.'}
${feats}
${owned
? ``
: onFree
? `
Free planla devam edebilirsin — bu ekranı istediğin zaman tekrar açabilirsin.
`
: `
Ödeme ekranı bir sonraki adımda açılır. Kodun varsa Ayarlar › Kampanya Kodları.
`}`;
el('sheet').classList.add('tall'); el('sheet').classList.add('on'); el('scrim').classList.add('on'); sizeSheet(); sizeSheet();
bindSheetGrab();
}
/* ---------- checkout ---------- */
// Card collection was removed entirely: Google Play prohibits apps from collecting card
// payment directly for in-app digital purchases (Play Billing is required instead), and
// this app is aiming for Play Store distribution - so this is now a placeholder that's
// honest about the state ("bu akış APK sürümünde açılacak") rather than a working-looking
// card form with nowhere real for the data to go. Real Play Billing wiring is what
// replaces the toast below once the APK build exists.
function openCheckout(){
el('sheet').innerHTML=`
Ödeme, Google Play'in kendi ekranında tamamlanır. Bakiyeni, kayıtlı ödeme yöntemlerini ve hediye kartlarını orada kullanabilirsin — kart bilgisi istemiyoruz.
`;
el('sheet').classList.add('tall'); el('sheet').classList.add('on'); el('scrim').classList.add('on'); sizeSheet(); sizeSheet();
bindSheetGrab();
wireCheckout();
}
function wireCheckout(){
// Nothing to validate here - the platform's billing sheet owns this flow, and
// pretending otherwise would be inventing a checkout that does not exist yet.
el('coPay').addEventListener('click',()=>{
toast('Google Play ödeme akışı APK sürümünde açılacak');
});
}
/* Placeholder screen. The fields are real and stored, but nothing authenticates yet -
which is stated on the screen rather than implied by a working-looking form. */
function openCredentials(){
el('sheet').innerHTML=`
E-Posta ve Şifre
Hesap girişi henüz açık değil. Buraya girdiğin e-posta cihazda saklanır; şifre, giriş sistemi eklendiğinde devreye girecek.
`;
el('sheet').classList.add('on'); el('scrim').classList.add('on'); sizeSheet();
bindSheetGrab();
}
/* ---------- Picture to 3D (TripoSR, tamamen cihaz üzerinde) ----------
Modeller HTML'e GÖMÜLÜ DEĞİL: "Oluşturmaya Başla"ya ilk basışta Hugging Face'ten indirilir
ve IndexedDB'ye kaydedilir ('hm-models' / 'f' - Klon Ses ile aynı depo). Sonraki seferlerde
internet gerekmez (ONNX Runtime'ın kendisi hariç; o da tarayıcı önbelleğinden gelir).
Zincir: görsel -> arka plan ayırma + 512x512 (nesne %85, gri zemin, TripoSR'ın beklediği gibi)
-> kodlayıcı (triposr_encoder_int8.onnx) -> triplane [1,3,40,64,64]
-> her 3D noktada 3 düzlemden bilinear örnek (120 özellik) -> MLP (mlp_quantized.onnx)
-> yoğunluk = exp(ham - 1), eşik 25, yarıçap 0.87 -> marching cubes -> vertex renkleri
-> küçük parçacık temizliği -> GLB.
Yoğunluk gridi 256^3 (TripoSR'ın kendi varsayılanı). 16.7M noktanın hepsini MLP'ye sokmak
telefonda dakikalar sürer; bunun yerine önce kaba grid (her 4 vokselde bir) hesaplanır, yüzeye
yakın bloklar (+1 blok komşuluk) tam çözünürlükte hesaplanır, geri kalan boşluk/iç kısım kaba
değerlerden doldurulur. Yüzey her zaman tam 256 çözünürlükte çıkar - kaliteden kısılan bir şey yok.
URL'leri kendi deponuza taşımak isterseniz yalnızca P3D_FILES'ı değiştirmeniz yeterli. */
const P3D_FILES = {
enc:{key:'p3d-enc-int8', label:'3D kodlayıcı', mb:416,
url:'https://huggingface.co/fernandotonon/QtMeshEditor-triposr-onnx/resolve/main/triposr_encoder_int8.onnx'},
mlp:{key:'p3d-mlp-q', label:'3D çözücü', mb:0.07,
url:'https://huggingface.co/yapaymizeka/image-to-3d/resolve/main/mlp_quantized.onnx'}
};
const P3D_ORT_BASE = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/';
const P3D_RES = 256, P3D_RADIUS = 0.87, P3D_BLOCK = 4;
const P3D_ISO = 1 + Math.log(25); // exp(ham-1) > 25 <=> ham > 1+ln25
const P3D_MARGIN = 3.0; // ham yoğunlukta "yüzeye yakın" payı
const P3D_BATCH = 32768;
/* Marching cubes üçgen tablosu (klasik Lorensen), 256 durum; her karakter bir kenar (0-b). */
const P3D_TRI_SRC = ";308;019;839931;2a1;083a12;90aa02;2a3a83a98;b23;b28820;190b23;1929b298b;3b11ba;0818a18ba;3b0b90ba9;b98ba9;478;307704;478190;479719731;478a12;470730a12;2a0a90784;a943a4734a32;84723b;b27247204;78419023b;219294b247b4;a1b13b478;ba7a07047a10;84793ba9b039;947b97a9b;954;954308;014415;834354315;5492a1;308549a12;54a42a402;2a33a8a58548;95423b;b28208549;541401b23;154b148b421b;3b1ba1495;954a08ba810a;4050b5ba5b03;a548a4ba8;958857;950530573;018178157;357153;7858952a1;a12930953573;0282585782a5;32a53a735;95857823b;57b05b20b509;23b078017157;7b2172571;ba3a13857895;0ba10a07b509057;0578070a5b030ba;a57a7b;5a6;308a65;a65019;83931965a;652251;652512830;905065026;985825265832;23b5a6;0828b25a6;b239015a6;a65b198b921b;65b53b513;8b0b505105b6;65bb53593903;b659b58b9;784a65;307047a65;847a65190;5a6147317941;125265847;516126704730;478690260596;926596932794973;47823ba65;a65b47b24204;47801923ba65;7b4b24294921a65;784365135b63;b047b4b105b6b51;b63653503059478;b659b5b47b94;49669a;a69649308;a61601640;31816864861a;129249264;308149124264;064026;483243642;4969a63b2;b0820b649a69;23b0a64061a0;18b21b14861a164;64b41b13b491;1649141b681018b;03b60b406;b64b48;7868a689a;730a709a067a;1a6167017807;1a6716317;267927897291;973093967291926;678068260;673632;b23a789a867a;79a67a70927b720;80701716761a23b;7b21727a671a;613b63691867689;b67910;03b60b078067;b67;6b7;3086b7;6b7190;1939836b7;b7612a;6b708312a;90a02a76b;6b72832a8a98;763362;768608620;763623901;627297987921;a16176137;a16617107087;3707a0a9076a;876a8698a;6b44b8;30b06b046;6b4b84190;46b14b31b941;84b46b12a;12a36b306046;0292a94b846b;346b36394a323a9;843423462;604206;019284624382;219429624;13861846816a;6a1061460;3a903936a438346;94696a;6b7954;0836b7549;014154b76;b76854835315;954b762a1;12a6b7308549;6b7254024a52;3282a8a484a56b7;236376954;549708760620;054150376236;815485821687862;954a76a17137;6a7a17187810954;a376a7a034a5a40;876a868548a5;6b5b95b89;56b5b3953093;b805b0150b56;56b35b153;2a16956b9b89;0939535b3b56a12;502a52580b565b8;32a53a36b356;895285625382;095605206;862382856180815;562521;68956963816a613;6a1061695609;0386a5;a56;5a77ab;5a7ab7083;b7a75a019;5b7ab5983193;b72712751;3081b75172b1;75b50b02b059;29832825972b275;23a35a375;208528758a25;190523753a25;275a25287921298;537513;108718517;590350753;598587;5a4a84ab8;045b05ab50b3;1905845a8ab8;4319414b3a454ab;5141b4b8412b;b512b1b450b3b04;5b845852b059502;59432b;a23a385a8458;45a24a042;4585a8a383a2190;45a24a419421;384534135;104145;590350584538;594;4979b79ab;0834b749b9ab;ab70a74071a0;4ab74b41a348431;1299242b4b74;91412427472b308;2b7427024;4832434b742b;9a4a343743a2;7208707a297479a;a401a0a743a2a37;7481a2;749179371;749179708710;037074;748;9b8ab9;b309b0ab9;801a81b8a;b31b1a;912b9289b;912b929309b3;2b8280;2b3;a238a39a8;09a0a2;801a818238a2;a21;389391;109;038;";
const P3D = (()=>{
/* ---------- depolama ---------- */
/* Bazı WebView'larda IndexedDB hiç cevap vermeden bekleyebiliyor; o yüzden her işlem süreli. */
const withTimeout = (p, ms, msg)=>Promise.race([p, new Promise((_,rej)=>setTimeout(()=>rej(new Error(msg)), ms))]);
let dbP = null;
const idb = ()=>dbP || (dbP = withTimeout(idbOpen(), 6000, 'Cihaz depolaması açılamadı').catch(e=>{ dbP=null; throw e; }));
const idbOpen = ()=>new Promise((res,rej)=>{ const r=indexedDB.open('hm-models',1);
r.onupgradeneeded=()=>{ if(!r.result.objectStoreNames.contains('f')) r.result.createObjectStore('f'); };
r.onsuccess=()=>res(r.result); r.onerror=()=>rej(r.error); });
const dbGet = async k=>{ const d=await idb(); return withTimeout(new Promise((res,rej)=>{ const q=d.transaction('f').objectStore('f').get(k); q.onsuccess=()=>res(q.result||null); q.onerror=()=>rej(q.error); }), 8000, 'Cihaz depolaması cevap vermedi'); };
const dbPut = async (k,v)=>{ const d=await idb(); return withTimeout(new Promise((res,rej)=>{ const t=d.transaction('f','readwrite'); t.objectStore('f').put(v,k); t.oncomplete=()=>res(); t.onerror=()=>rej(t.error); t.onabort=()=>rej(t.error||new Error('Kaydedilemedi (depolama dolu olabilir)')); }), 120000, 'Kaydetme çok uzun sürdü'); };
async function cachedSize(f){ try{ const v=await dbGet(f.key); return v ? (v.size!=null ? v.size : v.byteLength) : 0; }catch(_){ return 0; } }
/* İndirme: parçalar 16MB'lık Blob'lara dönüştürülerek toplanır, böylece 416MB'lık dosya indirilirken
bellekte ikinci bir kopyası tutulmaz. Bağlantı koparsa kaldığı yerden (Range) devam eder. */
async function download(f, onBytes, isCancelled, onStatus){
const CH = 16*1048576;
let got = 0, total = 0, blobs = [], cur = null, curLen = 0, tries = 0, lastErr = null;
const flush = ()=>{ if(cur && curLen){ blobs.push(new Blob([cur.subarray(0,curLen)])); } cur=null; curLen=0; };
for(;;){
try{
/* Takılma bekçisi: bağlantı kurulamazsa ya da 20 sn boyunca hiç veri gelmezse bağlantı
kesilip yeniden denenir. Eskiden böyle bir durumda ekran sonsuza kadar %0'da kalıyordu. */
const ac = new AbortController(); let lastData = Date.now(), stalled = false;
const wd = setInterval(()=>{ if(Date.now()-lastData > 20000){ stalled = true; ac.abort(); } }, 1000);
onStatus && onStatus(tries ? f.label+': bağlantı yenileniyor ('+(tries+1)+'. deneme)' : f.label+': sunucuya bağlanılıyor…');
let r;
try{ r = await fetch(f.url, Object.assign({signal: ac.signal, cache:'no-store'}, got ? {headers:{Range:'bytes='+got+'-'}} : {})); }
catch(e){ clearInterval(wd); throw new Error(stalled ? 'Sunucu cevap vermedi' : ('Bağlantı hatası: '+(e && e.message || e))); }
if(got && r.status !== 206){ got = 0; blobs = []; cur = null; curLen = 0; }
if(!r.ok) throw new Error(f.label+' indirilemedi ('+r.status+')');
if(!got) total = +r.headers.get('content-length') || 0;
onStatus && onStatus(f.label+': sunucu cevap verdi — HTTP '+r.status+', '+(total ? (total/1048576).toFixed(1)+' MB' : 'boyut bildirilmedi')+(r.redirected ? ', yönlendirildi' : ''), true);
if(!r.body){ clearInterval(wd); throw new Error('Tarayıcı akışlı indirmeyi desteklemiyor'); }
const rd = r.body.getReader();
for(;;){
if(isCancelled()){ clearInterval(wd); try{ rd.cancel(); }catch(_){} throw new Error('iptal'); }
let chunk;
try{ chunk = await rd.read(); }
catch(e){ clearInterval(wd); throw new Error(stalled ? 'İndirme 20 sn boyunca ilerlemedi' : ('İndirme kesildi: '+(e && e.message || e))); }
const {done, value} = chunk;
if(done) break;
lastData = Date.now();
let off = 0;
while(off < value.length){
if(!cur){ cur = new Uint8Array(CH); curLen = 0; }
const n = Math.min(CH-curLen, value.length-off);
cur.set(value.subarray(off, off+n), curLen); curLen += n; off += n;
if(curLen === CH) flush();
}
got += value.length;
onBytes(got, total || f.mb*1048576);
}
clearInterval(wd);
flush();
if(total && got !== total) throw new Error('Dosya eksik indi');
break;
}catch(e){
if(e.message === 'iptal') throw e;
flush();
lastErr = e;
onStatus && onStatus(f.label+': HATA — '+(e && (e.name+': '+e.message) || e)+(got ? ' ('+(got/1048576).toFixed(1)+' MB inmişti)' : ''), true);
if(++tries > 4) throw new Error(f.label+' indirilemedi ('+(e && e.message || 'bilinmeyen hata')+'). Model dosyalarını telefondan da seçebilirsin.');
await new Promise(r=>setTimeout(r, 1500*tries));
}
}
const blob = new Blob(blobs, {type:'application/octet-stream'});
/* Kaydedilemese bile bu oturumda bellekteki kopyayla devam edilir. */
try{ await dbPut(f.key, blob); }
catch(e){ console.warn('[P3D] model kaydedilemedi', e); toast('Model cihaza kaydedilemedi; bir sonraki seferde tekrar inecek'); }
return blob;
}
/* ---------- ONNX Runtime ---------- */
let ortP = null;
const loadOrt = ()=>ortP || (ortP = new Promise((res,rej)=>{
if(window.ort){ res(window.ort); return; }
const s = document.createElement('script'); s.src = P3D_ORT_BASE + 'ort.min.js';
s.onload = ()=>{ const o=window.ort; o.env.wasm.wasmPaths = P3D_ORT_BASE;
o.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency||2) : 1; res(o); };
s.onerror = ()=>{ ortP = null; rej(new Error('ONNX çalışma zamanı yüklenemedi (internet?)')); };
document.head.appendChild(s);
}));
async function session(blob){
const ort = await loadOrt();
let u8 = new Uint8Array(await blob.arrayBuffer());
const s = await ort.InferenceSession.create(u8, {executionProviders:['wasm']});
u8 = null;
return s;
}
/* ---------- görüntü hazırlama ---------- */
function loadImg(src){ return new Promise((res,rej)=>{ const i=new Image(); i.onload=()=>res(i); i.onerror=()=>rej(new Error('Görsel açılamadı')); i.src=src; }); }
async function prepImage(dataUrl){
const img = await loadImg(dataUrl);
const W0 = img.naturalWidth, H0 = img.naturalHeight;
const sc = Math.min(1, 1024/Math.max(W0,H0));
const W = Math.max(1, Math.round(W0*sc)), H = Math.max(1, Math.round(H0*sc));
const cv = document.createElement('canvas'); cv.width=W; cv.height=H;
const cx = cv.getContext('2d', {willReadFrequently:true});
cx.imageSmoothingQuality = 'high';
cx.drawImage(img, 0, 0, W, H);
const id = cx.getImageData(0,0,W,H), d = id.data, N = W*H;
const mask = new Uint8Array(N);
let transparent = 0;
for(let p=0;p N*0.01){
for(let p=0;p 127 ? 1 : 0;
} else {
/* Düz arka plan tahmini: kenar piksellerinin medyan rengi; kenardan başlayan ve bu renge
yakın kalan bölge arka plandır (taşma dolgusu). Nesnenin içindeki benzer renkler kenara
bağlı olmadığı için korunur. */
const bi = [];
for(let x=0;x{ const a=bi.map(p=>d[p*4+c]).sort((u,v)=>u-v); return a[a.length>>1]; };
const br=ch(0), bg=ch(1), bb=ch(2);
const dist = p=>{ const r=d[p*4]-br, g=d[p*4+1]-bg, b=d[p*4+2]-bb; return Math.sqrt(r*r+g*g+b*b); };
const bd = bi.map(dist).sort((u,v)=>u-v);
const tol = Math.min(80, Math.max(26, bd[Math.floor(bd.length*0.9)]*2.2));
const isBg = new Uint8Array(N), st = new Int32Array(N); let sp = 0;
for(const p of bi){ if(!isBg[p] && dist(p) < tol){ isBg[p]=1; st[sp++]=p; } }
while(sp){ const p=st[--sp], x=p%W, y=(p/W)|0;
const nb = [x>0?p-1:-1, x0?p-W:-1, y=0 && !isBg[q] && dist(q) < tol){ isBg[q]=1; st[sp++]=q; } } }
for(let p=0;p=0) continue;
const L = sizes.length; let n=0; sp=0; st[sp++]=p; lab[p]=L;
while(sp){ const q=st[--sp], x=q%W, y=(q/W)|0; n++;
const nb=[x>0?q-1:-1, x0?q-W:-1, y=0 && mask[r] && lab[r]<0){ lab[r]=L; st[sp++]=r; } } }
sizes.push(n); }
const big = sizes.length ? Math.max(...sizes) : 0;
for(let p=0;px1)x1=x; if(yy1)y1=y; }
let isolated = true;
if(area < N*0.01 || (area > N*0.92)){ isolated = false; mask.fill(1); x0=0; y0=0; x1=W-1; y1=H-1; }
// Yumuşak kenar (3x3 ortalama) -> alfa
const cut = document.createElement('canvas'); cut.width=W; cut.height=H;
const ccx = cut.getContext('2d'); const od = ccx.createImageData(W,H), o = od.data;
for(let y=0;y=H) continue;
for(let dx=-1;dx<=1;dx++){ const xx=x+dx; if(xx<0||xx>=W) continue; s+=mask[yy*W+xx]; c++; } }
const p=(y*W+x)*4; o[p]=d[p]; o[p+1]=d[p+1]; o[p+2]=d[p+2]; o[p+3]=Math.round(255*s/c); }
ccx.putImageData(od,0,0);
const bw = x1-x0+1, bh = y1-y0+1, S = 512, s = 0.85*S/Math.max(bw,bh);
const out = document.createElement('canvas'); out.width=S; out.height=S;
const ox = out.getContext('2d', {willReadFrequently:true});
ox.fillStyle = 'rgb(128,128,128)'; ox.fillRect(0,0,S,S);
ox.imageSmoothingQuality = 'high';
ox.drawImage(cut, x0, y0, bw, bh, (S-bw*s)/2, (S-bh*s)/2, bw*s, bh*s);
const px = ox.getImageData(0,0,S,S).data, n = S*S, input = new Float32Array(3*n);
for(let p=0;p düzlem başına kanal-son dizi
const [, P, C, H, W] = t.dims, src = t.data, planes = [];
for(let p=0;p
{ if(w===0 || x<0 || y<0 || x>=W || y>=H) return; const b=(y*W+x)*C; for(let c=0;c/dens/i.test(s)) || mlp.outputNames[0];
const cName = mlp.outputNames.find(s=>/col/i.test(s)) || mlp.outputNames[1];
for(let s=0;ssetTimeout(r,0));
}
return {dens, col};
}
/* ---------- yoğunluk gridi (kaba -> ince) ---------- */
async function densityGrid(ort, mlp, tp, onProg, isCancelled){
const R = P3D_RES, B = P3D_BLOCK, step = 2/(R-1);
const cid = []; for(let i=0;i -1 + i*step;
// 1) kaba grid
const cx = new Float32Array(NC*NC*NC*3); let q = 0;
for(let k=0;konProg(f*0.2), isCancelled)).dens;
const CI = (i,j,k)=> i + NC*(j + NC*k);
// 2) yüzeye yakın bloklar
const act = new Uint8Array(NB*NB*NB);
for(let k=0;k>1)&1), k+((c>>2)&1))]; if(vmx)mx=v; }
if(mx > P3D_ISO-P3D_MARGIN && mn < P3D_ISO+P3D_MARGIN) act[i+NB*(j+NB*k)] = 1;
}
const act2 = new Uint8Array(act.length);
for(let k=0;k=NB||b>=NB||c>=NB) continue; act2[a+NB*(b+NB*c)] = 1; } }
// 3) tüm grid kaba değerlerden (trilinear) doldurulur
const RR = R*R, field = new Float32Array(R*RR);
const bOf = new Int32Array(R), tOf = new Float32Array(R);
for(let i=0;ionProg(0.2+0.8*f), isCancelled)).dens;
for(let t=0;tInt8Array.from(s, ch=>parseInt(ch,16)));
async function marchingCubes(F, R, iso, onProg, isCancelled){
const RR = R*R;
let pos = new Float32Array(1<<20), np = 0, ind = new Uint32Array(1<<21), ni = 0;
let xe0=new Int32Array(RR).fill(-1), ye0=new Int32Array(RR).fill(-1);
let xe1=new Int32Array(RR), ye1=new Int32Array(RR); const ze=new Int32Array(RR);
const v = new Float32Array(8), vid = new Int32Array(12);
const CO = [[0,0,0],[1,0,0],[1,1,0],[0,1,0],[0,0,1],[1,0,1],[1,1,1],[0,1,1]];
const ED = [[0,1],[1,2],[3,2],[0,3],[4,5],[5,6],[7,6],[4,7],[0,4],[1,5],[2,6],[3,7]];
const addV = (i,j,k,e)=>{
const a = ED[e][0], b = ED[e][1], va = v[a], vb = v[b];
let t = (iso - va)/(vb - va); if(!(t>=0)) t=0; if(t>1) t=1;
if(np+3 > pos.length){ const n2=new Float32Array(pos.length*2); n2.set(pos); pos=n2; }
pos[np++] = i + CO[a][0] + t*(CO[b][0]-CO[a][0]);
pos[np++] = j + CO[a][1] + t*(CO[b][1]-CO[a][1]);
pos[np++] = k + CO[a][2] + t*(CO[b][2]-CO[a][2]);
return np/3 - 1;
};
for(let k=0;k iso) cs |= 1< ind.length){ const n2=new Uint32Array(ind.length*2); n2.set(ind); ind=n2; }
ind[ni++]=vid[0]; ind[ni++]=vid[2]; ind[ni++]=vid[1]; // dışa bakan yön
}
}
}
let tx=xe0; xe0=xe1; xe1=tx; tx=ye0; ye0=ye1; ye1=tx;
if(k%8===7){ onProg((k+1)/(R-1)); await new Promise(r=>setTimeout(r,0)); }
}
return {pos: pos.subarray(0,np), ind: ind.subarray(0,ni)};
}
/* ---------- küçük parçacık temizliği ---------- */
function removeSmall(pos, col, ind){
const nv = pos.length/3, par = new Int32Array(nv); for(let i=0;i{ while(par[x]!==x){ par[x]=par[par[x]]; x=par[x]; } return x; };
const u = (a,b)=>{ a=f(a); b=f(b); if(a!==b) par[a]=b; };
for(let t=0;t{ if(c>big) big=c; });
const keepMin = Math.max(24, big*0.02);
const remap = new Int32Array(nv).fill(-1); let nk = 0;
const ni = []; let removed = 0;
const outInd = new Uint32Array(ind.length); let oi = 0;
for(let t=0;t c<=0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
function buildGLB(pos, nrm, col, ind){
const nv = pos.length/3, lin = new Float32Array(col.length);
for(let i=0;imx[c])mx[c]=v; }
const u16 = nv < 65536, idxArr = u16 ? Uint16Array.from(ind) : Uint32Array.from(ind);
const parts = [pos, nrm, lin, idxArr], views = [], pad4 = x=>(x+3)&~3; let off = 0;
for(const p of parts){ views.push({buffer:0, byteOffset:off, byteLength:p.byteLength}); off = pad4(off + p.byteLength); }
views[0].target = views[1].target = views[2].target = 34962; views[3].target = 34963;
const json = {
asset:{version:'2.0', generator:'HyperMagic Studio - Picture To 3D (TripoSR)'},
scene:0, scenes:[{nodes:[0]}], nodes:[{mesh:0, name:'PictureTo3D'}],
meshes:[{name:'PictureTo3D', primitives:[{attributes:{POSITION:0, NORMAL:1, COLOR_0:2}, indices:3, material:0}]}],
materials:[{name:'VertexColor', pbrMetallicRoughness:{baseColorFactor:[1,1,1,1], metallicFactor:0, roughnessFactor:0.85}}],
buffers:[{byteLength:off}], bufferViews:views,
accessors:[
{bufferView:0, componentType:5126, count:nv, type:'VEC3', min:mn, max:mx},
{bufferView:1, componentType:5126, count:nv, type:'VEC3'},
{bufferView:2, componentType:5126, count:nv, type:'VEC3'},
{bufferView:3, componentType:u16?5123:5125, count:ind.length, type:'SCALAR'}]
};
let js = new TextEncoder().encode(JSON.stringify(json));
const jl = pad4(js.length), jb = new Uint8Array(jl).fill(0x20); jb.set(js);
const bin = new Uint8Array(off); let o = 0;
for(const p of parts){ bin.set(new Uint8Array(p.buffer, p.byteOffset, p.byteLength), o); o = pad4(o + p.byteLength); }
const total = 12 + 8 + jl + 8 + off, out = new ArrayBuffer(total), dv = new DataView(out);
dv.setUint32(0,0x46546C67,true); dv.setUint32(4,2,true); dv.setUint32(8,total,true);
dv.setUint32(12,jl,true); dv.setUint32(16,0x4E4F534A,true); new Uint8Array(out,20,jl).set(jb);
dv.setUint32(20+jl,off,true); dv.setUint32(24+jl,0x004E4942,true); new Uint8Array(out,28+jl,off).set(bin);
return new Blob([out], {type:'model/gltf-binary'});
}
return {cachedSize, download, dbGet, dbPut, withTimeout, loadOrt, session, prepImage, makePlanes,
evalPoints, densityGrid, marchingCubes, removeSmall, normals, buildGLB, sampleFeatures, TRI};
})();
/* ---------- ekranlar ---------- */
let p3dState = null, p3dRun = null;
const p3dLocal = {enc:null, mlp:null}; // telefondan seçilen, depoya yazılamamış modeller (bu oturum)
/* İnternetten inmezse: .onnx dosyaları telefondan seçilir. Hangisinin hangisi olduğu boyuttan anlaşılır
(kodlayıcı ~416 MB, çözücü ~67 KB). */
function p3dImportModels(){
const inp = document.createElement('input'); inp.type = 'file'; inp.multiple = true;
inp.onchange = async ()=>{
const files = [...(inp.files||[])]; if(!files.length) return;
let n = 0;
for(const f of files){
const k = f.size > 100*1048576 ? 'enc' : (f.size < 5*1048576 && f.size > 10000 ? 'mlp' : null);
if(!k){ toast(f.name+' tanınmadı'); continue; }
toast((k==='enc' ? '3D kodlayıcı' : '3D çözücü')+' kaydediliyor…');
p3dLocal[k] = f; n++;
try{ await P3D.dbPut(P3D_FILES[k].key, f); }
catch(e){ console.warn('[P3D] kayıt', e); toast('Cihaza kaydedilemedi; bu oturum boyunca kullanılacak'); }
}
if(!n) return;
const [a,b] = await Promise.all([P3D.cachedSize(P3D_FILES.enc), P3D.cachedSize(P3D_FILES.mlp)]);
const have = (a || p3dLocal.enc) && (b || p3dLocal.mlp);
if(p3dState){ p3dState.ready = !!have; const nt = el('p3dNote'); if(nt) nt.innerHTML = p3dNoteHtml(); }
toast(have ? 'Modeller hazır' : 'Bir dosya daha lazım: '+(!(a||p3dLocal.enc) ? 'triposr_encoder_int8.onnx' : 'mlp_quantized.onnx'));
};
inp.click();
}
function openPictureTo3D(){
p3dState = { file:null, dataUrl:null, ready:null };
renderP3DSheet();
el('sheet').classList.add('on'); el('scrim').classList.add('on'); sizeSheet();
bindSheetGrab();
Promise.all([P3D.cachedSize(P3D_FILES.enc), P3D.cachedSize(P3D_FILES.mlp)]).then(([a,b])=>{
if(!p3dState) return; p3dState.ready = !!(a && b); const n = el('p3dNote'); if(n) n.innerHTML = p3dNoteHtml();
});
}
function p3dNoteHtml(){
const r = p3dState && p3dState.ready;
return `${r===true
? 'Model cihazda hazır — internet gerekmeden çalışır.'
: 'İlk kullanımda 3D modeli (~'+P3D_FILES.enc.mb+' MB) indirilip cihaza kaydedilir; sonraki seferlerde tekrar indirilmez. Wi-Fi önerilir.'}
En iyi sonuç: tek nesne, sade/düz arka plan ya da arka planı silinmiş PNG.`;
}
function renderP3DSheet(){
const has = !!p3dState.dataUrl;
el('sheet').innerHTML=`