Calculators: options from Record Industry's calculator; its prices as a benchmark
Deploy test site / deploy (push) Successful in 45s

Reviewed Record Industry's (Haarlem, NL) public vinyl calculator: a
seven-step wizard with a live itemised cost list priced on its server.
Read its prices for a reference 12" LP at 200 / 300 / 500 / 1,000 copies
and for each option worth copying (checked October 2026).

Both artist demos (make-a-record and artist-order):
- audio arrives as digital files, analog tape (+ tape transfer) or a cut
  lacquer (no blanks, cutting or mastering; mastering choice disabled with
  the reason; cutting and mastering stages skipped in the timeline; the
  passport starts at "Lacquer received")
- labels printed, plain white or plain black (credit line; the label
  artwork drop is disabled for plain labels; the mock-up shows the plain
  label)
- press cleaning before a colour run, on by default, disabled for black
- 7" dinked (large centre hole), 7" only, drawn in the mock-up
- download card and barcode sticker extras
- run-out inscription (A-Z, 0-9, up to 15), scribed along the dead wax in
  the mock-up and named in the passport
- new rates (tape 3,800, cleaning 2,300 per title; plain labels -12 per
  disc; download card 43, barcode 6 per copy) are estimates with Record
  Industry's prices as the basis, each listed in the detailed demo
- make-a-record investor notes: Record Industry's 9 to 11 week quote after
  test-pressing approval against the 7 to 9 week CNX quote, and its 2 %
  reject allowance against 100 % optical inspection

Defaults unchanged (37,836 THB per title, 232 per copy at 100, break-even
41, goal 100, 548 kept).

Docs: artist-platform.md (choices table, benchmark section: what was
taken, what was left out, commercial terms worth deciding);
revenue-model.md (second Western data point: 219 THB a copy at 300
ex-works, per-title and per-copy reading against the assumptions, not a
re-base); production-timeline.md (its published lead times).
This commit is contained in:
grabowski committed 2026-10-16 09:42:57 +07:00
1 parent 83f1aa94dc
commit 5090609df3
5 files changed
+195 -42

No files matched your search

+54 -21
View File
@@ -472,6 +472,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="field"><span class="fl">Speed</span><div class="seg" data-k="rpm"><button type="button" data-v="33">33&#x2153; rpm</button><button type="button" data-v="45">45 rpm</button></div></div>
<div class="field"><span class="fl">Discs</span><div class="seg" data-k="discs" data-t="n"><button type="button" data-v="1">Single</button><button type="button" data-v="2">Double</button></div></div>
<div class="field" id="fWeight"><span class="fl">Weight</span><div class="seg" data-k="weight" data-t="n"><button type="button" data-v="140">140 g</button><button type="button" data-v="180">180 g heavyweight</button></div></div>
<div class="field" id="fDink"><span class="fl">Centre hole</span><div class="seg" data-k="dinked" data-t="b"><button type="button" data-v="false">Small</button><button type="button" data-v="true">Large (dinked)</button></div></div>
</div>
</div>
<div class="block">
@@ -486,6 +487,10 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="field" id="fC1"><span class="fl">Colour</span><div class="sw-row" data-c="c1"></div></div>
<div class="field" id="fC2"><span class="fl">Second colour</span><div class="sw-row" data-c="c2"></div></div>
</div>
<div class="rowc mt" style="align-items:center">
<div class="field" id="fClean"><span class="fl">Clean the press before your colour</span><div class="seg" data-k="cleaning" data-t="b"><button type="button" data-v="true">Yes, recommended</button><button type="button" data-v="false">No</button></div></div>
<p class="hint" id="cleanHint" style="max-width:400px;margin:0"></p>
</div>
</div>
<div class="block">
<h2>Packaging</h2>
@@ -496,19 +501,21 @@ document.documentElement.setAttribute("data-theme",t);})();
<button type="button" class="choice" data-k="jacket" data-v="none"><span class="ic"><span class="jic none"></span></span><b>My own jacket</b><span>You supply it</span></button>
</div>
<div class="rowc mt">
<div class="field"><span class="fl">Labels</span><div class="seg" data-k="labels"><button type="button" data-v="printed">Printed</button><button type="button" data-v="white">Plain white</button><button type="button" data-v="black">Plain black</button></div></div>
<div class="field"><span class="fl">Inner sleeve</span><div class="seg" data-k="inner"><button type="button" data-v="paper">Paper</button><button type="button" data-v="poly">Poly-lined</button></div></div>
<div class="field"><span class="fl">Extras</span><div class="chips"><button type="button" class="chip" data-b="shrink">Shrink wrap</button><button type="button" class="chip" data-b="insert">Printed insert</button><button type="button" class="chip" data-b="sticker">Hype sticker</button><button type="button" class="chip" data-b="numbered">Numbered edition</button></div></div>
<div class="field"><span class="fl">Extras</span><div class="chips"><button type="button" class="chip" data-b="shrink">Shrink wrap</button><button type="button" class="chip" data-b="insert">Printed insert</button><button type="button" class="chip" data-b="sticker">Hype sticker</button><button type="button" class="chip" data-b="numbered">Numbered edition</button><button type="button" class="chip" data-b="dlcard">Download card</button><button type="button" class="chip" data-b="barcode">Barcode sticker</button></div></div>
</div>
</div>
<div class="block">
<h2>Sound</h2>
<div class="field" style="margin-bottom:14px;align-items:flex-start"><span class="fl">Your audio arrives as</span><div class="seg" data-k="source"><button type="button" data-v="digital">Digital files</button><button type="button" data-v="tape">Analog tape</button><button type="button" data-v="lacquer">A cut lacquer</button></div></div>
<div class="cards c2">
<button type="button" class="choice" data-k="mastering" data-v="true" data-t="b"><b>Vinyl mastering</b><span>Our engineer masters your mix for the groove</span></button>
<button type="button" class="choice" data-k="mastering" data-v="false" data-t="b"><b>My master is vinyl-ready</b><span>Cut straight from your files</span></button>
</div>
<div class="rowc mt">
<div class="field"><span class="fl">Test pressings for you</span><div class="stepper"><button type="button" data-act="tps" data-d="-1" aria-label="Fewer">&#x2212;</button><b id="tpsV">5</b><button type="button" data-act="tps" data-d="1" aria-label="More">+</button></div></div>
<p class="hint" style="max-width:380px;margin:0">You hear and approve a real pressing before the run. The mother plate is archived, so a repress later skips cutting and plating.</p>
<p class="hint" id="srcHint" style="max-width:380px;margin:0">You hear and approve a real pressing before the run. The mother plate is archived, so a repress later skips cutting and plating.</p>
</div>
</div>
</section>
@@ -522,6 +529,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="block g2f">
<div class="field"><span class="fl">Artist</span><input type="text" id="artist" maxlength="60"></div>
<div class="field"><span class="fl">Release title</span><input type="text" id="title" maxlength="80"></div>
<div class="field" style="grid-column:1/-1"><span class="fl">Run-out inscription <span class="mute" style="font-weight:400">scribed by hand next to the run-out groove of every side: letters and numbers, up to 15</span></span><input type="text" id="catno" maxlength="15" spellcheck="false" autocomplete="off" style="max-width:260px;text-transform:uppercase;font-family:ui-monospace,Consolas,monospace;letter-spacing:0.08em"></div>
</div>
<div class="block">
<h2>Tracks and sides <small id="sideHint"></small></h2>
@@ -555,7 +563,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="eyebrow">Step 3 of 5</div>
<h1>Your price</h1>
<p class="lead">Every line itemised, with no fees that appear later. The price per copy falls fast as the run grows.</p>
<div class="inv"><span class="il">Investor note</span><p><b>Why short runs work here.</b> The one Thai plant on file quotes a 150-copy minimum that is "rarely accepted because the cost is way too high": its one-off cost per release (imported stampers, outsourced cutting) does not shrink with the run. In-house cutting and plating collapses that cost.</p><p>Prices on this page are indicative, at Western benchmark rates. The Thai market pays 650 to 950 THB a copy at 300 copies today; the real price list follows market validation.</p></div>
<div class="inv"><span class="il">Investor note</span><p><b>Why short runs work here.</b> The one Thai plant on file quotes a 150-copy minimum that is "rarely accepted because the cost is way too high": its one-off cost per release (imported stampers, outsourced cutting) does not shrink with the run. In-house cutting and plating collapses that cost.</p><p>Prices on this page are indicative, at Western benchmark rates. The Thai market pays 650 to 950 THB a copy at 300 copies today; the real price list follows market validation.</p><p><b>Faster than an established EU plant.</b> Record Industry (NL) quotes 9 to 11 weeks from test-pressing approval to the finished order (its calculator, October 2026); the CNX quote here is 7 to 9.</p></div>
<div class="panel block">
<div class="qtyhead">
<div class="field"><span class="fl">Copies</span><div class="qbig"><input type="number" id="qty" min="100" max="5000" step="10" aria-label="Copies"><span>copies</span></div></div>
@@ -610,7 +618,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="eyebrow">Step 5 of 5</div>
<h1 id="s5h">Campaign and production</h1>
<p class="lead" id="s5l"></p>
<div class="inv"><span class="il">Investor note</span><p><b>Traceability is the product.</b> Fan updates come from the factory's own event log, and every copy resolves to its stamper, press, cycle and side. At Making Vinyl Europe 2026 this is what the industry responded to most.</p><p><b>One release becomes a recurring one.</b> The archived mother makes a repress about 1.5 to 2.5 weeks of plant time: the highest-margin order the plant will take.</p></div>
<div class="inv"><span class="il">Investor note</span><p><b>Traceability is the product.</b> Fan updates come from the factory's own event log, and every copy resolves to its stamper, press, cycle and side. At Making Vinyl Europe 2026 this is what the industry responded to most. An established EU plant asks customers to accept about 2 % rejects (Record Industry FAQ, October 2026); CNX inspects every record optically.</p><p><b>One release becomes a recurring one.</b> The archived mother makes a repress about 1.5 to 2.5 weeks of plant time: the highest-margin order the plant will take.</p></div>
<div id="s5"></div>
</section>
</main>
@@ -650,7 +658,9 @@ const R={
press:{v:{12:115,10:95,7:55}},g180:{v:15},colour:{v:{solid:20,marbled:35,splatter:45}},
inner:{v:{paper:{12:7,10:6,7:4},poly:{12:15,10:12,7:8}}},assy:{v:10},
jacket:{v:{printed:{12:[86,73,64,55],10:[68,58,51,44],7:[27,23,20,17]},gatefold:{12:[127,109,95,82]},plain:{12:[27,27,27,27],10:[20,20,20,20],7:[8,8,8,8]}}},
handling:{v:0.10},shrink:{v:5},insert:{v:12},sticker:{v:6},numbered:{v:5},fee:{v:0.035},minRun:{v:100},deposit:{v:0.5}
handling:{v:0.10},shrink:{v:5},insert:{v:12},sticker:{v:6},numbered:{v:5},
/* added October 2026, estimates with Record Industry's calculator as the basis (see artist-order.html) */
tape:{v:3800},clean:{v:2300},plainLabel:{v:-12},dlcard:{v:43},barcode:{v:6},fee:{v:0.035},minRun:{v:100},deposit:{v:0.5}
};
const tierIdx=q=>q>=1000?3:q>=500?2:q>=300?1:0;
/* side length per format, minutes [comfortable, at most]: comfortable = lower end of the microcut.studio format guide;
@@ -659,7 +669,7 @@ const LIM={"12":{"33":[18,22],"45":[12,15]},"10":{"33":[12,15],"45":[8,12]},"7":
const SONG=272; /* seconds: the 4:32 average song the at-most table counts songs with */
const LOUD=0.6;
const JNAME={none:"Your own jacket",plain:"Plain jacket",printed:"Printed jacket",gatefold:"Gatefold jacket"};
const XN={shrink:"Shrink wrap",insert:"Insert",sticker:"Hype sticker",numbered:"Numbered"};
const XN={shrink:"Shrink wrap",insert:"Insert",sticker:"Hype sticker",numbered:"Numbered",dlcard:"Download card",barcode:"Barcode sticker"};
const ART={jacket:{t:"Jacket front",mm:{12:320,10:266,7:190}},label:{t:"Label",mm:{12:106,10:106,7:96}}};
/* stages, working days [low, high]: mature column of the production timeline (same table as artist-order.html) */
const STG=[
@@ -699,6 +709,7 @@ const S={
step:1,cur:"THB",size:"12",rpm:"33",discs:1,weight:140,colour:"black",c1:"#b3202a",c2:"#f2c14e",
qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false,
mastering:true,tps:5,loud:false,artist:"Mae Ping Collective",title:"Night Market Songs",
source:"digital",labels:"printed",cleaning:true,dinked:false,dlcard:false,barcode:false,catno:"MPC001",
tracks:[],demoTracks:true,rights:false,gate1:false,art:{jacket:null,label:null},
fanPrice:1200,days:30,cap:0,limited:false,capN:300,artistCopies:0,vat:false,
mode:null,launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0,prod:null,
@@ -725,7 +736,10 @@ const rpmT=()=>S.rpm==="33"?"33⅓ rpm":"45 rpm";
const colourName=()=>({black:"Black vinyl",solid:"Solid colour",marbled:"Marbled",splatter:"Splatter"})[S.colour];
const colourT=()=>({black:"black",solid:"solid colour",marbled:"marbled",splatter:"splatter"})[S.colour];
const SIZE_MM={"12":{rec:302,jk:314,lab:100,name:"12\" LP"},"10":{rec:250,jk:260,lab:100,name:"10\""},"7":{rec:175,jk:184,lab:90,name:"7\" single"}};
function descT(){return (S.discs>1?"2 × ":"")+SIZE_MM[S.size].name+", "+rpmT()+", "+(S.size==="12"?S.weight+" g ":"")+colourT()+" vinyl";}
function descT(){return (S.discs>1?"2 × ":"")+SIZE_MM[S.size].name+(dinkOn()?" (dinked)":"")+", "+rpmT()+", "+(S.size==="12"?S.weight+" g ":"")+colourT()+" vinyl";}
const dinkOn=()=>S.dinked&&S.size==="7",mastOn=()=>S.mastering&&S.source!=="lacquer",cleanOn=()=>S.cleaning&&S.colour!=="black";
/* a stage runs unless the order skips it: no mastering or cutting when the artist sends a cut lacquer */
const stageOn=s=>s.opt==="mastering"?mastOn():s.id==="cut"?S.source!=="lacquer":true;
function toast(t){$$(".toast").forEach(x=>x.remove());const el=document.createElement("div");el.className="toast";el.textContent=t;document.body.appendChild(el);setTimeout(()=>el.remove(),2600);}
function hsh(s){let h=2166136261;for(const c of String(s)){h^=c.charCodeAt(0);h=Math.imul(h,16777619);}return h>>>0;}
const idOf=(p,s,n)=>p+"-"+hsh(s).toString(36).toUpperCase().padStart(7,"0").slice(0,n||4);
@@ -734,19 +748,23 @@ function rnd(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.i
/* ---- pricing (same model as artist-order.html) ---- */
function quote(q){
const sz=S.size,d=S.discs,sides=2*d,T=[],C=[],du=d>1?"discs":"disc";
if(S.mastering)T.push({t:"Vinyl mastering",n:sides,u:"sides",r:R.master.v});
T.push({t:"Lacquer blanks",n:sides,u:"sides",r:R.blank.v});
T.push({t:"Lacquer cutting",n:sides,u:"sides",r:R.cut.v});
if(mastOn())T.push({t:"Vinyl mastering",n:sides,u:"sides",r:R.master.v});
if(S.source==="tape")T.push({t:"Analog tape transfer",n:1,u:"",r:R.tape.v});
if(S.source!=="lacquer"){T.push({t:"Lacquer blanks",n:sides,u:"sides",r:R.blank.v});T.push({t:"Lacquer cutting",n:sides,u:"sides",r:R.cut.v});}
T.push({t:"Plating: father, mother (kept for represses), stamper",n:sides,u:"sides",r:R.metal.v});
T.push({t:"Press setup",n:d,u:du,r:R.setup.v});
if(S.tps>0)T.push({t:"Test pressings for you",n:S.tps,u:"copies",r:R.tp.v});
if(cleanOn())T.push({t:"Press cleaned before your colour",n:1,u:"",r:R.clean.v});
C.push({t:"Pressing, "+sz+"\""+(sz==="12"?" 140 g":"")+", printed labels",n:d,u:du,r:R.press.v[sz]});
if(sz==="12"&&S.weight===180)C.push({t:"180 g heavyweight",n:d,u:du,r:R.g180.v});
if(S.colour!=="black")C.push({t:"Colour vinyl, "+colourT(),n:d,u:du,r:R.colour.v[S.colour]});
C.push({t:"Inner sleeve, "+(S.inner==="poly"?"poly-lined":"paper"),n:d,u:du,r:R.inner.v[S.inner][sz]});
C.push({t:"Assembly and packing",n:1,u:"",r:R.assy.v});
if(S.jacket!=="none"){const p=R.jacket.v[S.jacket][sz][tierIdx(q)];C.push({t:JNAME[S.jacket]+(sz==="7"&&S.jacket!=="plain"?" (picture sleeve)":""),n:1,u:"",r:Math.round(p*(1+R.handling.v)),pass:p});}
if(S.labels!=="printed")C.push({t:"Plain "+S.labels+" labels instead of printed",n:d,u:du,r:R.plainLabel.v});
if(S.shrink)C.push({t:"Shrink wrap",n:1,u:"",r:R.shrink.v});
if(S.dlcard)C.push({t:"Download card",n:1,u:"",r:R.dlcard.v});
if(S.barcode)C.push({t:"Barcode sticker",n:1,u:"",r:R.barcode.v});
if(S.insert)C.push({t:"Printed insert",n:1,u:"",r:R.insert.v});
if(S.sticker)C.push({t:"Hype sticker",n:1,u:"",r:R.sticker.v});
if(S.numbered)C.push({t:"Hand-numbered",n:1,u:"",r:R.numbered.v});
@@ -756,7 +774,7 @@ function quote(q){
}
function leadInfo(campaign){
const g3=STG.findIndex(s=>s.id==="g3");
const pre=STG.slice(0,g3+1).filter(s=>(!s.opt||S[s.opt])&&!((campaign||S.gate1)&&(s.id==="intake"||s.id==="g1")));
const pre=STG.slice(0,g3+1).filter(s=>stageOn(s)&&!((campaign||S.gate1)&&(s.id==="intake"||s.id==="g1")));
const lo=pre.reduce((a,s)=>a+s.d[0],0),hi=pre.reduce((a,s)=>a+s.d[1],0);
const pw=[Math.max(1,Math.round(lo/5)),Math.max(1,Math.ceil(hi/5))];
const printed=S.jacket==="printed"||S.jacket==="gatefold";
@@ -877,7 +895,9 @@ const jacketArt=()=>S.jacket==="printed"||S.jacket==="gatefold";
function checkList(){
const L=[],files=S.tracks.filter(t=>t.file),ready=files.filter(t=>t.info&&!t.info.reading);
if(!S.tracks.length)L.push(["bad","Track list","Add at least one track."]);
if(!files.length)L.push(["pend","Master files","Not added yet: these checks use the lengths typed in. Drop WAV, FLAC or AIFF files to check the files themselves."]);
if(S.source==="lacquer")L.push(["pend","Your lacquer","Cut by your studio and sent by express courier the day it is cut: a lacquer has to be plated within days. Inspected under the microscope on arrival. Files below are for the side plan and the previews."]);
else if(S.source==="tape")L.push(["pend","Your tape","Analog tape is transferred and checked on arrival; format, speed and EQ curve are confirmed with you first."]);
if(S.source!=="digital"){}else if(!files.length)L.push(["pend","Master files","Not added yet: these checks use the lengths typed in. Drop WAV, FLAC or AIFF files to check the files themselves."]);
else{
const lossy=ready.filter(t=>!t.info.lossless);
L.push(lossy.length?["bad","File format",lossy.map(t=>esc(t.file.name)).join(", ")+": not WAV, FLAC or AIFF. Send the lossless master."]:["ok","File format",ready.length+" lossless file"+(ready.length===1?"":"s")]);
@@ -893,7 +913,7 @@ function checkList(){
:warn.length?["warn","Side length",warn.map(s=>"Side "+s.n+" "+mmss(s.sec)).join(", ")+": past the "+mmss(lim[0])+" that sounds best, so the cut gets quieter"]
:empty.length?["warn","Side length","Side "+empty.map(s=>s.n).join(", ")+" has no music"]:["ok","Side length","Every side within the "+mmss(lim[0])+" that sounds best"]);
if(jacketArt()){const c=artChecks("jacket");L.push(!c?["pend","Jacket artwork","Not added yet"]:c.some(x=>x[0]==="bad")?["bad","Jacket artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:c.some(x=>x[0]!=="ok")?["warn","Jacket artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:["ok","Jacket artwork","Size, resolution and colour space fine"]);}
{const c=artChecks("label");L.push(!c?["pend","Label artwork","Not added yet"]:c.some(x=>x[0]==="bad")?["bad","Label artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:c.some(x=>x[0]!=="ok")?["warn","Label artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:["ok","Label artwork","Size, resolution and colour space fine"]);}
if(S.labels==="printed"){const c=artChecks("label");L.push(!c?["pend","Label artwork","Not added yet"]:c.some(x=>x[0]==="bad")?["bad","Label artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:c.some(x=>x[0]!=="ok")?["warn","Label artwork",c.filter(x=>x[0]!=="ok").map(x=>x[1]).join("; ")]:["ok","Label artwork","Size, resolution and colour space fine"]);}
L.push(["pend","Engineer check","After upload a cutting engineer confirms the sides are cuttable. This decides, not the bars above."]);
return L;
}
@@ -917,12 +937,16 @@ function mockSVG(id){
let g=`<g transform="translate(${x.toFixed(1)} ${cy.toFixed(1)}) scale(${s.toFixed(3)})"><g class="spin${S.rpm==="45"?" r45":""}"><circle r="105" fill="${base}"/>`;
if(S.colour==="marbled")g+=`<rect x="-105" y="-105" width="210" height="210" fill="${S.c2}" filter="url(#${id}m)" clip-path="url(#${id}c)"/>`;
if(S.colour==="splatter"){const r=rnd(7);let dots="";for(let i=0;i<80;i++){const a=r()*6.283,d=Math.sqrt(r())*100,z=1+r()*5;dots+=`<circle cx="${(Math.cos(a)*d).toFixed(1)}" cy="${(Math.sin(a)*d).toFixed(1)}" r="${z.toFixed(1)}" fill="${r()<0.8?S.c2:"#ffffff"}"/>`;}g+=`<g clip-path="url(#${id}c)">${dots}</g>`;}
for(let r=101;r>=rl+5;r-=3.2)g+=`<circle r="${r.toFixed(1)}" fill="none" stroke="${groove}" stroke-width="${(0.5/s).toFixed(2)}"/>`;
for(let r=101;r>=rl+10;r-=3.2)g+=`<circle r="${r.toFixed(1)}" fill="none" stroke="${groove}" stroke-width="${(0.5/s).toFixed(2)}"/>`;
/* run-out inscription, scribed in the dead wax between the label and the last groove */
if(S.catno){const rr=(rl+5).toFixed(1);g+=`<path id="${id}ro${x|0}" d="M0 -${rr} A${rr} ${rr} 0 1 1 -0.01 -${rr}" fill="none"/><text font-size="4.6" font-family="ui-monospace,Consolas,monospace" letter-spacing="0.8" fill="${dark?"rgba(255,255,255,0.55)":"rgba(0,0,0,0.5)"}"><textPath href="#${id}ro${x|0}" startOffset="8%">${esc(S.catno)} A</textPath></text>`;}
const L=rl.toFixed(1);
if(S.art.label&&S.art.label.url)g+=`<image href="${S.art.label.url}" x="-${L}" y="-${L}" width="${(2*rl).toFixed(1)}" height="${(2*rl).toFixed(1)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${id}l)"/>`;
if(S.labels==="white")g+=`<circle r="${L}" fill="#f3efe6"/><circle r="${(rl*0.86).toFixed(1)}" fill="none" stroke="#e2dccd" stroke-width="0.8"/>`;
else if(S.labels==="black")g+=`<circle r="${L}" fill="#1c1c1c"/><circle r="${(rl*0.86).toFixed(1)}" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="0.8"/>`;
else if(S.art.label&&S.art.label.url)g+=`<image href="${S.art.label.url}" x="-${L}" y="-${L}" width="${(2*rl).toFixed(1)}" height="${(2*rl).toFixed(1)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${id}l)"/>`;
else g+=`<circle r="${L}" fill="#efe6d2"/><circle r="${(rl*0.86).toFixed(1)}" fill="none" stroke="#d6c7a6" stroke-width="0.8"/><text y="${(-8*fs).toFixed(1)}" text-anchor="middle" font-size="${(6.6*fs).toFixed(1)}" font-weight="700" fill="#2a2a2a" font-family="system-ui,sans-serif">${esc(S.artist.slice(0,22))}</text><text y="${(13*fs).toFixed(1)}" text-anchor="middle" font-size="${(5.4*fs).toFixed(1)}" fill="#555" font-family="system-ui,sans-serif">${esc(S.title.slice(0,26))}</text><text y="${(24*fs).toFixed(1)}" text-anchor="middle" font-size="${(4.4*fs).toFixed(1)}" fill="#8a7d62" font-family="system-ui,sans-serif" letter-spacing="1">SIDE A · ${S.rpm==="33"?"33⅓":"45"}</text>`;
g+=`</g><circle r="105" fill="url(#${id}sh)"/>${wedge(-128,-104)}${wedge(52,76)}<circle r="105" fill="none" stroke="rgba(255,255,255,0.10)" stroke-width="${(1/s).toFixed(2)}"/>`;
return g+`<circle r="${(105*3.6/Z.rec).toFixed(2)}" class="hole"/></g>`;
return g+`<circle r="${(105*(dinkOn()?19.1:3.6)/Z.rec).toFixed(2)}" class="hole"/></g>`;
}
const recs=(S.discs>1?disc(cx+30*s):"")+disc(cx);
let jk="";const J=236;
@@ -986,6 +1010,12 @@ function syncControls(){
inp.disabled=off;pal.title=off?why:"Open the palette and pick any colour";pal.classList.toggle("off",off);pal.classList.toggle("on",!off&&custom);pal.style.setProperty("--cc",S[c]);
pal.querySelector(".pt").textContent=!off&&custom?"Your colour":"Palette";if(inp.value.toLowerCase()!==S[c].toLowerCase())inp.value=S[c];});
$("#tpsV").textContent=S.tps;
const s7=S.size==="7";$$("#fDink button").forEach(x=>{x.disabled=!s7;x.title=s7?"":"Only 7\" singles come with a large centre hole";});$("#fDink").classList.toggle("off",!s7);
$$("#fClean button").forEach(x=>{x.disabled=blk;x.title=blk?"Black vinyl needs no cleaning":"";});$("#fClean").classList.toggle("off",blk);
$("#cleanHint").textContent=blk?"Only for colour vinyl.":S.cleaning?"The press is purged before your run, so no trace of the previous colour gets in. Light colours can still show the odd speck.":"Saves the purge, but some records can carry streaks of the previous colour.";
const lq=S.source==="lacquer";$$(".choice[data-k=mastering]").forEach(x=>{x.disabled=lq;x.title=lq?"Your lacquer is already cut and mastered":"";if(lq){x.classList.remove("on");x.setAttribute("aria-pressed","false");}});
$("#srcHint").textContent=lq?"Your lacquer goes straight to plating. You still hear and approve a test pressing, and the mother is archived for represses.":S.source==="tape"?"Your tape is transferred in-house, then cut. You hear and approve a real pressing before the run.":"You hear and approve a real pressing before the run. The mother plate is archived, so a repress later skips cutting and plating.";
const dl=$("#dropLabel"),offL=S.labels!=="printed";dl.classList.toggle("off",offL);$("#fileLabel").disabled=offL;dl.title=offL?"Plain labels have no print":"";
const dj=$("#dropJacket"),offJ=!jacketArt();dj.classList.toggle("off",offJ);$("#fileJacket").disabled=offJ;dj.title=offJ?(S.jacket==="none"?"You supply your own jacket":"A plain jacket has no print"):"";
$$(".qchips .chip").forEach(c=>c.classList.toggle("on",+c.dataset.q===S.qty));
}
@@ -1106,7 +1136,7 @@ function trackerHTML(){
<div class="panel"><h2>${P.origin==="direct"?"Your order updates":"Updates your fans see"} <small>from the factory log</small></h2><ul class="feed">${feed}</ul><p class="hint">These come from the plant's own event log: the progress is the factory's record, not a status someone remembers to update.</p></div></div>${fin?passportHTML():""}`;
}
function newProd(origin,start,fans){
const L=leadInfo(true),st=STG.filter(s=>s.id!=="intake"&&s.id!=="g1"&&(!s.opt||S[s.opt]));
const L=leadInfo(true),st=STG.filter(s=>s.id!=="intake"&&s.id!=="g1"&&stageOn(s));
const mid=s=>(s.d[0]+s.d[1])/2,after=st.filter(s=>["run","fin","ship"].includes(s.id)).reduce((a,s)=>a+mid(s),0),qMid=(L.qw[0]+L.qw[1])/2*5;
let cur=new Date(start);
const rows=st.map(s=>{const d=s.id==="queue"?Math.max(0,qMid-after):mid(s);cur=addWD(cur,d);return Object.assign({},s,{date:new Date(cur)});});
@@ -1184,9 +1214,10 @@ function passportHTML(){
const run=by("run"),rd=new Date(run.date);rd.setHours(8+hsh(seed+"h")%9,hsh(seed+"m")%60,hsh(seed+"s")%60);
const p2=v=>String(v).padStart(2,"0"),ts=rd.getFullYear()+"-"+p2(rd.getMonth()+1)+"-"+p2(rd.getDate())+" "+p2(rd.getHours())+":"+p2(rd.getMinutes())+":"+p2(rd.getSeconds());
const st=idOf("S",seed+"st"),lac=idOf("L",seed+"l"),fa=idOf("F",seed+"f"),mo=idOf("M",seed+"mo"),lot=idOf("C",seed+"c",3);
const it=[["Master received","Checksums verified at both ends, stored in the client vault",P.start]];
const it=S.source==="lacquer"?[]:[[S.source==="tape"?"Tape received":"Master received",S.source==="tape"?"Transferred in-house, stored in the client vault":"Checksums verified at both ends, stored in the client vault",P.start]];
if(by("g2"))it.push(["Mastered for vinyl","Master approved by the artist",by("g2").date]);
it.push(["Cut to lacquer","Lacquer "+lac+", serial scribed in the run-out groove",by("cut").date]);
if(by("cut"))it.push(["Cut to lacquer","Lacquer "+lac+(S.catno?", \u201C"+S.catno+" A\u201D":", serial")+" scribed in the run-out",by("cut").date]);
else it.push(["Lacquer received","Cut by the artist's studio, inspected under the microscope on arrival",P.start]);
it.push(["Plated in-house","Father "+fa+" → mother "+mo+" (archived for represses) → stamper "+st,by("plate").date]);
it.push(["Test pressing approved","Heard and approved by the artist",by("g3").date]);
it.push(["Pressed","Press D1-B (Newbilt Duplex) at "+ts.slice(11)+", compound lot "+lot+", press data logged for this cycle",run.date]);
@@ -1223,6 +1254,7 @@ function renderStage(){
$("#mock").innerHTML=mockSVG("ms");
$("#stT").textContent=S.title||"Untitled";$("#stA").textContent=S.artist||"";
const sp=[(S.discs>1?"2 × ":"")+SIZE_MM[S.size].name,rpmT()];if(S.size==="12")sp.push(S.weight+" g");sp.push(colourName(),JNAME[S.jacket]);
if(S.labels!=="printed")sp.push("Plain "+S.labels+" labels");if(S.catno)sp.push("Run-out "+S.catno);if(S.source!=="digital")sp.push(S.source==="tape"?"From tape":"Your lacquer");
Object.keys(XN).forEach(k=>{if(S[k])sp.push(XN[k]);});
$("#stSpecs").innerHTML=sp.map(x=>`<span>${esc(x)}</span>`).join("");
const camp=(S.step===4||S.mode==="campaign")&&S.mode!=="direct";let pr,mb;
@@ -1249,7 +1281,7 @@ function launch(){if(!canLaunch()){if(!S.gate1)go(2);return;}resetCampaign();S.m
function setFanPrice(v){S.fanPrice=v;if(S.mode==="campaign")resetCampaign();renderAll();}
function setQty(q){S.qty=Math.min(5000,Math.max(100,Math.round(q)));if(S.mode==="direct")resetCampaign();renderAll();}
function syncInputs(){
$("#artist").value=S.artist;$("#title").value=S.title;$("#qty").value=S.qty;$("#qtyR").value=Math.min(2000,S.qty);
$("#artist").value=S.artist;$("#title").value=S.title;$("#catno").value=S.catno;$("#qty").value=S.qty;$("#qtyR").value=Math.min(2000,S.qty);
$("#fanPrice").value=S.fanPrice;$("#fanR").value=Math.min(3000,Math.max(500,S.fanPrice));$("#days").value=S.days;$("#cap").value=S.capN;$("#limT").checked=S.limited;$("#acop").value=S.artistCopies;$("#vat").checked=S.vat;$("#rights").checked=S.rights;
}
function resetDemo(){
@@ -1258,7 +1290,7 @@ function resetDemo(){
}
/* ---- events ---- */
const PLAN_KEYS=["size","rpm","discs","loud"],RUN_KEYS=["weight","colour","inner","jacket","mastering"];
const PLAN_KEYS=["size","rpm","discs","loud","source"],RUN_KEYS=["weight","colour","inner","jacket","mastering","labels","cleaning","dinked"];
function setK(k,v){
if(S[k]===v)return;S[k]=v;
if(k==="size"&&v!=="12"&&S.jacket==="gatefold")S.jacket="printed";
@@ -1310,6 +1342,7 @@ document.addEventListener("input",e=>{
else if(id==="qty"){const v=Math.round(+t.value);if(v>=100&&v<=5000)setQty(v);}
else if(t.type==="color"){const c=t.closest("[data-c]").dataset.c;S[c]=t.value;renderAll();}
else if(id==="artist"||id==="title"){S[id]=t.value;renderStage();if(S.mode==="campaign")updateS5();}
else if(id==="catno"){const v=t.value.toUpperCase().replace(/[^A-Z0-9]/g,"").slice(0,15);if(v!==t.value){const c=t.selectionStart;t.value=v;try{t.setSelectionRange(c,c);}catch(e){}}S.catno=v;renderStage();if(S.mode==="campaign")updateS5();}
else if(t.classList.contains("tt")){trkOf(t).title=t.value;S.demoTracks=false;renderSides();}
else if(id==="fanPrice"){const v=+t.value;if(v>=100&&v<=20000){S.fanPrice=v;if(S.mode==="campaign")resetCampaign();renderAll();}}
else if(id==="fanR"){S.fanPrice=+t.value;if(S.mode==="campaign")resetCampaign();renderAll();}