Files
Get-VRCMetaData/vrc_meta_viewer.html

832 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VRC Meta Viewer</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Outfit:wght@300;400;600;700&display=swap');
:root {
--bg: #0a0c14;
--panel: #0f1220;
--border: #1e2540;
--accent: #00e5ff;
--accent2: #7c3aed;
--accent3: #f472b6;
--text: #c9d4ef;
--muted: #4a5580;
--success: #10d98a;
--mono: 'Share Tech Mono', monospace;
--sans: 'Outfit', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
overflow-x: hidden;
}
/* grid bg */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,229,255,.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,229,255,.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.wrap {
position: relative;
z-index: 1;
max-width: 960px;
margin: 0 auto;
padding: 48px 24px;
}
/* header */
header {
display: flex;
align-items: baseline;
gap: 16px;
margin-bottom: 40px;
}
.logo {
font-family: var(--mono);
font-size: 11px;
letter-spacing: .2em;
color: var(--accent);
text-transform: uppercase;
opacity: .7;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -.02em;
color: #fff;
}
h1 span { color: var(--accent); }
/* drop zone */
#dropzone {
border: 2px dashed var(--border);
border-radius: 16px;
padding: 64px 32px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
position: relative;
overflow: hidden;
}
#dropzone::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 120%, rgba(0,229,255,.06) 0%, transparent 70%);
pointer-events: none;
}
#dropzone:hover, #dropzone.over {
border-color: var(--accent);
background: rgba(0,229,255,.03);
}
#dropzone .icon {
font-size: 48px;
margin-bottom: 16px;
opacity: .5;
}
#dropzone p {
color: var(--muted);
font-size: 15px;
}
#dropzone p strong {
color: var(--accent);
font-weight: 600;
}
#fileInput { display: none; }
/* results */
#results { display: none; margin-top: 40px; }
.preview-wrap {
display: grid;
grid-template-columns: 280px 1fr;
gap: 24px;
margin-bottom: 32px;
align-items: start;
}
@media (max-width: 640px) {
.preview-wrap { grid-template-columns: 1fr; }
}
.preview-img {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
position: relative;
}
.preview-img img {
width: 100%;
display: block;
max-height: 280px;
object-fit: cover;
}
.preview-badge {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(10,12,20,.85);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 10px;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
}
.file-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-name {
font-size: 20px;
font-weight: 600;
color: #fff;
word-break: break-all;
}
.file-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.stat-chip {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.stat-chip b { color: var(--text); }
/* section */
.section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid transparent;
transition: background .15s;
}
.section-head:hover { background: rgba(255,255,255,.02); }
.section-head.open { border-bottom-color: var(--border); }
.section-tag {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .15em;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.tag-basic { background: rgba(0,229,255,.15); color: var(--accent); }
.tag-exif { background: rgba(124,58,237,.2); color: #a78bfa; }
.tag-gps { background: rgba(16,217,138,.15); color: var(--success); }
.tag_vrc { background: rgba(244,114,182,.15);color: var(--accent3); }
.tag-iptc { background: rgba(251,191,36,.15); color: #fbbf24; }
.tag-png { background: rgba(99,102,241,.2); color: #818cf8; }
.tag-raw { background: rgba(75,85,99,.3); color: #9ca3af; }
.tag-xmp { background: rgba(234,179,8,.15); color: #facc15; }
.section-title {
font-weight: 600;
font-size: 14px;
color: #fff;
flex: 1;
}
.section-count {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.chevron {
color: var(--muted);
transition: transform .2s;
font-size: 12px;
}
.chevron.open { transform: rotate(90deg); }
.section-body {
display: none;
padding: 0;
}
.section-body.open { display: block; }
table { width: 100%; border-collapse: collapse; }
tr { border-bottom: 1px solid rgba(30,37,64,.5); }
tr:last-child { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,.015); }
td {
padding: 10px 20px;
font-size: 13px;
vertical-align: top;
line-height: 1.5;
}
td:first-child {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
width: 240px;
white-space: nowrap;
}
td:last-child {
color: var(--text);
word-break: break-all;
}
.highlight-val {
color: var(--accent);
font-weight: 600;
}
/* VRC section special */
.vrc-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(90deg, rgba(244,114,182,.08), transparent);
border-bottom: 1px solid var(--border);
}
.vrc-dot {
width: 8px; height: 8px;
background: var(--accent3);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(244,114,182,.4); }
50% { box-shadow: 0 0 0 6px rgba(244,114,182,0); }
}
.vrc-label { font-size: 13px; color: var(--accent3); font-weight: 600; }
/* no meta */
.empty-state {
padding: 24px 20px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
/* reset btn */
.btn-reset {
margin-top: 32px;
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-family: var(--sans);
font-size: 13px;
padding: 10px 24px;
border-radius: 8px;
cursor: pointer;
transition: border-color .2s, color .2s;
}
.btn-reset:hover { border-color: var(--accent); color: var(--accent); }
/* copy */
.copy-all-wrap { text-align: right; padding: 0 20px 16px; }
.btn-copy {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
letter-spacing: .1em;
padding: 5px 14px;
border-radius: 6px;
cursor: pointer;
transition: all .2s;
}
.btn-copy:hover { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="logo">// tool</span>
<h1>VRC <span>Meta</span> Viewer</h1>
</header>
<div id="dropzone" onclick="document.getElementById('fileInput').click()">
<input type="file" id="fileInput" accept="image/*">
<div class="icon">🖼️</div>
<p><strong>クリックして画像を選択</strong> または ドラッグ&ドロップ</p>
<p style="margin-top:8px;font-size:12px;">PNG / JPEG / WebP / TIFF など対応</p>
</div>
<div id="results"></div>
</div>
<script>
// ─── EXIF tag dictionaries ────────────────────────────────────────────
const EXIF_TAGS = {
0x010E:'ImageDescription',0x010F:'Make',0x0110:'Model',0x0112:'Orientation',
0x011A:'XResolution',0x011B:'YResolution',0x0128:'ResolutionUnit',
0x0131:'Software',0x0132:'DateTime',0x013B:'Artist',
0x013E:'WhitePoint',0x013F:'PrimaryChromaticities',
0x0211:'YCbCrCoefficients',0x0213:'YCbCrPositioning',
0x0214:'ReferenceBlackWhite',0x8298:'Copyright',
0x8769:'ExifIFD',0x8825:'GPSIFD',
0x9000:'ExifVersion',0x9003:'DateTimeOriginal',0x9004:'DateTimeDigitized',
0x9101:'ComponentsConfiguration',0x9102:'CompressedBitsPerPixel',
0x9201:'ShutterSpeedValue',0x9202:'ApertureValue',0x9203:'BrightnessValue',
0x9204:'ExposureBiasValue',0x9205:'MaxApertureValue',0x9206:'SubjectDistance',
0x9207:'MeteringMode',0x9208:'LightSource',0x9209:'Flash',
0x920A:'FocalLength',0x9214:'SubjectArea',
0xA000:'FlashpixVersion',0xA001:'ColorSpace',0xA002:'PixelXDimension',
0xA003:'PixelYDimension',0xA005:'InteroperabilityIFD',
0xA20E:'FocalPlaneXResolution',0xA20F:'FocalPlaneYResolution',
0xA210:'FocalPlaneResolutionUnit',0xA214:'SubjectLocation',
0xA215:'ExposureIndex',0xA217:'SensingMethod',
0xA300:'FileSource',0xA301:'SceneType',0xA302:'CFAPattern',
0xA401:'CustomRendered',0xA402:'ExposureMode',0xA403:'WhiteBalance',
0xA404:'DigitalZoomRatio',0xA405:'FocalLengthIn35mmFilm',
0xA406:'SceneCaptureType',0xA407:'GainControl',0xA408:'Contrast',
0xA409:'Saturation',0xA40A:'Sharpness',0xA40B:'DeviceSettingDescription',
0xA40C:'SubjectDistanceRange',0xA420:'ImageUniqueID',
0xA430:'CameraOwnerName',0xA431:'BodySerialNumber',
0xA432:'LensSpecification',0xA433:'LensMake',0xA434:'LensModel',
0x0100:'ImageWidth',0x0101:'ImageLength',0x0102:'BitsPerSample',
0x0103:'Compression',0x0106:'PhotometricInterpretation',
0x0111:'StripOffsets',0x0115:'SamplesPerPixel',0x0116:'RowsPerStrip',
0x0117:'StripByteCounts',0x011C:'PlanarConfiguration',
};
const GPS_TAGS = {
0:'GPSVersionID',1:'GPSLatitudeRef',2:'GPSLatitude',3:'GPSLongitudeRef',
4:'GPSLongitude',5:'GPSAltitudeRef',6:'GPSAltitude',7:'GPSTimeStamp',
8:'GPSSatellites',9:'GPSStatus',10:'GPSMeasureMode',11:'GPSDOP',
12:'GPSSpeedRef',13:'GPSSpeed',14:'GPSTrackRef',15:'GPSTrack',
16:'GPSImgDirectionRef',17:'GPSImgDirection',18:'GPSMapDatum',
27:'GPSProcessingMethod',29:'GPSDateStamp',
};
const ORIENTATION_MAP = {1:'正常',2:'水平反転',3:'180°回転',4:'垂直反転',5:'90°右回転+水平反転',6:'90°右回転',7:'90°左回転+水平反転',8:'90°左回転'};
const METERING_MAP = {0:'不明',1:'平均',2:'中央重点',3:'スポット',4:'マルチスポット',5:'マルチパターン',6:'部分'};
const FLASH_MAP = {0:'発光なし',1:'発光',5:'発光(ストロボなし)',7:'発光(ストロボあり)',9:'強制発光',13:'強制発光(ストロボなし)',15:'強制発光(ストロボあり)',16:'発光禁止',24:'自動(発光なし)',25:'自動(発光)',29:'自動(発光、ストロボなし)',31:'自動(発光、ストロボあり)',32:'フラッシュなし',65:'赤目軽減',69:'赤目軽減(ストロボなし)',71:'赤目軽減(ストロボあり)',73:'強制発光+赤目軽減',77:'強制発光+赤目軽減(ストロボなし)',79:'強制発光+赤目軽減(ストロボあり)',89:'自動+赤目軽減',93:'自動+赤目軽減(ストロボなし)',95:'自動+赤目軽減(ストロボあり)'};
const COLORSPACE_MAP= {1:'sRGB',65535:'未キャリブレーション'};
const RESOLUTION_UNIT_MAP={1:'なし',2:'インチ',3:'センチメートル'};
const EXPOSURE_MODE_MAP={0:'自動',1:'マニュアル',2:'自動ブラケット'};
const WHITE_BALANCE_MAP={0:'自動',1:'マニュアル'};
const SCENE_CAPTURE_MAP={0:'標準',1:'風景',2:'ポートレート',3:'夜景'};
const LIGHT_SOURCE_MAP={0:'不明',1:'太陽光',2:'蛍光灯',3:'タングステン',4:'フラッシュ',9:'晴天',10:'曇天',11:'日陰',12:'昼光色蛍光灯',13:'昼白色蛍光灯',14:'白色蛍光灯',15:'白熱電球',255:'その他'};
const SENSING_METHOD_MAP={1:'未定義',2:'1チップカラーエリアセンサ',3:'2チップカラーエリアセンサ',4:'3チップカラーエリアセンサ',5:'カラーシーケンシャルエリアセンサ',7:'トリリニアセンサ',8:'カラーシーケンシャルリニアセンサ'};
// ─── VRC-specific EXIF & PNG keyword detection ───────────────────────
const VRC_KEYS = ['vrc','vrchat','world','worldid','instanceid','author','photographerDisplayName'];
function isVRCKey(k) {
const l = k.toLowerCase();
return VRC_KEYS.some(v => l.includes(v));
}
// ─── Drag & Drop ──────────────────────────────────────────────────────
const dz = document.getElementById('dropzone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); handleFile(e.dataTransfer.files[0]); });
document.getElementById('fileInput').addEventListener('change', e => handleFile(e.target.files[0]));
function handleFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = e => parse(file, e.target.result);
reader.readAsArrayBuffer(file);
}
// ─── Main parse ───────────────────────────────────────────────────────
function parse(file, buf) {
const u8 = new Uint8Array(buf);
const result = {
basic: {
fileName: file.name,
fileSize: formatBytes(file.size),
fileType: file.type || '不明',
lastModified: new Date(file.lastModified).toLocaleString('ja-JP'),
},
exif: null,
gps: null,
iptc: null,
png: null,
vrc: [],
raw: [],
preview: null,
};
// preview
result.preview = URL.createObjectURL(file);
const sig2 = (u8[0] << 8) | u8[1];
const isPNG = u8[0]===137 && u8[1]===80 && u8[2]===78 && u8[3]===71;
if (sig2 === 0xFFD8) {
parseJPEG(u8, result);
} else if (isPNG) {
parsePNG(u8, result);
}
render(result, file);
}
// ─── JPEG parser ──────────────────────────────────────────────────────
function parseJPEG(u8, result) {
let off = 2;
while (off < u8.length - 4) {
if (u8[off] !== 0xFF) break;
const marker = (u8[off] << 8) | u8[off+1];
const len = (u8[off+2] << 8) | u8[off+3];
if (marker === 0xFFE1) {
const seg = u8.slice(off+4, off+2+len);
if (seg[0]===69&&seg[1]===120&&seg[2]===105&&seg[3]===102) {
readEXIF(seg.slice(6), result);
}
// XMP packet: "http://ns.adobe.com/xap/1.0/\0"
const xmpNs = [104,116,116,112,58,47,47,110,115,46,97,100,111,98,101,46,99,111,109,47,120,97,112,47,49,46,48,47,0];
if (xmpNs.every((b,i)=>seg[i]===b)) {
const xmpStr = new TextDecoder().decode(seg.slice(xmpNs.length));
readXMP(xmpStr, result);
}
}
if (marker === 0xFFED) {
const seg = u8.slice(off+4, off+2+len);
readIPTC(seg, result);
}
off += 2 + len;
}
}
// ─── EXIF reader ──────────────────────────────────────────────────────
function readEXIF(data, result) {
const le = data[0]===0x49;
const r16 = o => le ? (data[o]|(data[o+1]<<8)) : ((data[o]<<8)|data[o+1]);
const r32 = o => le ? (data[o]|(data[o+1]<<8)|(data[o+2]<<16)|(data[o+3]<<24)) : ((data[o]<<24)|(data[o+1]<<16)|(data[o+2]<<8)|data[o+3]);
function readIFD(ifdOff, tagDict, target) {
if (ifdOff < 0 || ifdOff + 2 > data.length) return;
const count = r16(ifdOff);
for (let i = 0; i < count; i++) {
const off = ifdOff + 2 + i * 12;
const tag = r16(off);
const type = r16(off+2);
const num = r32(off+4);
const name = tagDict[tag] || `Tag_0x${tag.toString(16).padStart(4,'0')}`;
// sub-IFD
if (tag === 0x8769 || tag === 0x8825) {
const subOff = r32(off+8);
readIFD(subOff, tag===0x8825?GPS_TAGS:EXIF_TAGS, tag===0x8825?(result.gps=result.gps||{}):(result.exif=result.exif||{}));
continue;
}
const val = readValue(data, r16, r32, type, num, off+8, le);
target[name] = val;
result.raw.push({tag:`0x${tag.toString(16).padStart(4,'0')}`, name, value: String(val)});
if (isVRCKey(name) || isVRCKey(String(val))) {
result.vrc.push({key: name, value: String(val)});
}
}
}
result.exif = {};
result.gps = {};
const ifd0 = r32(4);
readIFD(ifd0, EXIF_TAGS, result.exif);
}
function readValue(data, r16, r32, type, count, valOff, le) {
const BYTE_SIZES = [0,1,1,2,4,8,1,1,2,4,8,4,8];
const totalBytes = (BYTE_SIZES[type]||1) * count;
let dataOff = valOff;
if (totalBytes > 4) dataOff = le ? (data[valOff]|(data[valOff+1]<<8)|(data[valOff+2]<<16)|(data[valOff+3]<<24)) : ((data[valOff]<<24)|(data[valOff+1]<<16)|(data[valOff+2]<<8)|data[valOff+3]);
if (type===2) { // ASCII
let s=''; for(let i=0;i<count-1;i++) s+=String.fromCharCode(data[dataOff+i]||0); return s;
}
if (type===5||type===10) { // Rational
const vals=[];
for(let i=0;i<Math.min(count,4);i++){
const n=le?(data[dataOff+i*8]|(data[dataOff+i*8+1]<<8)|(data[dataOff+i*8+2]<<16)|(data[dataOff+i*8+3]<<24)):((data[dataOff+i*8]<<24)|(data[dataOff+i*8+1]<<16)|(data[dataOff+i*8+2]<<8)|data[dataOff+i*8+3]);
const d=le?(data[dataOff+i*8+4]|(data[dataOff+i*8+5]<<8)|(data[dataOff+i*8+6]<<16)|(data[dataOff+i*8+7]<<24)):((data[dataOff+i*8+4]<<24)|(data[dataOff+i*8+5]<<16)|(data[dataOff+i*8+6]<<8)|data[dataOff+i*8+7]);
vals.push(d===0?n:(n/d).toFixed(4));
}
return vals.join(', ');
}
if (type===3) { // SHORT
const vals=[];
for(let i=0;i<Math.min(count,8);i++) vals.push(r16(dataOff+i*2));
return count===1?vals[0]:vals.join(', ');
}
if (type===4||type===9) { // LONG
return r32(dataOff);
}
if (type===1||type===7) { // BYTE/UNDEFINED
const vals=[];
for(let i=0;i<Math.min(count,16);i++) vals.push(data[dataOff+i]);
if(count>16) return '['+vals.join(',')+' ...]';
return '['+vals.join(',')+']';
}
return `[type=${type},count=${count}]`;
}
// ─── IPTC reader ──────────────────────────────────────────────────────
const IPTC_TAGS = {5:'ObjectName',7:'EditStatus',10:'Urgency',15:'Category',20:'SupplementalCategories',22:'FixtureIdentifier',25:'Keywords',26:'ContentLocationCode',27:'ContentLocationName',30:'ReleaseDate',35:'ReleaseTime',37:'ExpirationDate',38:'ExpirationTime',40:'SpecialInstructions',55:'DateCreated',60:'TimeCreated',62:'DigitalCreationDate',63:'DigitalCreationTime',65:'OriginatingProgram',70:'ProgramVersion',80:'ByLine',85:'ByLineTitle',90:'City',92:'SubLocation',95:'Province',100:'CountryCode',101:'Country',103:'OriginalTransmissionReference',105:'Headline',110:'Credit',115:'Source',116:'Copyright',118:'Contact',120:'Caption',122:'Writer'};
function readIPTC(seg, result) {
result.iptc = {};
let i=0;
while(i<seg.length-4){
if(seg[i]===0x1C&&seg[i+1]===0x02){
const tag=seg[i+2];
const len=(seg[i+3]<<8)|seg[i+4];
const val=Array.from(seg.slice(i+5,i+5+len)).map(c=>String.fromCharCode(c)).join('');
const name=IPTC_TAGS[tag]||`IPTC_${tag}`;
result.iptc[name]=val;
if(isVRCKey(name)||isVRCKey(val)) result.vrc.push({key:name,value:val});
i+=5+len;
} else i++;
}
}
// ─── XMP parser ───────────────────────────────────────────────────────
function readXMP(xmlStr, result) {
result.xmp = {};
// Use DOMParser to parse the XMP XML
let doc;
try {
doc = new DOMParser().parseFromString(xmlStr, 'text/xml');
} catch(e) { return; }
// Friendly label map for known namespaces
const NS_LABELS = {
'http://ns.adobe.com/xap/1.0/': 'xmp',
'http://ns.vrchat.com/vrc/1.0/': 'vrc',
'http://purl.org/dc/elements/1.1/': 'dc',
'http://ns.adobe.com/tiff/1.0/': 'tiff',
'http://ns.adobe.com/exif/1.0/': 'exif',
'http://ns.adobe.com/photoshop/1.0/': 'photoshop',
'http://ns.adobe.com/xap/1.0/rights/': 'xmpRights',
};
function nsLabel(uri) {
return NS_LABELS[uri] || uri.split('/').filter(Boolean).pop() || uri;
}
function extractText(el) {
// rdf:Alt / rdf:Seq / rdf:Bag -> join li items
const li = el.querySelectorAll('li');
if (li.length) return Array.from(li).map(l=>l.textContent.trim()).filter(Boolean).join(', ');
return el.textContent.trim();
}
const descs = doc.querySelectorAll('Description');
descs.forEach(desc => {
Array.from(desc.children).forEach(child => {
const ns = nsLabel(child.namespaceURI || '');
const local = child.localName;
const key = ns ? `${ns}:${local}` : local;
const val = extractText(child);
if (!val) return;
result.xmp[key] = val;
result.raw.push({tag:'XMP', name:key, value:val});
if (isVRCKey(key) || isVRCKey(val)) {
// avoid duplicates
if (!result.vrc.find(v=>v.key===key)) {
result.vrc.push({key, value: val});
}
}
});
});
}
// ─── PNG parser ───────────────────────────────────────────────────────
function parsePNG(u8, result) {
result.png = {};
let off=8;
while(off<u8.length-8){
const len=(u8[off]<<24)|(u8[off+1]<<16)|(u8[off+2]<<8)|u8[off+3];
const type=String.fromCharCode(u8[off+4],u8[off+5],u8[off+6],u8[off+7]);
if(type==='IHDR'){
result.png['Width'] =((u8[off+8]<<24)|(u8[off+9]<<16)|(u8[off+10]<<8)|u8[off+11])+'px';
result.png['Height']=((u8[off+12]<<24)|(u8[off+13]<<16)|(u8[off+14]<<8)|u8[off+15])+'px';
result.png['BitDepth']=u8[off+16];
const colorTypeMap={0:'グレースケール',2:'RGB',3:'インデックス',4:'グレースケール+α',6:'RGBA'};
result.png['ColorType']=colorTypeMap[u8[off+17]]||u8[off+17];
result.png['Interlace']=u8[off+20]?'ありAdam7':'なし';
}
if(type==='tEXt'||type==='iTXt'||type==='zTXt'){
const chunk=u8.slice(off+8,off+8+len);
let nullIdx=chunk.indexOf(0);
if(nullIdx>0){
const key=String.fromCharCode(...chunk.slice(0,nullIdx));
let valStart=nullIdx+1;
if(type==='iTXt') valStart=nullIdx+4; // skip flags/encoding
const val=new TextDecoder().decode(chunk.slice(valStart));
result.png[key]=val;
if(isVRCKey(key)||isVRCKey(val)) result.vrc.push({key,value:val});
result.raw.push({tag:type,name:key,value:val});
// XMP embedded in PNG tEXt/iTXt with key "XML:com.adobe.xmp"
if(key==='XML:com.adobe.xmp'||key==='xmp') readXMP(val, result);
}
}
if(type==='eXIf'){
readEXIF(u8.slice(off+8,off+8+len), result);
}
if(type==='IEND') break;
off+=12+len;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
function formatBytes(b){const u=['B','KB','MB','GB'];let i=0;while(b>=1024&&i<3){b/=1024;i++;}return b.toFixed(1)+' '+u[i];}
function humanize(key, val) {
const n=Number(val);
if(key==='Orientation' && ORIENTATION_MAP[n]) return ORIENTATION_MAP[n];
if(key==='MeteringMode'&& METERING_MAP[n]) return METERING_MAP[n];
if(key==='Flash' && FLASH_MAP[n]) return FLASH_MAP[n];
if(key==='ColorSpace' && COLORSPACE_MAP[n])return COLORSPACE_MAP[n];
if(key==='ResolutionUnit'&&RESOLUTION_UNIT_MAP[n])return RESOLUTION_UNIT_MAP[n];
if(key==='ExposureMode'&&EXPOSURE_MODE_MAP[n])return EXPOSURE_MODE_MAP[n];
if(key==='WhiteBalance'&&WHITE_BALANCE_MAP[n])return WHITE_BALANCE_MAP[n];
if(key==='SceneCaptureType'&&SCENE_CAPTURE_MAP[n])return SCENE_CAPTURE_MAP[n];
if(key==='LightSource' &&LIGHT_SOURCE_MAP[n])return LIGHT_SOURCE_MAP[n];
if(key==='SensingMethod'&&SENSING_METHOD_MAP[n])return SENSING_METHOD_MAP[n];
return val;
}
// ─── Render ───────────────────────────────────────────────────────────
function render(result, file) {
const el = document.getElementById('results');
el.style.display='block';
dz.style.display='none';
let allText = '';
const sections=[];
// Build allText helper
function addToAll(label, rows){
allText+=`\n=== ${label} ===\n`;
rows.forEach(r=>allText+=`${r[0]}: ${r[1]}\n`);
}
// — Basic info
const basicRows=[
['ファイル名',result.basic.fileName],
['ファイルサイズ',result.basic.fileSize],
['MIMEタイプ',result.basic.fileType],
['最終更新日時',result.basic.lastModified],
];
addToAll('基本情報',basicRows);
sections.push({tag:'tag-basic',label:'基本情報',tagLabel:'BASIC',rows:basicRows,isVRC:false});
// — VRC
if(result.vrc && result.vrc.length>0){
const vrcRows = result.vrc.map(v=>[v.key,v.value]);
addToAll('VRChat メタデータ',vrcRows);
sections.push({tag:'tag_vrc',label:'VRChat メタデータ',tagLabel:'VRC',rows:vrcRows,isVRC:true,open:true});
}
// — XMP
if(result.xmp && Object.keys(result.xmp).length>0){
const rows=Object.entries(result.xmp).map(([k,v])=>[k,v]);
addToAll('XMP',rows);
sections.push({tag:'tag-xmp',label:'XMP メタデータ',tagLabel:'XMP',rows,open:true});
}
// — EXIF
if(result.exif && Object.keys(result.exif).length>0){
const rows=Object.entries(result.exif).map(([k,v])=>[k, humanize(k,v)]);
addToAll('EXIF',rows);
sections.push({tag:'tag-exif',label:'EXIF データ',tagLabel:'EXIF',rows,open:true});
}
// — GPS
if(result.gps && Object.keys(result.gps).length>0){
const rows=Object.entries(result.gps).map(([k,v])=>[k,v]);
addToAll('GPS',rows);
sections.push({tag:'tag-gps',label:'GPS 情報',tagLabel:'GPS',rows,open:true});
}
// — PNG
if(result.png && Object.keys(result.png).length>0){
const rows=Object.entries(result.png).map(([k,v])=>[k,v]);
addToAll('PNG チャンク',rows);
sections.push({tag:'tag-png',label:'PNG チャンク',tagLabel:'PNG',rows,open:false});
}
// — IPTC
if(result.iptc && Object.keys(result.iptc).length>0){
const rows=Object.entries(result.iptc).map(([k,v])=>[k,v]);
addToAll('IPTC',rows);
sections.push({tag:'tag-iptc',label:'IPTC データ',tagLabel:'IPTC',rows,open:false});
}
// — Raw (all tags)
if(result.raw && result.raw.length>0){
const rows=result.raw.map(r=>[`${r.tag} ${r.name}`,r.value]);
addToAll('全タグ(Raw)',rows);
sections.push({tag:'tag-raw',label:`全タグ (Raw) — ${result.raw.length}`,tagLabel:'RAW',rows,open:false});
}
// render HTML
let html=`
<div class="preview-wrap">
<div class="preview-img">
<img src="${result.preview}" alt="preview">
<div class="preview-badge">${result.basic.fileType}</div>
</div>
<div class="file-info">
<div class="file-name">${escHtml(result.basic.fileName)}</div>
<div class="file-stats">
<div class="stat-chip"><b>${result.basic.fileSize}</b></div>
<div class="stat-chip">更新: <b>${result.basic.lastModified}</b></div>
${result.vrc&&result.vrc.length>0?`<div class="stat-chip" style="border-color:rgba(244,114,182,.4);color:var(--accent3)">⚡ VRC メタデータあり</div>`:''}
</div>
<div class="copy-all-wrap" style="text-align:left;padding:0">
<button class="btn-copy" onclick="copyAll()">📋 全データをコピー</button>
</div>
</div>
</div>`;
sections.forEach((sec,idx)=>{
const isOpen = sec.open !== false;
html+=`<div class="section">
<div class="section-head ${isOpen?'open':''}" onclick="toggleSec(${idx})">
<span class="section-tag ${sec.tag}">${sec.tagLabel}</span>
<span class="section-title">${sec.label}</span>
<span class="section-count">${sec.rows.length} 件</span>
<span class="chevron ${isOpen?'open':''}">▶</span>
</div>`;
if(sec.isVRC) html+=`<div class="vrc-banner"><div class="vrc-dot"></div><span class="vrc-label">VRChat 関連メタデータを検出</span></div>`;
html+=`<div class="section-body ${isOpen?'open':''}"><table>`;
sec.rows.forEach(([k,v])=>{
const isVRCRow=isVRCKey(k)||isVRCKey(String(v));
html+=`<tr><td>${escHtml(k)}</td><td class="${isVRCRow?'highlight-val':''}">${escHtml(String(v))}</td></tr>`;
});
html+=`</table></div></div>`;
});
if(sections.length<=1){
html+=`<div class="section"><div class="empty-state">⚠️ この画像にはメタデータが含まれていないか、対応フォーマット外です</div></div>`;
}
html+=`<button class="btn-reset" onclick="reset()">↩ 別の画像を選択</button>`;
el.innerHTML=html;
window._allText=allText;
}
window.toggleSec=function(idx){
const heads=document.querySelectorAll('.section-head');
const bodies=document.querySelectorAll('.section-body');
const chevrons=document.querySelectorAll('.chevron');
const h=heads[idx],b=bodies[idx],c=chevrons[idx];
const open=b.classList.toggle('open');
h.classList.toggle('open',open);
c.classList.toggle('open',open);
};
window.copyAll=function(){
navigator.clipboard.writeText(window._allText||'').then(()=>{
const btn=document.querySelector('.btn-copy');
const orig=btn.textContent;
btn.textContent='✅ コピーしました';
setTimeout(()=>btn.textContent=orig,2000);
});
};
window.reset=function(){
document.getElementById('results').style.display='none';
document.getElementById('results').innerHTML='';
document.getElementById('dropzone').style.display='block';
document.getElementById('fileInput').value='';
};
function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
</script>
</body>
</html>